1/**
2 * `@alchemy.run/frontend-frameworks/nextjs/node` — Next.js on a Node
3 * container (`next build` + a custom `next({ dev: false })` server).
4 *
5 * Unlike the Cloudflare (`./index.ts`, OpenNext CF) and AWS (`./aws.ts`,
6 * OpenNext AWS) integrations, this module does **not** use OpenNext. The
7 * container-optimal path is a long-running Node process:
8 *
9 * - **`build`** runs the project's `next build` in a disposable child
10 * (cwd = project root), then writes the shared Node serve entry
11 * (`GET /health`, listens on `PORT`, default 3000) around
12 * {@link NEXT_PRODUCTION_APP_SOURCE}: `next({ dev: false }).prepare()`
13 * over the resolved build config + `getRequestHandler()`.
14 * - **`dev`** runs the real `next dev` CLI (plain Node), scoped.
15 *
16 * Composites should load this module as the framework specifier (same
17 * shape as `./aws`) or pass `target:
18 * "@alchemy.run/frontend-frameworks/nextjs/node"`.
19 */
20import * as FrameworkCore from "../core/index.ts";
21import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as Layer from "effect/Layer";
25import * as Path from "effect/Path";
26import * as Schema from "effect/Schema";
27import type { PlatformError } from "effect/PlatformError";
28import type * as Scope from "effect/Scope";
29import * as Stream from "effect/Stream";
30import * as ChildProcess from "effect/process/ChildProcess";
31import { createRequire } from "node:module";
32import type * as NodeChildProcessModule from "node:child_process";
33import type * as NodeNet from "node:net";
34import { runBuildChild } from "../core/BuildChild.ts";
35import {
36 NODE_BUNDLE_CONDITIONS,
37 NODE_SERVE_ENTRY_FILE_NAME,
38 writeNodeServeEntry,
39 type NodeServeHandler,
40} from "../core/NodeServe.ts";
41import {
42 DeployTargetError,
43 makeDeployTarget,
44 type DeployTarget,
45} from "../core/index.ts";
46
47const failFramework = (message: string) => (cause: unknown) =>
48 new FrameworkCore.FrameworkError({ framework: "nextjs", message, cause });
49
50const failTarget = (message: string, cause?: unknown) =>
51 new DeployTargetError({ platform: "node", message, cause });
52
53/** Options for the Next.js Node framework module / deploy target. */
54export interface NextjsNodeOptions {
55 readonly root?: string | undefined;
56 /**
57 * The deploy target module specifier the caller resolved this module as.
58 * This module IS the Node target — accepted and ignored (parity with
59 * `./aws`).
60 */
61 readonly target?: string | undefined;
62}
63
64export interface NextjsNodeTargetConfig {
65 readonly root?: string | undefined;
66}
67
68export interface NextjsNodeTarget extends DeployTarget<NextjsNodeTargetConfig> {}
69
70/** Serve-entry module name relative to the project root. */
71export const SERVER_ENTRY_NAME = NODE_SERVE_ENTRY_FILE_NAME;
72
73/**
74 * Serve-entry statements that start Next in production mode as `app`.
75 * Mirrors Next's standalone `server.js`: the resolved build config from
76 * `.next/required-server-files.json` is handed over via
77 * `__NEXT_PRIVATE_STANDALONE_CONFIG`, so Next never reloads
78 * `next.config.*` at runtime (which would recompile TypeScript configs
79 * and download SWC into a possibly read-only bundle). Expects `fs`,
80 * `path` and `fileURLToPath` in scope (both serve templates import them).
81 */
82export const NEXT_PRODUCTION_APP_SOURCE = [
83 'import next from "next";',
84 "const dir = path.dirname(fileURLToPath(import.meta.url));",
85 'process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(JSON.parse(fs.readFileSync(path.join(dir, ".next", "required-server-files.json"), "utf8")).config);',
86 "const app = next({ dev: false, dir });",
87 "await app.prepare();",
88].join("\n");
89
90/** The production Next request listener for the shared Node serve entry. */
91export const nextNodeServeHandler: NodeServeHandler = {
92 kind: "node",
93 imports: `${NEXT_PRODUCTION_APP_SOURCE}\nconst nextHandler = app.getRequestHandler();`,
94 expr: "nextHandler",
95};
96
97const resolveNextCli = (root: string) =>
98 Effect.try({
99 try: () => {
100 const require = createRequire(`${root.replace(/\/+$/, "")}/package.json`);
101 return require.resolve("next/dist/bin/next");
102 },
103 catch: failFramework(
104 `Failed to resolve "next" from ${root}. ` +
105 "It must be installed in your project.",
106 ),
107 });
108
109const runNextBuild = (options: {
110 readonly root: string;
111 readonly cli: string;
112}) =>
113 Effect.scoped(
114 Effect.gen(function* () {
115 const env = yield* Effect.sync(() => ({
116 ...process.env,
117 NODE_ENV: "production",
118 }));
119 const child = yield* ChildProcess.make("node", [options.cli, "build"], {
120 cwd: options.root,
121 stdin: "ignore",
122 stdout: "pipe",
123 stderr: "pipe",
124 env,
125 }).pipe(
126 Effect.mapError(
127 failFramework(
128 "Failed to spawn the next build CLI (is `node` on PATH?)",
129 ),
130 ),
131 );
132 const forward = (
133 stream: Stream.Stream<Uint8Array, PlatformError>,
134 dest: NodeJS.WriteStream,
135 ) =>
136 Stream.runForEach(stream, (chunk) =>
137 Effect.sync(() => dest.write(chunk)),
138 );
139 const { exitCode } = yield* Effect.all(
140 {
141 exitCode: child.exitCode,
142 stdout: forward(child.stdout, process.stdout),
143 stderr: forward(child.stderr, process.stderr),
144 },
145 { concurrency: "unbounded" },
146 ).pipe(
147 Effect.mapError(failFramework("Failed reading next build output")),
148 );
149 if (exitCode !== 0) {
150 return yield* Effect.fail(
151 failFramework(`The next build exited with code ${exitCode}`)(
152 undefined,
153 ),
154 );
155 }
156 }),
157 );
158
159/** The slice of `.next/required-server-files.json` the build inspects. */
160const RequiredServerFiles = Schema.Struct({
161 config: Schema.Struct({ output: Schema.optional(Schema.String) }),
162});
163
164const collectNextOutput = (root: string) =>
165 Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const nextDir = path.join(root, ".next");
169 const hasNext = yield* fs
170 .exists(nextDir)
171 .pipe(Effect.orElseSucceed(() => false));
172 if (!hasNext) {
173 return yield* Effect.fail(
174 failFramework(`The next build produced no ${nextDir}`)(undefined),
175 );
176 }
177 const serverFilesPath = path.join(nextDir, "required-server-files.json");
178 const { config } = yield* fs
179 .readFileString(serverFilesPath)
180 .pipe(
181 Effect.flatMap(
182 Schema.decodeUnknownEffect(
183 Schema.fromJsonString(RequiredServerFiles),
184 ),
185 ),
186 Effect.mapError(
187 failFramework(
188 `Failed to read the built Next config ${serverFilesPath}`,
189 ),
190 ),
191 );
192 if (config.output === "export") {
193 return yield* Effect.fail(
194 failFramework(
195 '`output: "export"` builds a static site with no Next server, so it ' +
196 'cannot run on the Node target. Remove `output: "export"` from ' +
197 "next.config, or deploy the exported `out/` directory as a static site.",
198 )(undefined),
199 );
200 }
201 const publicDir = path.join(root, "public");
202 const hasPublic = yield* fs
203 .exists(publicDir)
204 .pipe(Effect.orElseSucceed(() => false));
205 // No `clientDirExpression`: Next serves `.next/static` and `public/`
206 // itself from the serve entry's `dir` (the project root).
207 return yield* writeNodeServeEntry({
208 output: {
209 distDirectory: root,
210 clientDirectory: hasPublic ? root : nextDir,
211 serverModules: [],
212 externalWorkspaces: new Set<string>(),
213 },
214 servePath: path.join(root, SERVER_ENTRY_NAME),
215 serveModuleName: SERVER_ENTRY_NAME,
216 handler: nextNodeServeHandler,
217 }).pipe(
218 Effect.mapError((error) => failFramework(error.message)(error.cause)),
219 );
220 });
221
222export interface NextjsNodeBuildChildConfig {
223 readonly rootDir: string;
224 readonly config: NextjsNodeTargetConfig;
225}
226
227export const buildInChild = (config: NextjsNodeBuildChildConfig) =>
228 Effect.gen(function* () {
229 const fs = yield* FileSystem.FileSystem;
230 const path = yield* Path.Path;
231 const root = config.rootDir;
232 const cli = yield* resolveNextCli(root);
233 const spawnerLayer = NodeChildProcessSpawner.layer.pipe(
234 Layer.provide(
235 Layer.merge(
236 Layer.succeed(FileSystem.FileSystem)(fs),
237 Layer.succeed(Path.Path)(path),
238 ),
239 ),
240 );
241 yield* runNextBuild({ root, cli }).pipe(Effect.provide(spawnerLayer));
242 return yield* collectNextOutput(root);
243 });
244
245const makeNodeChildTarget = (
246 config: NextjsNodeTargetConfig = {},
247): NextjsNodeTarget =>
248 makeDeployTarget({
249 platform: "node",
250 config,
251 bundle: {
252 conditions: [...NODE_BUNDLE_CONDITIONS],
253 },
254 });
255
256/**
257 * Create the Node {@link NextjsNodeTarget}: wholesale `next build` in a
258 * child process, then a custom-server serve entry.
259 */
260export const makeNodeTarget = (
261 config: NextjsNodeTargetConfig = {},
262): NextjsNodeTarget => ({
263 ...makeNodeChildTarget(config),
264 build: (context) =>
265 runBuildChild({
266 module: import.meta.url,
267 rootDir: context.root,
268 env: context.env,
269 framework: "nextjs",
270 config: {
271 rootDir: context.root,
272 config,
273 } satisfies NextjsNodeBuildChildConfig,
274 }).pipe(Effect.mapError((error) => failTarget(error.message, error.cause))),
275});
276
277export const target = makeNodeTarget;
278
279export default makeNodeTarget;
280
281// ---------------------------------------------------------------------------
282// Framework-module contract (AWS.Website.Server / container composites)
283// ---------------------------------------------------------------------------
284
285const pickEphemeralPort: Effect.Effect<number, FrameworkCore.FrameworkError> =
286 Effect.callback((resume) => {
287 const net = createRequire(import.meta.url)("net") as typeof NodeNet;
288 const server = net.createServer();
289 server.once("error", (cause) =>
290 resume(
291 Effect.fail(
292 failFramework("Failed to allocate an ephemeral port")(cause),
293 ),
294 ),
295 );
296 server.listen(0, "127.0.0.1", () => {
297 const address = server.address();
298 if (address === null || typeof address === "string") {
299 server.close();
300 resume(
301 Effect.fail(failFramework("No TCP address for the port probe")(null)),
302 );
303 return;
304 }
305 const port = address.port;
306 server.close(() => resume(Effect.succeed(port)));
307 });
308 });
309
310interface NextDevChild {
311 readonly exited: () => boolean;
312 readonly output: () => string;
313}
314
315const spawnNextDev = (options: {
316 readonly root: string;
317 readonly cli: string;
318 readonly port: number;
319 readonly host?: string | undefined;
320}): Effect.Effect<NextDevChild, FrameworkCore.FrameworkError, Scope.Scope> =>
321 Effect.acquireRelease(
322 Effect.try({
323 try: () => {
324 const cp = createRequire(import.meta.url)(
325 "child_process",
326 ) as typeof NodeChildProcessModule;
327 const child = cp.spawn(
328 "node",
329 [
330 options.cli,
331 "dev",
332 "-p",
333 String(options.port),
334 ...(options.host !== undefined ? ["-H", options.host] : []),
335 ],
336 {
337 cwd: options.root,
338 stdio: ["ignore", "pipe", "pipe"],
339 detached: false,
340 },
341 );
342 let exited = false;
343 let output = "";
344 const capture = (chunk: unknown) => {
345 output += String(chunk);
346 if (output.length > 65536) output = output.slice(-32768);
347 process.stderr.write(String(chunk));
348 };
349 child.stdout?.on("data", capture);
350 child.stderr?.on("data", capture);
351 child.once("exit", () => {
352 exited = true;
353 });
354 return {
355 child,
356 handle: {
357 exited: () => exited,
358 output: () => output,
359 } satisfies NextDevChild,
360 };
361 },
362 catch: failFramework(
363 "Failed to spawn the next dev CLI (is `node` on PATH?)",
364 ),
365 }),
366 ({ child }) =>
367 Effect.callback<void>((resume) => {
368 if (child.exitCode !== null) {
369 resume(Effect.void);
370 return;
371 }
372 const killTimer = setTimeout(() => child.kill("SIGKILL"), 3000);
373 child.once("exit", () => {
374 clearTimeout(killTimer);
375 resume(Effect.void);
376 });
377 child.kill("SIGTERM");
378 }),
379 ).pipe(Effect.map(({ handle }) => handle));
380
381/**
382 * Poll until the allocated port accepts a TCP connection. Next 16 prints
383 * Ready and binds before loading next.config / compiling App Router, so a
384 * GET / probe with a 2s abort restarts the first compile and never
385 * converges.
386 */
387const awaitNextDevReady = (options: {
388 readonly url: string;
389 readonly child: NextDevChild;
390}): Effect.Effect<void, FrameworkCore.FrameworkError> =>
391 Effect.gen(function* () {
392 const parsed = yield* Effect.try({
393 try: () => new URL(options.url),
394 catch: failFramework(`Invalid next dev URL: ${options.url}`),
395 });
396 const port = Number(parsed.port);
397 const hostname = parsed.hostname;
398 for (let attempt = 0; attempt < 240; attempt++) {
399 if (options.child.exited()) {
400 return yield* Effect.fail(
401 failFramework(
402 `The next dev CLI exited before becoming ready:\n${options.child.output().slice(-4000)}`,
403 )(undefined),
404 );
405 }
406 const ready = yield* Effect.callback<boolean>((resume) => {
407 const net = createRequire(import.meta.url)("net") as typeof NodeNet;
408 let settled = false;
409 const finish = (value: boolean) => {
410 if (settled) return;
411 settled = true;
412 resume(Effect.succeed(value));
413 };
414 const socket = net.connect({ host: hostname, port }, () => {
415 socket.destroy();
416 finish(true);
417 });
418 socket.setTimeout(2000, () => {
419 socket.destroy();
420 finish(false);
421 });
422 socket.once("error", () => {
423 socket.destroy();
424 finish(false);
425 });
426 return Effect.sync(() => {
427 settled = true;
428 socket.destroy();
429 });
430 });
431 if (ready) return;
432 yield* Effect.sleep(500);
433 }
434 return yield* Effect.fail(
435 failFramework(
436 `Timed out waiting for the next dev server at ${options.url}`,
437 )(undefined),
438 );
439 });
440
441export interface NextjsNodeService {
442 readonly build: (
443 options?: FrameworkCore.FrameworkBuildOptions,
444 ) => Effect.Effect<
445 {
446 readonly distDirectory: string;
447 readonly clientDirectory: string;
448 readonly serverModules: Array<{ readonly name: string }>;
449 },
450 FrameworkCore.FrameworkError
451 >;
452 readonly dev: (
453 options?: FrameworkCore.FrameworkDevOptions,
454 ) => Effect.Effect<
455 FrameworkCore.FrameworkDevServer,
456 FrameworkCore.FrameworkError,
457 Scope.Scope
458 >;
459}
460
461/**
462 * Build the Next.js-on-Node framework service. See the module doc for the
463 * `build`/`dev` semantics.
464 */
465export const make: (
466 options?: NextjsNodeOptions,
467) => Effect.Effect<
468 NextjsNodeService,
469 never,
470 FileSystem.FileSystem | Path.Path
471> = Effect.fnUntraced(function* (options?: NextjsNodeOptions) {
472 const fs = yield* FileSystem.FileSystem;
473 const path = yield* Path.Path;
474 const resolveRoot = (override: string | undefined) =>
475 Effect.sync(() => path.resolve(override ?? options?.root ?? process.cwd()));
476
477 const build: NextjsNodeService["build"] = Effect.fn(function* (
478 buildOptions?: FrameworkCore.FrameworkBuildOptions,
479 ) {
480 const root = yield* resolveRoot(buildOptions?.root);
481 const nodeTarget = makeNodeTarget({ root });
482 const output = yield* nodeTarget.build!({ root, framework: "nextjs" }).pipe(
483 Effect.provideService(FileSystem.FileSystem, fs),
484 Effect.provideService(Path.Path, path),
485 Effect.mapError((error) => failFramework(error.message)(error.cause)),
486 );
487 return {
488 distDirectory: output.distDirectory ?? root,
489 clientDirectory: output.clientDirectory ?? path.join(root, ".next"),
490 serverModules: (output.serverModules ?? []).map((module_) => ({
491 name: module_.name,
492 })),
493 };
494 });
495
496 const dev: NextjsNodeService["dev"] = Effect.fn(function* (
497 devOptions?: FrameworkCore.FrameworkDevOptions,
498 ) {
499 const root = yield* resolveRoot(devOptions?.root);
500 const port = devOptions?.port ?? (yield* pickEphemeralPort);
501 const cli = yield* resolveNextCli(root);
502 const host = devOptions?.host ?? "127.0.0.1";
503 const child = yield* spawnNextDev({ root, cli, port, host });
504 const url = `http://${host}:${port}`;
505 yield* awaitNextDevReady({ url, child });
506 return { url };
507 });
508
509 return { build, dev };
510});