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 */
6import * as FrameworkCore from "../core/index.ts";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import type * as Scope from "effect/Scope";
11import type { Plugin, PluginOption } from "vite";
12import { createRequire } from "node:module";
13import { randomBytes } from "node:crypto";
14import { loadVinextBuildConfig } from "./BuildConfig.ts";
15import type * as NodeChildProcessModule from "node:child_process";
16import type * as NodeNet from "node:net";
17import { findEphemeralPort } from "../core/DevPort.ts";
18import {
19 loadProjectModule,
20 resolveProjectPackageDirectory,
21} from "../core/Loader.ts";
22import { makeVinextCachePlugin, type VinextCacheKind } from "./cache/plugin.ts";
23import { runVinextPrerenderIfConfigured } from "./Prerender.ts";
24import { loadVinextModule } from "./Modules.ts";
25import { toOutputFile, type BuildOutput } from "../core/index.ts";
26
27export 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`. */
31export const VINEXT_RSC_ENTRY = "server/index.js";
32
33/** Pages Router server entry vinext writes under `dist/server`. */
34export const VINEXT_PAGES_ENTRY = "server/entry.js";
35
36export 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
46export 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
198export 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
250export 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
280export interface VinextDevChild {
281 readonly exited: () => boolean;
282 readonly output: () => string;
283}
284
285export 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 */
356export 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
410export const pickEphemeralPort: Effect.Effect<
411 number,
412 FrameworkCore.FrameworkError
413> = findEphemeralPort();