1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6import type { PlatformError } from "effect/PlatformError";
7import * as Stream from "effect/Stream";
8import * as ChildProcess from "effect/process/ChildProcess";
9import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10import { fileURLToPath } from "node:url";
11
12export class RunnerError extends Data.TaggedError<"RunnerError">(
13 "RunnerError",
14)<{
15 readonly message: string;
16 readonly cause?: unknown;
17}> {}
18
19/**
20 * The JSON configuration passed to `runner.mjs` (as `process.argv[2]`).
21 * Mirrors the shape the runner script parses.
22 */
23export interface RunnerConfig {
24 /** The Next.js application root. */
25 readonly appDir: string;
26 /** Explicit config relative to `appDir`; otherwise discover `open-next.config.ts`. */
27 readonly configPath?: string | undefined;
28 /** Resource-selected cache configuration when no native config file is present. */
29 readonly cache?: "static-assets" | "kv" | undefined;
30 /** `compatibility_date` of the in-memory wrangler-config stand-in. */
31 readonly compatibilityDate: string;
32 /** Skip the internal `next build` (reuse an existing `.next`). @default false */
33 readonly skipNextBuild?: boolean | undefined;
34 /** Minify the OpenNext bundling steps. @default false */
35 readonly minify?: boolean | undefined;
36 /** Enable OpenNext debug logging. @default false */
37 readonly debug?: boolean | undefined;
38 /**
39 * The command the pipeline runs to build the Next.js app. Defaults (in the
40 * runner) to `npx next build` — NOT the app's `build` script, which by
41 * fixture convention is `e2e build` and would recurse into this runner.
42 */
43 readonly buildCommand?: string | undefined;
44}
45
46const BuildPaths = Schema.Struct({
47 openNextDirectory: Schema.String,
48 appBuildOutputPath: Schema.String,
49});
50
51/** Discover the same default filename as the native Cloudflare OpenNext CLI. */
52export const resolveConfigPath = Effect.fn(function* (
53 config: Pick<RunnerConfig, "appDir" | "configPath">,
54) {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const candidate = path.resolve(
58 config.appDir,
59 config.configPath ?? "open-next.config.ts",
60 );
61 if (yield* fs.exists(candidate)) {
62 if ((yield* fs.stat(candidate)).type !== "File") {
63 return yield* new RunnerError({
64 message: `OpenNext config is not a file: ${candidate}`,
65 });
66 }
67 return candidate;
68 }
69 if (config.configPath !== undefined) {
70 return yield* new RunnerError({
71 message: `OpenNext config file not found: ${candidate}`,
72 });
73 }
74 return undefined;
75});
76
77/** Absolute path of the runner script in the package's `nextjs` directory. */
78export const runnerPath = (): string =>
79 fileURLToPath(new URL("../nextjs/runner.mjs", import.meta.url));
80
81/**
82 * Run the programmatic `@opennextjs/cloudflare` build pipeline in a
83 * disposable `node` child process (`runner.mjs`). The pipeline mutates
84 * cwd-coupled module state, spawns `next build`, and can `process.exit(1)`,
85 * so it must never run inside the calling process.
86 *
87 * Output is piped and re-emitted through the parent's own
88 * `process.stdout`/`process.stderr` JS streams — NOT `stdio: "inherit"`.
89 * `inherit` writes straight to the process file descriptors, bypassing any
90 * in-process stdout capture (e.g. a test runner's log file), which silently
91 * discards `next build`'s failure output when the pipeline exits non-zero.
92 */
93export const runOpenNextBuild = (
94 config: RunnerConfig,
95): Effect.Effect<
96 typeof BuildPaths.Type,
97 RunnerError,
98 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
99> =>
100 Effect.scoped(
101 Effect.gen(function* () {
102 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const configPath = yield* resolveConfigPath(config);
105 // The parent owns cleanup even if OpenNext calls process.exit in the child.
106 const directory = yield* fs.makeTempDirectoryScoped({
107 prefix: "alchemy-nextjs-config-",
108 });
109 const outputPath = path.join(directory, "build-paths.json");
110 const child = yield* ChildProcess.make(
111 "node",
112 [
113 runnerPath(),
114 JSON.stringify({
115 ...config,
116 configPath,
117 generatedConfigPath:
118 configPath === undefined
119 ? path.join(directory, "open-next.config.mjs")
120 : undefined,
121 outputPath,
122 }),
123 ],
124 {
125 cwd: config.appDir,
126 // Keep upstream compiler scratch files inside the parent's scope too.
127 env: { TMPDIR: directory, TMP: directory, TEMP: directory },
128 extendEnv: true,
129 stdin: "ignore",
130 stdout: "pipe",
131 stderr: "pipe",
132 },
133 ).pipe(
134 Effect.mapError(
135 (cause) =>
136 new RunnerError({
137 message:
138 "Failed to spawn the OpenNext build runner (is `node` on PATH?)",
139 cause,
140 }),
141 ),
142 );
143 const forward = (
144 stream: Stream.Stream<Uint8Array, PlatformError>,
145 dest: NodeJS.WriteStream,
146 ) =>
147 Stream.runForEach(stream, (chunk) =>
148 Effect.sync(() => dest.write(chunk)),
149 );
150 const { exitCode } = yield* Effect.all(
151 {
152 exitCode: child.exitCode,
153 stdout: forward(child.stdout, process.stdout),
154 stderr: forward(child.stderr, process.stderr),
155 },
156 { concurrency: "unbounded" },
157 ).pipe(
158 Effect.mapError(
159 (cause) =>
160 new RunnerError({
161 message: "Failed reading the OpenNext build runner's output",
162 cause,
163 }),
164 ),
165 );
166 if (exitCode !== 0) {
167 return yield* new RunnerError({
168 message: `The OpenNext build pipeline exited with code ${exitCode}`,
169 });
170 }
171 return yield* fs
172 .readFileString(outputPath)
173 .pipe(
174 Effect.flatMap(
175 Schema.decodeUnknownEffect(Schema.fromJsonString(BuildPaths)),
176 ),
177 );
178 }).pipe(
179 Effect.mapError((cause) =>
180 cause instanceof RunnerError
181 ? cause
182 : new RunnerError({
183 message:
184 "Failed to prepare or read the OpenNext build configuration",
185 cause,
186 }),
187 ),
188 ),
189 );