1/**
2 * vinext data-cache adapter backed by Redis. Default export is the
3 * factory `virtual:vinext-cache-adapters` calls as
4 * `({ env, options }) => CacheHandler`.
5 *
6 * Speaks RESP through `connect` from `alchemy/Redis` — the same client
7 * Fly/Railway `*RedisHttp` layers use. One process-lifetime connection
8 * per URL. Prerender artifacts are seeded into Redis in the background.
9 */
10import { connect, type Connection } from "alchemy/Redis";
11import type { Arg, Reply } from "alchemy/Redis";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Scope from "effect/Scope";
15import { makeDataCacheHandler, type DataCacheStore } from "./handler.ts";
16import { readEnvString } from "./shared.ts";
17import { seedStoreFromPrerenderLogged } from "./seed.ts";
18
19export interface RedisAdapterOptions extends Record<string, unknown> {
20 /** Env var holding the Redis URL. @default "REDIS_URL" */
21 readonly urlEnv?: string;
22 readonly appPrefix?: string;
23 /** Default key TTL in seconds. @default 2592000 (30 days) */
24 readonly ttlSeconds?: number;
25 readonly tagCacheTtlMs?: number;
26}
27
28const DEFAULT_URL_ENV = "REDIS_URL";
29
30const asBulk = (reply: Reply): string | null =>
31 typeof reply === "string" ? reply : null;
32
33const send = (
34 connection: Connection,
35 command: string,
36 args: readonly Arg[] = [],
37): Promise<Reply> => Effect.runPromise(connection.send(command, args));
38
39const connections = new Map<string, Promise<Connection>>();
40
41const open = (url: string): Promise<Connection> => {
42 const existing = connections.get(url);
43 if (existing) return existing;
44 const scope = Scope.makeUnsafe();
45 const created = Effect.runPromise(
46 connect(url).pipe(Effect.provide(Layer.succeed(Scope.Scope, scope))),
47 );
48 connections.set(url, created);
49 created.catch(() => connections.delete(url));
50 return created;
51};
52
53const redisStore = (connection: Connection): DataCacheStore => ({
54 async getText(key) {
55 return asBulk(await send(connection, "GET", [key])) ?? undefined;
56 },
57 async putText(key, value, ttlMs) {
58 const args: Arg[] =
59 ttlMs !== undefined && ttlMs > 0
60 ? [key, value, "PX", ttlMs]
61 : [key, value];
62 await send(connection, "SET", args);
63 },
64 async delete(key) {
65 await send(connection, "DEL", [key]);
66 },
67});
68
69const createRedisDataCacheAdapter = ({
70 env,
71 options,
72}: {
73 env?: Record<string, unknown>;
74 options?: RedisAdapterOptions;
75}) => {
76 const urlEnv = options?.urlEnv ?? DEFAULT_URL_ENV;
77 const url = readEnvString(env, urlEnv);
78 if (url === undefined) {
79 throw new Error(
80 `[vinext] The Redis data cache adapter requires \`${urlEnv}\` in the process environment.`,
81 );
82 }
83 let handler: ReturnType<typeof makeDataCacheHandler> | undefined;
84 const ready = open(url).then(async (connection) => {
85 const store = redisStore(connection);
86 await seedStoreFromPrerenderLogged(store, "Redis");
87 handler = makeDataCacheHandler(store, options);
88 return handler;
89 });
90 return {
91 async get(key: string, ctx?: Record<string, unknown>) {
92 return (handler ?? (await ready)).get(key, ctx);
93 },
94 async set(
95 key: string,
96 data: Record<string, unknown> | null,
97 ctx?: Record<string, unknown>,
98 ) {
99 return (handler ?? (await ready)).set(key, data, ctx);
100 },
101 async revalidateTag(tags: string | Array<string>) {
102 return (handler ?? (await ready)).revalidateTag(tags);
103 },
104 resetRequestCache() {
105 handler?.resetRequestCache();
106 },
107 };
108};
109
110export default createRedisDataCacheAdapter;