diff --git a/.changeset/cloud-app-bundler-worker.md b/.changeset/cloud-app-bundler-worker.md new file mode 100644 index 000000000..190129218 --- /dev/null +++ b/.changeset/cloud-app-bundler-worker.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Run cloud custom tool bundling in a dynamically loaded Worker so dependency installation and bundling do not share the serving request isolate. diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 07f2a6fdc..5e3e66266 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -12,7 +12,7 @@ import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { makeDynamicWorkerAppToolExecutor, - makeNativeWorkerBundlerBackend, + makeDynamicWorkerBundlerBackend, } from "@executor-js/plugin-apps/cloud"; import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; @@ -58,18 +58,30 @@ interface CloudPluginDeps { readonly allowLocalNetwork?: boolean; readonly workerLoader?: { readonly get: ( - name: string, + name: string | null, factory: () => { readonly compatibilityDate: string; - readonly compatibilityFlags?: string[]; + readonly compatibilityFlags?: readonly string[]; readonly mainModule: string; - readonly modules: Readonly>; + readonly modules: Readonly>; readonly globalOutbound?: null; }, ) => { readonly getEntrypoint: () => unknown }; + readonly load?: (code: { + readonly compatibilityDate: string; + readonly compatibilityFlags?: readonly string[]; + readonly mainModule: string; + readonly modules: Readonly>; + readonly globalOutbound?: null; + }) => { readonly getEntrypoint: () => unknown }; }; } +const base64ToArrayBuffer = (value: string): ArrayBuffer => { + const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +}; + export default defineExecutorConfig({ plugins: ({ workosCredentials, @@ -91,7 +103,16 @@ export default defineExecutorConfig({ ...(workerLoader ? { executor: makeDynamicWorkerAppToolExecutor({ loader: workerLoader }), - bundler: makeNativeWorkerBundlerBackend(), + bundler: makeDynamicWorkerBundlerBackend({ + loader: workerLoader, + artifact: async () => { + const artifact = await import("virtual:executor/worker-bundler-artifact"); + return { + source: artifact.source, + wasm: base64ToArrayBuffer(artifact.wasmBase64), + }; + }, + }), } : {}), sourceKinds: ["git"], diff --git a/apps/cloud/scripts/build.mjs b/apps/cloud/scripts/build.mjs index aeae418e3..4310cabdf 100644 --- a/apps/cloud/scripts/build.mjs +++ b/apps/cloud/scripts/build.mjs @@ -22,7 +22,7 @@ const steps = [ // Workspace packages whose exports app code (or this very vite config) // resolves from dist under Node — vite's config loader externalizes bare // imports, so they must be built before vite starts. - "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react", + "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps", "vite build", ]; diff --git a/apps/cloud/src/worker-bundler-artifact.d.ts b/apps/cloud/src/worker-bundler-artifact.d.ts new file mode 100644 index 000000000..e0a8632b8 --- /dev/null +++ b/apps/cloud/src/worker-bundler-artifact.d.ts @@ -0,0 +1,4 @@ +declare module "virtual:executor/worker-bundler-artifact" { + export const source: string; + export const wasmBase64: string; +} diff --git a/apps/cloud/tsconfig.json b/apps/cloud/tsconfig.json index 129885505..29ae3c87a 100644 --- a/apps/cloud/tsconfig.json +++ b/apps/cloud/tsconfig.json @@ -3,6 +3,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 4cdbd7cf4..83be48137 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -5,6 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { workerBundlerArtifact } from "@executor-js/plugin-apps/vite"; import { unstable_readConfig } from "wrangler"; import { routes } from "./tsr.routes"; @@ -125,6 +126,7 @@ export default defineConfig(({ command, mode }) => { }, plugins: [ devCrashGuard(), + workerBundlerArtifact(), tailwindcss(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), diff --git a/bun.lock b/bun.lock index 3663a8347..e2362eb33 100644 --- a/bun.lock +++ b/bun.lock @@ -803,6 +803,7 @@ "zod": "4.3.6", }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.0", "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/react": "workspace:*", @@ -812,6 +813,7 @@ "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", + "vite": "catalog:", "vitest": "catalog:", }, "peerDependencies": { diff --git a/e2e/fixtures/custom-tools-git/custom-tools-shas.txt b/e2e/fixtures/custom-tools-git/custom-tools-shas.txt index 64f257947..ca359a139 100644 --- a/e2e/fixtures/custom-tools-git/custom-tools-shas.txt +++ b/e2e/fixtures/custom-tools-git/custom-tools-shas.txt @@ -1,3 +1,3 @@ -48e7995f99423b64c8adf2aa07186571d9e1f66e -b8de904b34df6cad1dc29a49986f3703c0fe87f4 -251a747551ac772f029d3f37e2e708e8fed20ee9 +1f4b8f61f800b8164678f38c1c8c325e38baaf36 +252ca58f9bea3e7dacaae04b828c57a497a6233c +f8b60f186b4a637bb5e5527da2a29da0f13b8683 diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v1.pack b/e2e/fixtures/custom-tools-git/custom-tools-v1.pack index f547c9245..086e39354 100644 Binary files a/e2e/fixtures/custom-tools-git/custom-tools-v1.pack and b/e2e/fixtures/custom-tools-git/custom-tools-v1.pack differ diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v2.pack b/e2e/fixtures/custom-tools-git/custom-tools-v2.pack index 2cd3e1987..830215f20 100644 Binary files a/e2e/fixtures/custom-tools-git/custom-tools-v2.pack and b/e2e/fixtures/custom-tools-git/custom-tools-v2.pack differ diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v3.pack b/e2e/fixtures/custom-tools-git/custom-tools-v3.pack index 2743fd360..fff2e2477 100644 Binary files a/e2e/fixtures/custom-tools-git/custom-tools-v3.pack and b/e2e/fixtures/custom-tools-git/custom-tools-v3.pack differ diff --git a/e2e/fixtures/custom-tools-git/generate.ts b/e2e/fixtures/custom-tools-git/generate.ts new file mode 100644 index 000000000..94667b96c --- /dev/null +++ b/e2e/fixtures/custom-tools-git/generate.ts @@ -0,0 +1,158 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixtureDir = dirname(fileURLToPath(import.meta.url)); + +const run = (cwd: string, args: readonly string[], stdin?: string): string => { + const proc = Bun.spawnSync({ + cmd: ["git", ...args], + cwd, + stdin: stdin ? new TextEncoder().encode(stdin) : undefined, + }); + if (!proc.success) { + throw new Error( + `git ${args.join(" ")} failed\n${proc.stderr.toString()}\n${proc.stdout.toString()}`, + ); + } + return proc.stdout.toString().trim(); +}; + +const write = async (root: string, path: string, body: string) => { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), body); +}; + +const packageJson = JSON.stringify({ dependencies: { effect: "4.0.0-beta.59", zod: "4.3.6" } }); +const executorJson = JSON.stringify({ description: "Custom tools e2e fixture" }); + +const echoTool = `import { z } from "zod"; +import { defineTool, integration } from "executor:app"; + +export default defineTool({ + description: "Echo a message through a projected app connection.", + integrations: { apps: integration("repo") }, + input: z.object({ message: z.string() }), + output: { + type: "object", + properties: { message: { type: "string" }, version: { type: "string" } }, + required: ["message", "version"], + }, + annotations: { readOnly: true }, + async handler(input) { + return { message: input.message, version: "v1" }; + }, +}); +`; + +const staticTool = `import { defineTool } from "executor:app"; + +export default defineTool({ + description: "Return a static fixture marker.", + input: { type: "object", properties: {} }, + output: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + }, + annotations: { readOnly: true }, + async handler() { + return { ok: true }; + }, +}); +`; + +const effectTool = `import { Effect } from "effect"; +import { defineTool } from "executor:app"; + +export default defineTool({ + description: "Return a marker produced through Effect.", + input: { type: "object", properties: {} }, + output: { + type: "object", + properties: { ok: { type: "boolean" }, dependency: { type: "string" } }, + required: ["ok", "dependency"], + }, + annotations: { readOnly: true }, + async handler() { + return Effect.runSync(Effect.succeed({ ok: true, dependency: "effect" })); + }, +}); +`; + +const extraTool = `import { defineTool } from "executor:app"; + +export default defineTool({ + description: "Return a second static fixture marker.", + input: { type: "object", properties: {} }, + output: { + type: "object", + properties: { added: { type: "boolean" } }, + required: ["added"], + }, + annotations: { readOnly: true }, + async handler() { + return { added: true }; + }, +}); +`; + +const badCollectTool = `import { defineTool } from "executor:app"; + +const input = { type: "object", properties: {} }; + +export default { + "bad-collect": defineTool({ + description: "Trigger a collect-stage fixture failure.", + input, + async handler() { + return { ok: true }; + }, + }), +}; +`; + +const commit = (root: string, message: string): string => { + run(root, ["add", "."]); + run(root, ["commit", "-q", "-m", message]); + return run(root, ["rev-parse", "HEAD"]); +}; + +const pack = async (root: string, sha: string, name: string) => { + const proc = Bun.spawnSync({ + cmd: ["git", "pack-objects", "--stdout", "--revs"], + cwd: root, + stdin: new TextEncoder().encode(`${sha}\n`), + }); + if (!proc.success) throw new Error(`git pack-objects failed: ${proc.stderr.toString()}`); + await writeFile(join(fixtureDir, name), proc.stdout); +}; + +const root = await mkdtemp(join(tmpdir(), "custom-tools-git-")); +try { + run(root, ["init", "-q"]); + run(root, ["config", "user.name", "Fixture Generator"]); + run(root, ["config", "user.email", "fixture@example.test"]); + + await write(root, "executor.json", executorJson); + await write(root, "package.json", packageJson); + await write(root, "bun.lock", ""); + await write(root, "tools/echo-tool.ts", echoTool); + await write(root, "tools/static-tool.ts", staticTool); + await write(root, "tools/effect-tool.ts", effectTool); + const sha1 = commit(root, "custom tools v1"); + + await write(root, "tools/extra-tool.ts", extraTool); + const sha2 = commit(root, "custom tools v2"); + + await write(root, "tools/bad-collect.ts", badCollectTool); + const sha3 = commit(root, "custom tools v3"); + + await writeFile(join(fixtureDir, "custom-tools-shas.txt"), `${sha1}\n${sha2}\n${sha3}\n`); + await pack(root, sha1, "custom-tools-v1.pack"); + await pack(root, sha2, "custom-tools-v2.pack"); + await pack(root, sha3, "custom-tools-v3.pack"); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/e2e/scenarios/custom-tools.test.ts b/e2e/scenarios/custom-tools.test.ts index 87560be71..2c7a351c0 100644 --- a/e2e/scenarios/custom-tools.test.ts +++ b/e2e/scenarios/custom-tools.test.ts @@ -232,10 +232,10 @@ const addSourceThroughConsole = (input: { await page.locator('input[type="password"]').fill(FIXTURE_GIT_TOKEN); await page.getByRole("button", { name: "Sync source" }).click(); await page.waitForURL(/\/integrations\/repo(?:\?|$)/, { timeout: 90_000 }); - await page.getByLabel("Source").getByText("2 tools").waitFor({ timeout: 90_000 }); + await page.getByLabel("Source").getByText("3 tools").waitFor({ timeout: 90_000 }); await page.getByRole("link", { name: "repo" }).waitFor({ timeout: 90_000 }); await page.getByRole("tab", { name: "Tools" }).click(); - await page.getByRole("button", { name: /repo\s+2/ }).click(); + await page.getByRole("button", { name: /repo\s+3/ }).click(); await page.getByRole("button", { name: "echo-tool", exact: true }).click(); await page.getByRole("tab", { name: "Run" }).click(); expect(await page.getByLabel("Connection").count()).toBe(0); @@ -411,7 +411,11 @@ scenario( const tools = yield* Effect.promise(() => request(target, identity, "/api/tools?integration=repo"), ); - expect(tools.body.map((tool) => tool.name).sort()).toEqual(["echo-tool", "static-tool"]); + expect(tools.body.map((tool) => tool.name).sort()).toEqual([ + "echo-tool", + "effect-tool", + "static-tool", + ]); const echoTool = tools.body.find((tool) => tool.name === "echo-tool"); expect(echoTool).toBeDefined(); @@ -454,6 +458,15 @@ scenario( expect(JSON.stringify(invoked.structured)).toContain("hello"); expect(JSON.stringify(invoked.structured)).toContain("v1"); + const effectInvoked = yield* Effect.promise(() => + execute(target, identity, `return await tools["repo.org.published.effect-tool"]({});`), + ); + expect(effectInvoked.status, effectInvoked.text).toBe("completed"); + expect(effectInvoked.isError, effectInvoked.text).toBe(false); + expect(effectInvoked.structured).toMatchObject({ + result: { ok: true, data: { ok: true, dependency: "effect" } }, + }); + expect(git.packRequests(), "initial publish fetched one pack").toBe(1); git.advance(); yield* syncSourceInConsole({ @@ -461,7 +474,7 @@ scenario( browser, identity, expectedNotice: "Added: extra-tool", - expectedToolCount: "3 tools", + expectedToolCount: "4 tools", }); yield* syncSourceInConsole({ @@ -469,7 +482,7 @@ scenario( browser, identity, expectedNotice: "Already up to date.", - expectedToolCount: "3 tools", + expectedToolCount: "4 tools", }); git.failCollect(); @@ -480,6 +493,7 @@ scenario( ); expect(afterFailedCollect.body.map((tool) => tool.name).sort()).toEqual([ "echo-tool", + "effect-tool", "extra-tool", "static-tool", ]); @@ -490,7 +504,7 @@ scenario( browser, identity, expectedNotice: "Already up to date.", - expectedToolCount: "3 tools", + expectedToolCount: "4 tools", }); yield* removeSourceThroughConsole({ target, browser, identity }); diff --git a/package.json b/package.json index bf9fa84ba..a703f82ce 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "release:publish:packages:prepare": "bun run scripts/publish-packages.ts --prepare-only", "release:smoke:packages": "bun run scripts/smoke-test-packed.ts", "clean": "bun run scripts/clean.ts", - "prepare": "effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/react build" + "prepare": "effect-language-service patch && effect-tsgo patch && bun run --cwd packages/core/vite-plugin build:bundle && bun run --cwd packages/plugins/apps build:bundle && bun run --cwd packages/react build" }, "dependencies": {}, "devDependencies": { diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 34f4062e1..e8798bafe 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -23,10 +23,16 @@ "./client": "./src/react/plugin-client.tsx", "./react": "./src/react/index.ts", "./selfhost": "./src/selfhost.ts", - "./testing": "./src/testing/index.ts" + "./testing": "./src/testing/index.ts", + "./vite": { + "types": "./src/vite.ts", + "bun": "./src/vite.ts", + "default": "./dist/vite.js" + } }, "scripts": { "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "build:bundle": "tsup", "typecheck": "tsgo --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -41,6 +47,7 @@ "zod": "4.3.6" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.15.0", "@effect/atom-react": "catalog:", "@effect/vitest": "catalog:", "@executor-js/react": "workspace:*", @@ -50,6 +57,7 @@ "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", + "vite": "catalog:", "vitest": "catalog:" }, "peerDependencies": { diff --git a/packages/plugins/apps/src/cloud.ts b/packages/plugins/apps/src/cloud.ts index db97f063c..c07e5d3f6 100644 --- a/packages/plugins/apps/src/cloud.ts +++ b/packages/plugins/apps/src/cloud.ts @@ -1,3 +1,3 @@ export { makeDynamicWorkerAppToolExecutor } from "./executor/dynamic-worker-app-tool-executor"; -export { makeNativeWorkerBundlerBackend } from "./pipeline/native-worker-bundler"; +export { makeDynamicWorkerBundlerBackend } from "./pipeline/dynamic-worker-bundler"; export { WORKER_BUNDLER_VERSION } from "./pipeline/worker-bundler-version"; diff --git a/packages/plugins/apps/src/cloudflare-workers-test.d.ts b/packages/plugins/apps/src/cloudflare-workers-test.d.ts new file mode 100644 index 000000000..55d5929c9 --- /dev/null +++ b/packages/plugins/apps/src/cloudflare-workers-test.d.ts @@ -0,0 +1,3 @@ +declare module "cloudflare:workers" { + export const env: unknown; +} diff --git a/packages/plugins/apps/src/executor/app-tool-executor.ts b/packages/plugins/apps/src/executor/app-tool-executor.ts index 175b927a1..90be90bf4 100644 --- a/packages/plugins/apps/src/executor/app-tool-executor.ts +++ b/packages/plugins/apps/src/executor/app-tool-executor.ts @@ -54,6 +54,11 @@ export interface InvokeOutcome { readonly output: unknown; } +export interface AppToolInvokeLimits { + readonly timeoutMs: number; + readonly isolateKey?: string; +} + export interface AppToolExecutor { readonly collect: ( bundle: string, @@ -64,7 +69,7 @@ export interface AppToolExecutor { entry: { readonly toolName: string }, input: unknown, bridge: AppToolBridge, - limits: { readonly timeoutMs: number }, + limits: AppToolInvokeLimits, ) => Effect.Effect; } diff --git a/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts b/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts index f55262a9d..50d377266 100644 --- a/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts +++ b/packages/plugins/apps/src/executor/dynamic-worker-app-tool-executor.ts @@ -19,8 +19,9 @@ type DynamicWorkerDefinition = { }; type DynamicWorkerLoader = { - readonly get: ( - name: string, + readonly load?: (code: DynamicWorkerDefinition) => { readonly getEntrypoint: () => unknown }; + readonly get?: ( + name: string | null, factory: () => DynamicWorkerDefinition, ) => { readonly getEntrypoint: () => unknown }; }; @@ -76,6 +77,10 @@ const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: strin cause, }); +// Bump this when appWorkerModule() changes so stable Worker Loader IDs do not +// reuse an isolate running an older driver around byte-identical app bundles. +export const DRIVER_VERSION = "1"; + const appWorkerModule = (): string => ` import { WorkerEntrypoint } from "cloudflare:workers"; import artifact from "./app.js"; @@ -256,21 +261,54 @@ export default class AppExecutor extends WorkerEntrypoint { const asEntrypoint = (value: unknown): DynamicAppEntrypoint => value as DynamicAppEntrypoint; -const startWorker = (loader: DynamicWorkerLoader, bundle: string): DynamicAppEntrypoint => - asEntrypoint( - loader - .get(`app-tool-${crypto.randomUUID()}`, () => ({ - compatibilityDate: "2025-06-01", - compatibilityFlags: ["nodejs_compat"], - mainModule: "driver.js", - modules: { - "driver.js": appWorkerModule(), - "app.js": bundle, - }, - globalOutbound: null, - })) - .getEntrypoint(), - ); +type StartedDynamicAppWorker = { + readonly stub: { readonly getEntrypoint: () => unknown }; + readonly entrypoint: DynamicAppEntrypoint; +}; + +const workerDefinition = (bundle: string): DynamicWorkerDefinition => ({ + compatibilityDate: "2025-06-01", + compatibilityFlags: ["nodejs_compat"], + mainModule: "driver.js", + modules: { + "driver.js": appWorkerModule(), + "app.js": bundle, + }, + globalOutbound: null, +}); + +const startOneShotWorker = ( + loader: DynamicWorkerLoader, + bundle: string, +): StartedDynamicAppWorker => { + const definition = workerDefinition(bundle); + const worker = + loader.load !== undefined + ? loader.load(definition) + : loader.get?.(`app-tool-${crypto.randomUUID()}`, () => definition); + if (worker === undefined) { + throw new AppExecutorError({ + kind: "collect", + message: "Worker Loader binding does not support load() or get()", + }); + } + return { stub: worker, entrypoint: asEntrypoint(worker.getEntrypoint()) }; +}; + +const startStableWorker = ( + loader: DynamicWorkerLoader, + bundle: string, + isolateKey: string, +): StartedDynamicAppWorker => { + const worker = loader.get?.(isolateKey, () => workerDefinition(bundle)); + if (worker === undefined) { + throw new AppExecutorError({ + kind: "invoke", + message: "Worker Loader binding does not support get()", + }); + } + return { stub: worker, entrypoint: asEntrypoint(worker.getEntrypoint()) }; +}; export const makeDynamicWorkerAppToolExecutor = ( options: DynamicWorkerAppToolExecutorOptions, @@ -278,7 +316,8 @@ export const makeDynamicWorkerAppToolExecutor = ( collect: (bundle, input) => Effect.tryPromise({ try: async () => { - const response = await startWorker(options.loader, bundle).collect(input); + const worker = startOneShotWorker(options.loader, bundle); + const response = await worker.entrypoint.collect(input); if (!response.ok) throw toAppExecutorError(response, "collect"); if (!stableStringify(response.value)) throw new Error(`collect failed for ${input.sourcePath}`); @@ -289,7 +328,12 @@ export const makeDynamicWorkerAppToolExecutor = ( invoke: (bundle, entry, input, bridge, limits) => Effect.tryPromise({ try: async () => { - const response = await startWorker(options.loader, bundle).invoke( + const worker = startStableWorker( + options.loader, + bundle, + limits.isolateKey ?? `app-tool-${crypto.randomUUID()}`, + ); + const response = await worker.entrypoint.invoke( { toolName: entry.toolName, input, timeoutMs: limits.timeoutMs }, bridge, ); diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 893271538..15eae167c 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -3,5 +3,5 @@ export * from "./authoring"; export { makeWorkerdAppToolExecutor } from "./executor/workerd-app-tool-executor"; export { makeDynamicWorkerAppToolExecutor } from "./executor/dynamic-worker-app-tool-executor"; export { makeWorkerBundlerBackend } from "./pipeline/worker-bundler"; +export { makeDynamicWorkerBundlerBackend } from "./pipeline/dynamic-worker-bundler"; export { WORKER_BUNDLER_VERSION } from "./pipeline/worker-bundler-version"; -export { makeNativeWorkerBundlerBackend } from "./pipeline/native-worker-bundler"; diff --git a/packages/plugins/apps/src/pipeline/bundler-driver.ts b/packages/plugins/apps/src/pipeline/bundler-driver.ts new file mode 100644 index 000000000..eed9673b8 --- /dev/null +++ b/packages/plugins/apps/src/pipeline/bundler-driver.ts @@ -0,0 +1,194 @@ +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse, executor/no-instanceof-tagged-error -- boundary: worker-bundler driver and package validation convert toolchain failures into typed AppExecutorError */ +import { AppExecutorError } from "../executor/app-tool-executor"; +import type { BundleInput } from "./bundle"; +import { PUBLISH_LIMITS } from "./publish"; + +const textEncoder = new TextEncoder(); + +export const executorAppSource = ` +const makeIntegrationDeclaration = (state) => Object.freeze({ + kind: "integration", + slug: state.slug, + mode: state.mode, + ...(state.description !== undefined ? { description: state.description } : {}), + array: () => makeIntegrationDeclaration({ ...state, mode: "many" }), + describe: (text) => makeIntegrationDeclaration({ ...state, description: text }), +}); +export const integration = (slug) => makeIntegrationDeclaration({ slug, mode: "one" }); +export const defineTool = (definition) => ({ ...definition, "~executorAppTool": true }); +`; + +export const DEFAULT_REGISTRY_ORIGINS = ["https://registry.npmjs.org"] as const; + +export const registryOriginsFromEnv = (): readonly string[] => [ + ...DEFAULT_REGISTRY_ORIGINS, + ...(process.env.EXECUTOR_NPM_REGISTRY ? [new URL(process.env.EXECUTOR_NPM_REGISTRY).origin] : []), +]; + +export const driverModule = (options: { + readonly token?: string; + readonly registryOrigins: readonly string[]; +}): string => ` +import { createWorker } from "./worker-bundler.js"; + +const allowedRegistryOrigins = new Set(${JSON.stringify(options.registryOrigins)}); +const originalFetch = globalThis.fetch.bind(globalThis); +globalThis.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input.url); + if (!allowedRegistryOrigins.has(url.origin)) { + throw new Error("publish worker outbound fetch blocked: " + url.origin); + } + return originalFetch(input, init); +}; + +const executorAppSource = ${JSON.stringify(executorAppSource)}; +const json = (body, status = 200) => Response.json(body, { status }); +const moduleCode = (module) => { + if (typeof module === "string") return module; + if (module && typeof module.js === "string") return module.js; + if (module && typeof module.cjs === "string") return module.cjs; + return null; +}; + +export default { + async fetch(request) { + ${ + options.token + ? `if (request.url.endsWith("/__health")) return json({ ok: true, runnerToken: ${JSON.stringify(options.token)} });` + : "" + } + if (!request.url.endsWith("/run")) return json({ ok: false, message: "not found" }, 404); + try { + const input = await request.json(); + const files = { ...input.files }; + files["__executor_entry.ts"] = "import artifact from " + JSON.stringify("./" + input.entry) + ";\\nexport default artifact;\\n"; + const result = await createWorker({ + files, + entryPoint: "__executor_entry.ts", + bundle: true, + target: "es2022", + minify: false, + jsx: "automatic", + conditions: ["workerd", "worker", "browser", "import", "default"], + virtualModules: { "executor:app": executorAppSource }, + }); + const code = moduleCode(result.modules[result.mainModule]); + if (code === null) { + return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule }); + } + const installFailure = (result.warnings ?? []).find((warning) => /failed to install/i.test(String(warning))); + if (installFailure) { + return json({ ok: false, message: String(installFailure) }); + } + for (const [path, module] of Object.entries(result.modules)) { + const source = moduleCode(module) ?? ""; + if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) { + return json({ ok: false, message: "package dependency includes unsupported native module artifact: " + path }); + } + } + return json({ ok: true, code, warnings: result.warnings ?? [] }); + } catch (error) { + return json({ ok: false, message: error && error.message ? error.message : String(error) }); + } + }, +}; +`; + +const blockedDependencySpec = (spec: string): boolean => + /^(?:https?:|git(?:\+|:)|file:|workspace:|link:|portal:)/.test(spec); + +export const packageBoundaryError = (input: BundleInput): AppExecutorError | null => { + for (const path of input.files.keys()) { + if (/\.node$|(?:^|\/)(?:binding\.gyp|node-gyp|prebuilds?|node_modules\/.*\.node)/.test(path)) { + return new AppExecutorError({ + kind: "bundle", + message: `package dependency includes unsupported native module artifact: ${path}`, + diagnostics: [ + { path, message: "native modules are not supported in app publish dependencies" }, + ], + }); + } + } + const rawPackageJson = input.files.get("package.json"); + if (rawPackageJson === undefined) return null; + try { + const parsed = JSON.parse(rawPackageJson) as { + readonly name?: unknown; + readonly scripts?: unknown; + readonly dependencies?: unknown; + readonly devDependencies?: unknown; + readonly optionalDependencies?: unknown; + readonly peerDependencies?: unknown; + }; + const scripts = + parsed.scripts !== null && + typeof parsed.scripts === "object" && + !Array.isArray(parsed.scripts) + ? (parsed.scripts as Record) + : {}; + const blocked = Object.keys(scripts).find((name) => + /^(pre|post)?install$|^prepare$/.test(name), + ); + const packageName = typeof parsed.name === "string" ? parsed.name : "package.json"; + if (blocked) { + return new AppExecutorError({ + kind: "bundle", + message: `package ${packageName} declares unsupported lifecycle script "${blocked}"`, + diagnostics: [ + { + path: "package.json", + message: `lifecycle script "${blocked}" is not allowed in app publish dependencies`, + }, + ], + }); + } + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ] as const) { + const deps = parsed[field]; + if (deps === null || typeof deps !== "object" || Array.isArray(deps)) continue; + for (const [name, spec] of Object.entries(deps as Record)) { + if (typeof spec === "string" && blockedDependencySpec(spec)) { + return new AppExecutorError({ + kind: "bundle", + message: `package dependency ${name} uses unsupported non-registry spec "${spec}"`, + diagnostics: [ + { + path: "package.json", + message: `dependency ${name} must resolve from an allowed npm registry`, + }, + ], + }); + } + } + } + return null; + } catch (cause) { + return new AppExecutorError({ + kind: "bundle", + message: "package.json is not valid JSON", + diagnostics: [{ path: "package.json", message: "invalid package.json" }], + cause, + }); + } +}; + +export const fileRecord = (files: ReadonlyMap): Record => { + const out: Record = {}; + for (const [path, source] of files) out[path] = source; + return out; +}; + +export const enforceOutputLimit = (entry: string, code: string): AppExecutorError | null => { + const size = textEncoder.encode(code).byteLength; + return size <= PUBLISH_LIMITS.maxTotalBytes + ? null + : new AppExecutorError({ + kind: "bundle", + message: `bundle for ${entry} is ${size} bytes, exceeding the limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, + diagnostics: [{ path: entry, message: "bundle exceeds publish size limit" }], + }); +}; diff --git a/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts b/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts new file mode 100644 index 000000000..18f2a2dcd --- /dev/null +++ b/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.ts @@ -0,0 +1,138 @@ +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: Worker Loader adapter converts bundler Worker failures into typed AppExecutorError */ +import { Effect } from "effect"; + +import { AppExecutorError } from "../executor/app-tool-executor"; +import type { BundleBackend, BundleOutput } from "./bundle"; +import { + driverModule, + enforceOutputLimit, + fileRecord, + packageBoundaryError, + registryOriginsFromEnv, +} from "./bundler-driver"; +import type { ToolchainRef } from "./descriptor"; +import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker-bundler-version"; + +type DynamicWorkerDefinition = { + readonly compatibilityDate: string; + readonly compatibilityFlags?: readonly string[]; + readonly mainModule: string; + readonly modules: Readonly>; +}; + +export type DynamicWorkerLoaderCompatible = { + readonly load?: (code: DynamicWorkerDefinition) => { readonly getEntrypoint: () => unknown }; + readonly get?: ( + name: string | null, + factory: () => DynamicWorkerDefinition, + ) => { readonly getEntrypoint: () => unknown }; +}; + +export interface DynamicWorkerBundlerArtifact { + readonly source: string; + readonly wasm: ArrayBuffer; +} + +export interface DynamicWorkerBundlerBackendOptions { + readonly loader: DynamicWorkerLoaderCompatible; + readonly artifact: () => Promise; +} + +type BundlerResponse = + | { readonly ok: true; readonly code: string; readonly warnings?: readonly unknown[] } + | { readonly ok: false; readonly message?: string }; + +type FetchEntrypoint = { + readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +type LoadedBundlerWorker = { + readonly stub: { readonly getEntrypoint: () => unknown }; + readonly entrypoint: FetchEntrypoint; +}; + +const asFetchEntrypoint = (value: unknown): FetchEntrypoint => value as FetchEntrypoint; + +const loadBundlerWorker = ( + loader: DynamicWorkerLoaderCompatible, + artifact: DynamicWorkerBundlerArtifact, +): LoadedBundlerWorker => { + const definition: DynamicWorkerDefinition = { + compatibilityDate: "2025-06-01", + compatibilityFlags: ["nodejs_compat"], + mainModule: "driver.js", + modules: { + "driver.js": driverModule({ registryOrigins: registryOriginsFromEnv() }), + "worker-bundler.js": artifact.source, + "esbuild.wasm": { wasm: artifact.wasm }, + }, + }; + const worker = + loader.load !== undefined + ? loader.load(definition) + : loader.get?.(`app-bundler-${crypto.randomUUID()}`, () => definition); + if (worker === undefined) { + throw new AppExecutorError({ + kind: "bundle", + message: "Worker Loader binding does not support load() or get()", + }); + } + return { stub: worker, entrypoint: asFetchEntrypoint(worker.getEntrypoint()) }; +}; + +const toolchain = (): ToolchainRef => ({ + bundler: { + name: "@cloudflare/worker-bundler", + version: `${WORKER_BUNDLER_VERSION} (esbuild-wasm ${WORKER_BUNDLER_ESBUILD_VERSION})`, + }, + executor: { name: "cloud-worker", version: "worker-loader" }, + target: "es2022", +}); + +export const makeDynamicWorkerBundlerBackend = ( + options: DynamicWorkerBundlerBackendOptions, +): BundleBackend => { + let artifactPromise: Promise | undefined; + const artifact = () => { + artifactPromise ??= options.artifact(); + return artifactPromise; + }; + return { + toolchain, + bundle: (input): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const boundaryError = packageBoundaryError(input); + if (boundaryError) throw boundaryError; + const worker = loadBundlerWorker(options.loader, await artifact()); + const response = await worker.entrypoint.fetch("https://executor-bundler.local/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ files: fileRecord(input.files), entry: input.entry }), + }); + const body = (await response.json()) as BundlerResponse; + if (!body.ok) { + const message = body.message ?? `worker-bundler failed for ${input.entry}`; + throw new AppExecutorError({ + kind: "bundle", + message, + diagnostics: [{ path: input.entry, message }], + }); + } + const limitError = enforceOutputLimit(input.entry, body.code); + if (limitError) throw limitError; + return { code: body.code, toolchain: toolchain() }; + }, + catch: (cause) => + cause instanceof AppExecutorError + ? cause + : new AppExecutorError({ + kind: "bundle", + message: `worker-bundler failed for ${input.entry}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }), + }), + }; +}; diff --git a/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts b/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts new file mode 100644 index 000000000..c9d34c659 --- /dev/null +++ b/packages/plugins/apps/src/pipeline/dynamic-worker-bundler.worker.test.ts @@ -0,0 +1,177 @@ +/* oxlint-disable executor/no-unknown-error-message -- test boundary: asserting the typed AppExecutorError message */ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { env } from "cloudflare:workers"; + +import { AppExecutorError } from "../executor/app-tool-executor"; +import { bundleEntry } from "./bundle"; +import { makeDynamicWorkerBundlerBackend } from "./dynamic-worker-bundler"; + +declare const __WORKER_BUNDLER_SOURCE__: string; +declare const __WORKER_BUNDLER_WASM_BASE64__: string; + +interface WorkerLoader { + readonly load: (code: { + readonly compatibilityDate: string; + readonly compatibilityFlags?: readonly string[]; + readonly mainModule: string; + readonly modules: Record; + }) => { readonly getEntrypoint: () => { readonly fetch: typeof fetch } }; + readonly get: ( + name: string | null, + factory: () => { + readonly compatibilityDate: string; + readonly compatibilityFlags?: readonly string[]; + readonly mainModule: string; + readonly modules: Record; + }, + ) => { readonly getEntrypoint: () => { readonly fetch: typeof fetch } }; +} + +const base64ToArrayBuffer = (value: string): ArrayBuffer => { + const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +}; + +const files = (entries: readonly (readonly [string, string])[]): ReadonlyMap => + new Map(entries); + +const artifact = async () => ({ + source: __WORKER_BUNDLER_SOURCE__, + wasm: base64ToArrayBuffer(__WORKER_BUNDLER_WASM_BASE64__), +}); + +const getOnly = (loader: WorkerLoader): WorkerLoader => ({ + load: undefined as never, + get: (name, factory) => loader.get(name, factory), +}); + +const driver = ` +import { createWorker } from "./worker-bundler.js"; + +const moduleCode = (module) => { + if (typeof module === "string") return module; + if (module && typeof module.js === "string") return module.js; + if (module && typeof module.cjs === "string") return module.cjs; + return null; +}; + +export default { + async fetch() { + const result = await createWorker({ + files: { + "tool.ts": "export default { answer: 42 };", + "__executor_entry.ts": "import artifact from './tool.ts';\\nexport default artifact;\\n", + }, + entryPoint: "__executor_entry.ts", + bundle: true, + target: "es2022", + minify: false, + jsx: "automatic", + conditions: ["workerd", "worker", "browser", "import", "default"], + virtualModules: {}, + }); + const code = moduleCode(result.modules[result.mainModule]); + return Response.json({ ok: typeof code === "string", code }); + }, +}; +`; + +describe("dynamic Worker worker-bundler spike", () => { + it("loads esbuild.wasm as a wasm module and bundles JavaScript", async () => { + const loader = (env as { readonly LOADER: WorkerLoader }).LOADER; + const worker = loader.load({ + compatibilityDate: "2025-06-01", + compatibilityFlags: ["nodejs_compat"], + mainModule: "driver.js", + modules: { + "driver.js": driver, + "worker-bundler.js": __WORKER_BUNDLER_SOURCE__, + "esbuild.wasm": { wasm: base64ToArrayBuffer(__WORKER_BUNDLER_WASM_BASE64__) }, + }, + }); + + const response = await worker.getEntrypoint().fetch("https://worker.test/run"); + const body = (await response.json()) as { readonly ok?: unknown; readonly code?: unknown }; + + expect(body.ok).toBe(true); + expect(body.code).toEqual(expect.stringContaining("answer")); + }); +}); + +describe("dynamic Worker bundler backend", () => { + const loader = getOnly((env as { readonly LOADER: WorkerLoader }).LOADER); + + it.effect("bundles a tool importing zod through the Worker Loader backend", () => + Effect.gen(function* () { + const bundled = yield* bundleEntry( + { + entry: "tools/greet.ts", + files: files([ + [ + "tools/greet.ts", + ` + import { z } from "zod"; + import { defineTool } from "executor:app"; + + const Input = z.object({ name: z.string() }); + + export default defineTool({ + description: "Greet", + input: Input, + async handler(input) { + return { greeting: "hello " + input.name }; + }, + }); + `, + ], + ["package.json", JSON.stringify({ dependencies: { zod: "4.3.6" } })], + ["bun.lock", ""], + ]), + }, + makeDynamicWorkerBundlerBackend({ loader, artifact }), + ); + + expect(bundled.code).toContain("Greet"); + expect(bundled.code).toContain("hello "); + }), + ); + + it.effect("surfaces install failures as bundle AppExecutorError failures", () => + Effect.gen(function* () { + const error = yield* bundleEntry( + { + entry: "tools/greet.ts", + files: files([ + [ + "tools/greet.ts", + ` + import missing from "executor-worker-bundler-missing-package"; + import { defineTool } from "executor:app"; + + export default defineTool({ + description: "Greet", + async handler() { + return { greeting: String(missing) }; + }, + }); + `, + ], + [ + "package.json", + JSON.stringify({ + dependencies: { "executor-worker-bundler-missing-package": "999.999.999" }, + }), + ], + ["bun.lock", ""], + ]), + }, + makeDynamicWorkerBundlerBackend({ loader, artifact }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(AppExecutorError); + expect(error.kind).toBe("bundle"); + expect(error.message).toMatch(/failed to install|No matching version|notarget/i); + }), + ); +}); diff --git a/packages/plugins/apps/src/pipeline/native-worker-bundler.ts b/packages/plugins/apps/src/pipeline/native-worker-bundler.ts deleted file mode 100644 index 988cfa0ec..000000000 --- a/packages/plugins/apps/src/pipeline/native-worker-bundler.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-json-parse, executor/no-double-cast, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: Workers-only dynamic import and package JSON validation return typed AppExecutorError */ -import { Effect } from "effect"; - -import { AppExecutorError } from "../executor/app-tool-executor"; -import { PUBLISH_LIMITS } from "./publish"; -import type { BundleBackend, BundleInput, BundleOutput } from "./bundle"; -import type { ToolchainRef } from "./descriptor"; -import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker-bundler-version"; - -const textEncoder = new TextEncoder(); - -const executorAppSource = ` -const makeIntegrationDeclaration = (state) => Object.freeze({ - kind: "integration", - slug: state.slug, - mode: state.mode, - ...(state.description !== undefined ? { description: state.description } : {}), - array: () => makeIntegrationDeclaration({ ...state, mode: "many" }), - describe: (text) => makeIntegrationDeclaration({ ...state, description: text }), -}); -export const integration = (slug) => makeIntegrationDeclaration({ slug, mode: "one" }); -export const defineTool = (definition) => ({ ...definition, "~executorAppTool": true }); -`; - -const packageScriptError = (input: BundleInput): AppExecutorError | null => { - const rawPackageJson = input.files.get("package.json"); - if (rawPackageJson === undefined) return null; - try { - const parsed = JSON.parse(rawPackageJson) as { - readonly name?: unknown; - readonly scripts?: unknown; - }; - const scripts = - parsed.scripts !== null && - typeof parsed.scripts === "object" && - !Array.isArray(parsed.scripts) - ? (parsed.scripts as Record) - : {}; - const blocked = Object.keys(scripts).find((name) => - /^(pre|post)?install$|^prepare$/.test(name), - ); - if (!blocked) return null; - const packageName = typeof parsed.name === "string" ? parsed.name : "package.json"; - return new AppExecutorError({ - kind: "bundle", - message: `package ${packageName} declares unsupported lifecycle script "${blocked}"`, - diagnostics: [ - { path: "package.json", message: `lifecycle script "${blocked}" is not allowed` }, - ], - }); - } catch (cause) { - return new AppExecutorError({ - kind: "bundle", - message: "package.json is not valid JSON", - diagnostics: [{ path: "package.json", message: "invalid package.json" }], - cause, - }); - } -}; - -const fileRecord = (files: ReadonlyMap, entry: string): Record => { - const out: Record = {}; - for (const [path, source] of files) out[path] = source; - out["__executor_entry.ts"] = - `import artifact from ${JSON.stringify(`./${entry}`)};\nexport default artifact;\n`; - return out; -}; - -const moduleCode = (module: unknown): string | null => { - if (typeof module === "string") return module; - if (module && typeof module === "object" && "js" in module && typeof module.js === "string") { - return module.js; - } - if (module && typeof module === "object" && "cjs" in module && typeof module.cjs === "string") { - return module.cjs; - } - return null; -}; - -const toolchain = (): ToolchainRef => ({ - bundler: { - name: "@cloudflare/worker-bundler", - version: `${WORKER_BUNDLER_VERSION} (esbuild-wasm ${WORKER_BUNDLER_ESBUILD_VERSION})`, - }, - executor: { name: "cloud-worker", version: "worker-loader" }, - target: "es2022", -}); - -export const makeNativeWorkerBundlerBackend = (): BundleBackend => ({ - toolchain, - bundle: (input): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const scriptError = packageScriptError(input); - if (scriptError) throw scriptError; - const mod = (await import("@cloudflare/worker-bundler")) as unknown as { - readonly createWorker: (options: { - readonly files: Record; - readonly entryPoint: string; - readonly bundle: true; - readonly target: string; - readonly minify: false; - readonly jsx: "automatic"; - readonly conditions: readonly string[]; - readonly virtualModules: Record; - }) => Promise<{ - readonly mainModule: string; - readonly modules: Readonly>; - }>; - }; - const result = await mod.createWorker({ - files: fileRecord(input.files, input.entry), - entryPoint: "__executor_entry.ts", - bundle: true, - target: "es2022", - minify: false, - jsx: "automatic", - conditions: ["workerd", "worker", "browser", "import", "default"], - virtualModules: { "executor:app": executorAppSource }, - }); - const code = moduleCode(result.modules[result.mainModule]); - if (code === null) { - throw new AppExecutorError({ - kind: "bundle", - message: `worker-bundler failed for ${input.entry}: missing JavaScript output`, - diagnostics: [{ path: input.entry, message: "missing JavaScript output" }], - }); - } - const size = textEncoder.encode(code).byteLength; - if (size > PUBLISH_LIMITS.maxTotalBytes) { - throw new AppExecutorError({ - kind: "bundle", - message: `bundle for ${input.entry} is ${size} bytes, exceeding the limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, - diagnostics: [{ path: input.entry, message: "bundle exceeds publish size limit" }], - }); - } - return { code, toolchain: toolchain() }; - }, - catch: (cause) => - cause instanceof AppExecutorError - ? cause - : new AppExecutorError({ - kind: "bundle", - message: `worker-bundler failed for ${input.entry}: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - cause, - }), - }), -}); diff --git a/packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts b/packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts new file mode 100644 index 000000000..6118ad8dd --- /dev/null +++ b/packages/plugins/apps/src/pipeline/worker-bundler-artifact.ts @@ -0,0 +1,43 @@ +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Node-side build helper reports missing worker-bundler artifacts */ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Resolved through the module graph rather than a path relative to this file: +// this module runs both from src/ (bun) and bundled into dist/ chunks (node +// loading the built vite plugin), where directory-counting breaks. The package +// only declares an `import` export condition, so resolve with +// import.meta.resolve (ESM conditions) and take the entry's parent dir. +const workerBundlerDistDir = (): string => { + const override = process.env.EXECUTOR_WORKER_BUNDLER_DIR; + if (override) return join(override, "dist"); + return dirname(fileURLToPath(import.meta.resolve("@cloudflare/worker-bundler"))); +}; + +export const bundledWorkerBundler = async (): Promise<{ + readonly source: string; + readonly wasm: Uint8Array; +}> => { + const distDir = workerBundlerDistDir(); + const bundledEntry = join(distDir, "index.bundled.js"); + const [sourceFromDisk, wasm] = await Promise.all([ + existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null), + readFile(join(distDir, "esbuild.wasm")), + ]); + if (sourceFromDisk !== null) return { source: sourceFromDisk, wasm }; + + const { build } = await import("esbuild"); + const result = await build({ + entryPoints: [join(distDir, "index.js")], + bundle: true, + format: "esm", + platform: "browser", + write: false, + external: ["./esbuild.wasm"], + logLevel: "silent", + }); + const source = result.outputFiles[0]?.text; + if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); + return { source, wasm }; +}; diff --git a/packages/plugins/apps/src/pipeline/worker-bundler.ts b/packages/plugins/apps/src/pipeline/worker-bundler.ts index fba4fd8c0..c51fa448d 100644 --- a/packages/plugins/apps/src/pipeline/worker-bundler.ts +++ b/packages/plugins/apps/src/pipeline/worker-bundler.ts @@ -1,9 +1,4 @@ /* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-error-constructor, executor/no-json-parse, executor/no-instanceof-tagged-error, executor/no-instanceof-error -- boundary: subprocess-backed bundler validates package JSON and converts worker failures into typed AppExecutorError */ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - import { createWorkerdModuleRunner, WORKERD_VERSION, @@ -11,222 +6,20 @@ import { import { Effect } from "effect"; import { AppExecutorError } from "../executor/app-tool-executor"; -import { PUBLISH_LIMITS } from "./publish"; -import type { BundleBackend, BundleInput, BundleOutput } from "./bundle"; +import type { BundleBackend, BundleOutput } from "./bundle"; +import { + driverModule, + enforceOutputLimit, + fileRecord, + packageBoundaryError, + registryOriginsFromEnv, +} from "./bundler-driver"; import type { ToolchainRef } from "./descriptor"; +import { bundledWorkerBundler } from "./worker-bundler-artifact"; import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker-bundler-version"; -const textEncoder = new TextEncoder(); -const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -const workerBundlerDir = - process.env.EXECUTOR_WORKER_BUNDLER_DIR ?? - join(packageDir, "node_modules", "@cloudflare", "worker-bundler"); - -const executorAppSource = ` -const makeIntegrationDeclaration = (state) => Object.freeze({ - kind: "integration", - slug: state.slug, - mode: state.mode, - ...(state.description !== undefined ? { description: state.description } : {}), - array: () => makeIntegrationDeclaration({ ...state, mode: "many" }), - describe: (text) => makeIntegrationDeclaration({ ...state, description: text }), -}); -export const integration = (slug) => makeIntegrationDeclaration({ slug, mode: "one" }); -export const defineTool = (definition) => ({ ...definition, "~executorAppTool": true }); -`; - -const DEFAULT_REGISTRY_ORIGINS = ["https://registry.npmjs.org"]; - -const driverModule = (token: string, registryOrigins: readonly string[]): string => ` -import { createWorker } from "./worker-bundler.js"; - -const allowedRegistryOrigins = new Set(${JSON.stringify(registryOrigins)}); -const originalFetch = globalThis.fetch.bind(globalThis); -globalThis.fetch = async (input, init) => { - const url = new URL(typeof input === "string" ? input : input.url); - if (!allowedRegistryOrigins.has(url.origin)) { - throw new Error("publish worker outbound fetch blocked: " + url.origin); - } - return originalFetch(input, init); -}; - -const executorAppSource = ${JSON.stringify(executorAppSource)}; -const json = (body, status = 200) => Response.json(body, { status }); -const moduleCode = (module) => { - if (typeof module === "string") return module; - if (module && typeof module.js === "string") return module.js; - if (module && typeof module.cjs === "string") return module.cjs; - return null; -}; - -export default { - async fetch(request) { - if (request.url.endsWith("/__health")) return json({ ok: true, runnerToken: ${JSON.stringify(token)} }); - if (!request.url.endsWith("/run")) return json({ ok: false, message: "not found" }, 404); - try { - const input = await request.json(); - const files = { ...input.files }; - files["__executor_entry.ts"] = "import artifact from " + JSON.stringify("./" + input.entry) + ";\\nexport default artifact;\\n"; - const result = await createWorker({ - files, - entryPoint: "__executor_entry.ts", - bundle: true, - target: "es2022", - minify: false, - jsx: "automatic", - conditions: ["workerd", "worker", "browser", "import", "default"], - virtualModules: { "executor:app": executorAppSource }, - }); - const code = moduleCode(result.modules[result.mainModule]); - if (code === null) { - return json({ ok: false, message: "worker-bundler did not return JavaScript for " + result.mainModule }); - } - const installFailure = (result.warnings ?? []).find((warning) => /failed to install/i.test(String(warning))); - if (installFailure) { - return json({ ok: false, message: String(installFailure) }); - } - for (const [path, module] of Object.entries(result.modules)) { - const source = moduleCode(module) ?? ""; - if (/\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(path) || /\\.node(?:$|[?#])|node-gyp|prebuild-install|node-pre-gyp/.test(source)) { - return json({ ok: false, message: "package dependency includes unsupported native module artifact: " + path }); - } - } - return json({ ok: true, code, warnings: result.warnings ?? [] }); - } catch (error) { - return json({ ok: false, message: error && error.message ? error.message : String(error) }); - } - }, -}; -`; - -const bundledWorkerBundler = async (): Promise<{ - readonly source: string; - readonly wasm: Uint8Array; -}> => { - const distDir = join(workerBundlerDir, "dist"); - const bundledEntry = join(distDir, "index.bundled.js"); - const [sourceFromDisk, wasm] = await Promise.all([ - existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null), - readFile(join(distDir, "esbuild.wasm")), - ]); - if (sourceFromDisk !== null) return { source: sourceFromDisk, wasm }; - - const { build } = await import("esbuild"); - const result = await build({ - entryPoints: [join(distDir, "index.js")], - bundle: true, - format: "esm", - platform: "browser", - write: false, - external: ["./esbuild.wasm"], - logLevel: "silent", - }); - const source = result.outputFiles[0]?.text; - if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); - return { source, wasm }; -}; - const workerBundlerModule = bundledWorkerBundler(); -const blockedDependencySpec = (spec: string): boolean => - /^(?:https?:|git(?:\\+|:)|file:|workspace:|link:|portal:)/.test(spec); - -const packageBoundaryError = (input: BundleInput): AppExecutorError | null => { - for (const path of input.files.keys()) { - if (/\.node$|(?:^|\/)(?:binding\.gyp|node-gyp|prebuilds?|node_modules\/.*\.node)/.test(path)) { - return new AppExecutorError({ - kind: "bundle", - message: `package dependency includes unsupported native module artifact: ${path}`, - diagnostics: [ - { path, message: "native modules are not supported in app publish dependencies" }, - ], - }); - } - } - const rawPackageJson = input.files.get("package.json"); - if (rawPackageJson === undefined) return null; - try { - const parsed = JSON.parse(rawPackageJson) as { - readonly name?: unknown; - readonly scripts?: unknown; - readonly dependencies?: unknown; - readonly devDependencies?: unknown; - readonly optionalDependencies?: unknown; - readonly peerDependencies?: unknown; - }; - const scripts = - parsed.scripts !== null && - typeof parsed.scripts === "object" && - !Array.isArray(parsed.scripts) - ? (parsed.scripts as Record) - : {}; - const blocked = Object.keys(scripts).find((name) => - /^(pre|post)?install$|^prepare$/.test(name), - ); - const packageName = typeof parsed.name === "string" ? parsed.name : "package.json"; - if (blocked) { - return new AppExecutorError({ - kind: "bundle", - message: `package ${packageName} declares unsupported lifecycle script "${blocked}"`, - diagnostics: [ - { - path: "package.json", - message: `lifecycle script "${blocked}" is not allowed in app publish dependencies`, - }, - ], - }); - } - for (const field of [ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", - ] as const) { - const deps = parsed[field]; - if (deps === null || typeof deps !== "object" || Array.isArray(deps)) continue; - for (const [name, spec] of Object.entries(deps as Record)) { - if (typeof spec === "string" && blockedDependencySpec(spec)) { - return new AppExecutorError({ - kind: "bundle", - message: `package dependency ${name} uses unsupported non-registry spec "${spec}"`, - diagnostics: [ - { - path: "package.json", - message: `dependency ${name} must resolve from an allowed npm registry`, - }, - ], - }); - } - } - } - return null; - } catch (cause) { - return new AppExecutorError({ - kind: "bundle", - message: "package.json is not valid JSON", - diagnostics: [{ path: "package.json", message: "invalid package.json" }], - cause, - }); - } -}; - -const fileRecord = (files: ReadonlyMap): Record => { - const out: Record = {}; - for (const [path, source] of files) out[path] = source; - return out; -}; - -const enforceOutputLimit = (entry: string, code: string): AppExecutorError | null => { - const size = textEncoder.encode(code).byteLength; - return size <= PUBLISH_LIMITS.maxTotalBytes - ? null - : new AppExecutorError({ - kind: "bundle", - message: `bundle for ${entry} is ${size} bytes, exceeding the limit of ${PUBLISH_LIMITS.maxTotalBytes} bytes`, - diagnostics: [{ path: entry, message: "bundle exceeds publish size limit" }], - }); -}; - const toolchain = (): ToolchainRef => ({ bundler: { name: "@cloudflare/worker-bundler", @@ -245,16 +38,10 @@ export const makeWorkerBundlerBackend = (): BundleBackend => ({ if (boundaryError) throw boundaryError; const modules = await workerBundlerModule; const token = crypto.randomUUID(); - const registryOrigins = [ - ...DEFAULT_REGISTRY_ORIGINS, - ...(process.env.EXECUTOR_NPM_REGISTRY - ? [new URL(process.env.EXECUTOR_NPM_REGISTRY).origin] - : []), - ]; const runner = createWorkerdModuleRunner({ mainModule: "driver.js", modules: { - "driver.js": driverModule(token, registryOrigins), + "driver.js": driverModule({ token, registryOrigins: registryOriginsFromEnv() }), "worker-bundler.js": modules.source, "esbuild.wasm": { kind: "wasm", bytes: modules.wasm }, }, diff --git a/packages/plugins/apps/src/plugin/apps-plugin.test.ts b/packages/plugins/apps/src/plugin/apps-plugin.test.ts index 14ecc1a2b..dee5bea1e 100644 --- a/packages/plugins/apps/src/plugin/apps-plugin.test.ts +++ b/packages/plugins/apps/src/plugin/apps-plugin.test.ts @@ -10,7 +10,8 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin import { FLUSH, pktLine } from "../git-client/pktline"; import { bundleEntry } from "../pipeline/bundle"; -import { makeInProcessAppToolExecutor } from "../executor/app-tool-executor"; +import { makeInProcessAppToolExecutor, type AppToolExecutor } from "../executor/app-tool-executor"; +import { DRIVER_VERSION } from "../executor/dynamic-worker-app-tool-executor"; import type { AppDescriptor } from "../pipeline/descriptor"; import { makeAppsPlugin, projectAppsToolSchema } from "./apps-plugin"; import type { AppSourceRecord, AppsStore } from "./store"; @@ -211,6 +212,7 @@ const fixtureFetch = async () => { const makeInvokeStore = (input: { readonly bundle: string; + readonly bundleKey?: string; readonly integrations: AppDescriptor["tools"][number]["integrations"]; }): AppsStore => ({ putBlob: () => Effect.succeed("bundle"), @@ -223,7 +225,7 @@ const makeInvokeStore = (input: { Effect.succeed({ app: "crm", name: "sync", - bundleKey: "bundle", + bundleKey: input.bundleKey ?? "bundle", description: "Sync", integrations: input.integrations, }), @@ -231,7 +233,7 @@ const makeInvokeStore = (input: { Effect.succeed({ app: "crm", name: "sync", - bundleKey: "bundle", + bundleKey: input.bundleKey ?? "bundle", description: "Sync", integrations: input.integrations, }), @@ -243,11 +245,14 @@ const makeInvokeStore = (input: { const invokeCtx = (input: { readonly bundle: string; + readonly bundleKey?: string; readonly execute: (address: string, args: unknown) => Effect.Effect; }) => ({ + owner: { tenant: "tenant-a", subject: null }, storage: makeInvokeStore({ bundle: input.bundle, + ...(input.bundleKey ? { bundleKey: input.bundleKey } : {}), integrations: { crm: { slug: "dealcloud", mode: "one" } }, }), connections: { @@ -702,6 +707,32 @@ describe("apps source sync", () => { }); describe("apps plugin invocation", () => { + it.effect("passes a tenant-scoped stable isolate key to the app tool executor", () => + Effect.gen(function* () { + let capturedIsolateKey: string | undefined; + const executor: AppToolExecutor = { + collect: () => Effect.succeed({ tools: [] }), + invoke: (_bundle, _entry, _input, _bridge, limits) => { + capturedIsolateKey = limits.isolateKey; + return Effect.succeed({ output: { ok: true } }); + }, + }; + const plugin = makeAppsPlugin({ executor }); + const result = yield* plugin.invokeTool!({ + ctx: invokeCtx({ + bundle: "export default {};", + bundleKey: "apps/test-bundle", + execute: () => Effect.succeed({}), + }), + toolRow: { integration: "crm", name: "sync" }, + args: { crm: "tools.dealcloud.org.main" }, + } as never); + + expect(result).toEqual({ ok: true }); + expect(capturedIsolateKey).toBe(`tenant-a:apps/test-bundle:${DRIVER_VERSION}`); + }), + ); + it.effect("surfaces uncaught inner tool failures without binding_error", () => Effect.gen(function* () { const bundled = yield* bundle(` diff --git a/packages/plugins/apps/src/plugin/apps-plugin.ts b/packages/plugins/apps/src/plugin/apps-plugin.ts index 186c6cfeb..5b83abdd1 100644 --- a/packages/plugins/apps/src/plugin/apps-plugin.ts +++ b/packages/plugins/apps/src/plugin/apps-plugin.ts @@ -40,6 +40,7 @@ import { } from "../source/local-directory-source"; import { AppSourceError, type AppSourceSnapshot } from "../source/app-source"; import type { PublishError } from "../pipeline/publish"; +import { DRIVER_VERSION } from "../executor/dynamic-worker-app-tool-executor"; const APPS_CONNECTION = ConnectionName.make("published"); const APPS_NO_AUTH = AuthTemplateSlug.make("none"); @@ -620,13 +621,19 @@ export const makeAppsPlugin = (options?: AppsPluginOptions) => resolver, invokeOptions, }); + // Tenant-scoped isolate key: bundleKey is content-addressed, so two + // orgs publishing byte-identical bundles would otherwise share a warm + // isolate and module-level state could cross tenants. const result = yield* (options?.executor ?? makeInProcessAppToolExecutor()) .invoke( bundle, { toolName: tool.name }, { ...bindings.input, ...bindings.bindings }, bridge, - { timeoutMs: 30_000 }, + { + timeoutMs: 30_000, + isolateKey: `${ctx.owner.tenant}:${tool.bundleKey}:${DRIVER_VERSION}`, + }, ) .pipe( Effect.catch((cause: unknown) => { diff --git a/packages/plugins/apps/src/vite.ts b/packages/plugins/apps/src/vite.ts new file mode 100644 index 000000000..d5f1d26c8 --- /dev/null +++ b/packages/plugins/apps/src/vite.ts @@ -0,0 +1,22 @@ +import type { Plugin } from "vite"; + +import { bundledWorkerBundler } from "./pipeline/worker-bundler-artifact"; + +const virtualId = "virtual:executor/worker-bundler-artifact"; +const resolvedVirtualId = `\0${virtualId}`; + +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"); + }, +}); diff --git a/packages/plugins/apps/tsconfig.json b/packages/plugins/apps/tsconfig.json index 1504bed72..d8cf3d0da 100644 --- a/packages/plugins/apps/tsconfig.json +++ b/packages/plugins/apps/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true, "lib": ["ES2022", "DOM"], diff --git a/packages/plugins/apps/tsup.config.ts b/packages/plugins/apps/tsup.config.ts index 15cbd426c..5cc9dd3fd 100644 --- a/packages/plugins/apps/tsup.config.ts +++ b/packages/plugins/apps/tsup.config.ts @@ -6,10 +6,11 @@ export default defineConfig({ api: "src/api.ts", authoring: "src/authoring.ts", "testing/index": "src/testing/index.ts", + vite: "src/vite.ts", }, format: ["esm"], dts: false, sourcemap: true, clean: true, - external: [/^@executor-js\//, /^effect/, /^@effect\//, "esbuild", "zod"], + external: [/^@executor-js\//, /^effect/, /^@effect\//, "esbuild", "zod", "vite"], }); diff --git a/packages/plugins/apps/vitest.config.ts b/packages/plugins/apps/vitest.config.ts index 698a11896..3ff789fe1 100644 --- a/packages/plugins/apps/vitest.config.ts +++ b/packages/plugins/apps/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["src/**/*.test.ts"], + exclude: ["src/**/*.worker.test.ts"], testTimeout: 30_000, hookTimeout: 30_000, }, diff --git a/packages/plugins/apps/vitest.worker.config.ts b/packages/plugins/apps/vitest.worker.config.ts new file mode 100644 index 000000000..412060683 --- /dev/null +++ b/packages/plugins/apps/vitest.worker.config.ts @@ -0,0 +1,58 @@ +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker-pool test config prepares a Vite artifact before tests start */ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +const packageDir = dirname(fileURLToPath(import.meta.url)); +const workerBundlerDist = join(packageDir, "node_modules", "@cloudflare", "worker-bundler", "dist"); + +const createWorkerBundlerArtifact = async (): Promise<{ + readonly source: string; + readonly wasmBase64: string; +}> => { + if ( + !existsSync(join(workerBundlerDist, "index.js")) || + !existsSync(join(workerBundlerDist, "esbuild.wasm")) + ) { + throw new Error(`@cloudflare/worker-bundler dist files not found at ${workerBundlerDist}`); + } + const { build } = await import("esbuild"); + const [result, wasm] = await Promise.all([ + build({ + entryPoints: [join(workerBundlerDist, "index.js")], + bundle: true, + format: "esm", + platform: "browser", + write: false, + external: ["./esbuild.wasm"], + logLevel: "silent", + }), + readFile(join(workerBundlerDist, "esbuild.wasm")), + ]); + const source = result.outputFiles[0]?.text; + if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); + return { source, wasmBase64: wasm.toString("base64") }; +}; + +const artifact = await createWorkerBundlerArtifact(); + +export default defineConfig({ + define: { + __WORKER_BUNDLER_SOURCE__: JSON.stringify(artifact.source), + __WORKER_BUNDLER_WASM_BASE64__: JSON.stringify(artifact.wasmBase64), + }, + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.worker.jsonc" }, + }), + ], + test: { + include: ["src/**/*.worker.test.ts"], + testTimeout: 120_000, + hookTimeout: 120_000, + }, +}); diff --git a/packages/plugins/apps/wrangler.worker.jsonc b/packages/plugins/apps/wrangler.worker.jsonc new file mode 100644 index 000000000..2291c1104 --- /dev/null +++ b/packages/plugins/apps/wrangler.worker.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "plugin-apps-worker-test", + "compatibility_date": "2025-06-01", + "compatibility_flags": ["nodejs_compat"], + "worker_loaders": [ + { + "binding": "LOADER", + }, + ], +}