1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Schedule from "effect/Schedule";
6import type * as Scope from "effect/Scope";
7import * as NodeChildProcess from "node:child_process";
8import * as NodeNet from "node:net";
9import { fileURLToPath } from "node:url";
10import { describe, expect, it } from "vitest";
11import { makeNeonServeEntrySource } from "../../core/NeonServe.ts";
12import {
13 NODE_BUNDLE_CONDITIONS,
14 makeNodeServeEntrySource,
15} from "../../core/NodeServe.ts";
16import {
17 NEXT_PRODUCTION_APP_SOURCE,
18 SERVER_ENTRY_NAME,
19 makeNodeTarget,
20 nextNodeServeHandler,
21 target,
22} from "../node.ts";
23
24describe("makeNodeTarget", () => {
25 it("declares the node platform and a wholesale next build (not OpenNext)", () => {
26 const node = makeNodeTarget();
27 expect(node.platform).toBe("node");
28 expect(node.build).toBeTypeOf("function");
29 expect(node.bundle?.conditions).toEqual([...NODE_BUNDLE_CONDITIONS]);
30 expect(node.bundle?.external ?? []).not.toContain("cloudflare:");
31 expect(node.bundle?.external ?? []).not.toContain("@aws-sdk/");
32 });
33
34 it("starts Next from the built config before preparing", () => {
35 const source = NEXT_PRODUCTION_APP_SOURCE;
36 expect(source).toContain(
37 'path.join(dir, ".next", "required-server-files.json")',
38 );
39 expect(source.indexOf("__NEXT_PRIVATE_STANDALONE_CONFIG")).toBeLessThan(
40 source.indexOf("next({ dev: false, dir })"),
41 );
42 expect(source.indexOf("next({ dev: false, dir })")).toBeLessThan(
43 source.indexOf("await app.prepare()"),
44 );
45 });
46
47 it("wires the built-config startup into the shared Node and Neon entries", () => {
48 const node = makeNodeServeEntrySource({ handler: nextNodeServeHandler });
49 expect(SERVER_ENTRY_NAME).toBe("serve-node.mjs");
50 expect(node).toContain(NEXT_PRODUCTION_APP_SOURCE);
51 expect(node).toContain('import * as fs from "node:fs"');
52 expect(node).toContain("/health");
53 expect(node).toContain("process.env.PORT");
54 expect(node).not.toContain("opennext");
55 expect(node).not.toContain("cloudflare");
56 const neon = makeNeonServeEntrySource({ handler: nextNodeServeHandler });
57 expect(neon).toContain(NEXT_PRODUCTION_APP_SOURCE);
58 });
59
60 it("exposes the named `target` module export as the factory", () => {
61 expect(target).toBe(makeNodeTarget);
62 });
63});
64
65const fixture = (files: Record<string, string>) =>
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const workspace = yield* Effect.sync(() =>
70 fileURLToPath(new URL("../../../../../", import.meta.url)),
71 );
72 const base = path.join(workspace, ".alchemy", "nextjs-node-tests");
73 yield* fs.makeDirectory(base, { recursive: true });
74 const root = yield* fs.makeTempDirectoryScoped({
75 directory: base,
76 prefix: "nextjs-",
77 });
78 yield* fs.symlink(
79 path.join(workspace, "examples", "aws-website-nextjs", "node_modules"),
80 path.join(root, "node_modules"),
81 );
82 for (const [name, contents] of Object.entries({
83 "package.json": '{"private":true,"type":"module"}',
84 ...files,
85 })) {
86 const file = path.join(root, name);
87 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
88 yield* fs.writeFileString(file, contents);
89 }
90 return root;
91 });
92
93const freePort = Effect.callback<number>((resume) => {
94 const server = NodeNet.createServer();
95 server.listen(0, "127.0.0.1", () => {
96 const address = server.address() as NodeNet.AddressInfo;
97 server.close(() => resume(Effect.succeed(address.port)));
98 });
99});
100
101const serve = (entry: string, port: number) =>
102 Effect.acquireRelease(
103 Effect.sync(() => {
104 let output = "";
105 const child = NodeChildProcess.spawn("node", [entry], {
106 env: { ...process.env, PORT: String(port), HOST: "127.0.0.1" },
107 stdio: ["ignore", "pipe", "pipe"],
108 });
109 child.stdout.on("data", (chunk) => (output += String(chunk)));
110 child.stderr.on("data", (chunk) => (output += String(chunk)));
111 return { child, output: () => output };
112 }),
113 ({ child }) => Effect.sync(() => child.kill("SIGKILL")),
114 );
115
116const run = <A, E>(
117 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
118) =>
119 Effect.runPromise(
120 effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
121 );
122
123describe("Next.js Node serve entry", { concurrent: false }, () => {
124 it(
125 "serves a real build without reloading next.config.ts at runtime",
126 () =>
127 run(
128 Effect.gen(function* () {
129 const fs = yield* FileSystem.FileSystem;
130 const path = yield* Path.Path;
131 const root = yield* fixture({
132 "app/layout.jsx":
133 "export default function Layout({children}) {return <html><body>{children}</body></html>}",
134 "app/page.jsx":
135 "export default function Home() {return <h1>Node Next</h1>}",
136 "app/api/echo/route.js":
137 "export async function POST(request) {return Response.json({value:await request.text()})}",
138 "next.config.ts":
139 'import type { NextConfig } from "next";\nconst config: NextConfig = { basePath: "/app", experimental: { cpus: 1 } };\nexport default config;\n',
140 });
141 const output = yield* makeNodeTarget().build!({
142 root,
143 framework: "nextjs",
144 });
145 expect(output.serverModules?.[0]?.name).toBe(SERVER_ENTRY_NAME);
146 // Any runtime load of the config now fails the server loudly.
147 yield* fs.writeFileString(
148 path.join(root, "next.config.ts"),
149 'throw new Error("next.config.ts was reloaded at runtime");\n',
150 );
151 const port = yield* freePort;
152 const server = yield* serve(path.join(root, SERVER_ENTRY_NAME), port);
153 const get = (pathname: string, init?: RequestInit) =>
154 Effect.tryPromise(() =>
155 fetch(`http://127.0.0.1:${port}${pathname}`, init),
156 );
157 const health = yield* get("/health").pipe(
158 Effect.retry({
159 schedule: Schedule.spaced("250 millis"),
160 times: 80,
161 while: () => server.child.exitCode === null,
162 }),
163 Effect.mapError(
164 (cause) =>
165 new Error(
166 `serve-node.mjs never became ready:\n${server.output()}`,
167 {
168 cause,
169 },
170 ),
171 ),
172 );
173 expect(health.status).toBe(200);
174 const page = yield* get("/app");
175 const html = yield* Effect.tryPromise(() => page.text());
176 expect(page.status, server.output()).toBe(200);
177 expect(html).toContain("Node Next");
178 const post = yield* get("/app/api/echo", {
179 method: "POST",
180 body: "value",
181 });
182 expect(post.status).toBe(200);
183 expect(yield* Effect.tryPromise(() => post.json())).toEqual({
184 value: "value",
185 });
186 expect(server.output()).not.toContain("reloaded at runtime");
187 }),
188 ),
189 180_000,
190 );
191 it(
192 'rejects `output: "export"` at build time',
193 () =>
194 run(
195 Effect.gen(function* () {
196 const root = yield* fixture({
197 "app/layout.jsx":
198 "export default function Layout({children}) {return <html><body>{children}</body></html>}",
199 "app/page.jsx":
200 "export default function Home() {return <h1>Static Next</h1>}",
201 "next.config.mjs":
202 'export default { output: "export", experimental: { cpus: 1 } };',
203 });
204 const error = yield* makeNodeTarget().build!({
205 root,
206 framework: "nextjs",
207 }).pipe(Effect.flip);
208 expect(error.message).toContain('`output: "export"`');
209 expect(error.message).toContain("cannot run on the Node target");
210 }),
211 ),
212 180_000,
213 );
214});