| 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 | */ |
| 13 | import { existsSync } from "node:fs"; |
| 14 | import { createRequire } from "node:module"; |
| 15 | import * as nodePath from "node:path"; |
| 16 | import * as Console from "effect/Console"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import { |
| 19 | buildVinextPrerenderKVPairs, |
| 20 | type VinextPrerenderKVPair, |
| 21 | } from "../PrerenderCache.ts"; |
| 22 | import type { DataCacheStore } from "./handler.ts"; |
| 23 | |
| 24 | export 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 | |
| 32 | export type LoadedPrerenderPairs = { |
| 33 | readonly pairs: ReadonlyArray<VinextPrerenderKVPair>; |
| 34 | readonly routeCount: number; |
| 35 | readonly warnings: ReadonlyArray<string>; |
| 36 | }; |
| 37 | |
| 38 | const 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 | |
| 48 | const 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 | |
| 62 | const ttlMsOf = (pair: VinextPrerenderKVPair): number | undefined => |
| 63 | pair.expirationTtl !== undefined && pair.expirationTtl > 0 |
| 64 | ? pair.expirationTtl * 1000 |
| 65 | : undefined; |
| 66 | |
| 67 | export 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`. */ |
| 79 | export 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 | |
| 110 | const 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 | |
| 121 | export 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. */ |
| 132 | export 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 | |
| 147 | export const _resolveForTests = { resolveVinextRoot, resolveServerDir }; |