From 2591b44abc21a19b5a20e884cf6ea8f5cfcdc334 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 10 Aug 2026 03:56:00 -0400 Subject: [PATCH] Inject sdk at runtime --- eleventy.config.js | 1 + package.json | 2 +- src/_data/pluginSdk.js | 21 ++++ src/_headers | 3 + src/_includes/head.html | 7 ++ src/_routes.json | 2 +- src/js/plugins/pluginBridge.js | 61 ++++++--- src/plugin-sdk/pluginSdk.11ty.js | 11 ++ tests/unit/specs/plugins/pluginBridge.test.js | 119 +++++++++++++++++- 9 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 src/_data/pluginSdk.js create mode 100644 src/plugin-sdk/pluginSdk.11ty.js diff --git a/eleventy.config.js b/eleventy.config.js index 9ad397cf..20991976 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -34,6 +34,7 @@ export default async function (eleventyConfig) { eleventyConfig.addPassthroughCopy("src/_headers"); eleventyConfig.addPassthroughCopy("src/_routes.json"); eleventyConfig.addPassthroughCopy("src/plugin-sandbox.html"); + eleventyConfig.addWatchTarget("impro-plugin/main.js"); // Prevent sandbox from being treated as a template eleventyConfig.ignores.add("src/plugin-sandbox.html"); diff --git a/package.json b/package.json index dba64a6e..8df27a51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.152", + "version": "0.18.153", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/_data/pluginSdk.js b/src/_data/pluginSdk.js new file mode 100644 index 00000000..7124208f --- /dev/null +++ b/src/_data/pluginSdk.js @@ -0,0 +1,21 @@ +import * as esbuild from "esbuild"; +import crypto from "node:crypto"; + +// SDK is built here so we can pass its hashed filename to the HTML +export default async function () { + const built = await esbuild.build({ + entryPoints: ["impro-plugin/main.js"], + bundle: true, + format: "iife", + globalName: "ImproPlugin", + write: false, + }); + const code = built.outputFiles[0].text; + const hash = crypto + .createHash("sha256") + .update(code) + .digest("hex") + .slice(0, 10); + + return { code, fileName: `pluginSdk.${hash}.js` }; +} diff --git a/src/_headers b/src/_headers index 599ef96d..9f624c67 100644 --- a/src/_headers +++ b/src/_headers @@ -16,3 +16,6 @@ /css/* Cache-Control: public, max-age=31536000, immutable + +/plugin-sdk/* + Cache-Control: public, max-age=31536000, immutable diff --git a/src/_includes/head.html b/src/_includes/head.html index bfcd6828..93790435 100644 --- a/src/_includes/head.html +++ b/src/_includes/head.html @@ -9,6 +9,12 @@ Impro + @@ -33,6 +39,7 @@ gitCommit: "{{ gitCommit }}", environment: "{{ environment }}", version: "{{ version }}", + pluginSdkFileName: "{{ pluginSdk.fileName }}", oauthScopes: "{{ oauthScopes }}", oauthOptionalScopes: "{{ oauthOptionalScopes }}", playwright: "{{ playwright }}" === "true", diff --git a/src/_routes.json b/src/_routes.json index 5a320b87..96b50522 100644 --- a/src/_routes.json +++ b/src/_routes.json @@ -1,5 +1,5 @@ { "version": 1, - "include": ["/js/*", "/css/*", "/img/*", "/oauth/*"], + "include": ["/js/*", "/css/*", "/img/*", "/oauth/*", "/plugin-sdk/*"], "exclude": [] } diff --git a/src/js/plugins/pluginBridge.js b/src/js/plugins/pluginBridge.js index 2b8b060d..1a810668 100644 --- a/src/js/plugins/pluginBridge.js +++ b/src/js/plugins/pluginBridge.js @@ -29,16 +29,15 @@ const logger = new Logger("[plugins]", isDev() ? "info" : "warn"); // Has same API as Worker, but runs code in a sandboxed iframe export class SandboxedWorker extends EventTarget { - constructor(source) { + constructor(wrappedSource) { super(); - this.source = source; this.frame = this._createSandboxFrame(); this._messageTarget = this.frame.contentWindow; this._handleWindowMessage = this._handleWindowMessage.bind(this); window.addEventListener("message", this._handleWindowMessage); this.frame.addEventListener("load", () => { this.frame.contentWindow.postMessage( - { type: "init", workerSource: wrapWorkerSource(source) }, + { type: "init", workerSource: wrappedSource }, "*", ); }); @@ -79,34 +78,63 @@ export class SandboxedWorker extends EventTarget { } } -export function wrapWorkerSource(source) { +export class PluginSdkError extends Error {} + +let __sdkSourcePromise = null; + +function getSdkSource() { + if (__sdkSourcePromise == null) { + __sdkSourcePromise = fetch(`/plugin-sdk/${window.env.pluginSdkFileName}`) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }) + .catch((error) => { + __sdkSourcePromise = null; + throw new PluginSdkError( + `Could not fetch plugin SDK: ${error.message}`, + ); + }); + } + return __sdkSourcePromise; +} + +export async function wrapWorkerSource(source) { + const sdkSource = await getSdkSource(); return /* js */ ` delete self.BroadcastChannel; delete self.SharedWorker; - self.module = {} + ${sdkSource} + + self.module = {}; + + self.require = (name) => { + if (name === "@impro.social/impro-plugin") return ImproPlugin; + throw new Error("Cannot find module \\"" + name + "\\""); + }; ${source} - const pluginClass = self.module.exports?.default + const pluginClass = self.module.exports?.default; if (pluginClass) { - pluginClass.register() + pluginClass.register(); } `; } -async function createSandboxedWorker(source) { - const worker = new SandboxedWorker(source); +function createSandboxedWorker(wrappedSource) { + const worker = new SandboxedWorker(wrappedSource); // in the future, we could add a handshake here to ensure worker has loaded return worker; } // Direct (unsandboxed) Worker for e2e tests -async function createDirectWorker(source) { - const blob = new Blob([wrapWorkerSource(source)], { +function createDirectWorker(wrappedSource) { + const blob = new Blob([wrappedSource], { type: "text/javascript", }); - return new Worker(URL.createObjectURL(blob), { type: "module" }); + return new Worker(URL.createObjectURL(blob)); } export class PluginInstance { @@ -158,9 +186,10 @@ export class PluginInstance { } static async loadFromSource(pluginId, manifest, source, callbacks) { + const wrappedSource = await wrapWorkerSource(source); const worker = !window.env.playwright // don't sandbox in e2e tests - ? await createSandboxedWorker(source) - : await createDirectWorker(source); + ? createSandboxedWorker(wrappedSource) + : createDirectWorker(wrappedSource); const instance = new PluginInstance(pluginId, manifest, worker, callbacks); try { return await instance.waitForReady(2000); @@ -377,6 +406,10 @@ export class PluginBridge { return pluginInstance; } catch (error) { this._pluginStylesLoader.unmount(pluginId); + if (error instanceof PluginSdkError) { + logger.error(`could not load "${pluginId}": plugin SDK unavailable`); + throw new Error("Plugin system failed to load"); + } logger.error(`"${pluginId}" failed during initialization:`, error); throw new Error("Plugin failed during initialization"); } diff --git a/src/plugin-sdk/pluginSdk.11ty.js b/src/plugin-sdk/pluginSdk.11ty.js new file mode 100644 index 00000000..c642fada --- /dev/null +++ b/src/plugin-sdk/pluginSdk.11ty.js @@ -0,0 +1,11 @@ +export default { + data() { + return { + permalink: (data) => `/plugin-sdk/${data.pluginSdk.fileName}`, + }; + }, + + render(data) { + return data.pluginSdk.code; + }, +}; diff --git a/tests/unit/specs/plugins/pluginBridge.test.js b/tests/unit/specs/plugins/pluginBridge.test.js index 0dca1f50..ed7f177b 100644 --- a/tests/unit/specs/plugins/pluginBridge.test.js +++ b/tests/unit/specs/plugins/pluginBridge.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; // PluginBridge reads window.env.playwright during loadFromSource; provide it @@ -10,9 +10,25 @@ const { PluginInstance, SandboxedWorker, Logger, + PluginSdkError, wrapWorkerSource, } = await import("/js/plugins/pluginBridge.js"); +const SDK_SOURCE = "var ImproPlugin = { Plugin: class {} };"; + +// wrapWorkerSource fetches the built SDK bundle. Registered inside a describe +// so the hooks stay scoped to it (spec files share one process). +function useStubbedSdkFetch() { + let originalFetch; + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, text: async () => SDK_SOURCE }); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); +} + class FakeWorker { constructor() { this.listeners = {}; @@ -150,12 +166,79 @@ async function expectError(promise) { } describe("PluginBridge:wrapWorkerSource", () => { - it("prepends a prelude that removes BroadcastChannel/SharedWorker", () => { - const wrapped = wrapWorkerSource("console.info('hi')"); + useStubbedSdkFetch(); + + it("prepends a prelude that removes BroadcastChannel/SharedWorker", async () => { + const wrapped = await wrapWorkerSource("console.info('hi')"); assert(wrapped.includes("delete self.BroadcastChannel")); assert(wrapped.includes("delete self.SharedWorker")); assert(wrapped.includes("console.info('hi')")); }); + + it("inlines the SDK bundle", async () => { + const wrapped = await wrapWorkerSource("console.info('hi')"); + assert(wrapped.includes(SDK_SOURCE)); + }); + + // Runs the wrapped source against a stand-in `self` so the require shim is + // exercised for real: a mismatch between the bundle's global name and the + // identifier the shim returns is a ReferenceError here rather than a + // runtime failure in the worker. + it("resolves the SDK package through the require shim", async () => { + const wrapped = await wrapWorkerSource(""); + const workerSelf = {}; + new Function("self", wrapped)(workerSelf); + const sdk = workerSelf.require("@impro.social/impro-plugin"); + assert(typeof sdk.Plugin === "function"); + }); + + it("throws from require for any other module name", async () => { + const wrapped = await wrapWorkerSource(""); + const workerSelf = {}; + new Function("self", wrapped)(workerSelf); + assert.throws(() => workerSelf.require("node:fs"), { + message: 'Cannot find module "node:fs"', + }); + }); +}); + +describe("PluginBridge:SDK fetch failures", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + // A fresh module instance per test, since the SDK source is memoized in a + // module global that other tests in this process may already have primed. + // The error class comes from that same instance, so instanceof still holds. + async function freshBridgeModule(name) { + return import(`/js/plugins/pluginBridge.js?${name}`); + } + + it("rejects with a PluginSdkError rather than a generic error", async () => { + globalThis.fetch = async () => ({ ok: false, status: 503 }); + const fresh = await freshBridgeModule("sdk-unavailable"); + const error = await expectError(fresh.wrapWorkerSource("user();")); + assert(error instanceof fresh.PluginSdkError); + assert(error.message.includes("503")); + }); + + it("does not memoize the failure, so a later load can succeed", async () => { + globalThis.fetch = async () => { + throw new TypeError("network down"); + }; + const fresh = await freshBridgeModule("sdk-retry"); + await expectError(fresh.wrapWorkerSource("user();")); + + globalThis.fetch = async () => ({ ok: true, text: async () => SDK_SOURCE }); + const wrapped = await fresh.wrapWorkerSource("user();"); + assert(wrapped.includes(SDK_SOURCE)); + }); }); describe("PluginBridge:isLoaded / getInstance", () => { @@ -659,6 +742,30 @@ describe("PluginBridge:loadPlugin success path", () => { assert.deepEqual(bridge.isLoaded("p1"), false); }); + it("blames the host, not the plugin, when the SDK can't be loaded", async () => { + const provider = makeProvider({ source: "// js", styles: ".x {}" }); + const stylesLoader = makeStylesLoader(); + const loadPluginInstance = async () => { + throw new PluginSdkError("Could not fetch plugin SDK: HTTP 503"); + }; + const { bridge } = makeBridge({ + provider, + stylesLoader, + loadPluginInstance, + }); + const originalError = console.error; + console.error = () => {}; + let error; + try { + error = await expectError(bridge.loadPlugin("p1", "1.0.0")); + } finally { + console.error = originalError; + } + assert.deepEqual(error.message, "Plugin system failed to load"); + assert.deepEqual(stylesLoader.unmounts, ["p1"]); + assert.deepEqual(bridge.isLoaded("p1"), false); + }); + it("routes onRegister and onHostCall callbacks back through the bridge", async () => { const provider = makeProvider({ source: "// js" }); let capturedCallbacks; @@ -985,8 +1092,10 @@ describe("internals:Logger", () => { }); describe("internals:wrapWorkerSource ordering", () => { - it("places the prelude before the user source so it runs first", () => { - const wrapped = wrapWorkerSource("user();"); + useStubbedSdkFetch(); + + it("places the prelude before the user source so it runs first", async () => { + const wrapped = await wrapWorkerSource("user();"); const preludeIndex = wrapped.indexOf("delete self.BroadcastChannel"); const sourceIndex = wrapped.indexOf("user();"); assert(preludeIndex >= 0 && sourceIndex >= 0); -- knotmirror 0.0.1