Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cloudflare/runtime-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion docs/runtime-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions scripts/bundle-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
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/<name>.json`
Expand All @@ -19,12 +32,12 @@ const root = resolve(import.meta.dir, "..");
*/
export async function bundleRuntimePlugin(entry: string): Promise<RuntimePluginRecord> {
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)};`,
Expand All @@ -34,11 +47,12 @@ export async function bundleRuntimePlugin(entry: string): Promise<RuntimePluginR
].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:*"],
// 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") },
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;
Expand Down
Loading