1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import type * as Esbuild from "esbuild";
4import * as NodePath from "node:path";
5
6export class BundleError extends Data.TaggedError<"BundleError">(
7 "BundleError",
8)<{
9 readonly message: string;
10 readonly cause?: unknown;
11}> {}
12
13/** The entry module name the final bundle pass emits. */
14export const WORKER_ENTRY_NAME = "worker.js";
15
16/**
17 * The per-output-file banner wrangler also injects: CJS-converted code inside
18 * the bundle (Next's compiled server) calls `require("fs")` etc. for
19 * externalized node builtins, and esbuild's `__require` shim throws
20 * `Dynamic require of "fs" is not supported` unless a real `require` is in
21 * scope. workerd's `nodejs_compat` implements `node:module.createRequire`.
22 *
23 * The `?? "file:///"` fallback is load-bearing: `import.meta.url` is
24 * `undefined` inside workerd modules, and a plain
25 * `createRequire(import.meta.url)` crashes the worker at startup.
26 */
27export const CREATE_REQUIRE_BANNER = [
28 `import { createRequire as __distilled_createRequire } from "node:module";`,
29 `const require = /* @__PURE__ */ __distilled_createRequire(import.meta.url ?? "file:///");`,
30].join("\n");
31
32/**
33 * OpenNext's `setWranglerExternal` esbuild plugin leaves `.wasm`/`.bin`
34 * imports in the server handler as ABSOLUTE paths (optionally with a
35 * `?module` suffix) "for wrangler to bundle". This plugin strips the suffix
36 * and routes them through esbuild's `copy` loader so they become relative
37 * file imports that can be registered as CompiledWasm/Data modules.
38 */
39export const makeWranglerExternalsPlugin = (): Esbuild.Plugin => ({
40 name: "distilled-nextjs:wrangler-externals",
41 setup(build) {
42 build.onResolve({ filter: /\.(wasm|bin)(\?module)?$/ }, (args) => {
43 const clean = args.path.replace(/\?module$/, "");
44 return {
45 path: NodePath.isAbsolute(clean)
46 ? clean
47 : NodePath.resolve(args.resolveDir, clean),
48 };
49 });
50 },
51});
52
53export interface BundleWorkerOptions {
54 /** The `.open-next` directory produced by the OpenNext build pipeline. */
55 readonly openNextDirectory: string;
56 /** Directory the finished module set is written to. */
57 readonly outDirectory: string;
58 /** Minify the emitted modules. @default false */
59 readonly minify?: boolean | undefined;
60}
61
62export interface BundleWorkerResult {
63 /** The esbuild metafile of the pass. */
64 readonly metafile: Esbuild.Metafile;
65}
66
67/**
68 * The "final bundle pass" wrangler normally performs at deploy time:
69 * `.open-next/worker.js` (and its deliberately-unresolved relative imports —
70 * `cloudflare/*.js`, `middleware/handler.mjs`, `.build/durable-objects/*.js`)
71 * is bundled into a self-contained ESM module set.
72 *
73 * The four load-bearing rules (each discovered by a boot failure, not docs):
74 *
75 * 1. `format: "esm"` + `splitting: true` keeps the upstream
76 * `await import("./server-functions/default/handler.mjs")` a lazy chunk.
77 * 2. `conditions: ["workerd", "worker"]` resolves workerd-flavored exports.
78 * 3. `external: ["cloudflare:*", "node:*"]` — provided by workerd and
79 * `nodejs_compat` respectively.
80 * 4. {@link CREATE_REQUIRE_BANNER} per output file (see its doc).
81 *
82 * Plus the `.wasm`/`.bin` `?module` copy rule
83 * ({@link makeWranglerExternalsPlugin}).
84 */
85export const bundleWorker = (
86 options: BundleWorkerOptions,
87): Effect.Effect<BundleWorkerResult, BundleError> =>
88 Effect.gen(function* () {
89 const esbuild = yield* Effect.tryPromise({
90 try: async () => await import("esbuild"),
91 catch: (cause) =>
92 new BundleError({ message: "Failed to load esbuild", cause }),
93 });
94 const result = yield* Effect.tryPromise({
95 try: () =>
96 esbuild.build({
97 // Pin the build's working directory: without it, esbuild uses its
98 // long-lived shared service process's cwd, which is captured when
99 // the service first spawns — under a concurrent engine that can be
100 // another build's transient chdir target (possibly deleted by the
101 // time this build runs), which fails resolution of even absolute
102 // entry paths.
103 absWorkingDir: options.openNextDirectory,
104 plugins: [makeWranglerExternalsPlugin()],
105 entryPoints: [
106 NodePath.join(options.openNextDirectory, WORKER_ENTRY_NAME),
107 ],
108 outdir: options.outDirectory,
109 bundle: true,
110 format: "esm",
111 platform: "node",
112 target: "es2022",
113 conditions: ["workerd", "worker"],
114 splitting: true,
115 minify: options.minify ?? false,
116 sourcemap: false,
117 metafile: true,
118 external: ["cloudflare:*", "node:*"],
119 loader: {
120 ".wasm": "copy",
121 ".bin": "copy",
122 },
123 entryNames: "[name]",
124 chunkNames: "chunks/[name]-[hash]",
125 assetNames: "[name]-[hash]",
126 banner: {
127 js: CREATE_REQUIRE_BANNER,
128 },
129 logLevel: "silent",
130 }),
131 catch: (cause) =>
132 new BundleError({
133 message: "The final worker bundle pass failed",
134 cause,
135 }),
136 });
137 return { metafile: result.metafile };
138 });