diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 5e3e66266..5f2759f89 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -75,6 +75,9 @@ interface CloudPluginDeps { readonly globalOutbound?: null; }) => { readonly getEntrypoint: () => unknown }; }; + readonly workerAssets?: { + readonly fetch: (request: Request) => Promise; + }; } const base64ToArrayBuffer = (value: string): ArrayBuffer => { @@ -82,6 +85,20 @@ const base64ToArrayBuffer = (value: string): ArrayBuffer => { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); }; +const assetRequest = (path: string): Request => new Request(new URL(path, "https://assets.local")); + +const fetchAsset = async ( + assets: { readonly fetch: (request: Request) => Promise }, + path: string, +): Promise => { + const response = await assets.fetch(assetRequest(path)); + if (!response.ok) { + // 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 + throw new Error(`failed to fetch worker-bundler asset ${path}: ${response.status}`); + } + return response; +}; + export default defineExecutorConfig({ plugins: ({ workosCredentials, @@ -89,6 +106,7 @@ export default defineExecutorConfig({ activeToolkitSlug, allowLocalNetwork, workerLoader, + workerAssets, }: CloudPluginDeps = {}) => [ openApiHttpPlugin({ @@ -107,9 +125,27 @@ export default defineExecutorConfig({ loader: workerLoader, artifact: async () => { const artifact = await import("virtual:executor/worker-bundler-artifact"); + if (artifact.source !== undefined && artifact.wasmBase64 !== undefined) { + return { + source: artifact.source, + wasm: base64ToArrayBuffer(artifact.wasmBase64), + }; + } + if (workerAssets === undefined) { + // 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 + throw new Error("worker-bundler artifact assets binding is unavailable"); + } + const [source, wasm] = await Promise.all([ + fetchAsset(workerAssets, artifact.sourcePath).then((response) => + response.text(), + ), + fetchAsset(workerAssets, artifact.wasmPath).then((response) => + response.arrayBuffer(), + ), + ]); return { - source: artifact.source, - wasm: base64ToArrayBuffer(artifact.wasmBase64), + source, + wasm, }; }, }), diff --git a/apps/cloud/scripts/assert-worker-bundler-assets.mjs b/apps/cloud/scripts/assert-worker-bundler-assets.mjs new file mode 100644 index 000000000..71851487f --- /dev/null +++ b/apps/cloud/scripts/assert-worker-bundler-assets.mjs @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const appRoot = new URL("..", import.meta.url); +const serverAssetsDir = new URL("dist/server/assets/", appRoot); +const clientAssetsDir = new URL("dist/client/assets/", appRoot); +const largeFileLimit = 3 * 1024 * 1024; +const sourceAssetMinimum = 512 * 1024; + +const fail = (message) => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build assertion script reports a failed artifact contract + throw new Error(message); +}; + +const fileSize = (dir, name) => statSync(join(dir.pathname, name)).size; + +const serverAssets = readdirSync(serverAssetsDir); +for (const name of serverAssets) { + const path = join(serverAssetsDir.pathname, name); + const size = statSync(path).size; + if (size <= largeFileLimit) continue; + if (name.includes("worker-bundler")) { + fail(`server assets include oversized worker-bundler chunk ${name}`); + } + const sample = readFileSync(path, "utf8"); + if (sample.includes("worker-bundler")) { + fail(`server assets include worker-bundler content in oversized chunk ${name}`); + } +} + +const clientAssets = readdirSync(clientAssetsDir); +const source = clientAssets.find((name) => /^worker-bundler-source-[a-f0-9]{12}\.js$/.test(name)); +const wasm = clientAssets.find((name) => + /^worker-bundler-esbuild-wasm-[a-f0-9]{12}\.wasm$/.test(name), +); + +if (source === undefined) fail("missing worker-bundler source asset"); +if (wasm === undefined) fail("missing worker-bundler esbuild wasm asset"); + +const sourceSize = fileSize(clientAssetsDir, source); +const wasmSize = fileSize(clientAssetsDir, wasm); +if (sourceSize <= sourceAssetMinimum) { + fail(`worker-bundler source asset is unexpectedly small: ${source}`); +} +if (wasmSize <= largeFileLimit) fail(`worker-bundler wasm asset is unexpectedly small: ${wasm}`); + +const wasmMagic = readFileSync(join(clientAssetsDir.pathname, wasm)).subarray(0, 4); +if (!wasmMagic.equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) { + fail(`worker-bundler wasm asset does not start with wasm magic bytes: ${wasm}`); +} + +console.log( + `[assert-worker-bundler-assets] ${source} (${sourceSize} bytes), ${wasm} (${wasmSize} bytes)`, +); diff --git a/apps/cloud/scripts/build.mjs b/apps/cloud/scripts/build.mjs index 4310cabdf..707bfc60a 100644 --- a/apps/cloud/scripts/build.mjs +++ b/apps/cloud/scripts/build.mjs @@ -24,6 +24,7 @@ const steps = [ // imports, so they must be built before vite starts. "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps", "vite build", + "node scripts/assert-worker-bundler-assets.mjs", ]; for (const step of steps) { diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index de0e7d15d..deba6b7e4 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -64,6 +64,7 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean; readonly workerLoader?: WorkerLoader; + readonly workerAssets?: { readonly fetch: (request: Request) => Promise }; }) => readonly AnyPlugin[]; // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault @@ -80,6 +81,7 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", workerLoader: env.LOADER, + workerAssets: env.ASSETS, }), }); diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index adb3e3b2d..4ef130bfa 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -22,6 +22,10 @@ declare global { DATABASE_URL?: string; EXECUTOR_DIRECT_DATABASE_URL?: string; + // Static asset binding emitted by @cloudflare/vite-plugin in + // dist/server/wrangler.json as assets.directory = "../client". + ASSETS: { readonly fetch: (request: Request) => Promise }; + // Plugin blob seam backend (wrangler.jsonc `r2_buckets`). Declared here // (optional) rather than regenerating worker-configuration.d.ts: test // workers and older local setups run without the binding, and the db diff --git a/apps/cloud/src/worker-bundler-artifact.d.ts b/apps/cloud/src/worker-bundler-artifact.d.ts index e0a8632b8..ad70339ab 100644 --- a/apps/cloud/src/worker-bundler-artifact.d.ts +++ b/apps/cloud/src/worker-bundler-artifact.d.ts @@ -1,4 +1,6 @@ declare module "virtual:executor/worker-bundler-artifact" { - export const source: string; - export const wasmBase64: string; + export const sourcePath: string; + export const wasmPath: string; + export const source: string | undefined; + export const wasmBase64: string | undefined; } diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 8a656b4af..267f428ef 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -4,6 +4,12 @@ "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], "main": "src/server.ts", + // The vite plugin fills in assets.directory (dist/client); the binding must + // be declared here or env.ASSETS does not exist at runtime. The apps plugin + // fetches the worker-bundler artifact through it on first sync. + "assets": { + "binding": "ASSETS", + }, "routes": [ { "pattern": "executor.sh", diff --git a/packages/plugins/apps/src/vite.ts b/packages/plugins/apps/src/vite.ts index d5f1d26c8..0de2d37d5 100644 --- a/packages/plugins/apps/src/vite.ts +++ b/packages/plugins/apps/src/vite.ts @@ -1,22 +1,74 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import type { Plugin } from "vite"; import { bundledWorkerBundler } from "./pipeline/worker-bundler-artifact"; const virtualId = "virtual:executor/worker-bundler-artifact"; const resolvedVirtualId = `\0${virtualId}`; +const clientAssetsDir = "dist/client/assets"; -export const workerBundlerArtifact = (): Plugin => ({ - name: "executor-worker-bundler-artifact", - resolveId(id) { - return id === virtualId ? resolvedVirtualId : null; - }, - async load(id) { - if (id !== resolvedVirtualId) return null; - const artifact = await bundledWorkerBundler(); - return [ - `export const source = ${JSON.stringify(artifact.source)};`, - `export const wasmBase64 = ${JSON.stringify(Buffer.from(artifact.wasm).toString("base64"))};`, - "export default { source, wasmBase64 };", - ].join("\n"); - }, +const digest = (value: string | Uint8Array): string => + createHash("sha256").update(value).digest("hex").slice(0, 12); + +const artifactPaths = (artifact: { + readonly source: string; + readonly wasm: Uint8Array; +}): { + readonly sourcePath: string; + readonly wasmPath: string; +} => ({ + sourcePath: `/assets/worker-bundler-source-${digest(artifact.source)}.js`, + wasmPath: `/assets/worker-bundler-esbuild-wasm-${digest(artifact.wasm)}.wasm`, }); + +export const workerBundlerArtifact = (): Plugin => { + let command: "build" | "serve" = "build"; + + return { + name: "executor-worker-bundler-artifact", + configResolved(config) { + command = config.command; + }, + resolveId(id) { + return id === virtualId ? resolvedVirtualId : null; + }, + async load(id) { + if (id !== resolvedVirtualId) return null; + const artifact = await bundledWorkerBundler(); + const paths = artifactPaths(artifact); + const output = [ + `export const sourcePath = ${JSON.stringify(paths.sourcePath)};`, + `export const wasmPath = ${JSON.stringify(paths.wasmPath)};`, + ]; + + if (command === "serve") { + output.push( + `export const source = ${JSON.stringify(artifact.source)};`, + `export const wasmBase64 = ${JSON.stringify(Buffer.from(artifact.wasm).toString("base64"))};`, + "export default { sourcePath, wasmPath, source, wasmBase64 };", + ); + } else { + output.push( + "export const source = undefined;", + "export const wasmBase64 = undefined;", + "export default { sourcePath, wasmPath, source, wasmBase64 };", + ); + } + + return output.join("\n"); + }, + async closeBundle() { + if (command !== "build") return; + const artifact = await bundledWorkerBundler(); + const paths = artifactPaths(artifact); + const outputDir = join(process.cwd(), clientAssetsDir); + await mkdir(outputDir, { recursive: true }); + await Promise.all([ + writeFile(join(process.cwd(), "dist/client", paths.sourcePath), artifact.source), + writeFile(join(process.cwd(), "dist/client", paths.wasmPath), artifact.wasm), + ]); + }, + }; +};