summaryrefslogtreecommitdiffhomepage
path: root/src/core/plugin-abi/load-diff.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/plugin-abi/load-diff.ts')
-rw-r--r--src/core/plugin-abi/load-diff.ts137
1 files changed, 137 insertions, 0 deletions
diff --git a/src/core/plugin-abi/load-diff.ts b/src/core/plugin-abi/load-diff.ts
new file mode 100644
index 0000000..b846a40
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.ts
@@ -0,0 +1,137 @@
+/**
+ * The frontend-module load DIFF (plan 03 §3.2) — pure decision logic. On
+ * boot, on `extensions-changed`, and on `workspace.set`, the composition
+ * root derives the DESIRED set of frontend plugins (loaded ∧
+ * workspace-enabled ∧ frontend-declaring) from `GET /plugins` + the active
+ * workspace's enablement rows, and diffs it against the IMPORTED set:
+ * newcomers are `import()`-ed + activated; stragglers are deactivated
+ * (module disposer + unregister-by-pluginId sweep); a changed import URL
+ * (backend reload → new content bust) is an unload + re-import.
+ *
+ * The handshake runs HERE: a plugin whose `frontend.apiVersion` range does
+ * not include FRONTEND_ABI_VERSION is reported "incompatible" and is NEVER
+ * imported — it is also (re)unloaded if a previous version of it was
+ * imported.
+ */
+import type { PluginListResponse, WorkspacePluginRow } from "@dispatch/transport-contract";
+import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
+
+/** One desired frontend plugin, fully URL-resolved. */
+export interface FrontendCandidate {
+ readonly id: string;
+ /** The import URL: the backend route + workspace + the content bust. */
+ readonly url: string;
+ /** The plugin's declared frontend ABI semver range (the handshake input). */
+ readonly apiVersion: string;
+}
+
+/** What the currently-imported modules look like to the diff. */
+export interface ImportedFrontendModule {
+ /** The URL the module was imported from (a change → unload + re-import). */
+ readonly url: string;
+}
+
+/** The diff verdict for one sync pass. */
+export interface FrontendLoadDecision {
+ /** Newcomers (or bust-changed plugins) to import + activate. */
+ readonly toLoad: readonly FrontendCandidate[];
+ /** Imported plugin ids no longer desired (or superseded by a new URL). */
+ readonly toUnload: readonly string[];
+ /** Handshake failures — "incompatible frontend plugin" in the manager UI. */
+ readonly incompatible: readonly { readonly id: string; readonly apiVersion: string }[];
+}
+
+/** The composition-root inputs for candidate derivation. */
+export interface FrontendCandidatesInput {
+ /** The active workspace whose enablement set applies. */
+ readonly workspaceId: string;
+ /** The plugin ids enabled in that workspace (see `usablePluginIds`). */
+ readonly usableIds: ReadonlySet<string>;
+ /** The backend HTTP base (the import URL's origin). */
+ readonly httpBase: string;
+}
+
+/**
+ * The desired frontend-plugin set from a `GET /plugins` response: LOADED ∧
+ * workspace-enabled ∧ frontend-declaring rows, one URL each. The URL is
+ * `/plugins/:id/frontend?workspace=<ws>&v=<bust>` — `v` is the content
+ * bust the additive list field carries (the entry's mtimeMs), so a
+ * backend-side reload (new mtime) produces a NEW URL → the diff re-imports.
+ */
+export function frontendCandidates(
+ list: PluginListResponse,
+ input: FrontendCandidatesInput,
+): readonly FrontendCandidate[] {
+ const out: FrontendCandidate[] = [];
+ for (const row of list.loaded) {
+ if (row.frontend === undefined) continue;
+ if (!input.usableIds.has(row.id)) continue;
+ out.push({
+ id: row.id,
+ apiVersion: row.frontend.apiVersion,
+ url: `${input.httpBase}/plugins/${encodeURIComponent(row.id)}/frontend?workspace=${encodeURIComponent(input.workspaceId)}&v=${encodeURIComponent(row.frontend.bust)}`,
+ });
+ }
+ return out;
+}
+
+/**
+ * Diff the desired set against the imported set. Handshake failures are
+ * NEVER in `toLoad` — and if such a plugin WAS imported (its manifest
+ * changed to an incompatible range), it lands in `toUnload`.
+ */
+export function decideFrontendLoads(
+ candidates: readonly FrontendCandidate[],
+ imported: ReadonlyMap<string, ImportedFrontendModule>,
+): FrontendLoadDecision {
+ const toLoad: FrontendCandidate[] = [];
+ const incompatible: { id: string; apiVersion: string }[] = [];
+ const desired = new Set<string>();
+ /** Imported ids whose URL changed (unload + re-import — fresh code). */
+ const superseded: string[] = [];
+
+ for (const candidate of candidates) {
+ if (!isFrontendCompatible(candidate.apiVersion)) {
+ incompatible.push({ id: candidate.id, apiVersion: candidate.apiVersion });
+ continue;
+ }
+ desired.add(candidate.id);
+ const current = imported.get(candidate.id);
+ if (current === undefined) {
+ toLoad.push(candidate);
+ } else if (current.url !== candidate.url) {
+ // The backend reloaded the plugin (a new content bust → new URL):
+ // unload the OLD instance (its disposer + registry sweep run), then
+ // import the fresh code.
+ superseded.push(candidate.id);
+ toLoad.push(candidate);
+ }
+ }
+
+ // Everything imported that is no longer a (handshake-passing) desired id,
+ // plus same-id candidates whose URL changed.
+ const toUnload = [...[...imported.keys()].filter((id) => !desired.has(id)), ...superseded];
+
+ return { toLoad, toUnload, incompatible };
+}
+
+/**
+ * The workspace-usable plugin ids from a `GET /workspaces/:id/plugins`
+ * response (plan-06 rows) — the FE mirror of the backend's
+ * `isPluginUsable`: tier "core" is usable unless explicitly `disabled`;
+ * every other tier is usable only when explicitly `enabled`. The response's
+ * `loaded: false` rows (records for unloaded plugins) cannot be desired
+ * anyway (candidates come from the LOADED set).
+ */
+export function usablePluginIds(rows: readonly WorkspacePluginRow[]): ReadonlySet<string> {
+ const usable = new Set<string>();
+ for (const row of rows) {
+ if (row.tier === "core" ? row.state !== "disabled" : row.state === "enabled") {
+ usable.add(row.pluginId);
+ }
+ }
+ return usable;
+}
+
+/** Re-export for the composition root's single-import convenience. */
+export { FRONTEND_ABI_VERSION, isFrontendCompatible };