| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as Esbuild from "esbuild"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | |
| 6 | export 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. */ |
| 14 | export 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 | */ |
| 27 | export 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 | */ |
| 39 | export 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 | |
| 53 | export 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 | |
| 62 | export 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 | */ |
| 85 | export 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 | }); |