1/**
2 * `@alchemy.run/frontend-frameworks/nextjs/aws` — Next.js on AWS Lambda via
3 * `@opennextjs/aws`.
4 *
5 * Unlike the Cloudflare Next.js integration (`./index.ts`, a
6 * `Layer<Framework>` over the `@opennextjs/cloudflare` pipeline), this module
7 * exports the standalone framework-module contract alchemy's
8 * `AWS.Website.Server` resource drives directly: `make(options)` resolves to
9 * `{ build, dev }`.
10 *
11 * - **`build`** drives the `@opennextjs/aws` build CLI (resolved from the
12 * *project's* dependency tree) in a disposable child process — the pipeline
13 * mutates cwd-coupled module state, spawns `next build`, and can
14 * `process.exit(1)`, so it must never run in the calling process. When the
15 * project has no `open-next.config.ts`, a minimal default is generated with
16 * the `aws-lambda-streaming` wrapper so the emitted handler streams on a
17 * Lambda Function URL (`invokeMode: RESPONSE_STREAM`). After the build, the
18 * authoritative `.open-next/open-next.output.json` manifest is read to
19 * verify the default origin is a streaming function.
20 * - **`dev`** runs the real `next dev` through Next's documented custom-server
21 * API (`next({ dev: true })` + `prepare()` + `getRequestHandler()` on an
22 * http server we own) — plain Node, which is already the AWS Lambda
23 * programming model. Scoped: closing the Scope stops the app and the server.
24 *
25 * The OpenNext output topology the alchemy composite (`AWS.Website.Nextjs`)
26 * deploys from `.open-next/`:
27 *
28 * - `server-functions/default/` — the SSR server Lambda (`index.handler`)
29 * - `image-optimization-function/` — the image optimizer Lambda (sharp, arm64)
30 * - `revalidation-function/` — the ISR revalidation consumer Lambda
31 * - `assets/` — static assets for the CDN (`_next/static`, `public/`, BUILD_ID)
32 * - `cache/` — the ISR/fetch cache seed (uploaded under the cache prefix)
33 * - `dynamodb-provider/` — the tag-cache table seed function
34 */
35import * as FrameworkCore from "../core/index.ts";
36import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
37import * as Effect from "effect/Effect";
38import * as FileSystem from "effect/FileSystem";
39import * as Layer from "effect/Layer";
40import * as Path from "effect/Path";
41import type { PlatformError } from "effect/PlatformError";
42import type * as Scope from "effect/Scope";
43import * as Stream from "effect/Stream";
44import * as ChildProcess from "effect/process/ChildProcess";
45import { createRequire } from "node:module";
46import type * as NodeChildProcessModule from "node:child_process";
47import type * as NodeNet from "node:net";
48
49const fail = (message: string) => (cause: unknown) =>
50 new FrameworkCore.FrameworkError({ framework: "nextjs", message, cause });
51
52/** Options for the Next.js AWS framework module. */
53export interface NextjsAwsOptions {
54 /** Project root. Defaults to the process working directory. */
55 readonly root?: string | undefined;
56 /**
57 * The deploy target module specifier the caller resolved this module as.
58 * This module IS the AWS target — the option is accepted (the
59 * `AWS.Website.Server` resource always passes it) and ignored.
60 */
61 readonly target?: string | undefined;
62 /**
63 * Path of the OpenNext config, relative to the project root. When the file
64 * does not exist, a minimal default with the `aws-lambda-streaming` wrapper
65 * is generated ({@link DEFAULT_OPEN_NEXT_CONFIG}).
66 * @default "open-next.config.ts"
67 */
68 readonly configPath?: string | undefined;
69 /**
70 * Extra CLI arguments appended to `open-next build` (e.g.
71 * `["--dangerously-use-unsupported-next-version"]`).
72 */
73 readonly buildArgs?: ReadonlyArray<string> | undefined;
74}
75
76/** The default server-module name of the server's Lambda entry (the actual
77 * name is derived from `open-next.output.json` after the build). */
78export const SERVER_ENTRY_NAME = "server-functions/default/index.mjs";
79
80/**
81 * Derive the entry-module name (relative to `.open-next/`) from the output
82 * manifest's default origin: `bundle` (e.g. `.open-next/server-functions/
83 * default`) plus the file half of `handler` (`index.handler` → `index.mjs`).
84 */
85export const deriveServerEntryName = (origin: {
86 readonly handler?: string;
87 readonly bundle?: string;
88}): string => {
89 const bundle = (origin.bundle ?? ".open-next/server-functions/default")
90 .replace(/^\.open-next\//, "")
91 .replace(/\/+$/, "");
92 const handler = origin.handler ?? "index.handler";
93 const file = handler.split(".").slice(0, -1).join(".") || "index";
94 return `${bundle}/${file}.mjs`;
95};
96
97/**
98 * The minimal `open-next.config.ts` generated when the project has none:
99 * the default (and only) server uses the `aws-lambda-streaming` wrapper so
100 * the emitted handler wraps `awslambda.streamifyResponse` and expects a
101 * Function URL with `invokeMode: RESPONSE_STREAM`.
102 */
103export const DEFAULT_OPEN_NEXT_CONFIG = `// Generated by @alchemy.run/frontend-frameworks/nextjs/aws.
104// The default OpenNext config for the AWS topology alchemy deploys: the
105// server streams its response on a Lambda Function URL. Edit freely, but
106// keep the streaming wrapper — the Function URL is created with
107// invokeMode: RESPONSE_STREAM.
108const config = {
109 default: {
110 override: {
111 wrapper: "aws-lambda-streaming",
112 },
113 },
114};
115
116export default config;
117`;
118
119/** The structural slice of `open-next.output.json` this module reads. */
120interface OpenNextOutputManifest {
121 readonly origins?: {
122 readonly default?: {
123 readonly type?: string;
124 readonly handler?: string;
125 readonly bundle?: string;
126 readonly streaming?: boolean;
127 };
128 };
129}
130
131/**
132 * Resolve the `@opennextjs/aws` CLI entry (`dist/index.js`, the `open-next`
133 * bin) from the *project's* dependency tree. The package's exports map has
134 * only a `"./*" -> "./dist/*"` pattern (no `"."` and no `./package.json`
135 * subpath), so the `index.js` subpath is the one stable resolvable path.
136 */
137const resolveOpenNextCli = (root: string) =>
138 Effect.try({
139 try: () => {
140 const require = createRequire(`${root.replace(/\/+$/, "")}/package.json`);
141 return require.resolve("@opennextjs/aws/index.js");
142 },
143 catch: fail(
144 `Failed to resolve "@opennextjs/aws" from ${root}. ` +
145 "It must be installed in your project (it drives the Next.js build).",
146 ),
147 });
148
149/**
150 * Run `open-next build` in a disposable node child process with the project
151 * root as cwd. Output is piped and re-emitted through the parent's own
152 * stdout/stderr JS streams (NOT `stdio: "inherit"`) so in-process capture —
153 * a test runner's log file — sees the build output.
154 */
155const runOpenNextBuild = (options: {
156 readonly root: string;
157 readonly cli: string;
158 readonly configPath: string;
159 readonly extraArgs: ReadonlyArray<string>;
160}) =>
161 Effect.scoped(
162 Effect.gen(function* () {
163 // Always plain `node` (never bun/`process.execPath`): the OpenNext CLI
164 // mutates its own process.env (NEXT_PRIVATE_STANDALONE,
165 // NEXT_PRIVATE_OUTPUT_TRACE_ROOT) before exec'ing `next build`, and
166 // those mutations do not reliably reach execSync children under bun.
167 const child = yield* ChildProcess.make(
168 "node",
169 [
170 options.cli,
171 "build",
172 "--config-path",
173 options.configPath,
174 ...options.extraArgs,
175 ],
176 {
177 cwd: options.root,
178 stdin: "ignore",
179 stdout: "pipe",
180 stderr: "pipe",
181 },
182 ).pipe(
183 Effect.mapError(
184 fail(
185 "Failed to spawn the @opennextjs/aws build CLI (is `node` on PATH?)",
186 ),
187 ),
188 );
189 const forward = (
190 stream: Stream.Stream<Uint8Array, PlatformError>,
191 dest: NodeJS.WriteStream,
192 ) =>
193 Stream.runForEach(stream, (chunk) =>
194 Effect.sync(() => dest.write(chunk)),
195 );
196 const { exitCode } = yield* Effect.all(
197 {
198 exitCode: child.exitCode,
199 stdout: forward(child.stdout, process.stdout),
200 stderr: forward(child.stderr, process.stderr),
201 },
202 { concurrency: "unbounded" },
203 ).pipe(
204 Effect.mapError(fail("Failed reading the OpenNext build's output")),
205 );
206 if (exitCode !== 0) {
207 return yield* Effect.fail(
208 fail(`The OpenNext build exited with code ${exitCode}`)(undefined),
209 );
210 }
211 }),
212 );
213
214// ---------------------------------------------------------------------------
215// dev — the real `next dev` CLI in a child process
216// ---------------------------------------------------------------------------
217
218/**
219 * Resolve the *project's* `next` CLI entry (`next/dist/bin/next`). The
220 * app's installed copy is the one driven, never a hoisted sibling.
221 */
222const resolveNextCli = (root: string) =>
223 Effect.try({
224 try: () => {
225 const require = createRequire(`${root.replace(/\/+$/, "")}/package.json`);
226 return require.resolve("next/dist/bin/next");
227 },
228 catch: fail(
229 `Failed to resolve "next" from ${root}. ` +
230 "It must be installed in your project.",
231 ),
232 });
233
234/** Bind an ephemeral port and release it, returning the port number. */
235const pickEphemeralPort: Effect.Effect<number, FrameworkCore.FrameworkError> =
236 Effect.callback((resume) => {
237 const net = createRequire(import.meta.url)("net") as typeof NodeNet;
238 const server = net.createServer();
239 server.once("error", (cause) =>
240 resume(Effect.fail(fail("Failed to allocate an ephemeral port")(cause))),
241 );
242 server.listen(0, "127.0.0.1", () => {
243 const address = server.address();
244 if (address === null || typeof address === "string") {
245 server.close();
246 resume(Effect.fail(fail("No TCP address for the port probe")(null)));
247 return;
248 }
249 const port = address.port;
250 server.close(() => resume(Effect.succeed(port)));
251 });
252 });
253
254interface NextDevChild {
255 readonly exited: () => boolean;
256 readonly output: () => string;
257}
258
259/**
260 * Spawn `node <next CLI> dev -p <port>` with the project root as cwd,
261 * scoped: closing the Scope kills the process tree (SIGTERM, then SIGKILL).
262 * Always plain `node` — the AWS Lambda programming model.
263 */
264const spawnNextDev = (options: {
265 readonly root: string;
266 readonly cli: string;
267 readonly port: number;
268 readonly host?: string | undefined;
269}): Effect.Effect<NextDevChild, FrameworkCore.FrameworkError, Scope.Scope> =>
270 Effect.acquireRelease(
271 Effect.try({
272 try: () => {
273 const cp = createRequire(import.meta.url)(
274 "child_process",
275 ) as typeof NodeChildProcessModule;
276 const child = cp.spawn(
277 "node",
278 [
279 options.cli,
280 "dev",
281 "-p",
282 String(options.port),
283 ...(options.host !== undefined ? ["-H", options.host] : []),
284 ],
285 {
286 cwd: options.root,
287 stdio: ["ignore", "pipe", "pipe"],
288 detached: false,
289 },
290 );
291 let exited = false;
292 let output = "";
293 const capture = (chunk: unknown) => {
294 output += String(chunk);
295 if (output.length > 65536) output = output.slice(-32768);
296 process.stderr.write(String(chunk));
297 };
298 child.stdout?.on("data", capture);
299 child.stderr?.on("data", capture);
300 child.once("exit", () => {
301 exited = true;
302 });
303 return {
304 child,
305 handle: {
306 exited: () => exited,
307 output: () => output,
308 } satisfies NextDevChild,
309 };
310 },
311 catch: fail("Failed to spawn the next dev CLI (is `node` on PATH?)"),
312 }),
313 ({ child }) =>
314 Effect.callback<void>((resume) => {
315 if (child.exitCode !== null) {
316 resume(Effect.void);
317 return;
318 }
319 const killTimer = setTimeout(() => child.kill("SIGKILL"), 3000);
320 child.once("exit", () => {
321 clearTimeout(killTimer);
322 resume(Effect.void);
323 });
324 child.kill("SIGTERM");
325 }),
326 ).pipe(Effect.map(({ handle }) => handle));
327
328/**
329 * Poll the dev server URL until it answers any HTTP response. Fails fast
330 * when the child exits before becoming ready.
331 */
332const awaitNextDevReady = (options: {
333 readonly url: string;
334 readonly child: NextDevChild;
335}): Effect.Effect<void, FrameworkCore.FrameworkError> =>
336 Effect.gen(function* () {
337 for (let attempt = 0; attempt < 240; attempt++) {
338 if (options.child.exited()) {
339 return yield* Effect.fail(
340 fail(
341 `The next dev CLI exited before becoming ready:\n${options.child.output().slice(-4000)}`,
342 )(undefined),
343 );
344 }
345 const ready = yield* Effect.tryPromise({
346 try: async () => {
347 const response = await fetch(options.url, {
348 signal: AbortSignal.timeout(2000),
349 });
350 return response.status < 600;
351 },
352 catch: () => "not-ready" as const,
353 }).pipe(Effect.orElseSucceed(() => false));
354 if (ready) return;
355 yield* Effect.sleep(500);
356 }
357 return yield* Effect.fail(
358 fail(`Timed out waiting for the next dev server at ${options.url}`)(
359 undefined,
360 ),
361 );
362 });
363
364/** The service shape {@link make} resolves to (the framework-module contract
365 * `AWS.Website.Server` drives; `serverModules` carries names only — the AWS
366 * deploy ships the OpenNext bundles from disk, never in-memory). */
367export interface NextjsAwsService {
368 readonly build: (
369 options?: FrameworkCore.FrameworkBuildOptions,
370 ) => Effect.Effect<
371 {
372 readonly distDirectory: string;
373 readonly clientDirectory: string;
374 readonly serverModules: Array<{ readonly name: string }>;
375 },
376 FrameworkCore.FrameworkError
377 >;
378 readonly dev: (
379 options?: FrameworkCore.FrameworkDevOptions,
380 ) => Effect.Effect<
381 FrameworkCore.FrameworkDevServer,
382 FrameworkCore.FrameworkError,
383 Scope.Scope
384 >;
385}
386
387/**
388 * Build the Next.js-on-AWS framework service. See the module doc for the
389 * `build`/`dev` semantics.
390 */
391export const make: (
392 options?: NextjsAwsOptions,
393) => Effect.Effect<NextjsAwsService, never, FileSystem.FileSystem | Path.Path> =
394 Effect.fnUntraced(function* (options?: NextjsAwsOptions) {
395 const fs = yield* FileSystem.FileSystem;
396 const path = yield* Path.Path;
397
398 const spawnerLayer = NodeChildProcessSpawner.layer.pipe(
399 Layer.provide(
400 Layer.merge(
401 Layer.succeed(FileSystem.FileSystem)(fs),
402 Layer.succeed(Path.Path)(path),
403 ),
404 ),
405 );
406
407 const resolveRoot = (override: string | undefined) =>
408 Effect.sync(() =>
409 path.resolve(override ?? options?.root ?? process.cwd()),
410 );
411
412 const build: NextjsAwsService["build"] = Effect.fn(function* (
413 buildOptions?: FrameworkCore.FrameworkBuildOptions,
414 ) {
415 const root = yield* resolveRoot(buildOptions?.root);
416 const configPath = options?.configPath ?? "open-next.config.ts";
417
418 // Generate the minimal streaming config when the project has none. The
419 // file lands in the project root (OpenNext resolves the config relative
420 // to its cwd) and is left in place so subsequent builds — and the memo
421 // input hash — see a stable tree.
422 const absoluteConfigPath = path.resolve(root, configPath);
423 const hasConfig = yield* fs
424 .exists(absoluteConfigPath)
425 .pipe(Effect.orElseSucceed(() => false));
426 if (!hasConfig) {
427 yield* fs
428 .writeFileString(absoluteConfigPath, DEFAULT_OPEN_NEXT_CONFIG)
429 .pipe(
430 Effect.mapError(
431 fail(
432 `Failed to write the default OpenNext config at ${absoluteConfigPath}`,
433 ),
434 ),
435 );
436 }
437
438 const cli = yield* resolveOpenNextCli(root);
439 yield* runOpenNextBuild({
440 root,
441 cli,
442 configPath,
443 extraArgs: options?.buildArgs ?? [],
444 }).pipe(Effect.provide(spawnerLayer));
445
446 const distDirectory = path.join(root, ".open-next");
447 const clientDirectory = path.join(distDirectory, "assets");
448
449 // The authoritative manifest: verify the topology the composite deploys
450 // (a streaming default server function) actually came out of the build.
451 const manifestPath = path.join(distDirectory, "open-next.output.json");
452 const manifestRaw = yield* fs
453 .readFileString(manifestPath)
454 .pipe(Effect.mapError(fail(`The build produced no ${manifestPath}`)));
455 const manifest = yield* Effect.try({
456 try: () => JSON.parse(manifestRaw) as OpenNextOutputManifest,
457 catch: fail(`Failed to parse ${manifestPath}`),
458 });
459 const defaultOrigin = manifest.origins?.default;
460 if (defaultOrigin?.type !== "function") {
461 return yield* Effect.fail(
462 fail(
463 `The OpenNext build's default origin is "${defaultOrigin?.type}", not a Lambda function. ` +
464 "The AWS deploy target only supports the function topology (no generateDockerfile).",
465 )(undefined),
466 );
467 }
468 if (defaultOrigin.streaming !== true) {
469 return yield* Effect.fail(
470 fail(
471 "The OpenNext build's default server does not stream. The Lambda Function URL is " +
472 "created with invokeMode: RESPONSE_STREAM, so open-next.config.ts must keep the streaming wrapper: " +
473 '`default: { override: { wrapper: "aws-lambda-streaming" } }`.',
474 )(undefined),
475 );
476 }
477
478 const entryName = deriveServerEntryName(defaultOrigin);
479 const entryPath = path.join(distDirectory, entryName);
480 if (
481 !(yield* fs.exists(entryPath).pipe(Effect.orElseSucceed(() => false)))
482 ) {
483 return yield* Effect.fail(
484 fail(`The build produced no server entry at ${entryPath}`)(undefined),
485 );
486 }
487
488 return {
489 distDirectory,
490 clientDirectory,
491 serverModules: [{ name: entryName }],
492 };
493 });
494
495 const dev: NextjsAwsService["dev"] = Effect.fn(function* (
496 devOptions?: FrameworkCore.FrameworkDevOptions,
497 ) {
498 const root = yield* resolveRoot(devOptions?.root);
499 const port = devOptions?.port ?? (yield* pickEphemeralPort);
500 const cli = yield* resolveNextCli(root);
501 const host = devOptions?.host;
502 const child = yield* spawnNextDev({ root, cli, port, host });
503 const url = `http://${host ?? "localhost"}:${port}`;
504 yield* awaitNextDevReady({ url, child });
505 return { url };
506 });
507
508 return { build, dev };
509 });
510
511export default make;