Skip to content

Commit 65dbdf2

Browse files
authored
Merge pull request #54 from sidequery/reproducible-plugin-bundles
Make published plugin bundles reproducible
2 parents 2b8cad2 + 15f26ae commit 65dbdf2

3 files changed

Lines changed: 39 additions & 10 deletions

File tree

‎cloudflare/runtime-plugins.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, beforeAll, expect, test } from "bun:test";
2-
import { readFile, readdir } from "node:fs/promises";
2+
import { cp, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
34
import { join } from "node:path";
45
import { Miniflare } from "miniflare";
56
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
7172
expect(() => parseRuntimePlugin({ ...record, hash: "x" })).toThrow("hash");
7273
});
7374

75+
test("bundles are reproducible from any directory and carry no build-machine paths", async () => {
76+
const elsewhere = await mkdtemp(join(tmpdir(), "plugin-source-"));
77+
try {
78+
await cp(fixture, join(elsewhere, "warehouse.plugin.ts"));
79+
const copy = await bundleRuntimePlugin(join(elsewhere, "warehouse.plugin.ts"));
80+
expect(copy.hash).toBe(record.hash);
81+
expect(copy.module).toBe(record.module);
82+
} finally { await rm(elsewhere, { recursive: true, force: true }); }
83+
for (const leaked of [tmpdir(), "artifacts-plugin-", import.meta.dir]) expect(record.module).not.toContain(leaked);
84+
});
85+
7486
test("published plugins are listed beside build-time ones; corrupt records are left out", async () => {
7587
const result = await client.callTool({ name: "plugins_list", arguments: {} });
7688
const plugins = (result.structuredContent as { plugins: { name: string; browser: boolean; operations: { name: string }[] }[] }).plugins;

‎docs/runtime-plugins.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ bun x wrangler r2 object put artifacts-plugins/plugins/warehouse.json --file war
128128
```
129129

130130
The bundle is one file: the handlers, their dependencies, and the input/output
131-
validators. Each node uses a new or replaced file within about five seconds. Delete
131+
validators. It is reproducible: the same plugin source and Artifacts checkout produce
132+
the same bytes on any machine, so a deployment repository can commit the bundle and
133+
check it against its source in CI. The plugin may live in another repository; its
134+
`@sidequery/artifacts/plugins` import resolves to the checkout used for bundling. Each node uses a new or replaced file within about five seconds. Delete
132135
the object to remove the plugin.
133136

134137
A published plugin works like a build-time plugin. It appears in `plugins_list` and

‎scripts/bundle-plugin.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ import { pluginInfo, pluginValidatorModule, validatePlugins } from "./prepare-pl
88
import { parseRuntimePlugin, runtimePluginKey, type RuntimePluginRecord } from "../src/plugins/runtime";
99

1010
const root = resolve(import.meta.dir, "..");
11+
const resolution = {
12+
// Validators import Ajv's runtime helpers; plugins may import the typed helper without installing the package.
13+
nodePaths: [join(root, "node_modules")],
14+
alias: { "@sidequery/artifacts/plugins": join(root, "src/plugins/config.ts") },
15+
};
16+
17+
/** Reads the plugin definition from a bundle of its module, so a plugin kept in
18+
* another repository needs no install and the metadata comes from the code that ships. */
19+
async function loadPlugin(entryPath: string, work: string): Promise<unknown> {
20+
const outfile = join(work, "definition.mjs");
21+
await build({ entryPoints: [entryPath], bundle: true, outfile, format: "esm", platform: "node", target: "es2022", logLevel: "silent", ...resolution });
22+
return (await import(pathToFileURL(outfile).href)).default;
23+
}
1124

1225
/**
1326
* Builds the self-contained record a deployment loads from `plugins/<name>.json`
@@ -19,12 +32,12 @@ const root = resolve(import.meta.dir, "..");
1932
*/
2033
export async function bundleRuntimePlugin(entry: string): Promise<RuntimePluginRecord> {
2134
const entryPath = resolve(entry);
22-
const [plugin] = validatePlugins([(await import(pathToFileURL(entryPath).href)).default]);
23-
if (plugin!.browser || plugin!.types) throw new Error(`${plugin!.name}: published plugins provide server operations only; browser modules need a deployment build`);
24-
if (!Object.keys(plugin!.operations ?? {}).length) throw new Error(`${plugin!.name} has no server operations`);
25-
const info = pluginInfo(plugin!);
2635
const work = await mkdtemp(join(tmpdir(), "artifacts-plugin-"));
2736
try {
37+
const [plugin] = validatePlugins([await loadPlugin(entryPath, work)]);
38+
if (plugin!.browser || plugin!.types) throw new Error(`${plugin!.name}: published plugins provide server operations only; browser modules need a deployment build`);
39+
if (!Object.keys(plugin!.operations ?? {}).length) throw new Error(`${plugin!.name} has no server operations`);
40+
const info = pluginInfo(plugin!);
2841
await writeFile(join(work, "validators.js"), pluginValidatorModule([info]));
2942
await writeFile(join(work, "entry.js"), [
3043
`import plugin from ${JSON.stringify(entryPath)};`,
@@ -34,11 +47,12 @@ export async function bundleRuntimePlugin(entry: string): Promise<RuntimePluginR
3447
].join("\n"));
3548
const bundle = await build({
3649
entryPoints: [join(work, "entry.js")], bundle: true, write: false, format: "esm", target: "es2022",
37-
platform: "browser", conditions: ["workerd", "worker", "browser"], external: ["node:*", "cloudflare:*"],
38-
// Validators import Ajv's runtime helpers; plugins may import the typed helper without installing the package.
39-
nodePaths: [join(root, "node_modules")],
40-
alias: { "@sidequery/artifacts/plugins": join(root, "src/plugins/config.ts") },
50+
platform: "browser", conditions: ["workerd", "worker", "browser"], external: ["node:*", "cloudflare:*"], ...resolution,
4151
define: { "process.env.NODE_ENV": '"production"' }, legalComments: "none", logLevel: "silent",
52+
// Minified output carries no module path comments, so the same source and
53+
// lockfile produce the same bytes on any machine and from any directory, and
54+
// a deployment repo can check a committed bundle against its source.
55+
minify: true,
4256
});
4357
if (bundle.outputFiles.length !== 1) throw new Error(`${plugin!.name} must bundle into one module`);
4458
const module = bundle.outputFiles[0]!.text;

0 commit comments

Comments
 (0)