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 */
27import type {
28 BindingHooks,
29 RuntimeWorker,
30} from "@alchemy.run/cloudflare-runtime/core";
31import * as FrameworkCore from "../core/index.ts";
32import * as Data from "effect/Data";
33import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
35import * as Path from "effect/Path";
36import type { PlatformError } from "effect/PlatformError";
37import type * as Scope from "effect/Scope";
38import * as NodeCrypto from "node:crypto";
39import { createRequire } from "node:module";
40import { runBuildChild } from "../core/BuildChild.ts";
41import * as Nextjs from "./Nextjs.ts";
42import * as Runner from "./Runner.ts";
43
44const packageVersion: string = createRequire(import.meta.url)(
45 "../../package.json",
46).version;
47
48const 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 */
58export 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). */
67export 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`. */
75export 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). */
82export 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`. */
92export 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. */
101export 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
112type WorkerWiring = Omit<
113 RuntimeWorker<BindingHooks>,
114 "compatibilityDate" | "compatibilityFlags" | "modules"
115>;
116
117/** The subset of alchemy's `DevContext` this provider consumes. */
118export 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
138export type SourceDevHandle = {
139 readonly mode: "server";
140 readonly url: URL;
141 readonly serviceBinding?: "http";
142};
143
144export type SourceError = SourceProviderError | PlatformError;
145
146export type SourceServices = FileSystem.FileSystem | Path.Path;
147
148/** Mirror of alchemy's `SourceProvider` (the arms this module implements). */
149export 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 */
177export 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 */
195export 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
238const 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). */
244const 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 */
265const 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 */
299const 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
311const ALWAYS_IGNORED_FILES = new Set([".DS_Store"]);
312
313/** Recursively list files under `root` (relative, `/`-separated), pruning `prune`d directories. */
314const 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
359const 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 */
383const 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
464const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB (Workers Assets limit)
465const MAX_ASSET_COUNT = 20_000;
466
467const 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 */
480const 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
558const 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
565const 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`. */
575const 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 */
596export 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
608export 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
628const 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 */
764const 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
782export default { make };