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
75 readonly globalOutbound?: null;75 readonly globalOutbound?: null;
76 }) => { readonly getEntrypoint: () => unknown };76 }) => { readonly getEntrypoint: () => unknown };
77 };77 };
78 readonly workerAssets?: {
79 readonly fetch: (request: Request) => Promise<Response>;
80 };
78}81}
79 82
80const base64ToArrayBuffer = (value: string): ArrayBuffer => {83const base64ToArrayBuffer = (value: string): ArrayBuffer => {
@@ -82,6 +85,20 @@ const base64ToArrayBuffer = (value: string): ArrayBuffer => { · 1 unmodified line
82 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);85 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
83};86};
84 87
88const assetRequest = (path: string): Request => new Request(new URL(path, "https://assets.local"));
89
90const 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
85export default defineExecutorConfig({102export default defineExecutorConfig({
86 plugins: ({103 plugins: ({
87 workosCredentials,104 workosCredentials,
@@ -89,6 +106,7 @@ export default defineExecutorConfig({ · 1 unmodified line
89 activeToolkitSlug,106 activeToolkitSlug,
90 allowLocalNetwork,107 allowLocalNetwork,
91 workerLoader,108 workerLoader,
109 workerAssets,
92 }: CloudPluginDeps = {}) =>110 }: CloudPluginDeps = {}) =>
93 [111 [
94 openApiHttpPlugin({112 openApiHttpPlugin({
@@ -107,9 +125,27 @@ export default defineExecutorConfig({ · 12 unmodified lines
107 loader: workerLoader,125 loader: workerLoader,
108 artifact: async () => {126 artifact: async () => {
109 const artifact = await import("virtual:executor/worker-bundler-artifact");127 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 ]);
110 return {146 return {
111 source: artifact.source,147 source,
112 wasm: base64ToArrayBuffer(artifact.wasmBase64),148 wasm,
113 };149 };
114 },150 },
115 }),151 }),
apps/cloud/scripts/assert-worker-bundler-assets.mjs+54−0
@@ -0,0 +1,54 @@
1import { readdirSync, readFileSync, statSync } from "node:fs";
2import { join } from "node:path";
3
4const appRoot = new URL("..", import.meta.url);
5const serverAssetsDir = new URL("dist/server/assets/", appRoot);
6const clientAssetsDir = new URL("dist/client/assets/", appRoot);
7const largeFileLimit = 3 * 1024 * 1024;
8const sourceAssetMinimum = 512 * 1024;
9
10const 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
15const fileSize = (dir, name) => statSync(join(dir.pathname, name)).size;
16
17const serverAssets = readdirSync(serverAssetsDir);
18for (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
31const clientAssets = readdirSync(clientAssetsDir);
32const source = clientAssets.find((name) => /^worker-bundler-source-[a-f0-9]{12}\.js$/.test(name));
33const wasm = clientAssets.find((name) =>
34 /^worker-bundler-esbuild-wasm-[a-f0-9]{12}\.wasm$/.test(name),
35);
36
37if (source === undefined) fail("missing worker-bundler source asset");
38if (wasm === undefined) fail("missing worker-bundler esbuild wasm asset");
39
40const sourceSize = fileSize(clientAssetsDir, source);
41const wasmSize = fileSize(clientAssetsDir, wasm);
42if (sourceSize <= sourceAssetMinimum) {
43 fail(`worker-bundler source asset is unexpectedly small: ${source}`);
44}
45if (wasmSize <= largeFileLimit) fail(`worker-bundler wasm asset is unexpectedly small: ${wasm}`);
46
47const wasmMagic = readFileSync(join(clientAssetsDir.pathname, wasm)).subarray(0, 4);
48if (!wasmMagic.equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) {
49 fail(`worker-bundler wasm asset does not start with wasm magic bytes: ${wasm}`);
50}
51
52console.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
24 // imports, so they must be built before vite starts.24 // imports, so they must be built before vite starts.
25 "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps",25 "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps",
26 "vite build",26 "vite build",
27 "node scripts/assert-worker-bundler-assets.mjs",
27];28];
28 29
29for (const step of steps) {30for (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
64 readonly activeToolkitSlug?: string;64 readonly activeToolkitSlug?: string;
65 readonly allowLocalNetwork?: boolean;65 readonly allowLocalNetwork?: boolean;
66 readonly workerLoader?: WorkerLoader;66 readonly workerLoader?: WorkerLoader;
67 readonly workerAssets?: { readonly fetch: (request: Request) => Promise<Response> };
67}) => readonly AnyPlugin[];68}) => readonly AnyPlugin[];
68 69
69// Fresh plugin instances per request, carrying the Worker env's WorkOS Vault70// 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
80 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,81 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
81 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",82 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
82 workerLoader: env.LOADER,83 workerLoader: env.LOADER,
84 workerAssets: env.ASSETS,
83 }),85 }),
84});86});
85 87
apps/cloud/src/env-augment.d.ts+4−0
@@ -22,6 +22,10 @@ declare global { · 21 unmodified lines
22 DATABASE_URL?: string;22 DATABASE_URL?: string;
23 EXECUTOR_DIRECT_DATABASE_URL?: string;23 EXECUTOR_DIRECT_DATABASE_URL?: string;
24 24
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
25 // Plugin blob seam backend (wrangler.jsonc `r2_buckets`). Declared here29 // Plugin blob seam backend (wrangler.jsonc `r2_buckets`). Declared here
26 // (optional) rather than regenerating worker-configuration.d.ts: test30 // (optional) rather than regenerating worker-configuration.d.ts: test
27 // workers and older local setups run without the binding, and the db31 // 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 @@
1declare module "virtual:executor/worker-bundler-artifact" {1declare module "virtual:executor/worker-bundler-artifact" {
2 export const source: string;2 export const sourcePath: string;
3 export const wasmBase64: string;3 export const wasmPath: string;
4 export const source: string | undefined;
5 export const wasmBase64: string | undefined;
4}6}
apps/cloud/wrangler.jsonc+6−0
@@ -4,6 +4,12 @@ · 3 unmodified lines
4 "compatibility_date": "2025-04-01",4 "compatibility_date": "2025-04-01",
5 "compatibility_flags": ["nodejs_compat"],5 "compatibility_flags": ["nodejs_compat"],
6 "main": "src/server.ts",6 "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 },
7 "routes": [13 "routes": [
8 {14 {
9 "pattern": "executor.sh",15 "pattern": "executor.sh",
packages/plugins/apps/src/vite.ts+66−14
@@ -1,22 +1,74 @@
1import { createHash } from "node:crypto";
2import { mkdir, writeFile } from "node:fs/promises";
3import { join } from "node:path";
1import type { Plugin } from "vite";4import type { Plugin } from "vite";
2 5
3import { bundledWorkerBundler } from "./pipeline/worker-bundler-artifact";6import { bundledWorkerBundler } from "./pipeline/worker-bundler-artifact";
4 7
5const virtualId = "virtual:executor/worker-bundler-artifact";8const virtualId = "virtual:executor/worker-bundler-artifact";
6const resolvedVirtualId = `\0${virtualId}`;9const resolvedVirtualId = `\0${virtualId}`;
10const clientAssetsDir = "dist/client/assets";
7 11
8export const workerBundlerArtifact = (): Plugin => ({12const digest = (value: string | Uint8Array): string =>
9 name: "executor-worker-bundler-artifact",13 createHash("sha256").update(value).digest("hex").slice(0, 12);
10 resolveId(id) {14
11 return id === virtualId ? resolvedVirtualId : null;15const artifactPaths = (artifact: {
12 },16 readonly source: string;
13 async load(id) {17 readonly wasm: Uint8Array;
14 if (id !== resolvedVirtualId) return null;18}): {
15 const artifact = await bundledWorkerBundler();19 readonly sourcePath: string;
16 return [20 readonly wasmPath: string;
17 `export const source = ${JSON.stringify(artifact.source)};`,21} => ({
18 `export const wasmBase64 = ${JSON.stringify(Buffer.from(artifact.wasm).toString("base64"))};`,22 sourcePath: `/assets/worker-bundler-source-${digest(artifact.source)}.js`,
19 "export default { source, wasmBase64 };",23 wasmPath: `/assets/worker-bundler-esbuild-wasm-${digest(artifact.wasm)}.wasm`,
20 ].join("\n");
21 },
22});24});
25
26export 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};