1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3/**
4 * Next.js dev v2 — real `next dev` (Turbopack HMR) with Cloudflare bindings,
5 * wrangler-free.
6 *
7 * {@link start} runs the documented custom-server API programmatically
8 * (`next({ dev: true, dir, hostname, port })` + `prepare()` +
9 * `getRequestHandler()` attached to an http.Server we own), so the caller
10 * controls the port/URL and shutdown is a clean `app.close()` — no CLI fork,
11 * no signal handlers, no `process.exit` choreography.
12 *
13 * Before the dev server starts, a `cloudflare-runtime` platform proxy
14 * ({@link PlatformProxy.open}) is opened with the worker's binding hooks and
15 * the resulting `{ env, cf, ctx }` is planted on
16 * `globalThis[Symbol.for("__cloudflare-context__")]` — the exact contract
17 * `@opennextjs/cloudflare`'s `getCloudflareContext()` reads (see upstream
18 * `src/api/cloudflare-context.ts`). `vm.runInContext` is additionally patched
19 * so Next's edge-runtime sandbox (middleware, edge routes) sees the same
20 * context, mirroring OpenNext's `monkeyPatchVmModuleEdgeContext`. App code
21 * therefore works without calling `initOpenNextCloudflareForDev()` and
22 * without wrangler.
23 *
24 * Fidelity notes (documented in the package README): app code runs in Node
25 * (or Next's edge-runtime VM), not workerd — bindings round-trip through the
26 * proxy, but CF-specific runtime behavior (workerd APIs and limits, ISR/cache
27 * semantics of the built worker) still needs the preview mode.
28 */
29import type {
30 BindingHooks,
31 WorkerdLogging,
32} from "@alchemy.run/cloudflare-runtime/core";
33import * as PlatformProxy from "@alchemy.run/cloudflare-runtime/core/platform-proxy/PlatformProxy";
34import type * as Runtime from "@alchemy.run/cloudflare-runtime/core/Runtime";
35import * as FrameworkCore from "../core/index.ts";
36import * as Effect from "effect/Effect";
37import type * as Scope from "effect/Scope";
38import * as NodeFs from "node:fs";
39import * as NodeHttp from "node:http";
40import { createRequire } from "node:module";
41import type * as NodeNet from "node:net";
42import type * as NodeVm from "node:vm";
43
44/** The symbol OpenNext uses to store/read the Cloudflare context. Must stay
45 * in sync with `@opennextjs/cloudflare`'s `cloudflareContextSymbol`. */
46export const CLOUDFLARE_CONTEXT_SYMBOL: unique symbol = Symbol.for(
47 "__cloudflare-context__",
48);
49
50/** The shape `getCloudflareContext()` expects (upstream `CloudflareContext`). */
51export interface CloudflareContextShape {
52 readonly env: Record<string, unknown>;
53 readonly cf: Record<string, unknown> | undefined;
54 readonly ctx: unknown;
55}
56
57export interface DevServerOptions {
58 /** The Next.js project root (the directory containing `next.config.*`). */
59 readonly root: string;
60 /** @default "localhost" */
61 readonly hostname?: string | undefined;
62 /** Port to listen on. Defaults to an ephemeral port. */
63 readonly port?: number | undefined;
64 /**
65 * Bindings to expose through `getCloudflareContext().env` — the same hook
66 * shapes `Runtime.start` accepts (`Text.local`, `KvNamespace.local`, …).
67 */
68 readonly bindings?: BindingHooks | undefined;
69 /** Compatibility date for the binding-proxy worker. */
70 readonly compatibilityDate?: string | undefined;
71 /** Compatibility flags for the binding-proxy worker. */
72 readonly compatibilityFlags?: ReadonlyArray<string> | undefined;
73 /** Name of the binding-proxy workerd service. @default "nextjs-dev-platform-proxy" */
74 readonly proxyName?: string | undefined;
75 readonly logging?: WorkerdLogging | undefined;
76}
77
78export interface DevServerInstance {
79 /** The local URL the dev server is listening on. */
80 readonly url: URL;
81}
82
83const fail = (message: string) => (cause: unknown) =>
84 new FrameworkCore.FrameworkError({ framework: "nextjs", message, cause });
85
86// ---------------------------------------------------------------------------
87// Cloudflare-context planting (OpenNext contract)
88// ---------------------------------------------------------------------------
89
90type GlobalWithContext = typeof globalThis & {
91 [CLOUDFLARE_CONTEXT_SYMBOL]?: CloudflareContextShape | undefined;
92};
93
94/**
95 * The context currently planted by an open dev server. The `vm.runInContext`
96 * patch consults this mutable reference so the (process-global, one-time)
97 * patch always reflects the latest scope: cleared on release, re-set by the
98 * next `start`.
99 */
100let currentContext: CloudflareContextShape | undefined;
101let vmPatched = false;
102
103/**
104 * Mirror of OpenNext's `monkeyPatchVmModuleEdgeContext`: Next dev evaluates
105 * edge functions (middleware, edge routes) with `vm.runInContext`, whose
106 * sandbox does not inherit `globalThis` symbols — inject the context into
107 * every contextified object so `getCloudflareContext()` works there too.
108 * Patched once per process; a cleared {@link currentContext} makes it a
109 * pass-through.
110 */
111const patchVmRunInContext = (): void => {
112 if (vmPatched) return;
113 vmPatched = true;
114 // The ESM namespace of "node:vm" is frozen; patch the (shared, mutable)
115 // CJS exports object — the one Next's sandbox `require`s.
116 const vm = createRequire(import.meta.url)("vm") as typeof NodeVm;
117 const original = vm.runInContext.bind(vm);
118 const patched: typeof vm.runInContext = (
119 code,
120 contextifiedObject,
121 options,
122 ) => {
123 if (currentContext !== undefined) {
124 const runtimeContext = contextifiedObject as GlobalWithContext;
125 runtimeContext[CLOUDFLARE_CONTEXT_SYMBOL] ??= currentContext;
126 }
127 return original(code, contextifiedObject, options);
128 };
129 vm.runInContext = patched;
130};
131
132/**
133 * Plant `{ env, cf, ctx }` on the global symbol (and arm the vm patch) for
134 * the lifetime of the surrounding scope; the previous value is restored on
135 * release.
136 */
137const plantCloudflareContext = (
138 context: CloudflareContextShape,
139): Effect.Effect<void, never, Scope.Scope> =>
140 Effect.acquireRelease(
141 Effect.sync(() => {
142 const global = globalThis as GlobalWithContext;
143 const previous = global[CLOUDFLARE_CONTEXT_SYMBOL];
144 global[CLOUDFLARE_CONTEXT_SYMBOL] = context;
145 currentContext = context;
146 patchVmRunInContext();
147 return previous;
148 }),
149 (previous) =>
150 Effect.sync(() => {
151 (globalThis as GlobalWithContext)[CLOUDFLARE_CONTEXT_SYMBOL] = previous;
152 currentContext = previous;
153 }),
154 ).pipe(Effect.asVoid);
155
156// ---------------------------------------------------------------------------
157// Loading `next` from the project root
158// ---------------------------------------------------------------------------
159
160type RequestHandler = (
161 req: NodeHttp.IncomingMessage,
162 res: NodeHttp.ServerResponse,
163) => Promise<void>;
164
165/** The subset of `NextCustomServer` (the public custom-server API) we drive. */
166interface NextDevApp {
167 prepare(): Promise<void>;
168 getRequestHandler(): RequestHandler;
169 close(): Promise<void>;
170}
171
172type CreateNextServer = (options: {
173 dev: boolean;
174 dir: string;
175 hostname: string;
176 port: number;
177}) => NextDevApp;
178
179/**
180 * Resolve the *project's* `next` (public entry, `dist/server/next.js`) — the
181 * fixture/app's installed copy is the one driven, never a hoisted sibling.
182 * The entry is CJS; unwrap the ESM-interop `default` nesting to the
183 * `createServer` function.
184 */
185const loadNext = (
186 root: string,
187): Effect.Effect<CreateNextServer, FrameworkCore.FrameworkError> =>
188 FrameworkCore.loadProjectModule<Record<string, unknown>>(root, "next").pipe(
189 Effect.mapError((error) =>
190 fail(`Failed to load "next" from ${root}`)(error.cause),
191 ),
192 Effect.flatMap((module_) => {
193 let candidate: unknown = module_;
194 for (let i = 0; i < 3 && typeof candidate !== "function"; i++) {
195 candidate = (candidate as Record<string, unknown> | undefined)?.default;
196 }
197 return typeof candidate === "function"
198 ? Effect.succeed(candidate as CreateNextServer)
199 : Effect.fail(
200 fail(
201 `The "next" package resolved from ${root} has no callable default export`,
202 )(undefined),
203 );
204 }),
205 );
206
207// ---------------------------------------------------------------------------
208// The http server (caller-owned port/URL)
209// ---------------------------------------------------------------------------
210
211interface HttpServerHandle {
212 readonly server: NodeHttp.Server;
213 readonly port: number;
214 readonly setHandler: (handler: RequestHandler) => void;
215}
216
217/**
218 * Listen before Next starts: the actual port must be known when the app is
219 * created (`port` seeds Turbopack's HMR/asset URLs), so the server binds
220 * first (503 until the handler is wired) and the ephemeral port feeds
221 * `next({ port })`. The HMR websocket upgrade is auto-wired by
222 * `getRequestHandler()` through `req.socket.server` on the first request.
223 */
224const acquireHttpServer = (
225 hostname: string,
226 port: number | undefined,
227): Effect.Effect<HttpServerHandle, FrameworkCore.FrameworkError, Scope.Scope> =>
228 Effect.acquireRelease(
229 Effect.callback<
230 HttpServerHandle & { readonly sockets: Set<NodeNet.Socket> },
231 FrameworkCore.FrameworkError
232 >((resume) => {
233 let handler:
234 | ((
235 req: NodeHttp.IncomingMessage,
236 res: NodeHttp.ServerResponse,
237 ) => Promise<void>)
238 | undefined;
239 const sockets = new Set<NodeNet.Socket>();
240 const server = NodeHttp.createServer((req, res) => {
241 if (handler !== undefined) {
242 void handler(req, res);
243 } else {
244 res.statusCode = 503;
245 res.end("Next.js dev server is starting");
246 }
247 });
248 server.on("connection", (socket) => {
249 sockets.add(socket);
250 socket.on("close", () => sockets.delete(socket));
251 });
252 server.once("error", (cause) =>
253 resume(
254 Effect.fail(
255 fail(`Failed to listen on ${hostname}:${port ?? 0}`)(cause),
256 ),
257 ),
258 );
259 server.listen(port ?? 0, hostname, () => {
260 const address = server.address();
261 if (address === null || typeof address === "string") {
262 resume(
263 Effect.fail(fail("The dev server has no TCP address")(address)),
264 );
265 return;
266 }
267 resume(
268 Effect.succeed({
269 server,
270 sockets,
271 port: address.port,
272 setHandler: (h) => {
273 handler = h;
274 },
275 }),
276 );
277 });
278 }),
279 ({ server, sockets }) =>
280 Effect.callback<void>((resume) => {
281 // Destroy live (keep-alive / HMR websocket) sockets so close() never
282 // hangs on an idle connection.
283 for (const socket of sockets) socket.destroy();
284 server.close(() => resume(Effect.void));
285 }),
286 );
287
288// ---------------------------------------------------------------------------
289// start
290// ---------------------------------------------------------------------------
291
292/**
293 * Start `next dev` with Cloudflare bindings. Scoped: closing the Scope stops
294 * the Next.js app (Turbopack), the http server, and the binding proxy, and
295 * restores the global Cloudflare context.
296 *
297 * Requires `Runtime.Runtime` (from `RuntimeServices.layerRuntime`) for the
298 * binding-proxy workerd instance.
299 */
300export const start = Effect.fn("Nextjs.DevServer.start")(function* (
301 options: DevServerOptions,
302) {
303 const hostname = options.hostname ?? "localhost";
304 // Resolve symlinks in the project root (macOS /tmp, /var/folders):
305 // Turbopack's watcher reports events under the real path, so a symlinked
306 // root never receives HMR invalidations.
307 const root = yield* Effect.sync(() => {
308 try {
309 return NodeFs.realpathSync.native(options.root);
310 } catch {
311 return options.root;
312 }
313 });
314
315 // 1. Open the platform proxy hosting the worker's bindings.
316 const proxy = yield* PlatformProxy.open({
317 name: options.proxyName ?? "nextjs-dev-platform-proxy",
318 ...(options.compatibilityDate !== undefined
319 ? { compatibilityDate: options.compatibilityDate }
320 : {}),
321 ...(options.compatibilityFlags !== undefined
322 ? { compatibilityFlags: [...options.compatibilityFlags] }
323 : {}),
324 bindings: options.bindings ?? [],
325 ...(options.logging !== undefined ? { logging: options.logging } : {}),
326 }).pipe(
327 Effect.mapError(fail("Failed to start the binding proxy for next dev")),
328 );
329
330 // 2. Plant the OpenNext cloudflare-context contract before any app code
331 // (next.config, instrumentation, routes) can call getCloudflareContext.
332 yield* plantCloudflareContext({
333 env: proxy.env,
334 cf: proxy.cf,
335 ctx: proxy.ctx,
336 });
337
338 // 3. Bind our http server first so the real port can seed `next({ port })`.
339 const http = yield* acquireHttpServer(hostname, options.port);
340
341 // 4. The public programmatic dev API: next({ dev: true }) + prepare().
342 const createNext = yield* loadNext(root);
343 const app = yield* Effect.acquireRelease(
344 Effect.tryPromise({
345 try: async () => {
346 const app = createNext({
347 dev: true,
348 dir: root,
349 hostname,
350 port: http.port,
351 });
352 await app.prepare();
353 return app;
354 },
355 catch: fail("Failed to start the Next.js dev server"),
356 }),
357 (app) => Effect.promise(() => app.close().catch(() => undefined)),
358 );
359 http.setHandler(app.getRequestHandler());
360
361 const urlHost =
362 hostname === "0.0.0.0" || hostname === "::" ? "127.0.0.1" : hostname;
363 return {
364 url: new URL(`http://${urlHost}:${http.port}`),
365 } satisfies DevServerInstance;
366}) as (
367 options: DevServerOptions,
368) => Effect.Effect<
369 DevServerInstance,
370 FrameworkCore.FrameworkError,
371 Runtime.Runtime | Scope.Scope
372>;