1import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import { pathToFileURL } from "node:url";
6import {
7 loadProjectModule,
8 resolveProjectPackageDirectory,
9 type ModuleLoadError,
10} from "../core/Loader.ts";
11import {
12 loadVinextBuildConfig,
13 type VinextBuildConfig,
14} from "./BuildConfig.ts";
15
16export type VinextPrerenderResult = {
17 readonly ran: boolean;
18 /** Absolute path of the RSC worker entry after path-table injection. */
19 readonly entryPath?: string;
20};
21
22const PATH_TABLE_START = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */";
23const PATH_TABLE_END = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */";
24
25const exists = (fs: FileSystem.FileSystem, filePath: string) =>
26 fs.exists(filePath).pipe(Effect.orElseSucceed(() => false));
27
28/**
29 * The KV data-cache adapter throws during local prerender (no Worker
30 * `env`) and vinext may log a wrangler.jsonc snippet. That hint is
31 * wrong on the Alchemy path — drop those lines so deploy logs do not
32 * look like a missing Wrangler file.
33 */
34const isWranglerKvHint = (text: string) =>
35 text.includes("failed to initialize the configured data cache adapter") ||
36 text.includes("Add it to wrangler.jsonc") ||
37 (text.includes("KV data cache adapter requires") &&
38 text.includes("KV namespace binding")) ||
39 text.includes('"kv_namespaces"');
40
41const withLocalPrerenderLogs = <A, E, R>(
42 effect: Effect.Effect<A, E, R>,
43): Effect.Effect<A, E, R> =>
44 Effect.acquireUseRelease(
45 Effect.sync(() => {
46 const original = {
47 log: console.log,
48 warn: console.warn,
49 error: console.error,
50 info: console.info,
51 };
52 const filter =
53 (write: typeof console.log) =>
54 (...args: Array<unknown>) => {
55 const text = args.map((arg) => String(arg)).join(" ");
56 if (isWranglerKvHint(text)) return;
57 write(...args);
58 };
59 console.log = filter(original.log);
60 console.warn = filter(original.warn);
61 console.error = filter(original.error);
62 console.info = filter(original.info);
63 return original;
64 }),
65 () => effect,
66 (original) =>
67 Effect.sync(() => {
68 console.log = original.log;
69 console.warn = original.warn;
70 console.error = original.error;
71 console.info = original.info;
72 }),
73 );
74
75/**
76 * After `vite build`, run vinext's prerender phase when the project
77 * configured `vinext({ prerender: true })` (or `{ routes: "*" }`).
78 *
79 * Alchemy's Website Vite path only runs `vite build`, which vinext does
80 * not prerender by itself — that step lives in `vinext build`. This hook
81 * is not a reimplementation: it resolves the project's `vinext` and calls
82 * `runPrerender` / `emitPrerenderPathManifest`. The path-table injection
83 * is the only Alchemy-owned glue.
84 */
85export const runVinextPrerenderIfConfigured = Effect.fn(function* (
86 rootDir: string,
87 cache: "kv" | "redis" | "s3" = "kv",
88 buildConfig?: VinextBuildConfig,
89) {
90 const path = yield* Path.Path;
91 const root = path.resolve(rootDir);
92 const vinextRoot = yield* resolveVinextRoot(root);
93
94 const importVinextDist = (rel: string) =>
95 Effect.promise(
96 () => import(pathToFileURL(path.join(vinextRoot, rel)).href),
97 );
98
99 const { resolveVinextPrerenderDecision, formatVinextPrerenderLabel } =
100 yield* importVinextDist("dist/config/prerender.js");
101 const config =
102 buildConfig ??
103 (yield* Effect.gen(function* () {
104 const vite = yield* loadProjectModule<typeof import("vite")>(
105 root,
106 "vite",
107 );
108 const loaded = yield* Effect.tryPromise(() =>
109 vite.loadConfigFromFile(
110 { command: "build", mode: "production" },
111 undefined,
112 root,
113 ),
114 );
115 return yield* loadVinextBuildConfig(root, loaded?.config.plugins);
116 }));
117 const decision = resolveVinextPrerenderDecision({
118 vinextPrerenderConfig: config.prerenderConfig,
119 nextOutput: config.nextConfig.output,
120 });
121 if (!decision) {
122 return { ran: false } satisfies VinextPrerenderResult;
123 }
124
125 yield* Console.log(` ${formatVinextPrerenderLabel(decision)}`);
126 if (cache === "kv") {
127 yield* Console.log(
128 " Local prerender has no Worker bindings. VINEXT_KV_CACHE is provisioned and seeded on deploy; do not add wrangler.jsonc.",
129 );
130 }
131
132 const { runPrerender, assertNoFatalPrerenderRoutes } =
133 (yield* importVinextDist("dist/build/run-prerender.js")) as {
134 runPrerender: (options: {
135 root: string;
136 nextConfig: unknown;
137 routeRootConfig: unknown;
138 }) => Promise<{ routes?: readonly unknown[] } | null>;
139 assertNoFatalPrerenderRoutes: (routes: readonly unknown[]) => void;
140 };
141 const prerenderResult = yield* withLocalPrerenderLogs(
142 Effect.promise(() =>
143 runPrerender({
144 root,
145 nextConfig: config.nextConfig,
146 routeRootConfig: config.routeRootConfig,
147 }),
148 ),
149 );
150 if (prerenderResult?.routes) {
151 assertNoFatalPrerenderRoutes(prerenderResult.routes);
152 }
153
154 const { emitPrerenderPathManifest } = yield* importVinextDist(
155 "dist/build/prerender-paths.js",
156 );
157 yield* Effect.promise(() =>
158 emitPrerenderPathManifest({
159 root,
160 nextConfig: config.nextConfig,
161 routeRootConfig: config.routeRootConfig,
162 buildIdentity: config.buildIdentity,
163 responseVary: config.responseVary,
164 requestRouting: config.requestRouting,
165 isResponsePolicyHeader: config.isResponsePolicyHeader,
166 }),
167 );
168 yield* injectPregeneratedConcretePaths(root);
169
170 const fs = yield* FileSystem.FileSystem;
171 const entryPath = path.join(root, "dist", "server", "index.js");
172 return {
173 ran: true,
174 entryPath: (yield* exists(fs, entryPath)) ? entryPath : undefined,
175 } satisfies VinextPrerenderResult;
176});
177
178/** Absolute path to the project's `vinext` package root. */
179export const resolveVinextRoot = (
180 root: string,
181): Effect.Effect<string, ModuleLoadError> =>
182 resolveProjectPackageDirectory(root, "vinext");
183
184const injectPregeneratedConcretePaths = (root: string) =>
185 Effect.gen(function* () {
186 const path = yield* Path.Path;
187 const fs = yield* FileSystem.FileSystem;
188 const workerEntry = path.join(root, "dist", "server", "index.js");
189 if (!(yield* exists(fs, workerEntry))) {
190 return;
191 }
192
193 const escapedStart = PATH_TABLE_START.replace(
194 /[.*+?^${}()|[\]\\]/g,
195 "\\$&",
196 );
197 const escapedEnd = PATH_TABLE_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
198 const re = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}\\n?`, "g");
199
200 let code = (yield* fs.readFileString(workerEntry)).replace(re, "");
201 const manifestPath = path.join(
202 root,
203 "dist",
204 "server",
205 "vinext-prerender.json",
206 );
207 if (!(yield* exists(fs, manifestPath))) {
208 yield* fs.writeFileString(workerEntry, code);
209 return;
210 }
211
212 const manifest = JSON.parse(yield* fs.readFileString(manifestPath)) as {
213 pregeneratedConcretePaths?: unknown;
214 };
215 const table = manifest.pregeneratedConcretePaths ?? [];
216 if (Array.isArray(table) && table.length > 0) {
217 code =
218 `${PATH_TABLE_START}\n` +
219 `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = ${JSON.stringify(table)};\n` +
220 `${PATH_TABLE_END}\n` +
221 code;
222 }
223 yield* fs.writeFileString(workerEntry, code);
224 });