| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import type { PlatformError } from "effect/PlatformError"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 9 | import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 10 | import { fileURLToPath } from "node:url"; |
| 11 | |
| 12 | export 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 | */ |
| 23 | export 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 | |
| 46 | const 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. */ |
| 52 | export 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. */ |
| 78 | export 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 | */ |
| 93 | export 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 | ); |