summaryrefslogtreecommitdiffhomepage
path: root/src/app/plugin-host.svelte.ts
blob: 80540050c217831094af349ad5e9946c04d60514 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
 * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue
 * between the ABI driver (`core/plugin-abi`), the view registry, the
 * surfaces ports, and the plugins HTTP edge.
 *
 * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app
 * store calls `sync()` on exactly those events) each fetch the plugin list +
 * the ACTIVE workspace's enablement rows, derive the desired candidate set
 * (pure), and run one driver pass. The driver's effects are injected HERE at
 * the edge:
 * - `importModule` — the dynamic `import()` (the only module-system touch);
 * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId
 *   attribution into the layout registry, app-store-backed surfaces ports,
 *   and a plugin-prefixed logger;
 * - `unregisterViewsByPlugin` — the registry's unload sweep.
 *
 * After every pass the per-plugin FE states are reported to the plugins
 * store (the manager UI renders "incompatible frontend plugin" and
 * activation failures).
 */
import {
  createFrontendDriver,
  FRONTEND_ABI_VERSION,
  type FrontendDriver,
  type FrontendHostAPI,
  type FrontendPluginState,
  frontendCandidates,
  usablePluginIds,
} from "../core/plugin-abi";
import {
  type LayoutAbiPort,
  registerPluginView,
  unregisterViewsByPlugin,
  type ViewComponent,
} from "../features/layout";
import type { PluginsHttp } from "../features/plugins";

/** The surfaces ports the ABI hands plugin views — backed by the app store. */
export interface FrontendSurfacesPorts {
  readonly subscribe: (surfaceId: string, conversationId?: string) => void;
  readonly unsubscribe: (surfaceId: string, conversationId?: string) => void;
  readonly invoke: (
    surfaceId: string,
    actionId: string,
    payload?: unknown,
    conversationId?: string,
  ) => void;
  readonly getSpec: (surfaceId: string) => unknown;
}

/** The shape of the plugins store the FE states are reported to. */
export interface FrontendStateReporter {
  setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
}

export interface PluginHostOptions {
  readonly http: PluginsHttp;
  /** The backend HTTP base (import-URL origin). */
  readonly httpBase: string;
  readonly reporter: FrontendStateReporter;
  /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */
  readonly activeWorkspaceId: () => string;
  readonly surfaces: FrontendSurfacesPorts;
  /**
   * The layout port (plan 04 §4.5) — the ABI's `api.layout` (applyOps to the
   * ACTIVE workspace's provider + getTree, null in no-provider mode). Built
   * by the composition root over the layout store + ops HTTP edge.
   */
  readonly layout: LayoutAbiPort;
}

export interface PluginHost {
  /**
   * Run one sync pass (all three triggers share it — the active workspace
   * is read per call, which is what makes `workspace.set` a distinct
   * trigger). Never throws: a failed list fetch keeps the current set.
   */
  sync(): Promise<void>;
}

export function createPluginHost(opts: PluginHostOptions): PluginHost {
  const driver: FrontendDriver = createFrontendDriver({
    importModule: (url) => import(/* @vite-ignore */ url),
    buildHostApi: (pluginId) => buildHostApi(pluginId),
    unregisterViewsByPlugin,
    logger: console,
  });

  function buildHostApi(pluginId: string): FrontendHostAPI {
    return {
      abiVersion: FRONTEND_ABI_VERSION,
      registerView: (reg) =>
        registerPluginView(
          {
            id: reg.id,
            label: reg.label,
            // ONE controlled cast at the ABI seam — the registry is a
            // heterogeneous component bag (register-views.ts precedent);
            // the plugin's own build already vetted its component.
            component: reg.component as ViewComponent,
            ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}),
          },
          pluginId,
        ),
      surfaces: {
        subscribe: (surfaceId, conversationId) =>
          opts.surfaces.subscribe(surfaceId, conversationId),
        unsubscribe: (surfaceId, conversationId) =>
          opts.surfaces.unsubscribe(surfaceId, conversationId),
        invoke: (surfaceId, actionId, payload, conversationId) =>
          opts.surfaces.invoke(surfaceId, actionId, payload, conversationId),
        getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never,
      },
      logger: {
        info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`),
        warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`),
      },
      // plan 04 §4.5: the layout port — the composition-root-built adapter
      // over the layout store + the ops HTTP edge (the plugin never sees
      // either; only this typed port).
      layout: opts.layout,
    };
  }

  return {
    async sync(): Promise<void> {
      // Both reads are fail-soft (null on network failure) — a down backend
      // keeps the CURRENT imported set untouched (no unload on a flaky
      // fetch, and the manager UI's last-known states persist).
      const workspaceId = opts.activeWorkspaceId();
      const [list, workspace] = await Promise.all([
        opts.http.list(),
        opts.http.workspacePlugins(workspaceId),
      ]);
      if (list === null) return;
      // Shape guard at the outermost edge (asymmetric testing: the shell
      // guards, the pure diff stays trusting): a malformed-but-200
      // `/plugins` response (wrong shape, contract drift, a test fake that
      // doesn't model the route) must fail SOFT exactly like `null` — keep
      // the current imported set, never throw from the fire-and-forget sync.
      if (!Array.isArray(list.loaded)) return;
      const candidates = frontendCandidates(list, {
        workspaceId,
        usableIds: usablePluginIds(workspace?.plugins ?? []),
        httpBase: opts.httpBase,
      });
      await driver.sync(candidates);
      opts.reporter.setFrontendStates(driver.states());
    },
  };
}