1import type {
2 BindingHooks,
3 Module,
4 RuntimeWorker,
5} from "@alchemy.run/cloudflare-runtime/core";
6import { DEFAULT_COMPATIBILITY_DATE } from "@alchemy.run/cloudflare-runtime/core/internal/constants";
7import * as Runtime from "@alchemy.run/cloudflare-runtime/core/Runtime";
8import * as RuntimeServices from "@alchemy.run/cloudflare-runtime/core/RuntimeServices";
9import * as DurableObjectNamespace from "@alchemy.run/cloudflare-runtime/core/bindings/DurableObjectNamespace";
10import * as Service from "@alchemy.run/cloudflare-runtime/core/bindings/Service";
11import * as Assets from "@alchemy.run/cloudflare-runtime/core/bindings/assets/Assets";
12import * as Credentials from "@distilled.cloud/cloudflare/Credentials";
13import * as FrameworkCore from "../core/index.ts";
14import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
15import * as NodeServices from "@effect/platform-node/NodeServices";
16import type * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";
21import type * as Scope from "effect/Scope";
22import * as FetchHttpClient from "effect/http/FetchHttpClient";
23import * as NodeNet from "node:net";
24import * as NodePath from "node:path";
25import * as Bundle from "./Bundle.ts";
26import * as DevServer from "./DevServer.ts";
27import * as Runner from "./Runner.ts";
28
29/**
30 * The subset of the e2e-harness `options.vite` convention this integration
31 * reads: the cloudflare worker configuration (compatibility date/flags,
32 * worker name/bindings/assets). Structurally compatible with
33 * `CloudflareVitePluginOptions` so harness fixtures stay uniform.
34 */
35export interface NextjsWorkerConfig {
36 readonly compatibilityDate?: string | undefined;
37 readonly compatibilityFlags?: Array<string> | undefined;
38 readonly worker?:
39 | Omit<
40 RuntimeWorker<BindingHooks>,
41 "compatibilityDate" | "compatibilityFlags" | "modules"
42 >
43 | undefined;
44}
45
46/** Options for the Next.js (OpenNext-based) `Framework` integration. */
47export interface NextjsFrameworkOptions {
48 /**
49 * Cloudflare worker configuration, following the e2e harness convention
50 * (`options.vite` carries compatibility date/flags and the worker's
51 * name/bindings/assets).
52 */
53 readonly vite?: NextjsWorkerConfig | undefined;
54 /** Next.js/OpenNext-specific knobs. */
55 readonly nextjs?:
56 | {
57 /**
58 * Optional explicit OpenNext config for direct framework consumers.
59 * Otherwise load `open-next.config.ts` when present, or generate defaults.
60 */
61 readonly configPath?: string | undefined;
62 /** Resource-selected cache adapters. Defaults to the read-only static-assets cache. */
63 readonly cache?: "static-assets" | "kv" | undefined;
64 /**
65 * The command the OpenNext pipeline runs to build the Next.js app.
66 * Takes precedence over an explicitly supplied OpenNext config.
67 * @default "npx next build"
68 */
69 readonly buildCommand?: string | undefined;
70 /** Skip the internal `next build` (reuse an existing `.next`). */
71 readonly skipNextBuild?: boolean | undefined;
72 /** Minify the OpenNext bundling steps and the final bundle pass. */
73 readonly minify?: boolean | undefined;
74 /**
75 * Enable OpenNext debug logging and verbose workerd output in dev
76 * (workerd otherwise swallows uncaught worker exceptions).
77 */
78 readonly debug?: boolean | undefined;
79 }
80 | undefined;
81 /** Dev-server behavior. */
82 readonly dev?:
83 | {
84 /**
85 * - `"preview"` (default): build the OpenNext worker and serve it
86 * under `cloudflare-runtime` (workerd) — production parity, no HMR.
87 * - `"hmr"`: run the real `next dev` (Turbopack HMR) in Node, with
88 * the worker's bindings proxied from `cloudflare-runtime` and
89 * planted on OpenNext's `getCloudflareContext()` contract
90 * ({@link DevServer.start}). App code runs in Node, not workerd —
91 * CF-specific runtime behavior and ISR/caching semantics still
92 * need `"preview"`.
93 * @default "preview"
94 */
95 readonly mode?: "preview" | "hmr" | undefined;
96 }
97 | undefined;
98 /** Project root. Defaults to the process working directory. */
99 readonly root?: string | undefined;
100 /**
101 * The host's runtime stack (a `Context.Context<RuntimeServices>`, e.g.
102 * alchemy's `DevContext.runtimeContext`). When provided, dev servers host
103 * the binding proxy in it instead of building the credential-free internal
104 * layer — this is what makes `Alchemy.remote()` bindings resolve in dev.
105 * Typed `unknown` to keep the option JSON-tolerant at the boundary; it is
106 * narrowed internally.
107 */
108 readonly services?: unknown;
109}
110
111/**
112 * Extract the edge-runtime function paths from `.next/server/
113 * middleware-manifest.json`. `middleware` entries are supported (OpenNext
114 * runs the middleware in the worker); `functions` entries are routes/pages
115 * compiled for the edge runtime, which `@opennextjs/cloudflare` does not
116 * support.
117 */
118export const listEdgeFunctions = (manifest: unknown): Array<string> => {
119 if (typeof manifest !== "object" || manifest === null) return [];
120 const functions = (manifest as { functions?: Record<string, unknown> })
121 .functions;
122 return functions === undefined ? [] : Object.keys(functions);
123};
124
125export { DEFAULT_COMPATIBILITY_DATE };
126
127/** The server-module name of the worker entry (`serverModules[0]`). */
128export const WORKER_ENTRY_MODULE = `worker/${Bundle.WORKER_ENTRY_NAME}`;
129
130/** The OpenNext revalidation-queue Durable Object class. */
131export const DO_QUEUE_CLASS = "DOQueueHandler";
132/** The binding name OpenNext expects for the revalidation-queue DO. */
133export const DO_QUEUE_BINDING = "NEXT_CACHE_DO_QUEUE";
134/** The self service binding OpenNext uses for ISR revalidation fetches. */
135export const SELF_REFERENCE_BINDING = "WORKER_SELF_REFERENCE";
136
137/** Build the {@link Runner.RunnerConfig} for a project (exported for testing). */
138export const makeRunnerConfig = (
139 root: string,
140 options?: NextjsFrameworkOptions,
141): Runner.RunnerConfig => ({
142 appDir: root,
143 configPath: options?.nextjs?.configPath,
144 cache: options?.nextjs?.cache ?? "static-assets",
145 compatibilityDate:
146 options?.vite?.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
147 skipNextBuild: options?.nextjs?.skipNextBuild ?? false,
148 minify: options?.nextjs?.minify ?? false,
149 debug: options?.nextjs?.debug ?? false,
150 buildCommand: options?.nextjs?.buildCommand,
151});
152
153/**
154 * Map a `BuildOutput`'s server modules to cloudflare-runtime worker modules
155 * (exported for testing). Sourcemaps are dropped; binary content is passed
156 * through as bytes, text content as strings.
157 */
158export const toRuntimeModules = (
159 serverModules: ReadonlyArray<FrameworkCore.OutputFile>,
160): Array<Module> =>
161 serverModules.flatMap((file): Module | Array<Module> => {
162 const asText = () =>
163 typeof file.content === "string"
164 ? file.content
165 : Buffer.from(file.content).toString("utf8");
166 const asBytes = () =>
167 typeof file.content === "string"
168 ? new TextEncoder().encode(file.content)
169 : file.content;
170 switch (NodePath.extname(file.name)) {
171 case ".js":
172 case ".mjs":
173 return { name: file.name, type: "ESModule", content: asText() };
174 case ".cjs":
175 return { name: file.name, type: "CommonJsModule", content: asText() };
176 case ".json":
177 return { name: file.name, type: "Json", content: asText() };
178 case ".wasm":
179 return { name: file.name, type: "Wasm", content: asBytes() };
180 case ".bin":
181 return { name: file.name, type: "Data", content: asBytes() };
182 case ".map":
183 return [];
184 default:
185 return { name: file.name, type: "Text", content: asText() };
186 }
187 });
188
189/**
190 * Whether the entry module exports the OpenNext revalidation-queue Durable
191 * Object class (exported for testing). OpenNext's `worker.js` re-exports the
192 * DO classes it ships; when present, dev declares the same-script
193 * SQLite-backed namespace + binding so `doQueue`-configured apps work and
194 * `memoryQueue` apps keep parity with the deployed worker shape.
195 */
196export const hasDoQueueClass = (
197 entry: FrameworkCore.OutputFile | undefined,
198): boolean => {
199 if (entry === undefined) return false;
200 const content =
201 typeof entry.content === "string"
202 ? entry.content
203 : Buffer.from(entry.content).toString("utf8");
204 return content.includes(DO_QUEUE_CLASS);
205};
206
207/** The services the runtime layer for dev is built from. */
208const makeRuntimeLayer = () =>
209 RuntimeServices.layerRuntime({
210 api: {
211 // Local-only serving: the account id is only consulted when remote
212 // bindings are used, which a preview-parity dev server does not
213 // require. Fall back to a placeholder so credentials stay optional.
214 accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "distilled-nextjs-local",
215 },
216 }).pipe(
217 Layer.provideMerge(NodeServices.layer),
218 Layer.provide(Layer.merge(Credentials.fromEnv(), FetchHttpClient.layer)),
219 );
220
221/**
222 * Front a target server with a TCP proxy on a fixed local port.
223 * `Runtime.start` always binds an ephemeral port, so honoring
224 * `FrameworkDevOptions.port` requires the forwarder.
225 */
226const proxyToPort = (
227 port: number,
228 target: URL,
229): Effect.Effect<URL, FrameworkCore.FrameworkError, Scope.Scope> =>
230 Effect.acquireRelease(
231 Effect.callback<NodeNet.Server, FrameworkCore.FrameworkError>((resume) => {
232 const server = NodeNet.createServer((socket) => {
233 const upstream = NodeNet.connect(Number(target.port), target.hostname);
234 socket.pipe(upstream);
235 upstream.pipe(socket);
236 const teardown = () => {
237 socket.destroy();
238 upstream.destroy();
239 };
240 socket.on("error", teardown);
241 upstream.on("error", teardown);
242 socket.on("close", teardown);
243 upstream.on("close", teardown);
244 });
245 server.once("error", (cause) =>
246 resume(
247 Effect.fail(
248 new FrameworkCore.FrameworkError({
249 framework: "nextjs",
250 message: `Failed to listen on port ${port}`,
251 cause,
252 }),
253 ),
254 ),
255 );
256 server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server)));
257 }),
258 (server) =>
259 Effect.callback<void>((resume) => {
260 server.close(() => resume(Effect.void));
261 }),
262 ).pipe(Effect.map(() => new URL(`http://127.0.0.1:${port}`)));
263
264/**
265 * The Next.js implementation of framework-core's `Framework` service,
266 * built on the `@opennextjs/cloudflare` build pipeline — wrangler-free:
267 *
268 * - `build` runs the pipeline in a disposable child process (`runner.mjs`
269 * vendors the two thin config wrappers so no wrangler code is ever
270 * imported), then performs the final bundle pass wrangler would normally
271 * do at deploy time ({@link Bundle.bundleWorker}), populates the
272 * static-assets incremental cache, and returns the `BuildOutput` in-memory.
273 * - `dev` (default mode `"preview"`) serves the built worker under
274 * `@alchemy.run/cloudflare-runtime/core` (workerd) with the OpenNext worker
275 * shape: `ASSETS` + run-worker-first, a `WORKER_SELF_REFERENCE` self
276 * service binding, and the same-script SQLite-backed revalidation-queue
277 * Durable Object. No HMR — file watching/rebuild is a later phase.
278 * - `dev` with `options.dev.mode: "hmr"` runs the real `next dev` (Turbopack
279 * HMR) in Node with the worker's bindings proxied from
280 * `cloudflare-runtime` ({@link DevServer.start}) — see the mode's fidelity
281 * notes on {@link NextjsFrameworkOptions}.
282 */
283export const make = (
284 options?: NextjsFrameworkOptions,
285): Layer.Layer<
286 FrameworkCore.Framework,
287 never,
288 FileSystem.FileSystem | Path.Path
289> =>
290 Layer.effect(
291 FrameworkCore.Framework,
292 Effect.gen(function* () {
293 const fs = yield* FileSystem.FileSystem;
294 const path = yield* Path.Path;
295
296 const fail = (message: string) => (cause: unknown) =>
297 new FrameworkCore.FrameworkError({
298 framework: "nextjs",
299 message,
300 cause,
301 });
302
303 const spawnerLayer = NodeChildProcessSpawner.layer.pipe(
304 Layer.provide(
305 Layer.merge(
306 Layer.succeed(FileSystem.FileSystem)(fs),
307 Layer.succeed(Path.Path)(path),
308 ),
309 ),
310 );
311
312 const resolveRoot = (override: string | undefined) =>
313 Effect.sync(() =>
314 path.resolve(override ?? options?.root ?? process.cwd()),
315 );
316
317 const paths = (root: string, openNextDirectory: string) => ({
318 openNextDirectory,
319 clientDirectory: path.join(openNextDirectory, "assets"),
320 cacheDirectory: path.join(openNextDirectory, "cache"),
321 distDirectory: path.resolve(root, "dist"),
322 workerDirectory: path.resolve(root, "dist", "worker"),
323 });
324
325 const build = Effect.fn(function* (
326 buildOptions?: FrameworkCore.FrameworkBuildOptions,
327 ) {
328 const root = yield* resolveRoot(buildOptions?.root);
329 // 1. The OpenNext build pipeline (spawns `next build` internally).
330 const buildPaths = yield* Runner.runOpenNextBuild(
331 makeRunnerConfig(root, options),
332 ).pipe(
333 Effect.mapError((error) => fail(error.message)(error.cause)),
334 Effect.provide(spawnerLayer),
335 Effect.provideService(FileSystem.FileSystem, fs),
336 Effect.provideService(Path.Path, path),
337 );
338 const p = paths(root, buildPaths.openNextDirectory);
339
340 // 1.5. Edge-runtime routes/pages are not supported by
341 // @opennextjs/cloudflare (it shims `next/dist/compiled/edge-runtime`
342 // to an empty module, so they 500 at runtime). Fail the build with
343 // the exact route list instead of shipping a mystery 500 — the same
344 // code runs fine on Workers under the node runtime.
345 const manifestPath = path.join(
346 buildPaths.appBuildOutputPath,
347 ".next",
348 "server",
349 "middleware-manifest.json",
350 );
351 const manifestRaw = yield* fs
352 .readFileString(manifestPath)
353 .pipe(Effect.orElseSucceed(() => undefined));
354 if (manifestRaw !== undefined) {
355 const edgeRoutes = yield* Effect.try({
356 try: () => listEdgeFunctions(JSON.parse(manifestRaw)),
357 catch: fail(`Failed to parse ${manifestPath}`),
358 });
359 if (edgeRoutes.length > 0) {
360 return yield* Effect.fail(
361 fail(
362 `Edge-runtime routes/pages are not supported by @opennextjs/cloudflare: ${edgeRoutes.join(", ")}. ` +
363 `Remove \`export const runtime = "edge"\` from these modules — the node runtime runs on Workers.`,
364 )(undefined),
365 );
366 }
367 }
368
369 // 2. The final bundle pass (what wrangler does implicitly on deploy).
370 yield* fs
371 .remove(p.workerDirectory, { recursive: true })
372 .pipe(Effect.ignore);
373 yield* Bundle.bundleWorker({
374 openNextDirectory: p.openNextDirectory,
375 outDirectory: p.workerDirectory,
376 minify: options?.nextjs?.minify ?? false,
377 }).pipe(Effect.mapError((error) => fail(error.message)(error.cause)));
378
379 // 3. populateCache, static-assets flavor: prerendered ISR/fetch cache
380 // entries are served read-only through the ASSETS binding.
381 const hasCache = yield* fs
382 .exists(p.cacheDirectory)
383 .pipe(Effect.orElseSucceed(() => false));
384 if (hasCache) {
385 yield* fs
386 .copy(
387 p.cacheDirectory,
388 path.join(p.clientDirectory, "cdn-cgi", "_next_cache"),
389 {
390 overwrite: true,
391 },
392 )
393 .pipe(
394 Effect.mapError(
395 fail("Failed to populate the static-assets incremental cache"),
396 ),
397 );
398 }
399
400 // 4. Collect the BuildOutput contract from disk (entry first).
401 const files = yield* FrameworkCore.readServerModulesFromDisk({
402 directory: p.workerDirectory,
403 prefix: "worker",
404 }).pipe(
405 Effect.provideService(FileSystem.FileSystem, fs),
406 Effect.mapError((error) => fail(error.message)(error.cause)),
407 );
408 const output: FrameworkCore.BuildOutput = {
409 distDirectory: p.distDirectory,
410 clientDirectory: p.clientDirectory,
411 serverModules: FrameworkCore.sortServerModules(
412 files,
413 WORKER_ENTRY_MODULE,
414 ),
415 externalWorkspaces: new Set(),
416 };
417 return output;
418 });
419
420 // The runtime context the dev servers host their binding proxy in:
421 // the caller-provided host stack (includes remote-bindings support,
422 // so `Alchemy.remote()` bindings resolve) when present, the internal
423 // credential-free layer otherwise.
424 const resolveRuntimeContext = Effect.fn(function* () {
425 if (options?.services !== undefined) {
426 return options.services as Context.Context<RuntimeServices.RuntimeServices>;
427 }
428 const scope = yield* Effect.scope;
429 return yield* Layer.buildWithScope(makeRuntimeLayer(), scope).pipe(
430 Effect.mapError(
431 fail("Failed to start the cloudflare-runtime services"),
432 ),
433 );
434 });
435
436 const dev = Effect.fn(function* (
437 devOptions?: FrameworkCore.FrameworkDevOptions,
438 ) {
439 const root = yield* resolveRoot(devOptions?.root);
440
441 // Dev v2 ("hmr"): real `next dev` (Turbopack HMR) in Node, bindings
442 // proxied from cloudflare-runtime onto OpenNext's
443 // getCloudflareContext() contract. No OpenNext build involved.
444 if (options?.dev?.mode === "hmr") {
445 const worker = options?.vite?.worker;
446 const context = yield* resolveRuntimeContext();
447 const server = yield* DevServer.start({
448 root,
449 port: devOptions?.port,
450 bindings: worker?.bindings ?? [],
451 compatibilityDate:
452 options?.vite?.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
453 compatibilityFlags: [
454 ...new Set([
455 "nodejs_compat",
456 ...(options?.vite?.compatibilityFlags ?? []),
457 ]),
458 ],
459 ...(worker?.name !== undefined
460 ? { proxyName: `${worker.name}-dev-proxy` }
461 : {}),
462 logging: options?.nextjs?.debug
463 ? { verbose: true }
464 : worker?.logging,
465 }).pipe(Effect.provideContext(context));
466 return { url: server.url.href };
467 }
468
469 // Preview parity: always build on dev start (OpenNext memoizes where
470 // it can). Watch + rebuild is a later phase.
471 const output = yield* build({ root });
472 if (
473 output.serverModules === undefined ||
474 output.serverModules.length === 0
475 ) {
476 return yield* Effect.fail(
477 fail("The build produced no server modules")(undefined),
478 );
479 }
480 const modules = toRuntimeModules(output.serverModules);
481 const worker = options?.vite?.worker;
482
483 const declaresDoQueue = (worker?.durableObjectNamespaces ?? []).some(
484 (namespace) => namespace.className === DO_QUEUE_CLASS,
485 );
486 const withDoQueue =
487 !declaresDoQueue && hasDoQueueClass(output.serverModules[0]);
488
489 const context = yield* resolveRuntimeContext();
490
491 const url = yield* Runtime.Runtime.use((runtime) =>
492 runtime.start({
493 name: worker?.name ?? "distilled-nextjs-dev",
494 compatibilityDate:
495 options?.vite?.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
496 compatibilityFlags: [
497 ...new Set([
498 "nodejs_compat",
499 ...(options?.vite?.compatibilityFlags ?? []),
500 ]),
501 ],
502 modules,
503 bindings: [
504 Assets.local("ASSETS"),
505 Service.self(SELF_REFERENCE_BINDING),
506 ...(withDoQueue
507 ? [
508 DurableObjectNamespace.local({
509 binding: DO_QUEUE_BINDING,
510 className: DO_QUEUE_CLASS,
511 }),
512 ]
513 : []),
514 ...(worker?.bindings ?? []),
515 ],
516 assets: {
517 ...worker?.assets,
518 directory: output.clientDirectory,
519 runWorkerFirst: worker?.assets?.runWorkerFirst ?? true,
520 },
521 durableObjectNamespaces: [
522 ...(withDoQueue
523 ? [{ className: DO_QUEUE_CLASS, sql: true }]
524 : []),
525 ...(worker?.durableObjectNamespaces ?? []),
526 ],
527 ...(worker?.hyperdrives !== undefined
528 ? { hyperdrives: worker.hyperdrives }
529 : {}),
530 ...(worker?.queueConsumers !== undefined
531 ? { queueConsumers: worker.queueConsumers }
532 : {}),
533 ...(worker?.unsafe !== undefined ? { unsafe: worker.unsafe } : {}),
534 logging: options?.nextjs?.debug
535 ? { verbose: true }
536 : worker?.logging,
537 }),
538 ).pipe(
539 Effect.provideContext(context),
540 Effect.mapError(
541 fail("Failed to start the dev worker in cloudflare-runtime"),
542 ),
543 );
544
545 if (
546 devOptions?.port !== undefined &&
547 String(devOptions.port) !== url.port
548 ) {
549 const proxied = yield* proxyToPort(devOptions.port, url);
550 return { url: proxied.href };
551 }
552 return { url: url.href };
553 });
554
555 return FrameworkCore.Framework.of({
556 build: (buildOptions) => build(buildOptions),
557 dev: (devOptions) => dev(devOptions),
558 });
559 }),
560 );