From b96e516ee06444206e9a798072ff2b97c2176642 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Tue, 13 Oct 2026 17:05:00 -0700 Subject: [PATCH] Publish runtime plugins as TypeScript source Deployments upload plugins/.ts instead of a prebuilt bundle. A node compiles each version on first use with the existing worker bundler and compile queue, asks the plugin to describe itself in an isolate with no secrets, and validates operation input and output with an interpreted JSON Schema draft 7 validator, since Workers cannot compile Ajv validators at runtime. Removes scripts/bundle-plugin.ts and the record format. --- bun.lock | 1 + cloudflare/compiled-artifact.test.ts | 1 + cloudflare/compiler.ts | 59 ++++++++--- cloudflare/native-gallery.test.ts | 2 +- cloudflare/plugins.ts | 6 +- cloudflare/runtime-plugins-celld.test.ts | 9 +- cloudflare/runtime-plugins.test.ts | 74 ++++++------- cloudflare/runtime-plugins.ts | 126 ++++++++++++++++------- docs/runtime-plugins.md | 41 ++++---- e2e/runtime-plugin-fixture.ts | 8 +- package.json | 1 + scripts/bundle-plugin.ts | 75 -------------- scripts/prepare-cloudflare.ts | 5 + src/plugins/config.ts | 2 +- src/plugins/runtime-entry.ts | 38 +++++-- src/plugins/runtime.ts | 50 +++++---- 16 files changed, 265 insertions(+), 233 deletions(-) delete mode 100644 scripts/bundle-plugin.ts diff --git a/bun.lock b/bun.lock index 0308add..766b518 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "@aws-sdk/client-s3": "3.1150.0", "@base-ui/react": "^1.8.0", "@better-auth/oauth-provider": "1.7.3", + "@cfworker/json-schema": "4.1.1", "@codemirror/commands": "^6.11.0", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", diff --git a/cloudflare/compiled-artifact.test.ts b/cloudflare/compiled-artifact.test.ts index 0db938e..348da10 100644 --- a/cloudflare/compiled-artifact.test.ts +++ b/cloudflare/compiled-artifact.test.ts @@ -19,6 +19,7 @@ async function start(directory: string, identity: string) { export async function compileArtifactSource(source, project) { return compile(source, "client", project); } export async function compileArtifactServerSource(source, project) { return compile(source, "server", project); } export async function compileScriptSource() { throw new Error("Unexpected script compile"); } + export async function compilePluginSource() { throw new Error("Unexpected plugin compile"); } export function typecheckArtifactSource() { return []; } export function typecheckArtifactServerSource() { return []; } ` })); diff --git a/cloudflare/compiler.ts b/cloudflare/compiler.ts index 49e7881..3b3f134 100644 --- a/cloudflare/compiler.ts +++ b/cloudflare/compiler.ts @@ -4,6 +4,7 @@ import ts from "../dist/cloudflare/typescript.js"; import files from "../dist/cloudflare/compiler-files.json"; import browserPlugins from "../dist/cloudflare/plugin-browser.json"; import browserRuntime from "../dist/cloudflare/browser-runtime.json"; +import pluginRuntime from "../dist/cloudflare/plugin-runtime.json"; import { sandboxToDiagnostics, type Diagnostic } from "../src/diagnostics"; import { scanArtifactSource } from "../src/sandbox"; import type { AppManifest, ResourceLedger } from "./native-worker/manifest"; @@ -183,17 +184,24 @@ export async function compileArtifactServerSource(source: string, project = empt } async function queueCompile(source: string, server: boolean, script = false, project = emptyProject(), native?: { manifest: AppManifest; resources: ResourceLedger }) { + const release = await compileSlot(); + if (!release) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] }; + try { return await (native ? compileNative(source, project, native.manifest, native.resources) : script ? compileScript(source, project) : server ? compileServer(source, project) : compileSource(source, project)); } + finally { release(); } +} + +/** Waits for this build's turn and returns its release, or null when busy. */ +async function compileSlot(): Promise<(() => void) | null> { // esbuild WASM and semantic checking share the isolate's 128 MiB budget. // Serialize builds and bound retained request sources under load. - if (pendingCompilations >= 8) return { ok: false, diagnostics: [{ severity: "error" as const, message: "Compiler is busy; retry shortly", file: server ? serverPath : sourcePath }] }; + if (pendingCompilations >= 8) return null; pendingCompilations++; const ticket = nextCompilation++; // celld 0.4.1 attributes native timers to the request driving a continuation. // Wait on this request's own timer instead of another request's promise, so // disconnecting a preview cannot cancel the next build's esbuild operations. while (ticket !== activeCompilation) await new Promise(resolve => setTimeout(resolve, 5)); - try { return await (native ? compileNative(source, project, native.manifest, native.resources) : script ? compileScript(source, project) : server ? compileServer(source, project) : compileSource(source, project)); } - finally { activeCompilation++; pendingCompilations--; } + return () => { activeCompilation++; pendingCompilations--; }; } export function compileNativeWorker(source: string, manifest: AppManifest, resources: ResourceLedger, project = emptyProject()) { @@ -330,16 +338,41 @@ async function compileScript(source: string, project: ArtifactProject) { if (result.warnings?.length) throw new Error(result.warnings.join("\n")); const js = result.modules[result.mainModule]; if (typeof js !== "string") throw new Error("Script compiler did not return JavaScript"); - const tree = ts.createSourceFile("script.js", js, ts.ScriptTarget.ES2022, false, ts.ScriptKind.JS); - const checkImports = (node: ts.Node) => { - const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier - : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined; - if (specifier && (!ts.isStringLiteral(specifier) || !/^(node:|cloudflare:)/.test(specifier.text))) { - throw new Error("Script has an unresolved external import; use Workers runtime builtins or self-contained source"); - } - ts.forEachChild(node, checkImports); - }; - checkImports(tree); + assertBuiltinImports(js, "Script has an unresolved external import; use Workers runtime builtins or self-contained source"); return {ok: true, js, diagnostics: [] as Diagnostic[]}; } catch (error) { return {ok: false, diagnostics: [{severity: "error" as const, file: "script.ts", message: error instanceof Error ? error.message : String(error)}]}; } } + +function assertBuiltinImports(js: string, message: string) { + const tree = ts.createSourceFile("module.js", js, ts.ScriptTarget.ES2022, false, ts.ScriptKind.JS); + const checkImports = (node: ts.Node) => { + const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier + : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined; + if (specifier && (!ts.isStringLiteral(specifier) || !/^(node:|cloudflare:)/.test(specifier.text))) throw new Error(message); + ts.forEachChild(node, checkImports); + }; + checkImports(tree); +} + +const PLUGIN_ENTRY = 'import plugin from "./plugin.ts";\nimport { runtimePluginHandler } from "artifacts:plugin-runtime";\nexport default runtimePluginHandler(plugin);\n'; +const PLUGIN_HELPER = "export function definePlugin(plugin) { return plugin; }\nexport function definePlugins(plugins) { return plugins; }\n"; + +/** Compiles a published plugin's single-file source with its isolate wrapper. + * It may import the typed `@sidequery/artifacts/plugins` helper and Workers + * builtins only. Shares the compile queue and isolate budget with artifacts. */ +export async function compilePluginSource(source: string): Promise<{ ok: true; js: string } | { ok: false; error: string }> { + const release = await compileSlot(); + if (!release) return { ok: false, error: "Compiler is busy; retry shortly" }; + try { + const result = await createWorker({ + files: { "plugin.ts": source, "entry.ts": PLUGIN_ENTRY }, entryPoint: "entry.ts", target: "es2022", sourcemap: false, + virtualModules: { "@sidequery/artifacts/plugins": PLUGIN_HELPER, "artifacts:plugin-runtime": pluginRuntime.js }, + }); + if (result.warnings?.length) throw new Error(result.warnings.join("\n")); + const js = result.modules[result.mainModule]; + if (typeof js !== "string") throw new Error("Plugin compiler did not return JavaScript"); + assertBuiltinImports(js, "A published plugin is one self-contained file; it may import @sidequery/artifacts/plugins and Workers builtins only"); + return { ok: true, js }; + } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } + finally { release(); } +} diff --git a/cloudflare/native-gallery.test.ts b/cloudflare/native-gallery.test.ts index a3d70c1..6ecfebb 100644 --- a/cloudflare/native-gallery.test.ts +++ b/cloudflare/native-gallery.test.ts @@ -11,7 +11,7 @@ test("gallery lists Worker drafts across workspaces with owner isolation, pagina build.onLoad({ filter: /\/cloudflare\/compiler\.ts$/ }, () => ({ loader: "ts", contents: ` export function compileNativeWorker() { return { ok: false, diagnostics: [{ severity: "error", message: "Fixture draft" }] }; } function unexpected() { throw new Error("Gallery reads must not compile or execute projects"); } - export { unexpected as compileArtifactSource, unexpected as compileArtifactServerSource, unexpected as compileScriptSource, + export { unexpected as compileArtifactSource, unexpected as compileArtifactServerSource, unexpected as compileScriptSource, unexpected as compilePluginSource, unexpected as typecheckArtifactSource, unexpected as typecheckArtifactServerSource }; ` })); } }], diff --git a/cloudflare/plugins.ts b/cloudflare/plugins.ts index 9ec3023..2156298 100644 --- a/cloudflare/plugins.ts +++ b/cloudflare/plugins.ts @@ -16,9 +16,9 @@ const dispatchInstalled = createPluginDispatcher(installed, { input: validatePlu export async function dispatchPlugin(request: PluginRequest, context?: PluginInvocationContext): Promise { assertPluginRequest(request, context); if (installedNames.has(request.plugin)) return dispatchInstalled(request, context); - const record = await runtimePlugin(context.env as RuntimePluginEnvironment, request.plugin); - if (!record?.operations.some(operation => operation.name === request.operation)) throw new PluginError("Plugin operation not found", 404); - return callRuntimePlugin(record, request.operation, pluginJson(request.input), context.user, context); + const plugin = await runtimePlugin(context.env as RuntimePluginEnvironment, request.plugin); + if (!plugin?.description.operations.some(operation => operation.name === request.operation)) throw new PluginError("Plugin operation not found", 404); + return callRuntimePlugin(plugin, request.operation, pluginJson(request.input), context.user, context); } export async function pluginCatalogFor(env: object): Promise { diff --git a/cloudflare/runtime-plugins-celld.test.ts b/cloudflare/runtime-plugins-celld.test.ts index c3d224b..20d468c 100644 --- a/cloudflare/runtime-plugins-celld.test.ts +++ b/cloudflare/runtime-plugins-celld.test.ts @@ -1,11 +1,10 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { bundleRuntimePlugin } from "../scripts/bundle-plugin"; import { prepareCelldConfig } from "../scripts/prepare-celld"; import { spawnCelld, stopCelld, waitForReady, type RunningCelld } from "../e2e/celld-auth-runtime"; @@ -30,7 +29,7 @@ beforeAll(async () => { const esbuild = process.env.CELLD_ESBUILD ?? resolve(root, "node_modules/.bin/esbuild"); if (!binary) throw new Error("celld was not found; install celld or set CELLD_BIN to its executable"); if (!existsSync(join(root, "dist/worker-app/worker.js"))) throw new Error("missing dist/worker-app/worker.js; run bun run build:cloudflare"); - const record = await bundleRuntimePlugin(join(root, "e2e/runtime-plugin-fixture.ts")); + const source = await readFile(join(root, "e2e/runtime-plugin-fixture.ts"), "utf8"); project = await mkdtemp(join(tmpdir(), "artifact-celld-plugins-")); await cp(join(root, "dist/worker-app"), join(project, "dist/worker-app"), { recursive: true }); await cp(join(root, "dist/cloudflare/assets"), join(project, "dist/cloudflare/assets"), { recursive: true }); @@ -46,7 +45,7 @@ beforeAll(async () => { await writeFile(configPath, JSON.stringify({ name: config.name, main: "seed.js", compatibility_date: config.compatibility_date, r2_buckets: [plugins] })); runtime = spawnCelld(binary, esbuild, project, port); await waitForReady(runtime, origin); - const seeded = await fetch(`${origin}/?key=plugins/test-warehouse.json`, { method: "PUT", body: JSON.stringify(record) }); + const seeded = await fetch(`${origin}/?key=plugins/test-warehouse.ts`, { method: "PUT", body: source }); if (!seeded.ok) throw new Error(`Seeding failed: ${seeded.status}\n${runtime.logs()}`); await stopCelld(runtime); await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); @@ -59,7 +58,7 @@ afterAll(async () => { if (project) await rm(project, { recursive: true, force: true }); }); -celldTest("a published plugin runs in its own isolate on celld", async () => { +celldTest("a published plugin source compiles and runs in its own isolate on celld", async () => { const client = new Client({ name: "runtime-plugin-celld", version: "1" }); try { await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`))); diff --git a/cloudflare/runtime-plugins.test.ts b/cloudflare/runtime-plugins.test.ts index 3b65fa2..fa46b84 100644 --- a/cloudflare/runtime-plugins.test.ts +++ b/cloudflare/runtime-plugins.test.ts @@ -1,23 +1,23 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { cp, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { Miniflare } from "miniflare"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { bundleRuntimePlugin } from "../scripts/bundle-plugin"; -import { parseRuntimePlugin, type RuntimePluginRecord } from "../src/plugins/runtime"; -const fixture = join(import.meta.dir, "../e2e/runtime-plugin-fixture.ts"); +const fixture = await readFile(join(import.meta.dir, "../e2e/runtime-plugin-fixture.ts"), "utf8"); let runtime: Miniflare; let client: Client; let origin: string; -let record: RuntimePluginRecord; -// Matches the host's per-isolate record cache. +// Matches the host's per-isolate source cache. const CACHE_MS = 5_000; -async function publish(key: string, body: string) { - await (await runtime.getR2Bucket("PLUGINS")).put(key, body); +/** The fixture's source under another name, as a separate published plugin. */ +const variant = (name: string, replace: [string, string][] = []) => + replace.reduce((source, [from, to]) => source.replaceAll(from, to), fixture.replace('name: "test-warehouse"', `name: "${name}"`)); + +async function publish(name: string, source: string) { + await (await runtime.getR2Bucket("PLUGINS")).put(`plugins/${name}.ts`, source); } async function call(plugin: string, operation: string, input: unknown) { const result = await client.callTool({ name: "artifact_plugin_call", arguments: { plugin, operation, input } }); @@ -25,7 +25,6 @@ async function call(plugin: string, operation: string, input: unknown) { } beforeAll(async () => { - record = await bundleRuntimePlugin(fixture); const modulesRoot = join(import.meta.dir, "../dist/worker-app"); const modules: Record }> = {}; for (const name of await readdir(modulesRoot)) { @@ -55,39 +54,26 @@ beforeAll(async () => { assets: { directory: join(import.meta.dir, "../dist/cloudflare/assets"), hasUserWorker: true, runWorkerFirst: true, htmlHandling: "none" }, }, dev: {} }] }); origin = (await runtime.ready).origin; - await publish("plugins/test-warehouse.json", JSON.stringify(record)); - await publish("plugins/test-misconfigured.json", JSON.stringify({ ...record, name: "test-misconfigured", secrets: ["PLUGIN_MISSING"] })); - await publish("plugins/test-corrupt.json", "{ not json"); - await publish("plugins/test-host-secret.json", JSON.stringify({ ...record, name: "test-host-secret", secrets: ["HOST_ONLY_SECRET"] })); + await publish("test-warehouse", fixture); + await publish("test-misconfigured", variant("test-misconfigured", [["PLUGIN_WAREHOUSE_TOKEN", "PLUGIN_MISSING"]])); + // A published plugin cannot ask for the host's own variables. + await publish("test-host-secret", variant("test-host-secret", [["PLUGIN_WAREHOUSE_TOKEN", "HOST_ONLY_SECRET"]])); + await publish("test-misnamed", fixture); + await publish("test-uncompilable", "export default definePlugin({ name: "); + await publish("test-external-import", `import { z } from "zod";\nexport const check = z.string();\n${variant("test-external-import")}`); client = new Client({ name: "runtime-plugin-test", version: "1" }); await client.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test`))); }, 60000); afterAll(async () => { await client?.close(); await runtime?.dispose(); }); -test("the bundle is one self-describing record with schemas and no browser module", () => { - expect(record).toMatchObject({ format: 1, name: "test-warehouse", secrets: ["PLUGIN_WAREHOUSE_TOKEN"] }); - expect(record.operations.map(operation => [operation.name, operation.readOnly])).toEqual([["query", true], ["restricted", false], ["broken", false]]); - expect(record.module).not.toContain("warehouse-test-token"); - expect(() => parseRuntimePlugin(record, "other-name")).toThrow("does not match"); - expect(() => parseRuntimePlugin({ ...record, hash: "x" })).toThrow("hash"); -}); - -test("bundles are reproducible from any directory and carry no build-machine paths", async () => { - const elsewhere = await mkdtemp(join(tmpdir(), "plugin-source-")); - try { - await cp(fixture, join(elsewhere, "warehouse.plugin.ts")); - const copy = await bundleRuntimePlugin(join(elsewhere, "warehouse.plugin.ts")); - expect(copy.hash).toBe(record.hash); - expect(copy.module).toBe(record.module); - } finally { await rm(elsewhere, { recursive: true, force: true }); } - for (const leaked of [tmpdir(), "artifacts-plugin-", import.meta.dir]) expect(record.module).not.toContain(leaked); -}); - -test("published plugins are listed beside build-time ones; corrupt records are left out", async () => { +test("published plugin sources are listed beside build-time plugins; invalid ones are left out", async () => { const result = await client.callTool({ name: "plugins_list", arguments: {} }); - const plugins = (result.structuredContent as { plugins: { name: string; browser: boolean; operations: { name: string }[] }[] }).plugins; + const plugins = (result.structuredContent as { plugins: { name: string; browser: boolean; operations: { name: string; readOnly: boolean; inputSchema: object }[] }[] }).plugins; expect(plugins.map(plugin => plugin.name).sort()).toEqual(["test-misconfigured", "test-warehouse"]); - expect(plugins.find(plugin => plugin.name === "test-warehouse")).toMatchObject({ browser: false, operations: [{ name: "query" }, { name: "restricted" }, { name: "broken" }] }); + const warehouse = plugins.find(plugin => plugin.name === "test-warehouse")!; + expect(warehouse.browser).toBe(false); + expect(warehouse.operations.map(operation => [operation.name, operation.readOnly])).toEqual([["query", true], ["restricted", false], ["malformed", false], ["broken", false]]); + expect(warehouse.operations[0]!.inputSchema).toMatchObject({ required: ["sql"] }); }); test("a call runs in the plugin's own isolate with the caller's identity and only its declared secrets", async () => { @@ -103,18 +89,21 @@ test("a call runs in the plugin's own isolate with the caller's identity and onl expect((await http.json() as { result: { sql: string } }).result.sql).toBe("select 2"); }); -test("schema, authorization and handler failures keep the build-time plugin semantics", async () => { +test("schema, authorization, handler and configuration failures keep the build-time plugin semantics", async () => { const cases: [string, string, unknown, string, number][] = [ ["test-warehouse", "query", {}, "Invalid plugin input", 400], + ["test-warehouse", "query", { sql: "select 1", extra: true }, "Invalid plugin input", 400], ["test-warehouse", "restricted", {}, "Plugin operation is not allowed", 403], + ["test-warehouse", "malformed", {}, "Plugin returned invalid output", 500], ["test-warehouse", "broken", {}, "Plugin operation failed", 500], ["test-warehouse", "missing", {}, "Plugin operation not found", 404], ["test-warehouse", "toString", {}, "Plugin operation not found", 404], ["not-published", "query", {}, "Plugin operation not found", 404], ["test-misconfigured", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], - ["test-corrupt", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], - // A published record cannot ask for the host's own variables. ["test-host-secret", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], + ["test-misnamed", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], + ["test-uncompilable", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], + ["test-external-import", "query", { sql: "select 1" }, "Plugin configuration unavailable", 500], ]; for (const [plugin, operation, input, error, status] of cases) { const { result, payload } = await call(plugin, operation, input); @@ -125,11 +114,8 @@ test("schema, authorization and handler failures keep the build-time plugin sema } }); -test("republishing the record replaces the running plugin without a host deploy", async () => { - const module = record.module.replace('"fixture-v1"', '"fixture-v2"'); - expect(module).not.toBe(record.module); - const hash = new Bun.CryptoHasher("sha256").update(module).digest("hex"); - await publish("plugins/test-warehouse.json", JSON.stringify({ ...record, module, hash })); +test("republishing the source replaces the running plugin without a host deploy", async () => { + await publish("test-warehouse", fixture.replace('"fixture-v1"', '"fixture-v2"')); await Bun.sleep(CACHE_MS + 500); const { payload } = await call("test-warehouse", "query", { sql: "select 1" }); expect(payload.result.version).toBe("fixture-v2"); diff --git a/cloudflare/runtime-plugins.ts b/cloudflare/runtime-plugins.ts index 78a0123..0aadb24 100644 --- a/cloudflare/runtime-plugins.ts +++ b/cloudflare/runtime-plugins.ts @@ -1,83 +1,134 @@ import { createHash } from "node:crypto"; +import { Validator } from "@cfworker/json-schema"; import { PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "../src/plugins/dispatch"; import { - parseRuntimePlugin, runtimePluginInfo, runtimePluginKey, PLUGIN_NAME, RUNTIME_PLUGIN_MAX_BYTES, RUNTIME_PLUGIN_PREFIX, - type RuntimePluginRecord, + parseRuntimePluginDescription, runtimePluginInfo, runtimePluginKey, PLUGIN_NAME, RUNTIME_PLUGIN_EXTENSION, RUNTIME_PLUGIN_MAX_BYTES, RUNTIME_PLUGIN_PREFIX, + type RuntimePluginDescription, } from "../src/plugins/runtime"; import type { PluginInfo, PluginUser } from "../src/plugins/types"; +import { compilePluginSource } from "./compiler"; import { scriptRuntimeConfig } from "./script-runtime"; /** Without a `PLUGINS` binding there are no runtime plugins; build-time plugins are unaffected. */ export type RuntimePluginEnvironment = { PLUGINS?: R2Bucket; LOADER?: WorkerLoader; ARTIFACTS_RUNTIME?: string }; +export type LoadedRuntimePlugin = { + description: RuntimePluginDescription; + /** SHA-256 of the source; part of the isolate cache key. */ + hash: string; + module: string; + validators: Map; +}; -// Short, per-isolate caches: a republished plugin is picked up within seconds -// on every node without a host redeploy, and a busy dashboard does not read -// the bucket on every call. +// Short, per-isolate source cache: a republished plugin is picked up within +// seconds on every node without a host redeploy, and a busy dashboard does not +// read the bucket on every call. Compiled plugins are cached by source hash. const CACHE_MS = 5_000; +const MAX_COMPILED = 32; const ERROR_STATUSES = new Set([400, 403, 404, 413, 500, 504]); -const records = new Map(); +const sources = new Map(); +const compiled = new Map>(); let catalog: { at: number; plugins: PluginInfo[] } | undefined; -async function readRecord(bucket: R2Bucket, key: string, name: string): Promise { +function runtimeConfig(env: RuntimePluginEnvironment) { return scriptRuntimeConfig(env.ARTIFACTS_RUNTIME); } + +async function readSource(bucket: R2Bucket, key: string) { const object = await bucket.get(key); if (!object) return null; - if (object.size > RUNTIME_PLUGIN_MAX_BYTES) throw new Error("Plugin record exceeds 16 MiB"); - return parseRuntimePlugin(JSON.parse(await object.text()), name); + if (object.size > RUNTIME_PLUGIN_MAX_BYTES) throw new Error("Plugin source exceeds 1 MiB"); + const text = await object.text(); + return { text, hash: createHash("sha256").update(text).digest("hex") }; +} + +/** Compiles the source, then asks the plugin to describe itself in an isolate + * with no secrets. Only the description, which the host checks, leaves it. */ +async function load(env: RuntimePluginEnvironment, name: string, source: { text: string; hash: string }): Promise { + if (!env.LOADER) throw new Error("Runtime plugins need the LOADER binding"); + const build = await compilePluginSource(source.text); + if (!build.ok) throw new Error(`Plugin ${name} did not compile: ${build.error}`); + const config = runtimeConfig(env); + const worker = env.LOADER.get(`runtime-plugin-describe:${source.hash}`, async () => ({ ...config, mainModule: "plugin.js", modules: { "plugin.js": build.js }, env: {} })); + const response = await worker.getEntrypoint().fetch(new Request("https://plugin.invalid/describe", { method: "POST", signal: AbortSignal.timeout(10_000) })); + const description = parseRuntimePluginDescription(await response.json(), name); + const validators = new Map(); + for (const operation of description.operations) { + // Draft 7 matches the Ajv default build-time plugins use. Validation is + // interpreted: Workers cannot compile validators from generated code. + validators.set(operation.name, { + input: new Validator(operation.inputSchema as object, "7", false), + ...(operation.outputSchema ? { output: new Validator(operation.outputSchema as object, "7", false) } : {}), + }); + } + return { description, hash: source.hash, module: build.js, validators }; +} + +function loaded(env: RuntimePluginEnvironment, name: string, source: { text: string; hash: string }) { + const key = `${name}\0${source.hash}`; + let plugin = compiled.get(key); + if (!plugin) { + plugin = load(env, name, source); + // A failure is not cached: fixing the plugin is a republish, and the next call retries. + plugin.catch(() => compiled.delete(key)); + compiled.set(key, plugin); + while (compiled.size > MAX_COMPILED) compiled.delete(compiled.keys().next().value!); + } + return plugin; } -export async function runtimePlugin(env: RuntimePluginEnvironment, name: string): Promise { +export async function runtimePlugin(env: RuntimePluginEnvironment, name: string): Promise { if (!env.PLUGINS || !PLUGIN_NAME.test(name)) return null; - const cached = records.get(name); - if (cached && Date.now() - cached.at < CACHE_MS) return cached.record; - let record: RuntimePluginRecord | null; - try { record = await readRecord(env.PLUGINS, runtimePluginKey(name), name); } - catch (error) { + try { + let cached = sources.get(name); + if (!cached || Date.now() - cached.at >= CACHE_MS) { + cached = { at: Date.now(), source: await readSource(env.PLUGINS, runtimePluginKey(name)) }; + sources.set(name, cached); + } + return cached.source ? await loaded(env, name, cached.source) : null; + } catch (error) { console.error(`Runtime plugin ${name} could not be loaded`, error); throw new PluginError("Plugin configuration unavailable", 500); } - records.set(name, { at: Date.now(), record }); - return record; } -/** Catalog entries for every valid published plugin. An invalid record is - * logged and left out of the listing; calling it still reports the error. */ +/** Catalog entries for every valid published plugin. A plugin that fails to + * load is logged and left out of the listing; calling it reports the error. */ export async function runtimePluginCatalog(env: RuntimePluginEnvironment): Promise { if (!env.PLUGINS) return []; if (catalog && Date.now() - catalog.at < CACHE_MS) return catalog.plugins; - const keys: string[] = []; + const names: string[] = []; let cursor: string | undefined; do { const page = await env.PLUGINS.list({ prefix: RUNTIME_PLUGIN_PREFIX, cursor }); - keys.push(...page.objects.map(object => object.key).filter(key => key.endsWith(".json"))); + names.push(...page.objects.map(object => object.key).filter(key => key.endsWith(RUNTIME_PLUGIN_EXTENSION)) + .map(key => key.slice(RUNTIME_PLUGIN_PREFIX.length, -RUNTIME_PLUGIN_EXTENSION.length))); cursor = page.truncated ? page.cursor : undefined; - } while (cursor && keys.length <= 64); + } while (cursor && names.length <= 64); const plugins: PluginInfo[] = []; - for (const key of keys.slice(0, 64)) { - const name = key.slice(RUNTIME_PLUGIN_PREFIX.length, -".json".length); + for (const name of names.slice(0, 64)) { try { - const record = await readRecord(env.PLUGINS, key, name); - if (record) plugins.push(runtimePluginInfo(record)); - } catch (error) { console.error(`Runtime plugin ${name} is invalid`, error); } + const plugin = await runtimePlugin(env, name); + if (plugin) plugins.push(runtimePluginInfo(plugin.description)); + } catch {} } catalog = { at: Date.now(), plugins }; return plugins; } /** Runs one operation in the plugin's own isolate. Its env holds only the - * secrets the record declares, read from the host's bindings; host credentials, + * secrets the plugin declares, read from the host's bindings; host credentials, * Durable Objects and storage bindings never enter it. */ -export async function callRuntimePlugin(record: RuntimePluginRecord, operation: string, input: unknown, user: PluginUser, context: PluginInvocationContext, timeoutMs = 30_000): Promise { +export async function callRuntimePlugin(plugin: LoadedRuntimePlugin, operation: string, input: unknown, user: PluginUser, context: PluginInvocationContext, timeoutMs = 30_000): Promise { const env = context.env as RuntimePluginEnvironment & Record; - if (!env.LOADER) throw new PluginError("Plugin configuration unavailable", 500); + const validators = plugin.validators.get(operation)!; + if (!validators.input.validate(input).valid) throw new PluginError("Invalid plugin input", 400); const secrets: Record = {}; - for (const name of record.secrets) { + for (const name of plugin.description.secrets) { const value = Object.hasOwn(env, name) ? env[name] : undefined; if (typeof value !== "string") throw new PluginError("Plugin configuration unavailable", 500); secrets[name] = value; } - const config = scriptRuntimeConfig(env.ARTIFACTS_RUNTIME); - const key = createHash("sha256").update(JSON.stringify(["runtime-plugin", record.name, record.hash, secrets, config])).digest("hex"); - const worker = env.LOADER.get(key, async () => ({ ...config, mainModule: "plugin.js", modules: { "plugin.js": record.module }, env: secrets })); + const config = runtimeConfig(env); + const key = createHash("sha256").update(JSON.stringify(["runtime-plugin", plugin.description.name, plugin.hash, secrets, config])).digest("hex"); + const worker = env.LOADER!.get(key, async () => ({ ...config, mainModule: "plugin.js", modules: { "plugin.js": plugin.module }, env: secrets })); // The isolate enforces the same deadline; this bounds a module that never answers. const signal = AbortSignal.timeout(timeoutMs + 1_000); let text: string; @@ -88,13 +139,16 @@ export async function callRuntimePlugin(record: RuntimePluginRecord, operation: text = await response.text(); } catch (error) { if (signal.aborted) throw new PluginError("Plugin operation timed out", 504); - console.error(`Runtime plugin ${record.name} failed to run`, error); + console.error(`Runtime plugin ${plugin.description.name} failed to run`, error); throw new PluginError("Plugin operation failed", 500); } if (new TextEncoder().encode(text).byteLength > PLUGIN_JSON_LIMIT + 1024) throw new PluginError("Plugin result exceeds 256 KiB", 500); let reply: { result?: unknown; error?: unknown; status?: unknown }; try { reply = JSON.parse(text); } catch { throw new PluginError("Plugin returned invalid JSON", 500); } - if (reply && Object.hasOwn(reply, "result")) return reply.result; + if (reply && Object.hasOwn(reply, "result")) { + if (validators.output && !validators.output.validate(reply.result).valid) throw new PluginError("Plugin returned invalid output", 500); + return reply.result; + } const status = typeof reply?.status === "number" && ERROR_STATUSES.has(reply.status) ? reply.status as 400 | 403 | 404 | 413 | 500 | 504 : 500; throw new PluginError(typeof reply?.error === "string" ? reply.error.slice(0, 200) : "Plugin operation failed", status); } diff --git a/docs/runtime-plugins.md b/docs/runtime-plugins.md index c207ad8..158ba25 100644 --- a/docs/runtime-plugins.md +++ b/docs/runtime-plugins.md @@ -82,9 +82,9 @@ Server-side plugin functions follow deployment upgrades. Browser libraries and t ## Publish a plugin without rebuilding A plugin that only provides server functions can also be published into a running -deployment. A published plugin needs no rebuild or redeploy of Artifacts: replacing its -file replaces the plugin. Add an R2 binding named `PLUGINS` to the deployment's -Wrangler configuration: +deployment as its TypeScript source. Publishing needs no build step and no rebuild +or redeploy of Artifacts: replacing the file replaces the plugin. Add an R2 binding +named `PLUGINS` to the deployment's Wrangler configuration: ```json "r2_buckets": [ @@ -93,7 +93,7 @@ Wrangler configuration: ] ``` -Write the plugin as one module whose default export is `definePlugin(...)`. It takes +Write the plugin as one file whose default export is `definePlugin(...)`. It takes the same fields as an entry in `artifacts.plugins.ts`, except `browser` and `types`: ```ts @@ -116,35 +116,38 @@ export default definePlugin({ }); ``` -Bundle it from a checkout of this repository, then upload the result to -`plugins/.json` in that bucket: +Upload it to `plugins/.ts` in that bucket. The object key must match the +plugin's `name`: ```sh -bun run scripts/bundle-plugin.ts warehouse.plugin.ts --out warehouse.json # celld: writes the fleet bucket directly; no node needs to be running. -celld r2 put artifacts-plugins plugins/warehouse.json --path warehouse.json --content-type application/json --bucket s3:// +celld r2 put artifacts-plugins plugins/warehouse.ts --path warehouse.plugin.ts --content-type text/plain --bucket s3:// # Cloudflare -bun x wrangler r2 object put artifacts-plugins/plugins/warehouse.json --file warehouse.json --remote +bun x wrangler r2 object put artifacts-plugins/plugins/warehouse.ts --file warehouse.plugin.ts --remote ``` -The bundle is one file: the handlers, their dependencies, and the input/output -validators. It is reproducible: the same plugin source and Artifacts checkout produce -the same bytes on any machine, so a deployment repository can commit the bundle and -check it against its source in CI. The plugin may live in another repository; its -`@sidequery/artifacts/plugins` import resolves to the checkout used for bundling. Each node uses a new or replaced file within about five seconds. Delete -the object to remove the plugin. +Each node uses a new or replaced file within about five seconds. Delete the object +to remove the plugin. A node compiles each version on its first use, with the same +bundler as scripts. The plugin is one self-contained file. It may import +`@sidequery/artifacts/plugins` (types only; no install needed) and Workers builtins +such as `node:crypto`, and nothing else. A plugin that does not compile, or whose +`name` does not match its key, is left out of `plugins_list`. Calling it returns +`Plugin configuration unavailable`, and the reason is in the server log. A published plugin works like a build-time plugin. It appears in `plugins_list` and -is called with `artifact_plugin_call` or `pluginCall`. It has the same schemas, -`authorize`, 30 second limit, 256 KiB limit and generic errors. It differs in these -ways: +is called with `artifact_plugin_call` or `pluginCall`. It has the same `authorize`, +30 second limit, 256 KiB limit and generic errors. It differs in these ways: - **Isolation.** It runs in its own isolate. Its environment contains only the secrets it declares, read from deployment variables with the same names. Other variables, - bindings and storage are unavailable to it. + bindings and storage are unavailable to it. The host learns the plugin's operations + and schemas by asking it to describe itself in an isolate with no secrets. - **Secrets.** It can declare only variables named `PLUGIN_*`. Write access to the bucket therefore never exposes the deployment's own credentials. Adding a secret still means changing the deployment's variables. +- **Schemas.** The host validates input and output with a JSON Schema draft 7 validator + that interprets each schema, because Workers cannot compile validators from + generated code. Build-time plugins use compiled Ajv validators. - **Server only.** Browser libraries still require a deployment build. Artifacts call published plugins with `pluginCall` directly. - **Names.** A build-time plugin with the same name takes precedence. diff --git a/e2e/runtime-plugin-fixture.ts b/e2e/runtime-plugin-fixture.ts index 1f8bc00..06dca71 100644 --- a/e2e/runtime-plugin-fixture.ts +++ b/e2e/runtime-plugin-fixture.ts @@ -2,7 +2,7 @@ import { definePlugin } from "@sidequery/artifacts/plugins"; const VERSION = "fixture-v1"; -/** Published at runtime by cloudflare/runtime-plugins.test.ts. */ +/** Published as source by cloudflare/runtime-plugins.test.ts and runtime-plugins-celld.test.ts. */ export default definePlugin({ name: "test-warehouse", description: "Runtime plugin test fixture", @@ -24,6 +24,12 @@ export default definePlugin({ authorize: user => user.subject === "nobody", handler: () => ({ reached: true }), }, + malformed: { + description: "Returns output its own schema rejects", + inputSchema: { type: "object" }, + outputSchema: { type: "object", required: ["value"] }, + handler: () => ({}), + }, broken: { description: "Throws with provider details", inputSchema: { type: "object" }, diff --git a/package.json b/package.json index c24e081..855dbff 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,7 @@ "@aws-sdk/client-s3": "3.1150.0", "@base-ui/react": "^1.8.0", "@better-auth/oauth-provider": "1.7.3", + "@cfworker/json-schema": "4.1.1", "@codemirror/commands": "^6.11.0", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", diff --git a/scripts/bundle-plugin.ts b/scripts/bundle-plugin.ts deleted file mode 100644 index 4ebd353..0000000 --- a/scripts/bundle-plugin.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { build } from "esbuild"; -import { pluginInfo, pluginValidatorModule, validatePlugins } from "./prepare-plugins"; -import { parseRuntimePlugin, runtimePluginKey, type RuntimePluginRecord } from "../src/plugins/runtime"; - -const root = resolve(import.meta.dir, ".."); -const resolution = { - // Validators import Ajv's runtime helpers; plugins may import the typed helper without installing the package. - nodePaths: [join(root, "node_modules")], - alias: { "@sidequery/artifacts/plugins": join(root, "src/plugins/config.ts") }, -}; - -/** Reads the plugin definition from a bundle of its module, so a plugin kept in - * another repository needs no install and the metadata comes from the code that ships. */ -async function loadPlugin(entryPath: string, work: string): Promise { - const outfile = join(work, "definition.mjs"); - await build({ entryPoints: [entryPath], bundle: true, outfile, format: "esm", platform: "node", target: "es2022", logLevel: "silent", ...resolution }); - return (await import(pathToFileURL(outfile).href)).default; -} - -/** - * Builds the self-contained record a deployment loads from `plugins/.json` - * in its `PLUGINS` bucket. The plugin module's default export is one - * `definePlugin(...)` with server operations. Its handlers, its dependencies, the - * shared dispatcher and standalone schema validators are bundled into one ES - * module, so the host never compiles or evaluates anything but that module, and - * only inside the plugin's own isolate. - */ -export async function bundleRuntimePlugin(entry: string): Promise { - const entryPath = resolve(entry); - const work = await mkdtemp(join(tmpdir(), "artifacts-plugin-")); - try { - const [plugin] = validatePlugins([await loadPlugin(entryPath, work)]); - if (plugin!.browser || plugin!.types) throw new Error(`${plugin!.name}: published plugins provide server operations only; browser modules need a deployment build`); - if (!Object.keys(plugin!.operations ?? {}).length) throw new Error(`${plugin!.name} has no server operations`); - const info = pluginInfo(plugin!); - await writeFile(join(work, "validators.js"), pluginValidatorModule([info])); - await writeFile(join(work, "entry.js"), [ - `import plugin from ${JSON.stringify(entryPath)};`, - 'import { validatePluginInput, validatePluginOutput } from "./validators.js";', - `import { runtimePluginHandler } from ${JSON.stringify(join(root, "src/plugins/runtime-entry.ts"))};`, - "export default runtimePluginHandler(plugin, { input: validatePluginInput, output: validatePluginOutput });", - ].join("\n")); - const bundle = await build({ - entryPoints: [join(work, "entry.js")], bundle: true, write: false, format: "esm", target: "es2022", - platform: "browser", conditions: ["workerd", "worker", "browser"], external: ["node:*", "cloudflare:*"], ...resolution, - define: { "process.env.NODE_ENV": '"production"' }, legalComments: "none", logLevel: "silent", - // Minified output carries no module path comments, so the same source and - // lockfile produce the same bytes on any machine and from any directory, and - // a deployment repo can check a committed bundle against its source. - minify: true, - }); - if (bundle.outputFiles.length !== 1) throw new Error(`${plugin!.name} must bundle into one module`); - const module = bundle.outputFiles[0]!.text; - return parseRuntimePlugin({ - format: 1, name: plugin!.name, description: plugin!.description, secrets: [...(plugin!.secrets ?? [])], - operations: info.operations, module, hash: createHash("sha256").update(module).digest("hex"), - }); - } finally { await rm(work, { recursive: true, force: true }); } -} - -if (import.meta.main) { - const [entry, flag, out] = process.argv.slice(2); - if (!entry || flag !== "--out" || !out) { - console.error("usage: bun run scripts/bundle-plugin.ts --out "); - process.exit(2); - } - const record = await bundleRuntimePlugin(entry); - await writeFile(out, JSON.stringify(record)); - console.log(JSON.stringify({ name: record.name, key: runtimePluginKey(record.name), hash: record.hash, operations: record.operations.map(operation => operation.name), secrets: record.secrets })); -} diff --git a/scripts/prepare-cloudflare.ts b/scripts/prepare-cloudflare.ts index 81df2b3..4f1630a 100644 --- a/scripts/prepare-cloudflare.ts +++ b/scripts/prepare-cloudflare.ts @@ -63,6 +63,11 @@ await writeFile(join(dirname(output), "browser-runtime.json"), JSON.stringify({ sharedModules: Object.fromEntries(Object.entries({ react: { value: react, key: "react" }, "react-dom": { value: reactDom, key: "reactDom" }, "react-dom/client": { value: reactDomClient, key: "reactDomClient" }, "react-router": { value: reactRouter, key: "reactRouter" }, "react/jsx-runtime": { value: jsx, key: "jsx" }, "react/jsx-dev-runtime": { value: jsxDev, key: "jsxDev" } }).map(([name, {value, key}]) => [name, `export default globalThis.__artifactsRuntime.${key};\n` + Object.keys(value).filter(item => item !== "default" && /^[A-Za-z_$][\w$]*$/.test(item)).map(item => `export const ${item} = globalThis.__artifactsRuntime.${key}.${item};`).join("\n")])), sdkModule: Object.keys(sdk).sort().map(name => `export const ${name} = globalThis.__artifactsRuntime.sdk.${name};`).join("\n"), })); +// The wrapper the host compiles together with each published plugin's source +// (cloudflare/compiler.ts compilePluginSource). +const pluginRuntime = await Bun.build({ entrypoints: [join(root, "src/plugins/runtime-entry.ts")], target: "browser", format: "esm", minify: true }); +if (!pluginRuntime.success) throw new Error(pluginRuntime.logs.join("\n")); +await writeFile(join(dirname(output), "plugin-runtime.json"), JSON.stringify({ js: await pluginRuntime.outputs[0]!.text() })); const identity = createHash("sha256").update(JSON.stringify(files)).update(runtimeJs) .update(JSON.stringify(plugins.browser)).update(JSON.stringify(plugins.catalog)) .update(await readFile(join(root, "bun.lock"), "utf8")) diff --git a/src/plugins/config.ts b/src/plugins/config.ts index 475868b..3981793 100644 --- a/src/plugins/config.ts +++ b/src/plugins/config.ts @@ -34,7 +34,7 @@ export function definePlugins(plugins return plugins; } -/** One plugin per module, for publishing at runtime with scripts/bundle-plugin.ts. */ +/** One plugin per module: the default export of a plugin published at runtime as `plugins/.ts`. */ export function definePlugin(plugin: T): T { return plugin; } diff --git a/src/plugins/runtime-entry.ts b/src/plugins/runtime-entry.ts index 49b36a5..26e10b3 100644 --- a/src/plugins/runtime-entry.ts +++ b/src/plugins/runtime-entry.ts @@ -1,18 +1,23 @@ import type { ArtifactPlugin } from "./config"; -import { createPluginDispatcher, PluginError, type PluginValidators } from "./dispatch"; +import { createPluginDispatcher, PluginError } from "./dispatch"; import type { PluginUser } from "./types"; /** - * The default export of a runtime plugin's bundled module (see - * scripts/bundle-plugin.ts). It runs in the plugin's own isolate, whose `env` - * holds only the plugin's declared secrets, and applies the same dispatcher as - * build-time plugins: input/output schemas, `authorize`, timeout, and fixed, - * provider-free error messages. The host reads `{ result }` or `{ error, status }`. + * Wraps a published plugin's default export inside its own isolate (the host + * compiles this module together with the plugin source; see + * cloudflare/runtime-plugins.ts). The isolate's `env` holds only the plugin's + * declared secrets. + * + * - POST /describe returns the plugin's metadata, without handlers. + * - POST /call runs one operation through the same dispatcher as build-time + * plugins: `authorize`, secrets, timeout and fixed, provider-free errors. + * The host validates input and output against the described schemas. */ -export function runtimePluginHandler(plugin: ArtifactPlugin, validators: PluginValidators) { - const dispatch = createPluginDispatcher([plugin], validators); +export function runtimePluginHandler(plugin: ArtifactPlugin) { + const dispatch = createPluginDispatcher([plugin], { input: () => true, output: () => true }); return { async fetch(request: Request, env: Record): Promise { + if (new URL(request.url).pathname === "/describe") return Response.json(describe(plugin)); let call: { operation?: unknown; input?: unknown; user?: PluginUser }; try { call = await request.json(); } catch { return Response.json({ error: "Invalid plugin request", status: 400 }); } @@ -27,3 +32,20 @@ export function runtimePluginHandler(plugin: ArtifactPlugin, validators: PluginV }, }; } + +function describe(plugin: ArtifactPlugin) { + if (!plugin || typeof plugin !== "object") return null; + return { + name: plugin.name, + description: plugin.description, + secrets: plugin.secrets ? [...plugin.secrets] : [], + // Browser modules need a deployment build; a published plugin that names one is rejected. + operations: plugin.browser || plugin.types ? [] : Object.entries(plugin.operations ?? {}).map(([name, operation]) => ({ + name, + description: operation?.description, + inputSchema: operation?.inputSchema, + ...(operation?.outputSchema ? { outputSchema: operation.outputSchema } : {}), + readOnly: operation?.readOnly === true, + })), + }; +} diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index bc8d856..1e3593b 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,25 +1,22 @@ import type { PluginInfo, PluginOperationInfo } from "./types"; /** - * A plugin published at runtime: one self-contained object per plugin under - * `plugins/` in the deployment's optional `PLUGINS` R2 bucket. `module` is the - * bundled server entry (scripts/bundle-plugin.ts); the host runs it in its own - * isolate and reads only the metadata here. Replacing the object publishes a - * new version; nothing is compiled by the host. + * A plugin published at runtime is its TypeScript source, stored as + * `plugins/.ts` in the deployment's optional `PLUGINS` R2 bucket. The host + * compiles it on first use and asks the plugin, inside its own isolate, to + * describe itself; this is that description. The host never evaluates plugin + * code itself. */ -export type RuntimePluginRecord = { - format: 1; +export type RuntimePluginDescription = { name: string; description: string; secrets: string[]; operations: PluginOperationInfo[]; - module: string; - /** SHA-256 of `module`; part of the isolate cache key. */ - hash: string; }; export const RUNTIME_PLUGIN_PREFIX = "plugins/"; -export const RUNTIME_PLUGIN_MAX_BYTES = 16 * 1024 * 1024; +export const RUNTIME_PLUGIN_EXTENSION = ".ts"; +export const RUNTIME_PLUGIN_MAX_BYTES = 1024 * 1024; export const PLUGIN_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; /** Published plugins read only `PLUGIN_*` deployment variables, so write * access to the bucket never reaches the host's own credentials (auth secret, @@ -29,31 +26,30 @@ const OPERATION_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; export function runtimePluginKey(name: string): string { if (!PLUGIN_NAME.test(name)) throw new Error("Invalid plugin name"); - return `${RUNTIME_PLUGIN_PREFIX}${name}.json`; + return `${RUNTIME_PLUGIN_PREFIX}${name}${RUNTIME_PLUGIN_EXTENSION}`; } const isSchema = (value: unknown) => !!value && typeof value === "object" && !Array.isArray(value); -/** Validates a stored record. A record that fails is a configuration error, never an empty plugin. */ -export function parseRuntimePlugin(value: unknown, expectedName?: string): RuntimePluginRecord { - const record = value as Partial | null; - if (!record || typeof record !== "object" || record.format !== 1) throw new Error("Unsupported plugin record format"); - if (typeof record.name !== "string" || !PLUGIN_NAME.test(record.name) || (expectedName !== undefined && record.name !== expectedName)) throw new Error("Plugin record name does not match its key"); - if (typeof record.description !== "string" || record.description.length > 8192) throw new Error("Invalid plugin description"); - if (!Array.isArray(record.secrets) || record.secrets.length > 32 || record.secrets.some(name => typeof name !== "string" || !RUNTIME_PLUGIN_SECRET.test(name))) throw new Error("Published plugin secrets must be named PLUGIN_*"); - if (!Array.isArray(record.operations) || !record.operations.length || record.operations.length > 128) throw new Error("Invalid plugin operations"); +/** Checks what a plugin reported about itself. A plugin that fails is a + * configuration error, never an empty plugin. */ +export function parseRuntimePluginDescription(value: unknown, expectedName: string): RuntimePluginDescription { + const plugin = value as Partial | null; + if (!plugin || typeof plugin !== "object") throw new Error("Plugin module must default-export definePlugin(...)"); + if (plugin.name !== expectedName) throw new Error(`Plugin name ${JSON.stringify(plugin.name)} does not match its object key ${expectedName}`); + if (typeof plugin.description !== "string" || plugin.description.length > 8192) throw new Error("Invalid plugin description"); + if (!Array.isArray(plugin.secrets) || plugin.secrets.length > 32 || plugin.secrets.some(name => typeof name !== "string" || !RUNTIME_PLUGIN_SECRET.test(name))) throw new Error("Published plugin secrets must be named PLUGIN_*"); + if (!Array.isArray(plugin.operations) || !plugin.operations.length || plugin.operations.length > 128) throw new Error("A published plugin needs between 1 and 128 server operations"); const names = new Set(); - for (const operation of record.operations) { + for (const operation of plugin.operations) { if (!operation || typeof operation.name !== "string" || !OPERATION_NAME.test(operation.name) || names.has(operation.name) || typeof operation.description !== "string" || operation.description.length > 8192 || typeof operation.readOnly !== "boolean" - || !isSchema(operation.inputSchema) || (operation.outputSchema !== undefined && !isSchema(operation.outputSchema))) throw new Error("Invalid plugin operation"); + || !isSchema(operation.inputSchema) || (operation.outputSchema !== undefined && !isSchema(operation.outputSchema))) throw new Error(`Invalid plugin operation ${JSON.stringify(operation?.name)}`); names.add(operation.name); } - if (typeof record.module !== "string" || !record.module) throw new Error("Plugin record has no module"); - if (typeof record.hash !== "string" || !/^[0-9a-f]{64}$/.test(record.hash)) throw new Error("Invalid plugin module hash"); - return record as RuntimePluginRecord; + return plugin as RuntimePluginDescription; } -export function runtimePluginInfo(record: RuntimePluginRecord): PluginInfo { - return { name: record.name, description: record.description, browser: false, operations: record.operations }; +export function runtimePluginInfo(plugin: RuntimePluginDescription): PluginInfo { + return { name: plugin.name, description: plugin.description, browser: false, operations: plugin.operations }; }