summaryrefslogtreecommitdiffhomepage
path: root/src/core/plugin-abi/driver.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
committerAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
commit5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch)
tree2b98579d25f3f16204feaf25ddac2746e4bbb934 /src/core/plugin-abi/driver.ts
parent2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff)
downloaddispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.tar.gz
dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.zip
feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle
Diffstat (limited to 'src/core/plugin-abi/driver.ts')
-rw-r--r--src/core/plugin-abi/driver.ts140
1 files changed, 140 insertions, 0 deletions
diff --git a/src/core/plugin-abi/driver.ts b/src/core/plugin-abi/driver.ts
new file mode 100644
index 0000000..7ba326f
--- /dev/null
+++ b/src/core/plugin-abi/driver.ts
@@ -0,0 +1,140 @@
+/**
+ * The frontend-module DRIVER (plan 03 §3.2) — the effectful lifecycle host.
+ * Owns the imported-module set and runs ONE sync pass per trigger (boot,
+ * `extensions-changed`, `workspace.set`):
+ *
+ * 1. diff (pure, `decideFrontendLoads`) the desired set against the
+ * imported set;
+ * 2. UNLOAD stragglers: run the module's deactivate disposer (if it
+ * returned one) then the host's unregister-by-pluginId sweep — the UI is
+ * immediately correct. HONEST LIMITATION: ESM cannot be un-fetched; the
+ * bytes linger in the module cache. Re-enable re-runs `activate` on the
+ * cached module (same URL ⇒ no re-fetch).
+ * 3. IMPORT newcomers: dynamic-import the URL (THE injected edge), call
+ * `activate(api)` with the plugin's own host API (forced pluginId
+ * attribution). A throwing/missing activate is LOUD: the plugin's FE
+ * half is marked failed (the backend half stays loaded) and the failure
+ * never crashes the host or blocks the other plugins.
+ *
+ * Syncs are serialized (a chain) — boot + extensions-changed + workspace
+ * switches can overlap, and import/unload ordering must stay coherent.
+ * No internal mocks: tests inject the import edge + host-API builder.
+ */
+import type {
+ FrontendCandidate,
+ FrontendLoadDecision,
+ ImportedFrontendModule,
+} from "./load-diff.js";
+import { decideFrontendLoads } from "./load-diff.js";
+import type { FrontendHostAPI, FrontendPluginModule, FrontendPluginState } from "./types.js";
+
+export interface FrontendDriverDeps {
+ /**
+ * THE edge: dynamic ESM import. Injected (the composition root binds
+ * `import(url)`; tests bind a fake) so nothing else in the driver touches
+ * the module system.
+ */
+ readonly importModule: (url: string) => Promise<unknown>;
+ /**
+ * Build the plugin's host API — the composition root closes over the
+ * registry + surfaces ports + logger and FORCES the pluginId attribution.
+ */
+ readonly buildHostApi: (pluginId: string) => FrontendHostAPI;
+ /** The unregister-by-pluginId sweep (the registry-side unload path). */
+ readonly unregisterViewsByPlugin: (pluginId: string) => void;
+ readonly logger: { info: (msg: string) => void; warn: (msg: string) => void };
+}
+
+export interface FrontendDriver {
+ /** Run one sync pass. Serialized; never throws (failures are states). */
+ sync(candidates: readonly FrontendCandidate[]): Promise<void>;
+ /** The per-plugin states the manager UI renders. */
+ states(): ReadonlyMap<string, FrontendPluginState>;
+}
+
+interface ImportedEntry extends ImportedFrontendModule {
+ readonly deactivate: (() => void) | undefined;
+}
+
+export function createFrontendDriver(deps: FrontendDriverDeps): FrontendDriver {
+ /** pluginId → the imported module's entry. */
+ const imported = new Map<string, ImportedEntry>();
+ /** pluginId → its last-known FE state (for the manager UI). */
+ const pluginStates = new Map<string, FrontendPluginState>();
+ /** The sync chain (serialization across triggers). */
+ let chain: Promise<void> = Promise.resolve();
+
+ /** Unload one plugin: module disposer FIRST, then the registry sweep. */
+ function unload(id: string, entry: ImportedEntry): void {
+ try {
+ entry.deactivate?.();
+ } catch (err) {
+ // A broken deactivate must not block the sweep — the registration
+ // ledger's force-clean philosophy (the views must not linger).
+ deps.logger.warn(`frontend-plugin "${id}": deactivate threw — ${String(err)}`);
+ }
+ try {
+ deps.unregisterViewsByPlugin(id);
+ } catch (err) {
+ deps.logger.warn(`frontend-plugin "${id}": unregister sweep threw — ${String(err)}`);
+ }
+ imported.delete(id);
+ pluginStates.delete(id);
+ deps.logger.info(`frontend-plugin "${id}": unloaded`);
+ }
+
+ /** Import + activate one candidate. Failures are LOUD states, not throws. */
+ async function load(candidate: FrontendCandidate): Promise<void> {
+ try {
+ const mod = (await deps.importModule(candidate.url)) as FrontendPluginModule | undefined;
+ const activate = mod?.default?.activate;
+ if (typeof activate !== "function") {
+ throw new Error("the module has no default.activate export");
+ }
+ const disposer = activate(deps.buildHostApi(candidate.id));
+ imported.set(candidate.id, {
+ url: candidate.url,
+ deactivate: typeof disposer === "function" ? disposer : undefined,
+ });
+ pluginStates.set(candidate.id, { kind: "loaded" });
+ deps.logger.info(`frontend-plugin "${candidate.id}": activated`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ pluginStates.set(candidate.id, { kind: "failed", error: message });
+ deps.logger.warn(`frontend-plugin "${candidate.id}": activation failed — ${message}`);
+ }
+ }
+
+ return {
+ async sync(candidates: readonly FrontendCandidate[]): Promise<void> {
+ const run = chain.then(() => runSync(candidates));
+ // The NEXT sync waits for this one; a rejected run must not poison the
+ // chain (runSync itself never throws, but belt-and-suspenders).
+ chain = run.catch(() => {});
+ await run;
+ },
+
+ states(): ReadonlyMap<string, FrontendPluginState> {
+ return new Map(pluginStates);
+ },
+ };
+
+ async function runSync(candidates: readonly FrontendCandidate[]): Promise<void> {
+ const decision: FrontendLoadDecision = decideFrontendLoads(candidates, imported);
+
+ // The handshake verdicts persist even when nothing else changes — the
+ // manager UI renders "incompatible frontend plugin" until the plugin's
+ // manifest (or the host) changes.
+ for (const bad of decision.incompatible) {
+ pluginStates.set(bad.id, { kind: "incompatible", apiVersion: bad.apiVersion });
+ }
+
+ for (const id of decision.toUnload) {
+ const entry = imported.get(id);
+ if (entry !== undefined) unload(id, entry);
+ }
+ for (const candidate of decision.toLoad) {
+ await load(candidate);
+ }
+ }
+}