| 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 | */ |
| 29 | import type { |
| 30 | BindingHooks, |
| 31 | WorkerdLogging, |
| 32 | } from "@alchemy.run/cloudflare-runtime/core"; |
| 33 | import * as PlatformProxy from "@alchemy.run/cloudflare-runtime/core/platform-proxy/PlatformProxy"; |
| 34 | import type * as Runtime from "@alchemy.run/cloudflare-runtime/core/Runtime"; |
| 35 | import * as FrameworkCore from "../core/index.ts"; |
| 36 | import * as Effect from "effect/Effect"; |
| 37 | import type * as Scope from "effect/Scope"; |
| 38 | import * as NodeFs from "node:fs"; |
| 39 | import * as NodeHttp from "node:http"; |
| 40 | import { createRequire } from "node:module"; |
| 41 | import type * as NodeNet from "node:net"; |
| 42 | import 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`. */ |
| 46 | export const CLOUDFLARE_CONTEXT_SYMBOL: unique symbol = Symbol.for( |
| 47 | "__cloudflare-context__", |
| 48 | ); |
| 49 | |
| 50 | /** The shape `getCloudflareContext()` expects (upstream `CloudflareContext`). */ |
| 51 | export interface CloudflareContextShape { |
| 52 | readonly env: Record<string, unknown>; |
| 53 | readonly cf: Record<string, unknown> | undefined; |
| 54 | readonly ctx: unknown; |
| 55 | } |
| 56 | |
| 57 | export 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 | |
| 78 | export interface DevServerInstance { |
| 79 | /** The local URL the dev server is listening on. */ |
| 80 | readonly url: URL; |
| 81 | } |
| 82 | |
| 83 | const fail = (message: string) => (cause: unknown) => |
| 84 | new FrameworkCore.FrameworkError({ framework: "nextjs", message, cause }); |
| 85 | |
| 86 | // --------------------------------------------------------------------------- |
| 87 | // Cloudflare-context planting (OpenNext contract) |
| 88 | // --------------------------------------------------------------------------- |
| 89 | |
| 90 | type 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 | */ |
| 100 | let currentContext: CloudflareContextShape | undefined; |
| 101 | let 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 | */ |
| 111 | const 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 | */ |
| 137 | const 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 | |
| 160 | type 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. */ |
| 166 | interface NextDevApp { |
| 167 | prepare(): Promise<void>; |
| 168 | getRequestHandler(): RequestHandler; |
| 169 | close(): Promise<void>; |
| 170 | } |
| 171 | |
| 172 | type 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 | */ |
| 185 | const 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 | |
| 211 | interface 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 | */ |
| 224 | const 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 | */ |
| 300 | export 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 | >; |