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 }; }