History

Serve the worker-bundler artifact from static assets (#1378)

The publish bundler artifact, worker-bundler source and esbuild wasm, is fetched from static assets at first use instead of being inlined into the worker script, returning the script to its previous size. Cold-start latency regressed while the 19MB artifact chunk was part of the script blob.

Rhys Sullivan committed 4 months ago

Commit 56b60bf1 parent 63e19e78 files · +175 −18Browse files

Unified · Split

8 files
  1. apps/cloud/executor.config.ts +38 −2
  2. apps/cloud/scripts/assert-worker-bundler-assets.mjs +54 −0
  3. apps/cloud/scripts/build.mjs +1 −0
  4. apps/cloud/src/engine/execution-stack.ts +2 −0
  5. apps/cloud/src/env-augment.d.ts +4 −0
  6. apps/cloud/src/worker-bundler-artifact.d.ts +4 −2
  7. apps/cloud/wrangler.jsonc +6 −0
  8. packages/plugins/apps/src/vite.ts +66 −14
apps/cloud/executor.config.ts+38−2
@@ -75,6 +75,9 @@ interface CloudPluginDeps { · 74 unmodified lines
7575 readonly globalOutbound?: null;
7676 }) => { readonly getEntrypoint: () => unknown };
7777 };
78+ readonly workerAssets?: {
79+ readonly fetch: (request: Request) => Promise<Response>;
80+ };
7881 }
7982
8083 const base64ToArrayBuffer = (value: string): ArrayBuffer => {
@@ -82,6 +85,20 @@ const base64ToArrayBuffer = (value: string): ArrayBuffer => { · 1 unmodified line
8285 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
8386 };
8487
88+const assetRequest = (path: string): Request => new Request(new URL(path, "https://assets.local"));
89+
90+const fetchAsset = async (
91+ assets: { readonly fetch: (request: Request) => Promise<Response> },
92+ path: string,
93+): Promise<Response> => {
94+ const response = await assets.fetch(assetRequest(path));
95+ if (!response.ok) {
96+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker artifact asset fetch must reject the dynamic bundler setup
97+ throw new Error(`failed to fetch worker-bundler asset ${path}: ${response.status}`);
98+ }
99+ return response;
100+};
101+
85102 export default defineExecutorConfig({
86103 plugins: ({
87104 workosCredentials,
@@ -89,6 +106,7 @@ export default defineExecutorConfig({ · 1 unmodified line
89106 activeToolkitSlug,
90107 allowLocalNetwork,
91108 workerLoader,
109+ workerAssets,
92110 }: CloudPluginDeps = {}) =>
93111 [
94112 openApiHttpPlugin({
@@ -107,9 +125,27 @@ export default defineExecutorConfig({ · 12 unmodified lines
107125 loader: workerLoader,
108126 artifact: async () => {
109127 const artifact = await import("virtual:executor/worker-bundler-artifact");
128+ if (artifact.source !== undefined && artifact.wasmBase64 !== undefined) {
129+ return {
130+ source: artifact.source,
131+ wasm: base64ToArrayBuffer(artifact.wasmBase64),
132+ };
133+ }
134+ if (workerAssets === undefined) {
135+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker artifact asset binding is required for production bundler setup
136+ throw new Error("worker-bundler artifact assets binding is unavailable");
137+ }
138+ const [source, wasm] = await Promise.all([
139+ fetchAsset(workerAssets, artifact.sourcePath).then((response) =>
140+ response.text(),
141+ ),
142+ fetchAsset(workerAssets, artifact.wasmPath).then((response) =>
143+ response.arrayBuffer(),
144+ ),
145+ ]);
110146 return {
111− source: artifact.source,
112− wasm: base64ToArrayBuffer(artifact.wasmBase64),
147+ source,
148+ wasm,
113149 };
114150 },
115151 }),
apps/cloud/scripts/assert-worker-bundler-assets.mjs+54−0
@@ -0,0 +1,54 @@
1+import { readdirSync, readFileSync, statSync } from "node:fs";
2+import { join } from "node:path";
3+
4+const appRoot = new URL("..", import.meta.url);
5+const serverAssetsDir = new URL("dist/server/assets/", appRoot);
6+const clientAssetsDir = new URL("dist/client/assets/", appRoot);
7+const largeFileLimit = 3 * 1024 * 1024;
8+const sourceAssetMinimum = 512 * 1024;
9+
10+const fail = (message) => {
11+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build assertion script reports a failed artifact contract
12+ throw new Error(message);
13+};
14+
15+const fileSize = (dir, name) => statSync(join(dir.pathname, name)).size;
16+
17+const serverAssets = readdirSync(serverAssetsDir);
18+for (const name of serverAssets) {
19+ const path = join(serverAssetsDir.pathname, name);
20+ const size = statSync(path).size;
21+ if (size <= largeFileLimit) continue;
22+ if (name.includes("worker-bundler")) {
23+ fail(`server assets include oversized worker-bundler chunk ${name}`);
24+ }
25+ const sample = readFileSync(path, "utf8");
26+ if (sample.includes("worker-bundler")) {
27+ fail(`server assets include worker-bundler content in oversized chunk ${name}`);
28+ }
29+}
30+
31+const clientAssets = readdirSync(clientAssetsDir);
32+const source = clientAssets.find((name) => /^worker-bundler-source-[a-f0-9]{12}\.js$/.test(name));
33+const wasm = clientAssets.find((name) =>
34+ /^worker-bundler-esbuild-wasm-[a-f0-9]{12}\.wasm$/.test(name),
35+);
36+
37+if (source === undefined) fail("missing worker-bundler source asset");
38+if (wasm === undefined) fail("missing worker-bundler esbuild wasm asset");
39+
40+const sourceSize = fileSize(clientAssetsDir, source);
41+const wasmSize = fileSize(clientAssetsDir, wasm);
42+if (sourceSize <= sourceAssetMinimum) {
43+ fail(`worker-bundler source asset is unexpectedly small: ${source}`);
44+}
45+if (wasmSize <= largeFileLimit) fail(`worker-bundler wasm asset is unexpectedly small: ${wasm}`);
46+
47+const wasmMagic = readFileSync(join(clientAssetsDir.pathname, wasm)).subarray(0, 4);
48+if (!wasmMagic.equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) {
49+ fail(`worker-bundler wasm asset does not start with wasm magic bytes: ${wasm}`);
50+}
51+
52+console.log(
53+ `[assert-worker-bundler-assets] ${source} (${sourceSize} bytes), ${wasm} (${wasmSize} bytes)`,
54+);
apps/cloud/scripts/build.mjs+1−0
@@ -24,6 +24,7 @@ const steps = [ · 23 unmodified lines
2424 // imports, so they must be built before vite starts.
2525 "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps",
2626 "vite build",
27+ "node scripts/assert-worker-bundler-assets.mjs",
2728 ];
2829
2930 for (const step of steps) {
apps/cloud/src/engine/execution-stack.ts+2−0
@@ -64,6 +64,7 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { · 63 unmodified lines
6464 readonly activeToolkitSlug?: string;
6565 readonly allowLocalNetwork?: boolean;
6666 readonly workerLoader?: WorkerLoader;
67+ readonly workerAssets?: { readonly fetch: (request: Request) => Promise<Response> };
6768 }) => readonly AnyPlugin[];
6869
6970 // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault
@@ -80,6 +81,7 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 10 unmodified lines
8081 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
8182 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
8283 workerLoader: env.LOADER,
84+ workerAssets: env.ASSETS,
8385 }),
8486 });
8587
apps/cloud/src/env-augment.d.ts+4−0
@@ -22,6 +22,10 @@ declare global { · 21 unmodified lines
2222 DATABASE_URL?: string;
2323 EXECUTOR_DIRECT_DATABASE_URL?: string;
2424
25+ // Static asset binding emitted by @cloudflare/vite-plugin in
26+ // dist/server/wrangler.json as assets.directory = "../client".
27+ ASSETS: { readonly fetch: (request: Request) => Promise<Response> };
28+
2529 // Plugin blob seam backend (wrangler.jsonc `r2_buckets`). Declared here
2630 // (optional) rather than regenerating worker-configuration.d.ts: test
2731 // workers and older local setups run without the binding, and the db
apps/cloud/src/worker-bundler-artifact.d.ts+4−2
@@ -1,4 +1,6 @@
11 declare module "virtual:executor/worker-bundler-artifact" {
2− export const source: string;
3− export const wasmBase64: string;
2+ export const sourcePath: string;
3+ export const wasmPath: string;
4+ export const source: string | undefined;
5+ export const wasmBase64: string | undefined;
46 }
apps/cloud/wrangler.jsonc+6−0
@@ -4,6 +4,12 @@ · 3 unmodified lines
44 "compatibility_date": "2025-04-01",
55 "compatibility_flags": ["nodejs_compat"],
66 "main": "src/server.ts",
7+ // The vite plugin fills in assets.directory (dist/client); the binding must
8+ // be declared here or env.ASSETS does not exist at runtime. The apps plugin
9+ // fetches the worker-bundler artifact through it on first sync.
10+ "assets": {
11+ "binding": "ASSETS",
12+ },
713 "routes": [
814 {
915 "pattern": "executor.sh",
packages/plugins/apps/src/vite.ts+66−14
@@ -1,22 +1,74 @@
1+import { createHash } from "node:crypto";
2+import { mkdir, writeFile } from "node:fs/promises";
3+import { join } from "node:path";
14 import type { Plugin } from "vite";
25
36 import { bundledWorkerBundler } from "./pipeline/worker-bundler-artifact";
47
58 const virtualId = "virtual:executor/worker-bundler-artifact";
69 const resolvedVirtualId = `\0${virtualId}`;
10+const clientAssetsDir = "dist/client/assets";
711
8−export const workerBundlerArtifact = (): Plugin => ({
9− name: "executor-worker-bundler-artifact",
10− resolveId(id) {
11− return id === virtualId ? resolvedVirtualId : null;
12− },
13− async load(id) {
14− if (id !== resolvedVirtualId) return null;
15− const artifact = await bundledWorkerBundler();
16− return [
17− `export const source = ${JSON.stringify(artifact.source)};`,
18− `export const wasmBase64 = ${JSON.stringify(Buffer.from(artifact.wasm).toString("base64"))};`,
19− "export default { source, wasmBase64 };",
20− ].join("\n");
21− },
12+const digest = (value: string | Uint8Array): string =>
13+ createHash("sha256").update(value).digest("hex").slice(0, 12);
14+
15+const artifactPaths = (artifact: {
16+ readonly source: string;
17+ readonly wasm: Uint8Array;
18+}): {
19+ readonly sourcePath: string;
20+ readonly wasmPath: string;
21+} => ({
22+ sourcePath: `/assets/worker-bundler-source-${digest(artifact.source)}.js`,
23+ wasmPath: `/assets/worker-bundler-esbuild-wasm-${digest(artifact.wasm)}.wasm`,
2224 });
25+
26+export const workerBundlerArtifact = (): Plugin => {
27+ let command: "build" | "serve" = "build";
28+
29+ return {
30+ name: "executor-worker-bundler-artifact",
31+ configResolved(config) {
32+ command = config.command;
33+ },
34+ resolveId(id) {
35+ return id === virtualId ? resolvedVirtualId : null;
36+ },
37+ async load(id) {
38+ if (id !== resolvedVirtualId) return null;
39+ const artifact = await bundledWorkerBundler();
40+ const paths = artifactPaths(artifact);
41+ const output = [
42+ `export const sourcePath = ${JSON.stringify(paths.sourcePath)};`,
43+ `export const wasmPath = ${JSON.stringify(paths.wasmPath)};`,
44+ ];
45+
46+ if (command === "serve") {
47+ output.push(
48+ `export const source = ${JSON.stringify(artifact.source)};`,
49+ `export const wasmBase64 = ${JSON.stringify(Buffer.from(artifact.wasm).toString("base64"))};`,
50+ "export default { sourcePath, wasmPath, source, wasmBase64 };",
51+ );
52+ } else {
53+ output.push(
54+ "export const source = undefined;",
55+ "export const wasmBase64 = undefined;",
56+ "export default { sourcePath, wasmPath, source, wasmBase64 };",
57+ );
58+ }
59+
60+ return output.join("\n");
61+ },
62+ async closeBundle() {
63+ if (command !== "build") return;
64+ const artifact = await bundledWorkerBundler();
65+ const paths = artifactPaths(artifact);
66+ const outputDir = join(process.cwd(), clientAssetsDir);
67+ await mkdir(outputDir, { recursive: true });
68+ await Promise.all([
69+ writeFile(join(process.cwd(), "dist/client", paths.sourcePath), artifact.source),
70+ writeFile(join(process.cwd(), "dist/client", paths.wasmPath), artifact.wasm),
71+ ]);
72+ },
73+ };
74+};