diff --git a/cloudflare/runtime-plugins.test.ts b/cloudflare/runtime-plugins.test.ts index ca01641..3b65fa2 100644 --- a/cloudflare/runtime-plugins.test.ts +++ b/cloudflare/runtime-plugins.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { readFile, readdir } from "node:fs/promises"; +import { cp, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { Miniflare } from "miniflare"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -71,6 +72,17 @@ test("the bundle is one self-describing record with schemas and no browser modul 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 () => { const result = await client.callTool({ name: "plugins_list", arguments: {} }); const plugins = (result.structuredContent as { plugins: { name: string; browser: boolean; operations: { name: string }[] }[] }).plugins; diff --git a/docs/runtime-plugins.md b/docs/runtime-plugins.md index b208fff..c207ad8 100644 --- a/docs/runtime-plugins.md +++ b/docs/runtime-plugins.md @@ -128,7 +128,10 @@ bun x wrangler r2 object put artifacts-plugins/plugins/warehouse.json --file war ``` The bundle is one file: the handlers, their dependencies, and the input/output -validators. Each node uses a new or replaced file within about five seconds. Delete +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. A published plugin works like a build-time plugin. It appears in `plugins_list` and diff --git a/scripts/bundle-plugin.ts b/scripts/bundle-plugin.ts index 1c2bf6a..4ebd353 100644 --- a/scripts/bundle-plugin.ts +++ b/scripts/bundle-plugin.ts @@ -8,6 +8,19 @@ import { pluginInfo, pluginValidatorModule, validatePlugins } from "./prepare-pl 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` @@ -19,12 +32,12 @@ const root = resolve(import.meta.dir, ".."); */ export async function bundleRuntimePlugin(entry: string): Promise { const entryPath = resolve(entry); - const [plugin] = validatePlugins([(await import(pathToFileURL(entryPath).href)).default]); - 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!); 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)};`, @@ -34,11 +47,12 @@ export async function bundleRuntimePlugin(entry: string): Promise