@abraca/plugin 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * `PluginRegistry` — the host-side manager for `AbraPlugin` instances.
3
+ *
4
+ * One registry per host (cou-shell, an `@abraca/nuxt` app, …). Generic over
5
+ * the plugin type so apps that narrow `AbraPlugin` to their own interface
6
+ * (e.g. `CouPlugin`, `AbracadabraPlugin`) get correctly-typed aggregator
7
+ * methods without casts.
8
+ *
9
+ * Two tiers:
10
+ *
11
+ * - **Built-in plugins**: registered before `freeze()`. Frozen at boot;
12
+ * `register()` after that point logs a warning and returns.
13
+ * - **Space plugins**: registered/unregistered any time. Reactive consumers
14
+ * poll `getSpacePluginVersion()` to detect changes.
15
+ *
16
+ * Aggregator methods return contributions from both tiers in registration
17
+ * order, built-ins first.
18
+ */
19
+
20
+ import type {
21
+ AbraPlugin,
22
+ AbraTiptapExtension,
23
+ EditorPluginCtx,
24
+ DragHandlePluginCtx,
25
+ CommandPaletteCtx,
26
+ AbraMentionProvider,
27
+ AbraAwarenessContribution,
28
+ AbraNodePanelSlot,
29
+ AbraSettingsPanel,
30
+ AbraKeyboardShortcut,
31
+ AbraCommandItem,
32
+ } from "./types.ts";
33
+
34
+ // ── Type helpers for narrowed plugin generics ─────────────────────────────────
35
+
36
+ /** Extract the value type from a `Record<string, V>`-shaped optional field. */
37
+ type RecordValueOf<T> = T extends Record<string, infer V> ? V : never;
38
+
39
+ /** Extract the item type from a `(ctx) => readonly (readonly T[])[]` field. */
40
+ type GroupedItemOf<T> = T extends
41
+ (...args: never[]) => readonly (infer Group)[]
42
+ ? Group extends readonly (infer Item)[]
43
+ ? Item
44
+ : never
45
+ : never;
46
+
47
+ /** Extract the array element type from an optional array field. */
48
+ type ElementOf<T> = T extends readonly (infer V)[] ? V : never;
49
+
50
+ /** Result-shape of `commandPaletteItems` collapsed to its item type. */
51
+ type CommandItemOf<T> = T extends
52
+ (...args: never[]) => infer R
53
+ ? R extends Promise<readonly (infer Item)[]>
54
+ ? Item
55
+ : R extends readonly (infer Item)[]
56
+ ? Item
57
+ : never
58
+ : never;
59
+
60
+ // ── Aggregator return types — derived from the plugin generic ─────────────────
61
+
62
+ type PageTypeOf<P> = P extends { pageTypes?: infer R } ? RecordValueOf<R> : never;
63
+ type CustomHandlerOf<P> = P extends { customHandlers?: () => infer R } ? R : never;
64
+ type ToolbarItemOf<P> = P extends { toolbarItems?: infer F } ? GroupedItemOf<F> : never;
65
+ type BubbleItemOf<P> = P extends { bubbleMenuItems?: infer F } ? GroupedItemOf<F> : never;
66
+ type SuggestionItemOf<P> = P extends { suggestionItems?: infer F } ? GroupedItemOf<F> : never;
67
+ type DragHandleItemOf<P> = P extends { dragHandleItems?: infer F } ? GroupedItemOf<F> : never;
68
+ type MentionProviderOf<P> = P extends { mentionProviders?: infer A }
69
+ ? ElementOf<A>
70
+ : never;
71
+ type AwarenessContributionOf<P> = P extends { awarenessContributions?: infer A }
72
+ ? ElementOf<A>
73
+ : never;
74
+ type CommandPaletteItemOf<P> = P extends { commandPaletteItems?: infer F }
75
+ ? CommandItemOf<F>
76
+ : never;
77
+ type NodePanelSlotOf<P> = P extends { nodePanelSlots?: infer A } ? ElementOf<A> : never;
78
+ type SettingsPanelOf<P> = P extends { settingsPanel?: infer V } ? NonNullable<V> : never;
79
+ type KeyboardShortcutOf<P> = P extends { keyboardShortcuts?: infer A } ? ElementOf<A> : never;
80
+
81
+ // ── Registry implementation ───────────────────────────────────────────────────
82
+
83
+ export class PluginRegistry<P extends AbraPlugin = AbraPlugin> {
84
+ private _builtins: P[] = [];
85
+ private _spacePlugins: P[] = [];
86
+ private _frozen = false;
87
+ private _spaceVersion = 0;
88
+
89
+ /**
90
+ * Register a built-in plugin. No-ops with a warning after `freeze()`.
91
+ * Use `registerSpacePlugin()` for plugins that arrive after boot.
92
+ */
93
+ register(plugin: P): void {
94
+ if (this._frozen) {
95
+ console.warn(
96
+ `[@abraca/plugin] Registry frozen — cannot register "${plugin.name}". Use registerSpacePlugin() for post-boot plugins.`,
97
+ );
98
+ return;
99
+ }
100
+ if (this._builtins.some((p) => p.name === plugin.name)) {
101
+ console.warn(
102
+ `[@abraca/plugin] Plugin "${plugin.name}" already registered`,
103
+ );
104
+ return;
105
+ }
106
+ this._builtins.push(plugin);
107
+ }
108
+
109
+ /** Lock the registry. Called once after all built-in + external plugins are loaded. */
110
+ freeze(): void {
111
+ this._frozen = true;
112
+ }
113
+
114
+ isFrozen(): boolean {
115
+ return this._frozen;
116
+ }
117
+
118
+ /** Reactive registration channel for space-driven plugins (bypasses freeze). */
119
+ registerSpacePlugin(plugin: P): void {
120
+ if (
121
+ this._spacePlugins.some((p) => p.name === plugin.name) ||
122
+ this._builtins.some((p) => p.name === plugin.name)
123
+ ) {
124
+ console.warn(
125
+ `[@abraca/plugin] Plugin "${plugin.name}" already registered`,
126
+ );
127
+ return;
128
+ }
129
+ this._spacePlugins.push(plugin);
130
+ this._spaceVersion++;
131
+ }
132
+
133
+ unregisterSpacePlugin(name: string): void {
134
+ const idx = this._spacePlugins.findIndex((p) => p.name === name);
135
+ if (idx === -1) return;
136
+ this._spacePlugins.splice(idx, 1);
137
+ this._spaceVersion++;
138
+ }
139
+
140
+ clearSpacePlugins(): void {
141
+ if (this._spacePlugins.length === 0) return;
142
+ this._spacePlugins.length = 0;
143
+ this._spaceVersion++;
144
+ }
145
+
146
+ /** Monotonically increasing counter — bump when space plugins change. */
147
+ getSpacePluginVersion(): number {
148
+ return this._spaceVersion;
149
+ }
150
+
151
+ getBuiltinPlugins(): readonly P[] {
152
+ return this._builtins;
153
+ }
154
+
155
+ getSpacePlugins(): readonly P[] {
156
+ return this._spacePlugins;
157
+ }
158
+
159
+ /** All active plugins — built-ins first, then space plugins. */
160
+ getPlugins(): readonly P[] {
161
+ return [...this._builtins, ...this._spacePlugins];
162
+ }
163
+
164
+ // ── Aggregators ──────────────────────────────────────────────────────────
165
+
166
+ getAllExtensions(): AbraTiptapExtension[] {
167
+ return this.getPlugins().flatMap((p) => p.extensions?.() ?? []);
168
+ }
169
+
170
+ getServerExtensions(): AbraTiptapExtension[] {
171
+ return this.getPlugins().flatMap((p) => p.serverExtensions?.() ?? []);
172
+ }
173
+
174
+ /** Resolves once every plugin with `extensionsReady` has settled. */
175
+ async waitForExtensions(): Promise<void> {
176
+ await Promise.all(
177
+ this.getPlugins().map((p) => p.extensionsReady ?? Promise.resolve()),
178
+ );
179
+ }
180
+
181
+ getAllPageTypes(): Record<string, PageTypeOf<P>> {
182
+ const merged: Record<string, PageTypeOf<P>> = {};
183
+ for (const p of this.getPlugins()) {
184
+ if (p.pageTypes) {
185
+ Object.assign(merged, p.pageTypes as Record<string, PageTypeOf<P>>);
186
+ }
187
+ }
188
+ return merged;
189
+ }
190
+
191
+ getAllCustomHandlers(): CustomHandlerOf<P> {
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
193
+ const merged: Record<string, any> = {};
194
+ for (const p of this.getPlugins()) {
195
+ if (p.customHandlers) Object.assign(merged, p.customHandlers());
196
+ }
197
+ return merged as CustomHandlerOf<P>;
198
+ }
199
+
200
+ getAllToolbarItems(ctx: EditorPluginCtx): ToolbarItemOf<P>[][] {
201
+ return this.getPlugins().flatMap(
202
+ (p) =>
203
+ (p.toolbarItems?.(ctx) ?? []) as readonly (readonly ToolbarItemOf<P>[])[],
204
+ ) as ToolbarItemOf<P>[][];
205
+ }
206
+
207
+ getAllBubbleMenuItems(ctx: EditorPluginCtx): BubbleItemOf<P>[][] {
208
+ return this.getPlugins().flatMap(
209
+ (p) =>
210
+ (p.bubbleMenuItems?.(ctx) ?? []) as readonly (readonly BubbleItemOf<P>[])[],
211
+ ) as BubbleItemOf<P>[][];
212
+ }
213
+
214
+ getAllSuggestionItems(ctx: EditorPluginCtx): SuggestionItemOf<P>[][] {
215
+ return this.getPlugins().flatMap(
216
+ (p) =>
217
+ (p.suggestionItems?.(ctx) ?? []) as readonly (readonly SuggestionItemOf<P>[])[],
218
+ ) as SuggestionItemOf<P>[][];
219
+ }
220
+
221
+ getAllDragHandleItems(ctx: DragHandlePluginCtx): DragHandleItemOf<P>[][] {
222
+ return this.getPlugins().flatMap(
223
+ (p) =>
224
+ (p.dragHandleItems?.(ctx) ?? []) as readonly (readonly DragHandleItemOf<P>[])[],
225
+ ) as DragHandleItemOf<P>[][];
226
+ }
227
+
228
+ getAllMentionProviders(): MentionProviderOf<P>[] {
229
+ return this.getPlugins().flatMap(
230
+ (p) => (p.mentionProviders ?? []) as unknown as readonly MentionProviderOf<P>[],
231
+ ) as MentionProviderOf<P>[];
232
+ }
233
+
234
+ getAllAwarenessContributions(): AwarenessContributionOf<P>[] {
235
+ return this.getPlugins().flatMap(
236
+ (p) => (p.awarenessContributions ?? []) as unknown as readonly AwarenessContributionOf<P>[],
237
+ ) as AwarenessContributionOf<P>[];
238
+ }
239
+
240
+ async getAllCommandPaletteItems(
241
+ ctx: CommandPaletteCtx,
242
+ ): Promise<CommandPaletteItemOf<P>[]> {
243
+ const results = await Promise.all(
244
+ this.getPlugins()
245
+ .filter((p) => p.commandPaletteItems)
246
+ .map((p) => Promise.resolve(p.commandPaletteItems!(ctx))),
247
+ );
248
+ const flat = results.flat() as CommandPaletteItemOf<P>[];
249
+ return flat.filter((item) => {
250
+ const w = (item as AbraCommandItem).when;
251
+ return !w || w(ctx);
252
+ });
253
+ }
254
+
255
+ getAllNodePanelSlots(): NodePanelSlotOf<P>[] {
256
+ return this.getPlugins().flatMap(
257
+ (p) => (p.nodePanelSlots ?? []) as unknown as readonly NodePanelSlotOf<P>[],
258
+ ) as NodePanelSlotOf<P>[];
259
+ }
260
+
261
+ getSettingsPanels(): SettingsPanelOf<P>[] {
262
+ return this.getPlugins()
263
+ .map((p) => p.settingsPanel)
264
+ .filter((v): v is NonNullable<typeof v> => v !== undefined) as SettingsPanelOf<P>[];
265
+ }
266
+
267
+ getAllKeyboardShortcuts(): KeyboardShortcutOf<P>[] {
268
+ return this.getPlugins().flatMap(
269
+ (p) => (p.keyboardShortcuts ?? []) as unknown as readonly KeyboardShortcutOf<P>[],
270
+ ) as KeyboardShortcutOf<P>[];
271
+ }
272
+ }
273
+
274
+ // ── Re-export aggregator types so consumer-side aliases can name them ─────────
275
+
276
+ export type {
277
+ AbraPlugin,
278
+ EditorPluginCtx,
279
+ DragHandlePluginCtx,
280
+ CommandPaletteCtx,
281
+ AbraMentionProvider,
282
+ AbraAwarenessContribution,
283
+ AbraNodePanelSlot,
284
+ AbraSettingsPanel,
285
+ AbraKeyboardShortcut,
286
+ AbraCommandItem,
287
+ };
package/src/sandbox.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Iframe-based sandbox for external plugins.
3
+ *
4
+ * Plugins from outside the built-in set are loaded into a sandboxed
5
+ * `<iframe sandbox="allow-scripts">` — same origin restricted to the
6
+ * iframe's null origin, no access to the parent's globals, no cookies,
7
+ * no localStorage. The plugin module evaluates inside the iframe and
8
+ * communicates back via `postMessage`.
9
+ *
10
+ * The host side of the bridge (this file) exposes the same surface as
11
+ * `createPluginHost(manifest)` — fetch, clipboard, notifications —
12
+ * gated by the same manifest capabilities. The difference: in Phase F
13
+ * the contract was advisory (a plugin could grab `globalThis.fetch`);
14
+ * here the iframe doesn't have access to those globals at all, so the
15
+ * only path is through `postMessage`.
16
+ *
17
+ * Wire protocol (versioned via `v: 1` envelope):
18
+ *
19
+ * host → frame:
20
+ * { v:1, kind:'init', pluginId, capabilities, artifactUrl }
21
+ * { v:1, kind:'fetch.response', id, ok, status, body }
22
+ * { v:1, kind:'clipboard.response', id, value? }
23
+ * { v:1, kind:'notification.response', id }
24
+ * { v:1, kind:'error', id, message }
25
+ *
26
+ * frame → host:
27
+ * { v:1, kind:'ready' }
28
+ * { v:1, kind:'fetch.request', id, url, init? }
29
+ * { v:1, kind:'clipboard.read.request', id }
30
+ * { v:1, kind:'clipboard.write.request', id, text }
31
+ * { v:1, kind:'notification.request', id, title, options? }
32
+ * { v:1, kind:'plugin.export', value } // the plugin's default export
33
+ * { v:1, kind:'plugin.error', message }
34
+ *
35
+ * Phase F.2 ships the host side only. The frame-side runtime that
36
+ * imports plugin bundles + speaks this protocol is `sandbox-runtime.ts`
37
+ * (sibling file).
38
+ */
39
+
40
+ import {
41
+ CapabilityDenied,
42
+ matchesNetworkCapability,
43
+ type GuardedClipboard,
44
+ type GuardedFetch,
45
+ type GuardedNotifications,
46
+ type PluginHostOptions,
47
+ } from "./host.ts";
48
+ import type { PluginCapability, PluginManifest } from "./manifest.ts";
49
+
50
+ /** Public result of `loadSandboxedPlugin` — what the host actually keeps. */
51
+ export interface SandboxedPlugin {
52
+ /** Stable id from the manifest. */
53
+ pluginId: string;
54
+ /** Capabilities granted to this sandbox. */
55
+ capabilities: ReadonlySet<PluginCapability>;
56
+ /** The plugin's default export, copied across the postMessage boundary. */
57
+ exported: unknown;
58
+ /** Tear down: removes the iframe + closes the bridge. */
59
+ dispose(): void;
60
+ }
61
+
62
+ export interface SandboxOptions extends PluginHostOptions {
63
+ /**
64
+ * Override the document the iframe attaches to. Tests inject a jsdom
65
+ * document; production hosts use the default `document` global.
66
+ */
67
+ doc?: Document;
68
+ /**
69
+ * Hard timeout (ms) on the plugin's `ready` handshake. Defaults to
70
+ * 10 s. Plugins that don't post `ready` within the window get the
71
+ * sandbox torn down + a load failure.
72
+ */
73
+ readyTimeoutMs?: number;
74
+ }
75
+
76
+ interface InboundEnvelope {
77
+ v: 1;
78
+ kind: string;
79
+ id?: string;
80
+ [k: string]: unknown;
81
+ }
82
+
83
+ /**
84
+ * Spin up a sandboxed iframe, load the plugin bundle inside it, wait
85
+ * for the plugin to post `ready`, then return a handle. The host bridges
86
+ * capability-gated APIs to the iframe via postMessage.
87
+ */
88
+ export async function loadSandboxedPlugin(
89
+ manifest: Pick<PluginManifest, "id" | "capabilities">,
90
+ artifactUrl: string,
91
+ options: SandboxOptions = {},
92
+ ): Promise<SandboxedPlugin> {
93
+ const doc = options.doc ?? (typeof document !== "undefined" ? document : null);
94
+ if (!doc) {
95
+ throw new Error("loadSandboxedPlugin requires a Document — call from a browser context");
96
+ }
97
+
98
+ const granted = new Set<PluginCapability>([
99
+ ...(manifest.capabilities.required ?? []),
100
+ ...(manifest.capabilities.optional ?? []),
101
+ ]);
102
+ const pluginId = manifest.id;
103
+
104
+ const iframe = doc.createElement("iframe");
105
+ // `allow-scripts` lets the plugin run; we deliberately omit
106
+ // `allow-same-origin` so the iframe has a null origin — it can't
107
+ // reach our cookies, localStorage, IndexedDB, or service worker.
108
+ iframe.setAttribute("sandbox", "allow-scripts");
109
+ iframe.setAttribute("aria-hidden", "true");
110
+ iframe.style.display = "none";
111
+ iframe.style.width = "0";
112
+ iframe.style.height = "0";
113
+ iframe.style.border = "0";
114
+
115
+ // The runtime that the plugin runs inside the frame. Imports the
116
+ // plugin bundle by URL, listens for messages, and proxies the
117
+ // gated APIs through postMessage.
118
+ const frameSrc = buildFrameSource(artifactUrl);
119
+ const blob = new Blob([frameSrc], { type: "text/html" });
120
+ iframe.src = URL.createObjectURL(blob);
121
+
122
+ doc.body.appendChild(iframe);
123
+
124
+ const pendingFetch = new Map<string, (resp: Response) => void>();
125
+ const pendingFetchErr = new Map<string, (err: Error) => void>();
126
+ const pendingClip = new Map<string, (v: unknown) => void>();
127
+ const pendingClipErr = new Map<string, (err: Error) => void>();
128
+ const pendingNotif = new Map<string, () => void>();
129
+ const pendingNotifErr = new Map<string, (err: Error) => void>();
130
+
131
+ let resolveReady!: (value: unknown) => void;
132
+ let rejectReady!: (err: Error) => void;
133
+ const ready = new Promise<unknown>((res, rej) => {
134
+ resolveReady = res;
135
+ rejectReady = rej;
136
+ });
137
+
138
+ const post = (envelope: Record<string, unknown>) => {
139
+ iframe.contentWindow?.postMessage({ v: 1, ...envelope }, "*");
140
+ };
141
+
142
+ const messageHandler = async (e: MessageEvent) => {
143
+ if (e.source !== iframe.contentWindow) return;
144
+ const msg = e.data as InboundEnvelope | null;
145
+ if (!msg || msg.v !== 1) return;
146
+
147
+ switch (msg.kind) {
148
+ case "ready": {
149
+ post({ kind: "init", pluginId, capabilities: [...granted], artifactUrl });
150
+ break;
151
+ }
152
+
153
+ case "plugin.export": {
154
+ resolveReady(msg.value);
155
+ break;
156
+ }
157
+
158
+ case "plugin.error": {
159
+ rejectReady(new Error(String(msg.message ?? "plugin failed to load")));
160
+ break;
161
+ }
162
+
163
+ case "fetch.request": {
164
+ const id = String(msg.id);
165
+ const url = String(msg.url);
166
+ const init = msg.init as RequestInit | undefined;
167
+ try {
168
+ const host = extractHost(url);
169
+ if (!host || !matchesNetworkCapability(granted, host)) {
170
+ throw new CapabilityDenied(
171
+ pluginId,
172
+ host ? `network:${host}` : "network",
173
+ "declare in capabilities.required or capabilities.optional",
174
+ );
175
+ }
176
+ const real = options.fetch ?? globalThis.fetch;
177
+ const resp = await real(url, init);
178
+ const body = await resp.text();
179
+ post({
180
+ kind: "fetch.response",
181
+ id,
182
+ ok: resp.ok,
183
+ status: resp.status,
184
+ body,
185
+ });
186
+ }
187
+ catch (err) {
188
+ post({ kind: "error", id, message: (err as Error).message });
189
+ }
190
+ break;
191
+ }
192
+
193
+ case "clipboard.read.request": {
194
+ const id = String(msg.id);
195
+ try {
196
+ if (!granted.has("clipboard:read")) {
197
+ throw new CapabilityDenied(pluginId, "clipboard:read");
198
+ }
199
+ const value = options.clipboard?.readText
200
+ ? await options.clipboard.readText()
201
+ : typeof navigator !== "undefined" && navigator.clipboard?.readText
202
+ ? await navigator.clipboard.readText()
203
+ : "";
204
+ post({ kind: "clipboard.response", id, value });
205
+ }
206
+ catch (err) {
207
+ post({ kind: "error", id, message: (err as Error).message });
208
+ }
209
+ break;
210
+ }
211
+
212
+ case "clipboard.write.request": {
213
+ const id = String(msg.id);
214
+ try {
215
+ if (!granted.has("clipboard:write")) {
216
+ throw new CapabilityDenied(pluginId, "clipboard:write");
217
+ }
218
+ const text = String(msg.text ?? "");
219
+ if (options.clipboard?.writeText) await options.clipboard.writeText(text);
220
+ else if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
221
+ await navigator.clipboard.writeText(text);
222
+ }
223
+ post({ kind: "clipboard.response", id });
224
+ }
225
+ catch (err) {
226
+ post({ kind: "error", id, message: (err as Error).message });
227
+ }
228
+ break;
229
+ }
230
+
231
+ case "notification.request": {
232
+ const id = String(msg.id);
233
+ try {
234
+ if (!granted.has("notifications:show")) {
235
+ throw new CapabilityDenied(pluginId, "notifications:show");
236
+ }
237
+ const title = String(msg.title ?? "");
238
+ const opts = msg.options as NotificationOptions | undefined;
239
+ if (options.showNotification) await options.showNotification(title, opts);
240
+ else if (typeof Notification !== "undefined") new Notification(title, opts);
241
+ post({ kind: "notification.response", id });
242
+ }
243
+ catch (err) {
244
+ post({ kind: "error", id, message: (err as Error).message });
245
+ }
246
+ break;
247
+ }
248
+ }
249
+ };
250
+
251
+ (typeof window !== "undefined" ? window : (doc.defaultView as Window))
252
+ ?.addEventListener("message", messageHandler);
253
+
254
+ const timeoutMs = options.readyTimeoutMs ?? 10_000;
255
+ const timeoutHandle = setTimeout(
256
+ () => rejectReady(new Error(`plugin '${pluginId}' did not become ready within ${timeoutMs}ms`)),
257
+ timeoutMs,
258
+ );
259
+
260
+ let exported: unknown;
261
+ try {
262
+ exported = await ready;
263
+ }
264
+ catch (err) {
265
+ // Tear down on failure — never leak the iframe.
266
+ (typeof window !== "undefined" ? window : (doc.defaultView as Window))
267
+ ?.removeEventListener("message", messageHandler);
268
+ iframe.remove();
269
+ URL.revokeObjectURL(iframe.src);
270
+ clearTimeout(timeoutHandle);
271
+ throw err;
272
+ }
273
+ clearTimeout(timeoutHandle);
274
+
275
+ // Drain any in-flight requests so they don't hang refs to disposed iframe.
276
+ const dispose = () => {
277
+ for (const reject of pendingFetchErr.values()) reject(new Error("sandbox disposed"));
278
+ for (const reject of pendingClipErr.values()) reject(new Error("sandbox disposed"));
279
+ for (const reject of pendingNotifErr.values()) reject(new Error("sandbox disposed"));
280
+ pendingFetch.clear();
281
+ pendingFetchErr.clear();
282
+ pendingClip.clear();
283
+ pendingClipErr.clear();
284
+ pendingNotif.clear();
285
+ pendingNotifErr.clear();
286
+ (typeof window !== "undefined" ? window : (doc.defaultView as Window))
287
+ ?.removeEventListener("message", messageHandler);
288
+ iframe.remove();
289
+ URL.revokeObjectURL(iframe.src);
290
+ };
291
+
292
+ return {
293
+ pluginId,
294
+ capabilities: granted,
295
+ exported,
296
+ dispose,
297
+ };
298
+ }
299
+
300
+ // ── Helpers (shared with host.ts but inlined here to avoid export churn) ─────
301
+
302
+ function extractHost(rawUrl: string): string | null {
303
+ try {
304
+ return new URL(rawUrl, "http://placeholder").host;
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ // ── Frame runtime ────────────────────────────────────────────────────────────
312
+
313
+ /**
314
+ * HTML+JS that runs inside the iframe. Imports the plugin bundle by URL
315
+ * (in the iframe's null origin — no cookies, no localStorage), proxies
316
+ * fetch/clipboard/notifications back to the host via postMessage, and
317
+ * posts the plugin's default export once it loads.
318
+ *
319
+ * Kept inline so the package has zero runtime files — every consumer
320
+ * picks up the latest version on a single import.
321
+ */
322
+ function buildFrameSource(artifactUrl: string): string {
323
+ // The frame's globals: a guarded `pluginHost` mirror, ready for
324
+ // plugins to import. The plugin code reads `globalThis.pluginHost`.
325
+ return `<!doctype html>
326
+ <html><head><meta charset="utf-8"></head><body>
327
+ <script type="module">
328
+ const pending = new Map();
329
+ let nextId = 0;
330
+ function rpc(kind, payload) {
331
+ return new Promise((resolve, reject) => {
332
+ const id = String(++nextId);
333
+ pending.set(id, { resolve, reject });
334
+ parent.postMessage({ v: 1, kind, id, ...payload }, '*');
335
+ });
336
+ }
337
+ window.addEventListener('message', (e) => {
338
+ const msg = e.data;
339
+ if (!msg || msg.v !== 1) return;
340
+ if (msg.kind === 'init') {
341
+ globalThis.__abraPluginContext = {
342
+ pluginId: msg.pluginId,
343
+ capabilities: new Set(msg.capabilities),
344
+ };
345
+ return;
346
+ }
347
+ if (msg.id && pending.has(msg.id)) {
348
+ const { resolve, reject } = pending.get(msg.id);
349
+ pending.delete(msg.id);
350
+ if (msg.kind === 'error') reject(new Error(msg.message));
351
+ else resolve(msg);
352
+ }
353
+ });
354
+ globalThis.pluginHost = {
355
+ async fetch(url, init) {
356
+ const r = await rpc('fetch.request', { url: String(url), init });
357
+ return new Response(r.body, { status: r.status });
358
+ },
359
+ clipboard: {
360
+ readText() { return rpc('clipboard.read.request', {}).then(r => r.value); },
361
+ writeText(text) { return rpc('clipboard.write.request', { text }).then(() => undefined); },
362
+ },
363
+ notifications: {
364
+ show(title, options) { return rpc('notification.request', { title, options }).then(() => undefined); },
365
+ },
366
+ };
367
+ parent.postMessage({ v: 1, kind: 'ready' }, '*');
368
+ try {
369
+ const mod = await import(${JSON.stringify(artifactUrl)});
370
+ const value = mod && (mod.default ?? mod);
371
+ // Strip functions before posting — they don't survive postMessage.
372
+ // We send the plain serialisable bits; functions stay in the iframe
373
+ // and the host calls them via separate RPC channels (future work).
374
+ const serialisable = JSON.parse(JSON.stringify(value, (_, v) =>
375
+ typeof v === 'function' ? '[Function]' : v));
376
+ parent.postMessage({ v: 1, kind: 'plugin.export', value: serialisable }, '*');
377
+ } catch (e) {
378
+ parent.postMessage({ v: 1, kind: 'plugin.error', message: String((e && e.message) || e) }, '*');
379
+ }
380
+ </script></body></html>`;
381
+ }