| 1 | /** |
| 2 | * The alchemy Worker source-provider entry for Next.js |
| 3 | * (`@alchemy.run/frontend-frameworks/nextjs/source`). |
| 4 | * |
| 5 | * The default export implements alchemy's `WorkerSourceModule` contract |
| 6 | * (`packages/alchemy/src/Cloudflare/Workers/Source.ts`) **structurally** — |
| 7 | * this package deliberately does not import alchemy types. The shapes |
| 8 | * mirrored here are: |
| 9 | * |
| 10 | * - `WorkerSourceModule` — `{ make(options) => Effect<SourceProvider> }` |
| 11 | * - `SourceProvider` — `{ ownsAssets, build(ctx), hash(ctx, previous), dev(ctx) }` |
| 12 | * - `SourceBuildOutput` — `{ bundle: { files, hash }, assets: AssetReadResult, hash }` |
| 13 | * - `AssetReadResult` — the manifest shape alchemy's asset uploader consumes |
| 14 | * (per-file hash = sha256 hex truncated to 32 chars, the Workers Assets |
| 15 | * API's content-address format) |
| 16 | * |
| 17 | * `build()` wraps this package's `Framework` service (the OpenNext-based, |
| 18 | * wrangler-free pipeline in `Nextjs.ts`): server modules become the worker |
| 19 | * bundle (entry first), `.open-next/assets` becomes the static-assets |
| 20 | * manifest, and the project tree is content-hashed for rebuild-free diffs. |
| 21 | * `hash()` never builds — it recomputes the input-tree hash (including this |
| 22 | * package's version, so an adapter upgrade busts the memo). `dev()` is v1 |
| 23 | * preview parity: the built worker served under cloudflare-runtime (workerd); |
| 24 | * ISR revalidation writes are a documented no-op (read-only static-assets |
| 25 | * incremental cache). |
| 26 | */ |
| 27 | import type { |
| 28 | BindingHooks, |
| 29 | RuntimeWorker, |
| 30 | } from "@alchemy.run/cloudflare-runtime/core"; |
| 31 | import * as FrameworkCore from "../core/index.ts"; |
| 32 | import * as Data from "effect/Data"; |
| 33 | import * as Effect from "effect/Effect"; |
| 34 | import * as FileSystem from "effect/FileSystem"; |
| 35 | import * as Path from "effect/Path"; |
| 36 | import type { PlatformError } from "effect/PlatformError"; |
| 37 | import type * as Scope from "effect/Scope"; |
| 38 | import * as NodeCrypto from "node:crypto"; |
| 39 | import { createRequire } from "node:module"; |
| 40 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 41 | import * as Nextjs from "./Nextjs.ts"; |
| 42 | import * as Runner from "./Runner.ts"; |
| 43 | |
| 44 | const packageVersion: string = createRequire(import.meta.url)( |
| 45 | "../../package.json", |
| 46 | ).version; |
| 47 | |
| 48 | const PROVIDER = "@alchemy.run/frontend-frameworks/nextjs/source"; |
| 49 | |
| 50 | // ───────────────────────────────────────────────────────────────────── |
| 51 | // Structural mirrors of alchemy's Source.ts contract |
| 52 | // ───────────────────────────────────────────────────────────────────── |
| 53 | |
| 54 | /** |
| 55 | * Structural mirror of alchemy's `Cloudflare.Workers.SourceProviderError` |
| 56 | * (same `_tag`, so alchemy-side `Effect.catchTag` handles it as its own). |
| 57 | */ |
| 58 | export class SourceProviderError extends Data.TaggedError( |
| 59 | "Cloudflare.Workers.SourceProviderError", |
| 60 | )<{ |
| 61 | readonly provider: string; |
| 62 | readonly message: string; |
| 63 | readonly cause?: unknown; |
| 64 | }> {} |
| 65 | |
| 66 | /** Mirror of alchemy's `SourceHash` (the Worker `hash` attribute slots). */ |
| 67 | export interface SourceHash { |
| 68 | readonly bundle: string | undefined; |
| 69 | readonly assets: string | undefined; |
| 70 | readonly input: string | undefined; |
| 71 | readonly additionalWorkspaces: Array<string> | undefined; |
| 72 | } |
| 73 | |
| 74 | /** Mirror of alchemy's `Bundle.BundleFile`. */ |
| 75 | export interface SourceBundleFile { |
| 76 | readonly path: string; |
| 77 | readonly content: string | Uint8Array; |
| 78 | readonly hash: string; |
| 79 | } |
| 80 | |
| 81 | /** Mirror of alchemy's `AssetReadResult` (Workers Assets manifest shape). */ |
| 82 | export interface SourceAssets { |
| 83 | directory: string; |
| 84 | config: Record<string, unknown> | undefined; |
| 85 | manifest: Record<string, { hash: string; size: number }>; |
| 86 | _headers: string | undefined; |
| 87 | _redirects: string | undefined; |
| 88 | hash: string; |
| 89 | } |
| 90 | |
| 91 | /** Mirror of alchemy's `SourceBuildOutput`. */ |
| 92 | export interface SourceBuildOutput { |
| 93 | readonly bundle: |
| 94 | | { readonly files: Array<SourceBundleFile>; readonly hash: string } |
| 95 | | undefined; |
| 96 | readonly assets: SourceAssets | undefined; |
| 97 | readonly hash: SourceHash; |
| 98 | } |
| 99 | |
| 100 | /** The subset of alchemy's `SourceContext` this provider consumes. */ |
| 101 | export interface SourceContext { |
| 102 | readonly dotAlchemy?: string; |
| 103 | readonly id: string; |
| 104 | readonly workerName: string; |
| 105 | readonly compatibility: { |
| 106 | readonly date: string; |
| 107 | readonly flags: Array<string>; |
| 108 | }; |
| 109 | readonly assets?: string | Record<string, unknown> | undefined; |
| 110 | } |
| 111 | |
| 112 | type WorkerWiring = Omit< |
| 113 | RuntimeWorker<BindingHooks>, |
| 114 | "compatibilityDate" | "compatibilityFlags" | "modules" |
| 115 | >; |
| 116 | |
| 117 | /** The subset of alchemy's `DevContext` this provider consumes. */ |
| 118 | export interface DevContext extends SourceContext { |
| 119 | readonly worker: { |
| 120 | readonly bindings: NonNullable<WorkerWiring["bindings"]>; |
| 121 | readonly durableObjectNamespaces: NonNullable< |
| 122 | WorkerWiring["durableObjectNamespaces"] |
| 123 | >; |
| 124 | readonly hyperdrives: NonNullable<WorkerWiring["hyperdrives"]>; |
| 125 | readonly queueConsumers: Effect.Effect< |
| 126 | NonNullable<WorkerWiring["queueConsumers"]> |
| 127 | >; |
| 128 | readonly assets: WorkerWiring["assets"] | undefined; |
| 129 | }; |
| 130 | /** |
| 131 | * The host's runtime stack (a `Context.Context<RuntimeServices>`) — the |
| 132 | * dev binding proxy is hosted in it instead of the credential-free |
| 133 | * internal layer, so `Alchemy.remote()` bindings resolve in dev. |
| 134 | */ |
| 135 | readonly runtimeContext: unknown; |
| 136 | } |
| 137 | |
| 138 | export type SourceDevHandle = { |
| 139 | readonly mode: "server"; |
| 140 | readonly url: URL; |
| 141 | readonly serviceBinding?: "http"; |
| 142 | }; |
| 143 | |
| 144 | export type SourceError = SourceProviderError | PlatformError; |
| 145 | |
| 146 | export type SourceServices = FileSystem.FileSystem | Path.Path; |
| 147 | |
| 148 | /** Mirror of alchemy's `SourceProvider` (the arms this module implements). */ |
| 149 | export interface SourceProvider { |
| 150 | readonly ownsAssets: boolean; |
| 151 | readonly build: ( |
| 152 | ctx: SourceContext, |
| 153 | ) => Effect.Effect<SourceBuildOutput, SourceError, SourceServices>; |
| 154 | readonly hash: ( |
| 155 | ctx: SourceContext, |
| 156 | previous: SourceHash | undefined, |
| 157 | ) => Effect.Effect<Partial<SourceHash>, SourceError, SourceServices>; |
| 158 | readonly dev: ( |
| 159 | ctx: DevContext, |
| 160 | ) => Effect.Effect< |
| 161 | SourceDevHandle, |
| 162 | SourceError, |
| 163 | SourceServices | Scope.Scope |
| 164 | >; |
| 165 | } |
| 166 | |
| 167 | // ───────────────────────────────────────────────────────────────────── |
| 168 | // Options |
| 169 | // ───────────────────────────────────────────────────────────────────── |
| 170 | |
| 171 | /** |
| 172 | * Controls which project files are content-hashed to decide whether the |
| 173 | * OpenNext build needs to re-run. Mirrors the semantics of alchemy's |
| 174 | * `MemoOptions` (`Command/Memo.ts`) with a built-in glob matcher: |
| 175 | * `**` crosses directory boundaries, `*`/`?` stay within a segment. |
| 176 | */ |
| 177 | export interface NextjsMemoOptions { |
| 178 | /** Glob patterns of files to hash, relative to the project root. @default all files */ |
| 179 | readonly include?: Array<string> | undefined; |
| 180 | /** Glob patterns to exclude from hashing. Build outputs (`.next`, `.open-next`, `dist`) and `node_modules` are always excluded. */ |
| 181 | readonly exclude?: Array<string> | undefined; |
| 182 | /** |
| 183 | * Include the nearest package-manager lockfile in the hash, even when it |
| 184 | * lives above the project root (e.g. a monorepo root). |
| 185 | * @default true when both `include` and `exclude` are unset; false otherwise |
| 186 | */ |
| 187 | readonly lockfile?: boolean | undefined; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * The JSON-serializable options of the `@alchemy.run/frontend-frameworks/nextjs/source` |
| 192 | * descriptor (`WorkerProps.source.options`). Must stay JSON-stable — the |
| 193 | * descriptor persists in state and participates in the Worker metadata hash. |
| 194 | */ |
| 195 | export interface NextjsSourceOptions { |
| 196 | /** The Next.js project root. Defaults to the process working directory. */ |
| 197 | readonly root?: string | undefined; |
| 198 | /** Rebuild-scope configuration (which files bust the build memo). */ |
| 199 | readonly memo?: NextjsMemoOptions | undefined; |
| 200 | /** Optional explicit config; otherwise discover `open-next.config.ts`, falling back to generated defaults. */ |
| 201 | readonly configPath?: string | undefined; |
| 202 | /** Resource-selected cache adapters. Defaults to the read-only static-assets cache. */ |
| 203 | readonly cache?: "static-assets" | "kv" | undefined; |
| 204 | /** |
| 205 | * The command the OpenNext pipeline runs to build the Next.js app. |
| 206 | * Takes precedence over an explicitly supplied OpenNext config. |
| 207 | * @default "npx next build" |
| 208 | */ |
| 209 | readonly buildCommand?: string | undefined; |
| 210 | /** Skip the internal `next build` (reuse an existing `.next`). */ |
| 211 | readonly skipNextBuild?: boolean | undefined; |
| 212 | /** Minify the OpenNext bundling steps and the final bundle pass. */ |
| 213 | readonly minify?: boolean | undefined; |
| 214 | /** Enable OpenNext debug logging (and verbose workerd output in dev). */ |
| 215 | readonly debug?: boolean | undefined; |
| 216 | /** Dev-server behavior (JSON-stable; does not affect the build). */ |
| 217 | readonly dev?: |
| 218 | | { |
| 219 | /** |
| 220 | * - `"preview"` (default): build the OpenNext worker and serve it |
| 221 | * under `cloudflare-runtime` (workerd) — production parity, no HMR. |
| 222 | * - `"hmr"`: run the real `next dev` (Turbopack HMR) in Node with the |
| 223 | * worker's bindings proxied from `cloudflare-runtime` onto |
| 224 | * OpenNext's `getCloudflareContext()` contract. App code runs in |
| 225 | * Node, not workerd — CF-specific runtime behavior and ISR/caching |
| 226 | * semantics still need `"preview"`. |
| 227 | * @default "preview" |
| 228 | */ |
| 229 | readonly mode?: "preview" | "hmr" | undefined; |
| 230 | } |
| 231 | | undefined; |
| 232 | } |
| 233 | |
| 234 | // ───────────────────────────────────────────────────────────────────── |
| 235 | // Hashing helpers (mirroring alchemy's Memo.ts / Assets.ts semantics) |
| 236 | // ───────────────────────────────────────────────────────────────────── |
| 237 | |
| 238 | const sha256Hex = (input: string | Uint8Array): Effect.Effect<string> => |
| 239 | Effect.sync(() => |
| 240 | NodeCrypto.createHash("sha256").update(input).digest("hex"), |
| 241 | ); |
| 242 | |
| 243 | /** JSON.stringify with recursively sorted object keys (stable across runs). */ |
| 244 | const stableStringify = (value: unknown): string => { |
| 245 | const stable = (v: unknown): unknown => { |
| 246 | if (Array.isArray(v)) return v.map(stable); |
| 247 | if (v !== null && typeof v === "object") { |
| 248 | return Object.fromEntries( |
| 249 | Object.entries(v as Record<string, unknown>) |
| 250 | .filter(([, entry]) => entry !== undefined) |
| 251 | .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) |
| 252 | .map(([k, entry]) => [k, stable(entry)]), |
| 253 | ); |
| 254 | } |
| 255 | return v; |
| 256 | }; |
| 257 | return JSON.stringify(stable(value ?? null)); |
| 258 | }; |
| 259 | |
| 260 | /** |
| 261 | * Compile a glob to a RegExp: `**` crosses `/` boundaries, `*` and `?` stay |
| 262 | * within a path segment. Enough for memo scoping (`app/**`, `package.json`); |
| 263 | * not a full gitignore engine. |
| 264 | */ |
| 265 | const globToRegExp = (glob: string): RegExp => { |
| 266 | let out = ""; |
| 267 | for (let i = 0; i < glob.length; i++) { |
| 268 | const ch = glob[i]!; |
| 269 | if (ch === "*") { |
| 270 | if (glob[i + 1] === "*") { |
| 271 | // `**/` also matches zero directories. |
| 272 | if (glob[i + 2] === "/") { |
| 273 | out += "(?:.*/)?"; |
| 274 | i += 2; |
| 275 | } else { |
| 276 | out += ".*"; |
| 277 | i += 1; |
| 278 | } |
| 279 | } else { |
| 280 | out += "[^/]*"; |
| 281 | } |
| 282 | } else if (ch === "?") { |
| 283 | out += "[^/]"; |
| 284 | } else if ("\\^$.|+()[]{}".includes(ch)) { |
| 285 | out += `\\${ch}`; |
| 286 | } else { |
| 287 | out += ch; |
| 288 | } |
| 289 | } |
| 290 | return new RegExp(`^${out}$`); |
| 291 | }; |
| 292 | |
| 293 | /** |
| 294 | * Directories that are never inputs: VCS metadata and this integration's own |
| 295 | * build outputs. Pruning them is load-bearing — `build()` writes `.next`, |
| 296 | * `.open-next`, and `dist` into the project root, so hashing them would make |
| 297 | * every deploy dirty its own memo. |
| 298 | */ |
| 299 | const ALWAYS_PRUNED = new Set([ |
| 300 | "node_modules", |
| 301 | ".git", |
| 302 | ".next", |
| 303 | ".open-next", |
| 304 | "dist", |
| 305 | ".turbo", |
| 306 | ".vercel", |
| 307 | ".alchemy", |
| 308 | ".wrangler", |
| 309 | ]); |
| 310 | |
| 311 | const ALWAYS_IGNORED_FILES = new Set([".DS_Store"]); |
| 312 | |
| 313 | /** Recursively list files under `root` (relative, `/`-separated), pruning `prune`d directories. */ |
| 314 | const listProjectFiles = Effect.fn(function* ( |
| 315 | root: string, |
| 316 | prune: ReadonlySet<string>, |
| 317 | dotAlchemy?: string, |
| 318 | ) { |
| 319 | const fs = yield* FileSystem.FileSystem; |
| 320 | const path = yield* Path.Path; |
| 321 | const runtimeDirectory = |
| 322 | dotAlchemy === undefined |
| 323 | ? undefined |
| 324 | : path.resolve(process.cwd(), dotAlchemy); |
| 325 | const out: Array<string> = []; |
| 326 | const walk = (relative: string): Effect.Effect<void, PlatformError, never> => |
| 327 | Effect.gen(function* () { |
| 328 | const absolute = relative === "" ? root : path.join(root, relative); |
| 329 | const entries = yield* fs.readDirectory(absolute); |
| 330 | for (const entry of entries) { |
| 331 | const rel = relative === "" ? entry : `${relative}/${entry}`; |
| 332 | if (runtimeDirectory !== undefined) { |
| 333 | const runtimeRelative = path.relative( |
| 334 | runtimeDirectory, |
| 335 | path.join(root, rel), |
| 336 | ); |
| 337 | if ( |
| 338 | runtimeRelative === "" || |
| 339 | (!path.isAbsolute(runtimeRelative) && |
| 340 | runtimeRelative !== ".." && |
| 341 | !runtimeRelative.startsWith(`..${path.sep}`)) |
| 342 | ) |
| 343 | continue; |
| 344 | } |
| 345 | const info = yield* fs.stat(path.join(root, rel)); |
| 346 | if (info.type === "Directory") { |
| 347 | if (!prune.has(entry)) { |
| 348 | yield* walk(rel); |
| 349 | } |
| 350 | } else if (info.type === "File" && !ALWAYS_IGNORED_FILES.has(entry)) { |
| 351 | out.push(rel); |
| 352 | } |
| 353 | } |
| 354 | }); |
| 355 | yield* walk(""); |
| 356 | return out.sort(); |
| 357 | }); |
| 358 | |
| 359 | const findUp = Effect.fn(function* (start: string, filenames: Array<string>) { |
| 360 | const fs = yield* FileSystem.FileSystem; |
| 361 | const path = yield* Path.Path; |
| 362 | let dir = start; |
| 363 | for (;;) { |
| 364 | for (const filename of filenames) { |
| 365 | const candidate = path.join(dir, filename); |
| 366 | if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { |
| 367 | return candidate; |
| 368 | } |
| 369 | } |
| 370 | const parent = path.dirname(dir); |
| 371 | if (parent === dir) return undefined; |
| 372 | dir = parent; |
| 373 | } |
| 374 | }); |
| 375 | |
| 376 | /** |
| 377 | * Content-hash the project's input tree. Deterministic for an unchanged tree |
| 378 | * and machine-independent for identical bytes: only root-relative paths are |
| 379 | * hashed, never absolute ones. The material additionally covers this |
| 380 | * package's version and the build-affecting options, so an adapter upgrade |
| 381 | * or an options change busts the memo without any file edit. |
| 382 | */ |
| 383 | const hashInputTree = Effect.fn(function* ( |
| 384 | root: string, |
| 385 | options: NextjsSourceOptions, |
| 386 | dotAlchemy?: string, |
| 387 | ) { |
| 388 | const fs = yield* FileSystem.FileSystem; |
| 389 | const memo = options.memo ?? {}; |
| 390 | const include = (memo.include ?? ["**/*"]).map(globToRegExp); |
| 391 | const exclude = (memo.exclude ?? []).map(globToRegExp); |
| 392 | const files = (yield* listProjectFiles( |
| 393 | root, |
| 394 | ALWAYS_PRUNED, |
| 395 | dotAlchemy, |
| 396 | )).filter( |
| 397 | (file) => |
| 398 | include.some((re) => re.test(file)) && |
| 399 | !exclude.some((re) => re.test(file)), |
| 400 | ); |
| 401 | const path = yield* Path.Path; |
| 402 | const entries = yield* Effect.forEach( |
| 403 | files, |
| 404 | (file) => |
| 405 | fs.readFile(path.join(root, file)).pipe( |
| 406 | Effect.flatMap(sha256Hex), |
| 407 | Effect.map((hash) => `${file}:${hash}`), |
| 408 | ), |
| 409 | { concurrency: 16 }, |
| 410 | ); |
| 411 | // Mirror alchemy's Memo.ts default: the nearest lockfile participates |
| 412 | // only when the memo scope wasn't explicitly narrowed. |
| 413 | const includeLockfile = memo.lockfile ?? !(memo.include || memo.exclude); |
| 414 | let lockfileHash: string | undefined; |
| 415 | if (includeLockfile) { |
| 416 | const lockfile = yield* findUp(root, [ |
| 417 | "bun.lock", |
| 418 | "bun.lockb", |
| 419 | "package-lock.json", |
| 420 | "pnpm-lock.yaml", |
| 421 | "yarn.lock", |
| 422 | ]); |
| 423 | if (lockfile !== undefined) { |
| 424 | lockfileHash = yield* fs |
| 425 | .readFile(lockfile) |
| 426 | .pipe(Effect.flatMap(sha256Hex)); |
| 427 | } |
| 428 | } |
| 429 | const configPath = yield* Runner.resolveConfigPath({ |
| 430 | appDir: root, |
| 431 | configPath: options.configPath, |
| 432 | }).pipe(Effect.mapError(frameworkError)); |
| 433 | const configHash = |
| 434 | configPath === undefined |
| 435 | ? undefined |
| 436 | : yield* fs.readFile(configPath).pipe(Effect.flatMap(sha256Hex)); |
| 437 | return yield* sha256Hex( |
| 438 | stableStringify({ |
| 439 | version: packageVersion, |
| 440 | config: { |
| 441 | path: |
| 442 | configPath === undefined |
| 443 | ? undefined |
| 444 | : path.relative(root, configPath).replaceAll("\\", "/"), |
| 445 | hash: configHash, |
| 446 | }, |
| 447 | options: { |
| 448 | configPath: options.configPath, |
| 449 | cache: options.cache, |
| 450 | buildCommand: options.buildCommand, |
| 451 | skipNextBuild: options.skipNextBuild, |
| 452 | minify: options.minify, |
| 453 | }, |
| 454 | files: entries, |
| 455 | lockfile: lockfileHash, |
| 456 | }), |
| 457 | ); |
| 458 | }); |
| 459 | |
| 460 | // ───────────────────────────────────────────────────────────────────── |
| 461 | // Assets (mirror of alchemy's readAssets manifest semantics) |
| 462 | // ───────────────────────────────────────────────────────────────────── |
| 463 | |
| 464 | const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB (Workers Assets limit) |
| 465 | const MAX_ASSET_COUNT = 20_000; |
| 466 | |
| 467 | const maybeReadString = Effect.fn(function* (file: string) { |
| 468 | const fs = yield* FileSystem.FileSystem; |
| 469 | return yield* fs |
| 470 | .readFileString(file) |
| 471 | .pipe(Effect.orElseSucceed(() => undefined)); |
| 472 | }); |
| 473 | |
| 474 | /** |
| 475 | * Read the client directory into the Workers Assets manifest shape alchemy's |
| 476 | * uploader consumes: keys are `/`-prefixed paths, per-file hash is the sha256 |
| 477 | * hex digest truncated to 32 chars (the API's content-address format), and |
| 478 | * the aggregate `hash` covers config + manifest + `_headers`/`_redirects`. |
| 479 | */ |
| 480 | const readClientAssets = Effect.fn(function* ( |
| 481 | directory: string, |
| 482 | config: Record<string, unknown> | undefined, |
| 483 | ) { |
| 484 | const fs = yield* FileSystem.FileSystem; |
| 485 | const path = yield* Path.Path; |
| 486 | const [files, _headers, _redirects] = yield* Effect.all([ |
| 487 | // The client directory is a build product (`.open-next/assets`): every |
| 488 | // file in it is servable, so nothing is pruned (mirroring readAssets). |
| 489 | listProjectFiles(directory, new Set<string>()), |
| 490 | maybeReadString(path.join(directory, "_headers")), |
| 491 | maybeReadString(path.join(directory, "_redirects")), |
| 492 | ]); |
| 493 | const ignore = yield* maybeReadString(path.join(directory, ".assetsignore")); |
| 494 | const ignored = (ignore ?? "") |
| 495 | .split("\n") |
| 496 | .map((line) => line.trim()) |
| 497 | .filter((line) => line.length > 0 && !line.startsWith("#")) |
| 498 | .map(globToRegExp); |
| 499 | const manifest: Record<string, { hash: string; size: number }> = {}; |
| 500 | let count = 0; |
| 501 | yield* Effect.forEach( |
| 502 | files, |
| 503 | Effect.fn(function* (name) { |
| 504 | if ( |
| 505 | name === ".assetsignore" || |
| 506 | name === "_headers" || |
| 507 | name === "_redirects" || |
| 508 | ignored.some((re) => re.test(name)) |
| 509 | ) { |
| 510 | return; |
| 511 | } |
| 512 | const file = path.join(directory, name); |
| 513 | const stat = yield* fs.stat(file); |
| 514 | const size = Number(stat.size); |
| 515 | if (size > MAX_ASSET_SIZE) { |
| 516 | return yield* new SourceProviderError({ |
| 517 | provider: PROVIDER, |
| 518 | message: `Asset ${name} is too large (${(size / 1024 / 1024).toFixed(1)} MB; the Workers Assets limit is 25 MB)`, |
| 519 | }); |
| 520 | } |
| 521 | count++; |
| 522 | if (count > MAX_ASSET_COUNT) { |
| 523 | return yield* new SourceProviderError({ |
| 524 | provider: PROVIDER, |
| 525 | message: `Too many assets in ${directory} (the Workers Assets limit is ${MAX_ASSET_COUNT})`, |
| 526 | }); |
| 527 | } |
| 528 | const hash = (yield* fs |
| 529 | .readFile(file) |
| 530 | .pipe(Effect.flatMap(sha256Hex))).slice(0, 32); |
| 531 | manifest[`/${name}`] = { hash, size }; |
| 532 | }), |
| 533 | { concurrency: 16 }, |
| 534 | ); |
| 535 | const sortedManifest = Object.fromEntries( |
| 536 | Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b)), |
| 537 | ); |
| 538 | // Like alchemy's readAssets, `directory` is deliberately excluded from the |
| 539 | // hash material: identical bytes at a different absolute path must produce |
| 540 | // the same hash. |
| 541 | const hash = yield* sha256Hex( |
| 542 | stableStringify({ config, manifest: sortedManifest, _headers, _redirects }), |
| 543 | ); |
| 544 | return { |
| 545 | directory, |
| 546 | config, |
| 547 | manifest: sortedManifest, |
| 548 | _headers, |
| 549 | _redirects, |
| 550 | hash, |
| 551 | } satisfies SourceAssets; |
| 552 | }); |
| 553 | |
| 554 | // ───────────────────────────────────────────────────────────────────── |
| 555 | // The provider |
| 556 | // ───────────────────────────────────────────────────────────────────── |
| 557 | |
| 558 | const assetsConfigOf = ( |
| 559 | ctx: SourceContext, |
| 560 | ): Record<string, unknown> | undefined => |
| 561 | ctx.assets !== undefined && typeof ctx.assets !== "string" |
| 562 | ? (ctx.assets as Record<string, unknown>) |
| 563 | : undefined; |
| 564 | |
| 565 | const frameworkError = (cause: { |
| 566 | readonly message: string; |
| 567 | }): SourceProviderError => |
| 568 | new SourceProviderError({ |
| 569 | provider: PROVIDER, |
| 570 | message: cause.message, |
| 571 | cause, |
| 572 | }); |
| 573 | |
| 574 | /** Run `use` against a freshly-built `Framework` service for `options`. */ |
| 575 | const withFramework = <A, E, R>( |
| 576 | options: Nextjs.NextjsFrameworkOptions, |
| 577 | use: ( |
| 578 | framework: FrameworkCore.Framework["Service"], |
| 579 | ) => Effect.Effect<A, E, R>, |
| 580 | ) => |
| 581 | Effect.gen(function* () { |
| 582 | const framework = yield* FrameworkCore.Framework; |
| 583 | return yield* use(framework); |
| 584 | }).pipe(Effect.provide(Nextjs.make(options))); |
| 585 | |
| 586 | /** |
| 587 | * The production build runs in a disposable child process with |
| 588 | * `cwd = project root` (see `core/BuildChild.ts`). The OpenNext pipeline |
| 589 | * itself already runs in its own child (`runner.mjs`, nested inside this |
| 590 | * one), but the surrounding steps — `open-next.config.ts` handling, the |
| 591 | * final bundle pass, cache population — execute framework/user code that |
| 592 | * must not run in the engine process either. The shared `core/BuildChildRunner` |
| 593 | * entry imports this module in the child and calls the exported |
| 594 | * {@link buildInChild}. |
| 595 | */ |
| 596 | export interface NextjsBuildChildConfig { |
| 597 | readonly rootDir: string; |
| 598 | readonly compatibilityDate: string; |
| 599 | readonly compatibilityFlags: Array<string>; |
| 600 | readonly configPath: string | undefined; |
| 601 | readonly cache: "static-assets" | "kv" | undefined; |
| 602 | readonly buildCommand: string | undefined; |
| 603 | readonly skipNextBuild: boolean | undefined; |
| 604 | readonly minify: boolean | undefined; |
| 605 | readonly debug: boolean | undefined; |
| 606 | } |
| 607 | |
| 608 | export const buildInChild = (config: NextjsBuildChildConfig) => |
| 609 | withFramework( |
| 610 | { |
| 611 | root: config.rootDir, |
| 612 | vite: { |
| 613 | compatibilityDate: config.compatibilityDate, |
| 614 | compatibilityFlags: config.compatibilityFlags, |
| 615 | }, |
| 616 | nextjs: { |
| 617 | configPath: config.configPath, |
| 618 | cache: config.cache, |
| 619 | buildCommand: config.buildCommand, |
| 620 | skipNextBuild: config.skipNextBuild, |
| 621 | minify: config.minify, |
| 622 | debug: config.debug, |
| 623 | }, |
| 624 | }, |
| 625 | (framework) => framework.build({ root: config.rootDir }), |
| 626 | ); |
| 627 | |
| 628 | const makeProvider = (options: NextjsSourceOptions): SourceProvider => { |
| 629 | const frameworkOptions = ( |
| 630 | ctx: SourceContext, |
| 631 | ): Nextjs.NextjsFrameworkOptions => ({ |
| 632 | root: options.root, |
| 633 | vite: { |
| 634 | compatibilityDate: ctx.compatibility.date, |
| 635 | compatibilityFlags: ctx.compatibility.flags, |
| 636 | }, |
| 637 | nextjs: { |
| 638 | configPath: options.configPath, |
| 639 | cache: options.cache, |
| 640 | buildCommand: options.buildCommand, |
| 641 | skipNextBuild: options.skipNextBuild, |
| 642 | minify: options.minify, |
| 643 | debug: options.debug, |
| 644 | }, |
| 645 | dev: options.dev, |
| 646 | }); |
| 647 | |
| 648 | const resolveRoot = Effect.fn(function* () { |
| 649 | const path = yield* Path.Path; |
| 650 | return path.resolve( |
| 651 | options.root ?? (yield* Effect.sync(() => process.cwd())), |
| 652 | ); |
| 653 | }); |
| 654 | |
| 655 | return { |
| 656 | ownsAssets: true, |
| 657 | |
| 658 | build: Effect.fn(function* (ctx) { |
| 659 | const root = yield* resolveRoot(); |
| 660 | const output = yield* runBuildChild({ |
| 661 | module: import.meta.url, |
| 662 | rootDir: root, |
| 663 | framework: "nextjs", |
| 664 | config: { |
| 665 | rootDir: root, |
| 666 | compatibilityDate: ctx.compatibility.date, |
| 667 | compatibilityFlags: ctx.compatibility.flags, |
| 668 | configPath: options.configPath, |
| 669 | cache: options.cache, |
| 670 | buildCommand: options.buildCommand, |
| 671 | skipNextBuild: options.skipNextBuild, |
| 672 | minify: options.minify, |
| 673 | debug: options.debug, |
| 674 | } satisfies NextjsBuildChildConfig, |
| 675 | }).pipe(Effect.mapError(frameworkError)); |
| 676 | if ( |
| 677 | output.serverModules === undefined || |
| 678 | output.serverModules.length === 0 |
| 679 | ) { |
| 680 | return yield* new SourceProviderError({ |
| 681 | provider: PROVIDER, |
| 682 | message: "The OpenNext build produced no server modules", |
| 683 | }); |
| 684 | } |
| 685 | const files = output.serverModules.map((file): SourceBundleFile => ({ |
| 686 | path: file.name, |
| 687 | content: file.content, |
| 688 | hash: file.hash, |
| 689 | })); |
| 690 | const bundleHash = yield* sha256Hex( |
| 691 | stableStringify(files.map(({ path, hash }) => ({ path, hash }))), |
| 692 | ); |
| 693 | const [assets, input] = yield* Effect.all([ |
| 694 | output.clientDirectory !== undefined |
| 695 | ? readClientAssets(output.clientDirectory, assetsConfigOf(ctx)) |
| 696 | : Effect.succeed(undefined), |
| 697 | hashInputTree(root, options, ctx.dotAlchemy), |
| 698 | ]); |
| 699 | return { |
| 700 | bundle: { files, hash: bundleHash }, |
| 701 | assets, |
| 702 | hash: { |
| 703 | bundle: bundleHash, |
| 704 | assets: assets?.hash, |
| 705 | input, |
| 706 | additionalWorkspaces: undefined, |
| 707 | }, |
| 708 | } satisfies SourceBuildOutput; |
| 709 | }), |
| 710 | |
| 711 | // Rebuild-free: the input-tree hash is the change signal (like the vite |
| 712 | // source). `previous` is never consulted — state can be stale/foreign. |
| 713 | hash: Effect.fn(function* (ctx, _previous) { |
| 714 | const root = yield* resolveRoot(); |
| 715 | return { input: yield* hashInputTree(root, options, ctx.dotAlchemy) }; |
| 716 | }), |
| 717 | |
| 718 | // Default ("preview"): always build on dev start (OpenNext memoizes |
| 719 | // where possible) and serve the built worker under cloudflare-runtime |
| 720 | // (workerd) with the alchemy-managed binding wiring. No HMR; ISR |
| 721 | // revalidation writes are a no-op. |
| 722 | // With `options.dev.mode: "hmr"`, the Framework instead runs the real |
| 723 | // `next dev` (Turbopack HMR) in Node — the alchemy-managed bindings are |
| 724 | // proxied through cloudflare-runtime's platform proxy onto OpenNext's |
| 725 | // getCloudflareContext() contract. |
| 726 | dev: Effect.fn(function* (ctx) { |
| 727 | const root = yield* resolveRoot(); |
| 728 | const queueConsumers = yield* ctx.worker.queueConsumers; |
| 729 | const devOptions: Nextjs.NextjsFrameworkOptions = { |
| 730 | ...frameworkOptions(ctx), |
| 731 | // The host's runtime stack (includes remote-bindings support) — the |
| 732 | // dev binding proxy is hosted in it instead of the credential-free |
| 733 | // internal layer, so `Alchemy.remote()` bindings resolve in dev. |
| 734 | services: ctx.runtimeContext, |
| 735 | vite: { |
| 736 | compatibilityDate: ctx.compatibility.date, |
| 737 | compatibilityFlags: ctx.compatibility.flags, |
| 738 | worker: { |
| 739 | name: ctx.workerName, |
| 740 | bindings: ctx.worker.bindings, |
| 741 | durableObjectNamespaces: ctx.worker.durableObjectNamespaces, |
| 742 | hyperdrives: ctx.worker.hyperdrives, |
| 743 | queueConsumers, |
| 744 | assets: ctx.worker.assets, |
| 745 | }, |
| 746 | }, |
| 747 | }; |
| 748 | const server = yield* withFramework(devOptions, (framework) => |
| 749 | framework.dev({ root }), |
| 750 | ).pipe(Effect.mapError(frameworkError)); |
| 751 | return { |
| 752 | mode: "server", |
| 753 | url: new URL(server.url), |
| 754 | serviceBinding: options.dev?.mode === "hmr" ? "http" : undefined, |
| 755 | } satisfies SourceDevHandle; |
| 756 | }), |
| 757 | }; |
| 758 | }; |
| 759 | |
| 760 | /** |
| 761 | * The `WorkerSourceModule` implementation: validate the descriptor options |
| 762 | * and hand back the provider. |
| 763 | */ |
| 764 | const make = ( |
| 765 | options: unknown, |
| 766 | ): Effect.Effect<SourceProvider, SourceProviderError, SourceServices> => { |
| 767 | if ( |
| 768 | options !== undefined && |
| 769 | (typeof options !== "object" || Array.isArray(options)) |
| 770 | ) { |
| 771 | return Effect.fail( |
| 772 | new SourceProviderError({ |
| 773 | provider: PROVIDER, |
| 774 | message: |
| 775 | "Invalid source options: expected a plain JSON object (NextjsSourceOptions)", |
| 776 | }), |
| 777 | ); |
| 778 | } |
| 779 | return Effect.succeed(makeProvider((options ?? {}) as NextjsSourceOptions)); |
| 780 | }; |
| 781 | |
| 782 | export default { make }; |