1/**
2 * Seed a persistent vinext data-cache store from `dist/server` prerender
3 * artifacts.
4 *
5 * One write path for every platform: load pairs, then `sink.putText`.
6 *
7 * - Cloudflare: `Website.Vinext` build calls {@link seedPrerenderTo} with
8 * {@link kvHttpSink} (distilled KV HTTP, deploy machine).
9 * - Node / Lambda: the Redis / S3 runtime calls
10 * {@link seedStoreFromPrerenderLogged} on first cache op (artifacts
11 * ship in the image).
12 */
13import { existsSync } from "node:fs";
14import { createRequire } from "node:module";
15import * as nodePath from "node:path";
16import * as Console from "effect/Console";
17import * as Effect from "effect/Effect";
18import {
19 buildVinextPrerenderKVPairs,
20 type VinextPrerenderKVPair,
21} from "../PrerenderCache.ts";
22import type { DataCacheStore } from "./handler.ts";
23
24export interface SeedSink<E = never, R = never> {
25 readonly putText: (
26 key: string,
27 value: string,
28 ttlMs?: number,
29 ) => Effect.Effect<unknown, E, R>;
30}
31
32export type LoadedPrerenderPairs = {
33 readonly pairs: ReadonlyArray<VinextPrerenderKVPair>;
34 readonly routeCount: number;
35 readonly warnings: ReadonlyArray<string>;
36};
37
38const resolveVinextRoot = (cwd: string): string | undefined => {
39 const projectRequire = createRequire(nodePath.join(cwd, "package.json"));
40 // vinext does not export package.json; use Node's package search directories.
41 for (const directory of projectRequire.resolve.paths("vinext") ?? []) {
42 const root = nodePath.join(directory, "vinext");
43 if (existsSync(nodePath.join(root, "package.json"))) return root;
44 }
45 return undefined;
46};
47
48const resolveServerDir = (cwd: string): string | undefined => {
49 const fromEnv = process.env.VINEXT_SERVER_DIR;
50 if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
51 const candidates = [
52 nodePath.join(cwd, "dist", "server"),
53 nodePath.join(cwd, "server"),
54 cwd,
55 ];
56 for (const dir of candidates) {
57 if (existsSync(nodePath.join(dir, "vinext-prerender.json"))) return dir;
58 }
59 return undefined;
60};
61
62const ttlMsOf = (pair: VinextPrerenderKVPair): number | undefined =>
63 pair.expirationTtl !== undefined && pair.expirationTtl > 0
64 ? pair.expirationTtl * 1000
65 : undefined;
66
67export const loadPrerenderPairs = async (
68 cwd: string,
69): Promise<LoadedPrerenderPairs> => {
70 const vinextRoot = resolveVinextRoot(cwd);
71 const serverDir = resolveServerDir(cwd);
72 if (vinextRoot === undefined || serverDir === undefined) {
73 return { pairs: [], routeCount: 0, warnings: [] };
74 }
75 return buildVinextPrerenderKVPairs(vinextRoot, serverDir);
76};
77
78/** Platform-agnostic seed: pairs → `sink.putText`. */
79export const seedPrerenderTo = <E, R>(
80 sink: SeedSink<E, R>,
81 options: { readonly rootDir: string; readonly label: string },
82): Effect.Effect<
83 { readonly count: number; readonly routeCount: number },
84 E,
85 R
86> =>
87 Effect.gen(function* () {
88 const loaded = yield* Effect.promise(() =>
89 loadPrerenderPairs(options.rootDir),
90 );
91 for (const warning of loaded.warnings) yield* Console.warn(warning);
92 if (loaded.pairs.length === 0) {
93 return { count: 0, routeCount: loaded.routeCount };
94 }
95 yield* Effect.forEach(
96 loaded.pairs,
97 (pair) => sink.putText(pair.key, pair.value, ttlMsOf(pair)),
98 { concurrency: 8 },
99 );
100 yield* Console.log(
101 `[vinext] seeded ${loaded.pairs.length} prerender cache ${
102 loaded.pairs.length === 1 ? "entry" : "entries"
103 } into ${options.label} (${loaded.routeCount} route${
104 loaded.routeCount === 1 ? "" : "s"
105 })`,
106 );
107 return { count: loaded.pairs.length, routeCount: loaded.routeCount };
108 });
109
110const promiseSink = (store: DataCacheStore): SeedSink => ({
111 putText: (key, value, ttlMs) =>
112 Effect.promise(() => store.getText(key)).pipe(
113 Effect.flatMap((existing) =>
114 existing === undefined
115 ? Effect.promise(() => store.putText(key, value, ttlMs))
116 : Effect.void,
117 ),
118 ),
119});
120
121export const seedStoreFromPrerender = async (
122 store: DataCacheStore,
123 cwd = process.cwd(),
124): Promise<number> => {
125 const { count } = await Effect.runPromise(
126 seedPrerenderTo(promiseSink(store), { rootDir: cwd, label: "store" }),
127 );
128 return count;
129};
130
131/** Await seed and log how many entries landed. Never throws. */
132export const seedStoreFromPrerenderLogged = async (
133 store: DataCacheStore,
134 label: string,
135 cwd = process.cwd(),
136): Promise<number> => {
137 try {
138 const { count } = await Effect.runPromise(
139 seedPrerenderTo(promiseSink(store), { rootDir: cwd, label }),
140 );
141 return count;
142 } catch {
143 return 0;
144 }
145};
146
147export const _resolveForTests = { resolveVinextRoot, resolveServerDir };