History

Package workerd for local binaries

Rhys Sullivan committed 4 months ago

Commit 4be58bc1 parent da4f37c8 files · +491 −56Browse files

Unified · Split

8 files
  1. apps/cli/scripts/smoke-packed-apps.ts +192 −0
  2. apps/cli/src/build.ts +92 −1
  3. apps/cli/src/native-bindings.ts +19 −5
  4. apps/desktop/src/sidecar/native-bindings.ts +18 −5
  5. packages/kernel/runtime-workerd-subprocess/src/index.test.ts +41 −1
  6. packages/kernel/runtime-workerd-subprocess/src/index.ts +106 −28
  7. packages/plugins/apps/src/executor/workerd-app-tool-executor.ts +2 −2
  8. packages/plugins/apps/src/pipeline/worker-bundler.ts +21 −14
apps/cli/scripts/smoke-packed-apps.ts+192−0
@@ -0,0 +1,192 @@
1+/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- boundary: standalone build smoke harness tears down a spawned packed binary */
2+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
4+import { join, resolve } from "node:path";
5+import { spawn, type Subprocess } from "bun";
6+
7+const binary = process.argv[2];
8+if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts <packed-binary>");
9+
10+const AUTH_TOKEN = "packed-apps-smoke-token";
11+const READY_TIMEOUT_MS = 45_000;
12+
13+const fail = (message: string): never => {
14+ throw new Error(`[smoke-packed-apps] FAIL: ${message}`);
15+};
16+
17+const requestJson = async <T>(origin: string, path: string, init: RequestInit = {}): Promise<T> => {
18+ const response = await fetch(new URL(path, origin), {
19+ ...init,
20+ headers: {
21+ authorization: `Bearer ${AUTH_TOKEN}`,
22+ ...(init.body === undefined ? {} : { "content-type": "application/json" }),
23+ ...init.headers,
24+ },
25+ });
26+ const text = await response.text();
27+ if (!response.ok) fail(`${init.method ?? "GET"} ${path} returned ${response.status}: ${text}`);
28+ return (text.length > 0 ? JSON.parse(text) : null) as T;
29+};
30+
31+const waitForReadyPort = async (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> =>
32+ new Promise((resolveReady, rejectReady) => {
33+ const timer = setTimeout(
34+ () => rejectReady(new Error("packed binary did not report ready in time")),
35+ READY_TIMEOUT_MS,
36+ );
37+ let stdout = "";
38+ let stderr = "";
39+ const finish = (result: number | Error): void => {
40+ clearTimeout(timer);
41+ if (result instanceof Error) rejectReady(result);
42+ else resolveReady(result);
43+ };
44+ void proc.exited.then((code) => {
45+ finish(new Error(`packed binary exited before ready with code ${code}: ${stderr}`));
46+ });
47+ void (async () => {
48+ const reader = proc.stdout.getReader();
49+ const decoder = new TextDecoder();
50+ try {
51+ while (true) {
52+ const chunk = await reader.read();
53+ if (chunk.done) return;
54+ stdout += decoder.decode(chunk.value);
55+ const match = stdout.match(/EXECUTOR_READY:(\d+)/);
56+ if (match) {
57+ finish(Number(match[1]));
58+ return;
59+ }
60+ }
61+ } catch (cause) {
62+ finish(cause instanceof Error ? cause : new Error(String(cause)));
63+ }
64+ })();
65+ void (async () => {
66+ const reader = proc.stderr.getReader();
67+ const decoder = new TextDecoder();
68+ while (true) {
69+ const chunk = await reader.read();
70+ if (chunk.done) return;
71+ stderr += decoder.decode(chunk.value);
72+ }
73+ })().catch(() => undefined);
74+ });
75+
76+const main = async (): Promise<void> => {
77+ const fixture = await mkdtemp(join(tmpdir(), "executor-packed-apps-fixture-"));
78+ const dataDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-data-"));
79+ const scopeDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-scope-"));
80+ let proc: Subprocess<"ignore", "pipe", "pipe"> | null = null;
81+ try {
82+ await mkdir(join(fixture, "tools"), { recursive: true });
83+ await writeFile(
84+ join(fixture, "tools", "greet.ts"),
85+ `
86+ import { z } from "zod";
87+ import { defineTool } from "executor:app";
88+
89+ const Input = z.object({ name: z.string() });
90+
91+ export default defineTool({
92+ description: "Greet",
93+ input: Input,
94+ async handler(input) {
95+ return { greeting: "hello " + input.name };
96+ },
97+ });
98+ `,
99+ );
100+ await writeFile(
101+ join(fixture, "package.json"),
102+ `${JSON.stringify({ name: "packed-apps-smoke", dependencies: { zod: "4.3.6" } })}\n`,
103+ );
104+ await writeFile(join(fixture, "bun.lock"), "");
105+
106+ proc = spawn({
107+ cmd: [
108+ resolve(binary),
109+ "daemon",
110+ "run",
111+ "--foreground",
112+ "--port",
113+ "0",
114+ "--hostname",
115+ "127.0.0.1",
116+ "--auth-token",
117+ AUTH_TOKEN,
118+ ],
119+ env: {
120+ ...process.env,
121+ EXECUTOR_CLIENT: "desktop",
122+ EXECUTOR_DATA_DIR: dataDir,
123+ EXECUTOR_SCOPE_DIR: scopeDir,
124+ },
125+ stdin: "ignore",
126+ stdout: "pipe",
127+ stderr: "pipe",
128+ });
129+
130+ const port = await waitForReadyPort(proc);
131+ const origin = `http://127.0.0.1:${port}`;
132+ console.log(`[smoke-packed-apps] ready on ${origin}`);
133+
134+ await requestJson(origin, "/api/apps/sources", {
135+ method: "POST",
136+ body: JSON.stringify({
137+ kind: "local-directory",
138+ slug: "packed-apps-smoke",
139+ app: "packed-apps-smoke",
140+ path: fixture,
141+ }),
142+ });
143+
144+ const sync = await requestJson<{
145+ readonly status: string;
146+ readonly tools: readonly string[];
147+ readonly errors?: readonly unknown[];
148+ }>(origin, "/api/apps/sources/packed-apps-smoke/sync", { method: "POST" });
149+ if (sync.status !== "published") fail(`sync failed: ${JSON.stringify(sync.errors)}`);
150+ if (JSON.stringify(sync.tools) !== JSON.stringify(["greet"])) {
151+ fail(`expected greet tool, got ${JSON.stringify(sync.tools)}`);
152+ }
153+
154+ const invoked = await requestJson<{
155+ readonly status: string;
156+ readonly structured: unknown;
157+ readonly isError: boolean;
158+ readonly text: string;
159+ }>(origin, "/api/executions", {
160+ method: "POST",
161+ body: JSON.stringify({
162+ code: `export default await tools["packed-apps-smoke.org.published.greet"]({ name: "Ada" })`,
163+ autoApprove: true,
164+ }),
165+ });
166+ if (invoked.status !== "completed" || invoked.isError) {
167+ fail(`execution failed: ${JSON.stringify(invoked)}`);
168+ }
169+ if (
170+ JSON.stringify(invoked.structured) !==
171+ JSON.stringify({
172+ status: "completed",
173+ result: { ok: true, data: { greeting: "hello Ada" } },
174+ logs: [],
175+ })
176+ ) {
177+ fail(`unexpected execution result: ${JSON.stringify(invoked)}`);
178+ }
179+ console.log("[smoke-packed-apps] OK - local-directory app source bundled and invoked");
180+ } finally {
181+ if (proc) {
182+ proc.kill("SIGTERM");
183+ await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 3000))]);
184+ proc.kill("SIGKILL");
185+ }
186+ await rm(fixture, { recursive: true, force: true }).catch(() => undefined);
187+ await rm(dataDir, { recursive: true, force: true }).catch(() => undefined);
188+ await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined);
189+ }
190+};
191+
192+await main();
apps/cli/src/build.ts+92−1
@@ -2,7 +2,7 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line
22 import { existsSync } from "node:fs";
33 import { createRequire } from "node:module";
44 import { resolve, join, dirname } from "node:path";
5−import { fileURLToPath } from "node:url";
5+import { fileURLToPath, pathToFileURL } from "node:url";
66 import { parseArgs } from "node:util";
77 import { $ } from "bun";
88
@@ -11,6 +11,8 @@ const cliRoot = resolve(repoRoot, "apps/cli"); · 2 unmodified lines
1111 const webRoot = resolve(repoRoot, "apps/local");
1212 const distDir = resolve(cliRoot, "dist");
1313 const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm";
14+const WORKERD_VERSION = "1.20260708.1";
15+const WORKER_BUNDLER_DIRNAME = "worker-bundler";
1416
1517 const resolveQuickJsWasmPath = (): string => {
1618 const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
@@ -33,6 +35,34 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 16 unmodified lines
3335 return wasmPath;
3436 };
3537
38+const resolveWorkerBundlerDistPath = (): string => {
39+ const req = createRequire(join(repoRoot, "packages/plugins/apps/package.json"));
40+ const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json");
41+ const distPath = join(dirname(pkgJson), "dist");
42+ if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) {
43+ throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`);
44+ }
45+ return distPath;
46+};
47+
48+const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => {
49+ const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js");
50+ if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`);
51+ const { build } = await import(pathToFileURL(esbuildPath).href);
52+ const result = await build({
53+ entryPoints: [join(distPath, "index.js")],
54+ bundle: true,
55+ format: "esm",
56+ platform: "browser",
57+ external: ["./esbuild.wasm"],
58+ logLevel: "silent",
59+ write: false,
60+ });
61+ const source = result.outputFiles[0]?.text;
62+ if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
63+ return source;
64+};
65+
3666 // ---------------------------------------------------------------------------
3767 // Metadata
3868 // ---------------------------------------------------------------------------
@@ -119,6 +149,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines
119149 };
120150
121151 const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor");
152+const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd");
122153
123154 const isCurrentPlatform = (t: Target) =>
124155 t.os === process.platform && t.arch === process.arch && !t.abi;
@@ -211,6 +242,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines
211242 }
212243 };
213244
245+const resolveWorkerdBinary = (t: Target): string | null => {
246+ const platformMap: Record<string, string> = {
247+ "darwin-arm64": "@cloudflare/workerd-darwin-arm64",
248+ "darwin-x64": "@cloudflare/workerd-darwin-64",
249+ "linux-arm64": "@cloudflare/workerd-linux-arm64",
250+ "linux-x64": "@cloudflare/workerd-linux-64",
251+ "win32-x64": "@cloudflare/workerd-windows-64",
252+ };
253+ const key = [t.os, t.arch, t.abi].filter(Boolean).join("-");
254+ const pkg = platformMap[key];
255+ if (!pkg) return null;
256+ const binary = workerdBinaryName(t);
257+ try {
258+ const req = createRequire(
259+ join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"),
260+ );
261+ const pkgJson = req.resolve(`${pkg}/package.json`);
262+ for (const candidate of [
263+ join(dirname(pkgJson), "bin", binary),
264+ join(dirname(pkgJson), binary),
265+ ]) {
266+ if (existsSync(candidate)) return candidate;
267+ }
268+ } catch {
269+ const packageRoot = join(
270+ repoRoot,
271+ `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`,
272+ );
273+ for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) {
274+ if (existsSync(candidate)) return candidate;
275+ }
276+ }
277+ return null;
278+};
279+
214280 // ---------------------------------------------------------------------------
215281 // Build mode
216282 // ---------------------------------------------------------------------------
@@ -333,6 +399,8 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 116 unmodified lines
333399
334400 const quickJsWasmPath = resolveQuickJsWasmPath();
335401 const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath();
402+ const workerBundlerDistPath = resolveWorkerBundlerDistPath();
403+ const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath);
336404
337405 try {
338406 for (const target of targets) {
@@ -375,12 +443,35 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 36 unmodified lines
375443 await cp(libsqlNative, join(binDir, "libsql.node"));
376444 }
377445
446+ const workerdBinary = resolveWorkerdBinary(target);
447+ if (workerdBinary && existsSync(workerdBinary)) {
448+ await cp(workerdBinary, join(binDir, workerdBinaryName(target)));
449+ if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755);
450+ }
451+
452+ await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), {
453+ recursive: true,
454+ });
455+ await writeFile(
456+ join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"),
457+ packedWorkerBundlerSource,
458+ );
459+
378460 // Smoke test on current platform
379461 if (isCurrentPlatform(target)) {
380462 const bin = join(binDir, binaryName(target));
381463 console.log(` Smoke test: ${bin} --version`);
382464 const version = await $`${bin} --version`.text();
383465 console.log(` OK: ${version.trim()}`);
466+ console.log(` Smoke test: packed apps source sync and invoke`);
467+ const smoke = Bun.spawn(
468+ ["bun", "run", join(cliRoot, "scripts/smoke-packed-apps.ts"), bin],
469+ {
470+ cwd: repoRoot,
471+ stdio: ["ignore", "inherit", "inherit"],
472+ },
473+ );
474+ if ((await smoke.exited) !== 0) throw new Error("packed apps smoke failed");
384475 }
385476
386477 // Variant package.json. All variants publish to the SAME npm package
apps/cli/src/native-bindings.ts+19−5
@@ -1,11 +1,10 @@
11 // ---------------------------------------------------------------------------
22 // Native-binding bootstrap for the `bun build --compile` binary.
33 //
4−// `bun --compile` bundles JS into bunfs but does NOT include `.node` native
5−// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside
6−// the binary fails. build.ts copies each platform's `.node` next to the
7−// executable (`libsql.node`, `keyring.node`); here we publish their on-disk
8−// paths via env vars the loaders read.
4+// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5+// wasm sidecars, or runtime-read package files. build.ts copies those artifacts
6+// next to the executable; here we publish their on-disk paths via env vars the
7+// loaders read.
98 //
109 // This MUST be the FIRST import in main.ts. ES modules evaluate every import
1110 // before the importer's own body, and libSQL resolves its native addon EAGERLY
@@ -43,3 +42,18 @@ if ( · 31 unmodified lines
4342 ) {
4443 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
4544 }
45+
46+const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
47+if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
48+ process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
49+}
50+
51+const workerBundlerOnDisk = join(execDir, "worker-bundler");
52+if (
53+ typeof Bun !== "undefined" &&
54+ !process.env.EXECUTOR_WORKER_BUNDLER_DIR &&
55+ existsSync(join(workerBundlerOnDisk, "dist", "index.js")) &&
56+ existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm"))
57+) {
58+ process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk;
59+}
apps/desktop/src/sidecar/native-bindings.ts+18−5
@@ -1,11 +1,9 @@
11 // ---------------------------------------------------------------------------
22 // Native-binding bootstrap for manually compiled sidecar binaries.
33 //
4−// `bun --compile` bundles JS into bunfs but does NOT include `.node` native
5−// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside
6−// the binary fails. If this dev sidecar is compiled directly, copy each
7−// platform's `.node` next to the executable (`libsql.node`, `keyring.node`);
8−// here we publish their
4+// `bun --compile` bundles JS into bunfs but does NOT include native binaries,
5+// wasm sidecars, or runtime-read package files. If this dev sidecar is compiled
6+// directly, copy those artifacts next to the executable; here we publish their
97 // on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts.
108 //
119 // This MUST be the FIRST import in server.ts. ES modules evaluate every import
@@ -44,3 +42,18 @@ if ( · 32 unmodified lines
4442 ) {
4543 process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
4644 }
45+
46+const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd");
47+if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) {
48+ process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk;
49+}
50+
51+const workerBundlerOnDisk = join(execDir, "worker-bundler");
52+if (
53+ typeof Bun !== "undefined" &&
54+ !process.env.EXECUTOR_WORKER_BUNDLER_DIR &&
55+ existsSync(join(workerBundlerOnDisk, "dist", "index.js")) &&
56+ existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm"))
57+) {
58+ process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk;
59+}
packages/kernel/runtime-workerd-subprocess/src/index.test.ts+41−1
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { stat } from "node:fs/promises";
2+import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
34 import { join } from "node:path";
45 import * as Data from "effect/Data";
56 import * as Effect from "effect/Effect";
@@ -9,6 +10,7 @@ import { · 3 unmodified lines
910 createWorkerdModuleRunner,
1011 isWorkerdAvailable,
1112 makeWorkerdSubprocessExecutor,
13+ workerdBinaryCandidatesForTest,
1214 WORKERD_VERSION,
1315 } from "./index";
1416
@@ -107,6 +109,44 @@ it("exports the pinned workerd version", () => { · 92 unmodified lines
107109 expect(WORKERD_VERSION).toBe("1.20260708.1");
108110 });
109111
112+describe("workerd binary resolution", () => {
113+ it("honors EXECUTOR_WORKERD_BIN before probing", async () => {
114+ const tmp = await mkdtemp(join(tmpdir(), "executor-workerd-bin-test-"));
115+ const previous = process.env.EXECUTOR_WORKERD_BIN;
116+ const bin = join(tmp, "workerd");
117+ try {
118+ await writeFile(bin, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
119+ await chmod(bin, 0o755);
120+ process.env.EXECUTOR_WORKERD_BIN = bin;
121+ expect(isWorkerdAvailable()).toBe(true);
122+ } finally {
123+ if (previous === undefined) {
124+ delete process.env.EXECUTOR_WORKERD_BIN;
125+ } else {
126+ process.env.EXECUTOR_WORKERD_BIN = previous;
127+ }
128+ await rm(tmp, { recursive: true, force: true });
129+ }
130+ });
131+
132+ it("orders generic package probes before platform package probes", () => {
133+ const roots = ["/app", "/workspace"];
134+ expect(workerdBinaryCandidatesForTest(roots, "darwin", "arm64")).toEqual([
135+ "/app/node_modules/workerd/bin/workerd",
136+ "/workspace/node_modules/workerd/bin/workerd",
137+ "/app/node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd",
138+ "/app/node_modules/@cloudflare/workerd-darwin-arm64/workerd",
139+ "/workspace/node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd",
140+ "/workspace/node_modules/@cloudflare/workerd-darwin-arm64/workerd",
141+ ]);
142+ });
143+
144+ it("probes workerd.exe in the Windows package root layout", () => {
145+ const candidates = workerdBinaryCandidatesForTest(["C:\\app"], "win32", "x64");
146+ expect(candidates).toContain("C:\\app/node_modules/@cloudflare/workerd-windows-64/workerd.exe");
147+ });
148+});
149+
110150 describe.skipIf(!isWorkerdAvailable())("runtime-workerd-subprocess", () => {
111151 it("boots and executes a trivial module", async () => {
112152 const runner = makeRunner();
packages/kernel/runtime-workerd-subprocess/src/index.ts+106−28
@@ -1,7 +1,9 @@
11 /* oxlint-disable executor/no-try-catch-or-throw, executor/no-promise-reject, executor/no-instanceof-tagged-error -- subprocess boundary normalizes JavaScript errors into typed runtime errors */
22 import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";
3+import { existsSync, readFileSync } from "node:fs";
34 import { access, mkdir, rm, writeFile } from "node:fs/promises";
45 import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
6+import { createRequire } from "node:module";
57 import { createServer as createNetServer, type Server as NetServer } from "node:net";
68 import type { Readable } from "node:stream";
79 import { tmpdir } from "node:os";
@@ -77,6 +79,7 @@ type PendingRequest = { · 69 unmodified lines
7779 };
7880
7981 const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
82+const packageRequire = createRequire(import.meta.url);
8083
8184 const normalizeError = (cause: unknown): Error =>
8285 cause instanceof Error ? cause : new Error(String(cause));
@@ -150,16 +153,86 @@ const reserveLocalPort = (): Promise<{ · 67 unmodified lines
150153 });
151154 });
152155
153−const findWorkerdBin = async (configured?: string): Promise<string> => {
154− if (configured) return configured;
156+const workerdBinaryName = (platform: NodeJS.Platform = process.platform): string =>
157+ platform === "win32" ? "workerd.exe" : "workerd";
158+
159+const detectLinuxMusl = (): boolean => {
160+ if (process.platform !== "linux") return false;
161+ if (existsSync("/etc/alpine-release")) return true;
162+ try {
163+ return readFileSync("/usr/bin/ldd", "utf8").toLowerCase().includes("musl");
164+ } catch {
165+ return false;
166+ }
167+};
168+
169+const workerdPlatformPackage = (
170+ platform: NodeJS.Platform = process.platform,
171+ arch: NodeJS.Architecture = process.arch,
172+): string | null => {
173+ if (platform === "darwin" && arch === "arm64") return "@cloudflare/workerd-darwin-arm64";
174+ if (platform === "darwin" && arch === "x64") return "@cloudflare/workerd-darwin-64";
175+ if (platform === "linux" && detectLinuxMusl()) return null;
176+ if (platform === "linux" && arch === "arm64") return "@cloudflare/workerd-linux-arm64";
177+ if (platform === "linux" && arch === "x64") return "@cloudflare/workerd-linux-64";
178+ if (platform === "win32" && arch === "x64") return "@cloudflare/workerd-windows-64";
179+ return null;
180+};
181+
182+const workerdPackageDirCandidates = (packageName: string): readonly string[] => {
183+ const packageJson = `${packageName}/package.json`;
184+ const resolved = new Set<string>();
185+ for (const req of [
186+ packageRequire,
187+ createRequire(join(process.cwd(), "package.json")),
188+ createRequire(join(packageDir, "package.json")),
189+ ]) {
190+ try {
191+ resolved.add(dirname(req.resolve(packageJson)));
192+ } catch {
193+ // Try the next package-manager layout.
194+ }
195+ }
196+ return [...resolved];
197+};
198+
199+export const workerdBinaryCandidatesForTest = (
200+ roots: readonly string[],
201+ platform: NodeJS.Platform,
202+ arch: NodeJS.Architecture,
203+): readonly string[] => {
204+ const binary = workerdBinaryName(platform);
205+ const platformPackage = workerdPlatformPackage(platform, arch);
206+ return [
207+ ...roots.map((root) => join(root, "node_modules", "workerd", "bin", binary)),
208+ ...(platformPackage
209+ ? roots.flatMap((root) => [
210+ join(root, "node_modules", platformPackage, "bin", binary),
211+ join(root, "node_modules", platformPackage, binary),
212+ ])
213+ : []),
214+ ];
215+};
155216
156− const candidates = [
157− join(packageDir, "node_modules", "workerd", "bin", "workerd"),
158− join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"),
159− join(process.cwd(), "node_modules", "workerd", "bin", "workerd"),
217+const workerdBinaryCandidates = (): readonly string[] => {
218+ const roots = [packageDir, join(packageDir, "..", "..", ".."), process.cwd()];
219+ const platformPackage = workerdPlatformPackage();
220+ return [
221+ ...(platformPackage
222+ ? workerdPackageDirCandidates(platformPackage).flatMap((dir) => [
223+ join(dir, "bin", workerdBinaryName()),
224+ join(dir, workerdBinaryName()),
225+ ])
226+ : []),
227+ ...workerdBinaryCandidatesForTest(roots, process.platform, process.arch),
160228 ];
229+};
161230
162− for (const candidate of candidates) {
231+const findWorkerdBin = async (configured?: string): Promise<string> => {
232+ if (configured) return configured;
233+ if (process.env.EXECUTOR_WORKERD_BIN) return process.env.EXECUTOR_WORKERD_BIN;
234+
235+ for (const candidate of workerdBinaryCandidates()) {
163236 try {
164237 await access(candidate);
165238 return candidate;
@@ -168,36 +241,41 @@ const findWorkerdBin = async (configured?: string): Promise<string> => { · 2 unmodified lines
168241 }
169242 }
170243
171− return "workerd";
172−};
173−
174−export const isWorkerdAvailable = (workerdBin?: string): boolean => {
175− const configured = workerdBin ?? "workerd";
176− const result = spawnSync(configured, ["--version"], {
244+ if (workerdPlatformPackage() === null) throw new Error("workerd is unavailable on this platform");
245+ const pathWorkerd = spawnSync("workerd", ["--version"], {
177246 stdio: "ignore",
178247 timeout: 5000,
179248 });
180− if (result.error === undefined && result.status === 0) return true;
249+ if (pathWorkerd.error !== undefined || pathWorkerd.status !== 0) {
250+ throw new Error("workerd is unavailable on this platform");
251+ }
252+ return "workerd";
253+};
181254
182− const local = spawnSync(
183− join(packageDir, "node_modules", "workerd", "bin", "workerd"),
184− ["--version"],
185− {
255+export const isWorkerdAvailable = (workerdBin?: string): boolean => {
256+ const configured = workerdBin ?? process.env.EXECUTOR_WORKERD_BIN;
257+ if (configured) {
258+ const result = spawnSync(configured, ["--version"], {
186259 stdio: "ignore",
187260 timeout: 5000,
188− },
189− );
190− if (local.error === undefined && local.status === 0) return true;
261+ });
262+ return result.error === undefined && result.status === 0;
263+ }
191264
192− const workspace = spawnSync(
193− join(packageDir, "..", "..", "..", "node_modules", "workerd", "bin", "workerd"),
194− ["--version"],
195− {
265+ for (const candidate of workerdBinaryCandidates()) {
266+ const result = spawnSync(candidate, ["--version"], {
196267 stdio: "ignore",
197268 timeout: 5000,
198− },
199− );
200− return workspace.error === undefined && workspace.status === 0;
269+ });
270+ if (result.error === undefined && result.status === 0) return true;
271+ }
272+
273+ if (workerdPlatformPackage() === null) return false;
274+ const result = spawnSync("workerd", ["--version"], {
275+ stdio: "ignore",
276+ timeout: 5000,
277+ });
278+ return result.error === undefined && result.status === 0;
201279 };
202280
203281 const capnpString = (value: string): string => JSON.stringify(value);
packages/plugins/apps/src/executor/workerd-app-tool-executor.ts+2−2
@@ -1,4 +1,4 @@
1−/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error -- workerd boundary converts worker envelopes into typed AppExecutorError */
1+/* oxlint-disable executor/no-try-catch-or-throw, executor/no-double-cast, executor/no-promise-reject, executor/no-instanceof-tagged-error, executor/no-instanceof-error, executor/no-unknown-error-message -- workerd boundary converts worker envelopes and subprocess startup failures into typed AppExecutorError */
22 import { Effect } from "effect";
33
44 import {
@@ -47,7 +47,7 @@ const mapCause = (cause: unknown, kind: AppExecutorError["kind"], message: strin · 42 unmodified lines
4747 ? cause
4848 : new AppExecutorError({
4949 kind,
50− message,
50+ message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`,
5151 cause,
5252 });
5353
packages/plugins/apps/src/pipeline/worker-bundler.ts+21−14
@@ -1,4 +1,5 @@
11 /* 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 */
2+import { existsSync } from "node:fs";
23 import { readFile } from "node:fs/promises";
34 import { dirname, join } from "node:path";
45 import { fileURLToPath } from "node:url";
@@ -7,7 +8,6 @@ import { · 2 unmodified lines
78 createWorkerdModuleRunner,
89 WORKERD_VERSION,
910 } from "@executor-js/runtime-workerd-subprocess";
10−import { build } from "esbuild";
1111 import { Effect } from "effect";
1212
1313 import { AppExecutorError } from "../executor/app-tool-executor";
@@ -18,7 +18,9 @@ import { WORKER_BUNDLER_ESBUILD_VERSION, WORKER_BUNDLER_VERSION } from "./worker · 4 unmodified lines
1818
1919 const textEncoder = new TextEncoder();
2020 const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
21−const workerBundlerDir = join(packageDir, "node_modules", "@cloudflare", "worker-bundler");
21+const workerBundlerDir =
22+ process.env.EXECUTOR_WORKER_BUNDLER_DIR ??
23+ join(packageDir, "node_modules", "@cloudflare", "worker-bundler");
2224
2325 const executorAppSource = `
2426 const makeIntegrationDeclaration = (state) => Object.freeze({
@@ -97,19 +99,24 @@ const bundledWorkerBundler = async (): Promise<{ · 72 unmodified lines
9799 readonly source: string;
98100 readonly wasm: Uint8Array;
99101 }> => {
100− const entry = join(workerBundlerDir, "dist", "index.js");
101− const [result, wasm] = await Promise.all([
102− build({
103− entryPoints: [entry],
104− bundle: true,
105− format: "esm",
106− platform: "browser",
107− write: false,
108− external: ["./esbuild.wasm"],
109− logLevel: "silent",
110− }),
111− readFile(join(workerBundlerDir, "dist", "esbuild.wasm")),
102+ const distDir = join(workerBundlerDir, "dist");
103+ const bundledEntry = join(distDir, "index.bundled.js");
104+ const [sourceFromDisk, wasm] = await Promise.all([
105+ existsSync(bundledEntry) ? readFile(bundledEntry, "utf8") : Promise.resolve(null),
106+ readFile(join(distDir, "esbuild.wasm")),
112107 ]);
108+ if (sourceFromDisk !== null) return { source: sourceFromDisk, wasm };
109+
110+ const { build } = await import("esbuild");
111+ const result = await build({
112+ entryPoints: [join(distDir, "index.js")],
113+ bundle: true,
114+ format: "esm",
115+ platform: "browser",
116+ write: false,
117+ external: ["./esbuild.wasm"],
118+ logLevel: "silent",
119+ });
113120 const source = result.outputFiles[0]?.text;
114121 if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler");
115122 return { source, wasm };