| 1 | /** |
| 2 | * Shared programmatic Vinext builds and native development CLI helpers. |
| 3 | * Production builds run in the target's isolated Node child so Alchemy can |
| 4 | * inject deployment adapters without modifying application configuration. |
| 5 | */ |
| 6 | import * as FrameworkCore from "../core/index.ts"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import type * as Scope from "effect/Scope"; |
| 11 | import type { Plugin, PluginOption } from "vite"; |
| 12 | import { createRequire } from "node:module"; |
| 13 | import { randomBytes } from "node:crypto"; |
| 14 | import { loadVinextBuildConfig } from "./BuildConfig.ts"; |
| 15 | import type * as NodeChildProcessModule from "node:child_process"; |
| 16 | import type * as NodeNet from "node:net"; |
| 17 | import { findEphemeralPort } from "../core/DevPort.ts"; |
| 18 | import { |
| 19 | loadProjectModule, |
| 20 | resolveProjectPackageDirectory, |
| 21 | } from "../core/Loader.ts"; |
| 22 | import { makeVinextCachePlugin, type VinextCacheKind } from "./cache/plugin.ts"; |
| 23 | import { runVinextPrerenderIfConfigured } from "./Prerender.ts"; |
| 24 | import { loadVinextModule } from "./Modules.ts"; |
| 25 | import { toOutputFile, type BuildOutput } from "../core/index.ts"; |
| 26 | |
| 27 | export const failFramework = (message: string) => (cause: unknown) => |
| 28 | new FrameworkCore.FrameworkError({ framework: "vinext", message, cause }); |
| 29 | |
| 30 | /** App Router RSC entry vinext writes under `dist/server`. */ |
| 31 | export const VINEXT_RSC_ENTRY = "server/index.js"; |
| 32 | |
| 33 | /** Pages Router server entry vinext writes under `dist/server`. */ |
| 34 | export const VINEXT_PAGES_ENTRY = "server/entry.js"; |
| 35 | |
| 36 | export const resolveVinextCli = (root: string) => |
| 37 | resolveProjectPackageDirectory(root, "vinext").pipe( |
| 38 | Effect.map((directory) => `${directory}/dist/cli.js`), |
| 39 | Effect.mapError( |
| 40 | failFramework( |
| 41 | `Failed to resolve "vinext" from ${root}. It must be installed in your project.`, |
| 42 | ), |
| 43 | ), |
| 44 | ); |
| 45 | |
| 46 | export const runVinextBuild = (options: { |
| 47 | readonly root: string; |
| 48 | readonly cache: VinextCacheKind; |
| 49 | }) => |
| 50 | Effect.gen(function* () { |
| 51 | const { root } = options; |
| 52 | const fs = yield* FileSystem.FileSystem; |
| 53 | const path = yield* Path.Path; |
| 54 | yield* Effect.sync(() => { |
| 55 | process.env.NODE_ENV = "production"; |
| 56 | }); |
| 57 | const vite = yield* loadProjectModule<typeof import("vite")>(root, "vite"); |
| 58 | const { default: vinext } = yield* loadVinextModule<{ |
| 59 | default(options?: { disableAppRouter?: boolean }): PluginOption; |
| 60 | }>(root, "index.js"); |
| 61 | const cache = yield* makeVinextCachePlugin(root, options.cache); |
| 62 | const { loadDotenv } = yield* loadVinextModule<{ |
| 63 | loadDotenv(options: { root: string; mode: string }): void; |
| 64 | }>(root, "config/dotenv.js"); |
| 65 | yield* Effect.sync(() => loadDotenv({ root, mode: "production" })); |
| 66 | const loaded = yield* Effect.tryPromise(() => |
| 67 | vite.loadConfigFromFile( |
| 68 | { command: "build", mode: "production" }, |
| 69 | undefined, |
| 70 | root, |
| 71 | ), |
| 72 | ); |
| 73 | const plugins = loaded?.config.plugins ?? [vinext()]; |
| 74 | const config = yield* loadVinextBuildConfig(root, plugins); |
| 75 | const { runWithPreviewBuildCredentials } = yield* loadVinextModule<{ |
| 76 | runWithPreviewBuildCredentials<T>(callback: () => T): T; |
| 77 | }>(root, "build/preview-credentials.js"); |
| 78 | const { clearPagesClientAssetsBuildMetadata } = yield* loadVinextModule<{ |
| 79 | clearPagesClientAssetsBuildMetadata(session: string): void; |
| 80 | }>(root, "build/pages-client-assets-module.js"); |
| 81 | const hasDirectory = (name: string) => |
| 82 | Effect.gen(function* () { |
| 83 | return ( |
| 84 | (yield* fs.exists(path.join(root, name))) || |
| 85 | (yield* fs.exists(path.join(root, "src", name))) |
| 86 | ); |
| 87 | }); |
| 88 | const hybrid = |
| 89 | (yield* hasDirectory("app")) && (yield* hasDirectory("pages")); |
| 90 | if (loaded?.config.build?.emptyOutDir !== false) { |
| 91 | yield* fs.remove(path.join(root, "dist"), { |
| 92 | recursive: true, |
| 93 | force: true, |
| 94 | }); |
| 95 | } |
| 96 | yield* Effect.acquireUseRelease( |
| 97 | Effect.sync(() => { |
| 98 | const shared = { |
| 99 | __VINEXT_SHARED_BUILD_ID: config.nextConfig.buildId, |
| 100 | __VINEXT_SHARED_RSC_COMPATIBILITY_ID: config.rscCompatibilityId, |
| 101 | __VINEXT_SHARED_RSC_BUILD_IDENTITY: randomBytes(16).toString("hex"), |
| 102 | __VINEXT_SHARED_REVALIDATE_SECRET: randomBytes(32).toString("hex"), |
| 103 | __VINEXT_SHARED_PRERENDER_SECRET: randomBytes(32).toString("hex"), |
| 104 | ...(hybrid |
| 105 | ? { |
| 106 | __VINEXT_PAGES_CLIENT_ASSETS_BUILD_SESSION: |
| 107 | randomBytes(16).toString("hex"), |
| 108 | } |
| 109 | : {}), |
| 110 | }; |
| 111 | const previous = Object.fromEntries( |
| 112 | Object.keys(shared).map((key) => [key, process.env[key]]), |
| 113 | ); |
| 114 | Object.assign(process.env, shared); |
| 115 | return { |
| 116 | previous, |
| 117 | session: shared.__VINEXT_PAGES_CLIENT_ASSETS_BUILD_SESSION, |
| 118 | }; |
| 119 | }), |
| 120 | () => |
| 121 | Effect.gen(function* () { |
| 122 | yield* Effect.tryPromise(() => |
| 123 | runWithPreviewBuildCredentials(async () => { |
| 124 | const buildConfig = await vite.loadConfigFromFile( |
| 125 | { command: "build", mode: "production" }, |
| 126 | undefined, |
| 127 | root, |
| 128 | ); |
| 129 | const builder = await vite.createBuilder({ |
| 130 | ...buildConfig?.config, |
| 131 | root, |
| 132 | configFile: false, |
| 133 | plugins: [buildConfig?.config.plugins ?? [vinext()], cache], |
| 134 | logLevel: "warn", |
| 135 | }); |
| 136 | await builder.buildApp(); |
| 137 | if (!hybrid) return; |
| 138 | // The native App Router builder leaves the hybrid Pages server to its caller. |
| 139 | const flattened: Plugin[] = []; |
| 140 | const flatten = async (value: PluginOption): Promise<void> => { |
| 141 | const plugin = await value; |
| 142 | if (Array.isArray(plugin)) { |
| 143 | for (const child of plugin) await flatten(child); |
| 144 | } else if (plugin) flattened.push(plugin); |
| 145 | }; |
| 146 | const pagesConfig = await vite.loadConfigFromFile( |
| 147 | { command: "build", mode: "production", isSsrBuild: true }, |
| 148 | undefined, |
| 149 | root, |
| 150 | ); |
| 151 | await flatten(pagesConfig?.config.plugins ?? []); |
| 152 | const transforms = flattened.filter( |
| 153 | (plugin) => |
| 154 | !plugin.name.startsWith("vinext:") && |
| 155 | !plugin.name.startsWith("vite:react") && |
| 156 | !plugin.name.startsWith("rsc:") && |
| 157 | plugin.name !== "vite-rsc-load-module-dev-proxy" && |
| 158 | !plugin.name.startsWith("vite-plugin-cloudflare"), |
| 159 | ); |
| 160 | await vite.build({ |
| 161 | root, |
| 162 | configFile: false, |
| 163 | plugins: [ |
| 164 | transforms, |
| 165 | vinext({ disableAppRouter: true }), |
| 166 | cache, |
| 167 | ], |
| 168 | resolve: { |
| 169 | dedupe: [ |
| 170 | "react", |
| 171 | "react-dom", |
| 172 | "react/jsx-runtime", |
| 173 | "react/jsx-dev-runtime", |
| 174 | ], |
| 175 | }, |
| 176 | build: { |
| 177 | outDir: "dist/server", |
| 178 | emptyOutDir: false, |
| 179 | ssr: "virtual:vinext-server-entry", |
| 180 | rolldownOptions: { output: { entryFileNames: "entry.js" } }, |
| 181 | }, |
| 182 | }); |
| 183 | }), |
| 184 | ); |
| 185 | yield* runVinextPrerenderIfConfigured(root, options.cache, config); |
| 186 | }), |
| 187 | ({ previous, session }) => |
| 188 | Effect.sync(() => { |
| 189 | if (session) clearPagesClientAssetsBuildMetadata(session); |
| 190 | for (const [key, value] of Object.entries(previous)) { |
| 191 | if (value === undefined) delete process.env[key]; |
| 192 | else process.env[key] = value; |
| 193 | } |
| 194 | }), |
| 195 | ); |
| 196 | }).pipe(Effect.mapError(failFramework("Failed to build vinext"))); |
| 197 | |
| 198 | export const collectVinextDist = (root: string) => |
| 199 | Effect.gen(function* () { |
| 200 | const fs = yield* FileSystem.FileSystem; |
| 201 | const path = yield* Path.Path; |
| 202 | const distDir = path.join(root, "dist"); |
| 203 | const serverDir = path.join(distDir, "server"); |
| 204 | const clientDir = path.join(distDir, "client"); |
| 205 | const rscEntry = path.join(distDir, VINEXT_RSC_ENTRY); |
| 206 | const pagesEntry = path.join(distDir, VINEXT_PAGES_ENTRY); |
| 207 | const hasDist = yield* fs |
| 208 | .exists(distDir) |
| 209 | .pipe(Effect.orElseSucceed(() => false)); |
| 210 | if (!hasDist) { |
| 211 | return yield* Effect.fail( |
| 212 | failFramework(`The vinext build produced no ${distDir}`)(undefined), |
| 213 | ); |
| 214 | } |
| 215 | const hasRsc = yield* fs |
| 216 | .exists(rscEntry) |
| 217 | .pipe(Effect.orElseSucceed(() => false)); |
| 218 | const hasPages = yield* fs |
| 219 | .exists(pagesEntry) |
| 220 | .pipe(Effect.orElseSucceed(() => false)); |
| 221 | if (!hasRsc && !hasPages) { |
| 222 | return yield* Effect.fail( |
| 223 | failFramework( |
| 224 | `The vinext build produced no server entry at ${rscEntry} or ${pagesEntry}`, |
| 225 | )(undefined), |
| 226 | ); |
| 227 | } |
| 228 | const hasClient = yield* fs |
| 229 | .exists(clientDir) |
| 230 | .pipe(Effect.orElseSucceed(() => false)); |
| 231 | yield* fs |
| 232 | .writeFileString( |
| 233 | path.join(serverDir, "package.json"), |
| 234 | `${JSON.stringify({ type: "module" }, null, 2)}\n`, |
| 235 | ) |
| 236 | .pipe( |
| 237 | Effect.mapError( |
| 238 | failFramework("Failed to write dist/server/package.json"), |
| 239 | ), |
| 240 | ); |
| 241 | return { |
| 242 | distDirectory: distDir, |
| 243 | clientDirectory: hasClient ? clientDir : undefined, |
| 244 | serverDir, |
| 245 | hasRsc, |
| 246 | hasPages, |
| 247 | }; |
| 248 | }); |
| 249 | |
| 250 | export const pinServeModule = ( |
| 251 | output: Omit<BuildOutput, "serverModules" | "externalWorkspaces"> & { |
| 252 | readonly serverDir: string; |
| 253 | }, |
| 254 | serveModuleName: string, |
| 255 | serveSource: string, |
| 256 | ) => |
| 257 | Effect.gen(function* () { |
| 258 | const fs = yield* FileSystem.FileSystem; |
| 259 | const path = yield* Path.Path; |
| 260 | const servePath = path.join( |
| 261 | output.serverDir, |
| 262 | path.basename(serveModuleName), |
| 263 | ); |
| 264 | yield* fs |
| 265 | .writeFileString(servePath, serveSource) |
| 266 | .pipe( |
| 267 | Effect.mapError( |
| 268 | failFramework(`Failed to write the serve entry at ${servePath}`), |
| 269 | ), |
| 270 | ); |
| 271 | const serveModule = yield* toOutputFile(serveModuleName, serveSource); |
| 272 | return { |
| 273 | distDirectory: output.distDirectory, |
| 274 | clientDirectory: output.clientDirectory, |
| 275 | serverModules: [serveModule], |
| 276 | externalWorkspaces: new Set<string>(), |
| 277 | } satisfies BuildOutput; |
| 278 | }); |
| 279 | |
| 280 | export interface VinextDevChild { |
| 281 | readonly exited: () => boolean; |
| 282 | readonly output: () => string; |
| 283 | } |
| 284 | |
| 285 | export const spawnVinextDev = (options: { |
| 286 | readonly root: string; |
| 287 | readonly cli: string; |
| 288 | readonly port: number; |
| 289 | readonly host?: string | undefined; |
| 290 | }): Effect.Effect<VinextDevChild, FrameworkCore.FrameworkError, Scope.Scope> => |
| 291 | Effect.acquireRelease( |
| 292 | Effect.try({ |
| 293 | try: () => { |
| 294 | const cp = createRequire(import.meta.url)( |
| 295 | "child_process", |
| 296 | ) as typeof NodeChildProcessModule; |
| 297 | const child = cp.spawn( |
| 298 | "node", |
| 299 | [ |
| 300 | options.cli, |
| 301 | "dev", |
| 302 | "-p", |
| 303 | String(options.port), |
| 304 | ...(options.host !== undefined ? ["-H", options.host] : []), |
| 305 | ], |
| 306 | { |
| 307 | cwd: options.root, |
| 308 | stdio: ["ignore", "pipe", "pipe"], |
| 309 | detached: false, |
| 310 | }, |
| 311 | ); |
| 312 | let exited = false; |
| 313 | let output = ""; |
| 314 | const capture = (chunk: unknown) => { |
| 315 | output += String(chunk); |
| 316 | if (output.length > 65536) output = output.slice(-32768); |
| 317 | process.stderr.write(String(chunk)); |
| 318 | }; |
| 319 | child.stdout?.on("data", capture); |
| 320 | child.stderr?.on("data", capture); |
| 321 | child.once("exit", () => { |
| 322 | exited = true; |
| 323 | }); |
| 324 | return { |
| 325 | child, |
| 326 | handle: { |
| 327 | exited: () => exited, |
| 328 | output: () => output, |
| 329 | } satisfies VinextDevChild, |
| 330 | }; |
| 331 | }, |
| 332 | catch: failFramework( |
| 333 | "Failed to spawn the vinext dev CLI (is `node` on PATH?)", |
| 334 | ), |
| 335 | }), |
| 336 | ({ child }) => |
| 337 | Effect.callback<void>((resume) => { |
| 338 | if (child.exitCode !== null) { |
| 339 | resume(Effect.void); |
| 340 | return; |
| 341 | } |
| 342 | const killTimer = setTimeout(() => child.kill("SIGKILL"), 3000); |
| 343 | child.once("exit", () => { |
| 344 | clearTimeout(killTimer); |
| 345 | resume(Effect.void); |
| 346 | }); |
| 347 | child.kill("SIGTERM"); |
| 348 | }), |
| 349 | ).pipe(Effect.map(({ handle }) => handle)); |
| 350 | |
| 351 | /** |
| 352 | * Poll until the allocated port accepts a TCP connection. vinext/Vite can |
| 353 | * bind before the first App Router compile finishes, so an HTTP GET with |
| 354 | * a short abort restarts that compile and never converges. |
| 355 | */ |
| 356 | export const awaitVinextDevReady = (options: { |
| 357 | readonly url: string; |
| 358 | readonly child: VinextDevChild; |
| 359 | }): Effect.Effect<void, FrameworkCore.FrameworkError> => |
| 360 | Effect.gen(function* () { |
| 361 | const parsed = yield* Effect.try({ |
| 362 | try: () => new URL(options.url), |
| 363 | catch: failFramework(`Invalid vinext dev URL: ${options.url}`), |
| 364 | }); |
| 365 | const port = Number(parsed.port); |
| 366 | const hostname = parsed.hostname; |
| 367 | for (let attempt = 0; attempt < 240; attempt++) { |
| 368 | if (options.child.exited()) { |
| 369 | return yield* Effect.fail( |
| 370 | failFramework( |
| 371 | `The vinext dev CLI exited before becoming ready:\n${options.child.output().slice(-4000)}`, |
| 372 | )(undefined), |
| 373 | ); |
| 374 | } |
| 375 | const ready = yield* Effect.callback<boolean>((resume) => { |
| 376 | const net = createRequire(import.meta.url)("net") as typeof NodeNet; |
| 377 | let settled = false; |
| 378 | const finish = (value: boolean) => { |
| 379 | if (settled) return; |
| 380 | settled = true; |
| 381 | resume(Effect.succeed(value)); |
| 382 | }; |
| 383 | const socket = net.connect({ host: hostname, port }, () => { |
| 384 | socket.destroy(); |
| 385 | finish(true); |
| 386 | }); |
| 387 | socket.setTimeout(2000, () => { |
| 388 | socket.destroy(); |
| 389 | finish(false); |
| 390 | }); |
| 391 | socket.once("error", () => { |
| 392 | socket.destroy(); |
| 393 | finish(false); |
| 394 | }); |
| 395 | return Effect.sync(() => { |
| 396 | settled = true; |
| 397 | socket.destroy(); |
| 398 | }); |
| 399 | }); |
| 400 | if (ready) return; |
| 401 | yield* Effect.sleep(500); |
| 402 | } |
| 403 | return yield* Effect.fail( |
| 404 | failFramework( |
| 405 | `Timed out waiting for the vinext dev server at ${options.url}`, |
| 406 | )(undefined), |
| 407 | ); |
| 408 | }); |
| 409 | |
| 410 | export const pickEphemeralPort: Effect.Effect< |
| 411 | number, |
| 412 | FrameworkCore.FrameworkError |
| 413 | > = findEphemeralPort(); |