| 1 | import * as Console from "effect/Console"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import { pathToFileURL } from "node:url"; |
| 6 | import { |
| 7 | loadProjectModule, |
| 8 | resolveProjectPackageDirectory, |
| 9 | type ModuleLoadError, |
| 10 | } from "../core/Loader.ts"; |
| 11 | import { |
| 12 | loadVinextBuildConfig, |
| 13 | type VinextBuildConfig, |
| 14 | } from "./BuildConfig.ts"; |
| 15 | |
| 16 | export type VinextPrerenderResult = { |
| 17 | readonly ran: boolean; |
| 18 | /** Absolute path of the RSC worker entry after path-table injection. */ |
| 19 | readonly entryPath?: string; |
| 20 | }; |
| 21 | |
| 22 | const PATH_TABLE_START = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */"; |
| 23 | const PATH_TABLE_END = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */"; |
| 24 | |
| 25 | const 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 | */ |
| 34 | const 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 | |
| 41 | const 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 | */ |
| 85 | export 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. */ |
| 179 | export const resolveVinextRoot = ( |
| 180 | root: string, |
| 181 | ): Effect.Effect<string, ModuleLoadError> => |
| 182 | resolveProjectPackageDirectory(root, "vinext"); |
| 183 | |
| 184 | const 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 | }); |