1import { describe, expect, it } from "vitest";
2import { makeDataCacheHandler, type DataCacheStore } from "../cache/handler.ts";
3import { makeVinextCachePlugin } from "../cache/plugin.ts";
4import { fileURLToPath } from "node:url";
5import { loadProjectModule } from "../../core/Loader.ts";
6import { kvAdapter } from "../cache/kv.ts";
7import createKvDataCacheAdapter from "../cache/kv-runtime.ts";
8import { redisAdapter } from "../cache/redis.ts";
9import { s3Adapter } from "../cache/s3.ts";
10import { kvHttpNamespaceFromEnv } from "../cache/kv-http.ts";
11import { seedPrerenderTo, type SeedSink } from "../cache/seed.ts";
12import {
13 keySpace,
14 readEnvString,
15 restoreArrayBuffers,
16 serializeForJSON,
17 validateCacheEntry,
18 validateTag,
19} from "../cache/shared.ts";
20import * as Effect from "effect/Effect";
21
22const memoryStore = (): DataCacheStore & { data: Map<string, string> } => {
23 const data = new Map<string, string>();
24 return {
25 data,
26 async getText(key) {
27 return data.get(key);
28 },
29 async putText(key, value) {
30 data.set(key, value);
31 },
32 async delete(key) {
33 data.delete(key);
34 },
35 };
36};
37
38describe("vinext cache adapters", () => {
39 it.each(["redis", "s3", "kv"] as const)(
40 "injects the %s cache without application configuration",
41 async (kind) => {
42 const root = fileURLToPath(
43 new URL(
44 "../../../../../examples/prisma-website-vinext/",
45 import.meta.url,
46 ),
47 );
48 const plugin = await Effect.runPromise(makeVinextCachePlugin(root, kind));
49 const vite = await Effect.runPromise(
50 loadProjectModule<typeof import("vite")>(root, "vite"),
51 );
52 const server = await vite.createServer({
53 root,
54 configFile: false,
55 plugins: [plugin],
56 server: { watch: null },
57 optimizeDeps: { noDiscovery: true },
58 });
59 try {
60 const container = server.environments.ssr.pluginContainer;
61 const id = await container.resolveId("virtual:vinext-cache-adapters");
62 expect(await container.load(id!.id)).toContain(`${kind}-runtime.js`);
63 const cdnId = await container.resolveId(
64 "virtual:vinext-cdn-cache-adapter",
65 );
66 const cdn = await container.load(cdnId!.id);
67 expect(cdn).toContain("hasConfiguredDataCache = true");
68 expect(cdn).not.toContain(`${kind}-runtime.js`);
69 } finally {
70 await server.close();
71 }
72 },
73 );
74
75 it("redisAdapter points at alchemy/Redis connect runtime", () => {
76 const descriptor = redisAdapter({
77 urlEnv: "REDIS_URL",
78 appPrefix: "app",
79 });
80 expect(descriptor.adapter).toMatch(/redis-runtime\.js$/);
81 expect(descriptor.options).toEqual({
82 urlEnv: "REDIS_URL",
83 appPrefix: "app",
84 });
85 });
86
87 it("s3Adapter points at the runtime factory", () => {
88 const descriptor = s3Adapter({ bucketEnv: "CACHE_BUCKET_NAME" });
89 expect(descriptor.adapter).toMatch(/s3-runtime\.js$/);
90 expect(descriptor.options?.bucketEnv).toBe("CACHE_BUCKET_NAME");
91 });
92
93 it("kvAdapter points at the runtime factory", () => {
94 const descriptor = kvAdapter({ binding: "VINEXT_KV_CACHE" });
95 expect(descriptor.adapter).toMatch(/kv-runtime\.js$/);
96 expect(descriptor.options?.binding).toBe("VINEXT_KV_CACHE");
97 });
98
99 it("KV factory throws without a binding and round-trips through a mock namespace", async () => {
100 expect(() => createKvDataCacheAdapter({ env: {} })).toThrow(
101 /VINEXT_KV_CACHE/,
102 );
103 const data = new Map<string, string>();
104 const ns = {
105 async get(key: string) {
106 return data.get(key) ?? null;
107 },
108 async put(key: string, value: string) {
109 data.set(key, value);
110 },
111 async delete(key: string) {
112 data.delete(key);
113 },
114 };
115 const handler = createKvDataCacheAdapter({
116 env: { VINEXT_KV_CACHE: ns },
117 });
118 await handler.set("page", {
119 kind: "APP_PAGE",
120 html: "<p>kv</p>",
121 tags: ["posts"],
122 revalidate: 60,
123 });
124 const hit = await handler.get("page");
125 expect((hit?.value as { html?: string })?.html).toBe("<p>kv</p>");
126 expect(data.size).toBeGreaterThan(0);
127 });
128
129 it("round-trips APP_PAGE ArrayBuffers through JSON", () => {
130 const bytes = new Uint8Array([1, 2, 3, 4]).buffer;
131 const serialized = serializeForJSON({
132 kind: "APP_PAGE",
133 html: "<p>ok</p>",
134 rscData: bytes,
135 }) as Record<string, unknown>;
136 expect(typeof serialized.rscData).toBe("string");
137 const restored = restoreArrayBuffers(serialized);
138 expect(restored?.rscData).toBeInstanceOf(ArrayBuffer);
139 expect([...new Uint8Array(restored!.rscData as ArrayBuffer)]).toEqual([
140 1, 2, 3, 4,
141 ]);
142 });
143
144 it("rejects invalid tags and cache entries", () => {
145 expect(validateTag("")).toBeNull();
146 expect(validateTag("ok")).toBe("ok");
147 expect(validateTag("a:b")).toBeNull();
148 expect(validateCacheEntry({ lastModified: 1, tags: [] })).toBeNull();
149 expect(
150 validateCacheEntry({
151 lastModified: 1,
152 tags: [],
153 revalidateAt: null,
154 expireAt: null,
155 value: null,
156 }),
157 ).not.toBeNull();
158 });
159
160 it("namespaces entry and tag keys", () => {
161 const keys = keySpace("site");
162 expect(keys.entryKey("abc")).toBe("site:cache:abc");
163 expect(keys.tagKey("posts")).toBe("site:__tag:posts");
164 });
165
166 it("hashes keys that exceed the KV byte limit", () => {
167 const keys = keySpace(undefined);
168 expect(keys.entryKey("abc")).toBe("cache:abc");
169 const hashed = keys.entryKey("x".repeat(600));
170 expect(hashed.startsWith("cache:__hash:")).toBe(true);
171 expect(hashed.length).toBeLessThan(40);
172 });
173
174 it("rejects a non-string redis urlEnv", () => {
175 expect(() => redisAdapter({ urlEnv: 1 as unknown as string })).toThrow(
176 /urlEnv/,
177 );
178 });
179
180 it("reads env strings from the bag then process.env", () => {
181 expect(readEnvString({ REDIS_URL: "redis://bag" }, "REDIS_URL")).toBe(
182 "redis://bag",
183 );
184 expect(readEnvString({ REDIS_URL: "" }, "REDIS_URL")).toBe(
185 process.env.REDIS_URL || undefined,
186 );
187 expect(readEnvString(undefined, "VINEXT_MISSING_ENV_KEY")).toBeUndefined();
188 });
189
190 it("round-trips a cache entry through the shared handler", async () => {
191 const store = memoryStore();
192 const handler = makeDataCacheHandler(store, { appPrefix: "site" });
193 await handler.set("page", {
194 kind: "APP_PAGE",
195 html: "<p>ok</p>",
196 tags: ["posts"],
197 revalidate: 60,
198 });
199 const hit = await handler.get("page");
200 expect(hit).not.toBeNull();
201 expect((hit?.value as { html?: string })?.html).toBe("<p>ok</p>");
202 expect(store.data.has("site:cache:page")).toBe(true);
203 });
204
205 it("treats revalidateTag as a miss for tagged entries", async () => {
206 const store = memoryStore();
207 const handler = makeDataCacheHandler(store);
208 await handler.set("page", {
209 kind: "APP_PAGE",
210 html: "<p>old</p>",
211 tags: ["posts"],
212 revalidate: 60,
213 });
214 await handler.revalidateTag("posts");
215 expect(await handler.get("page")).toBeNull();
216 });
217
218 it("kvHttpNamespaceFromEnv reads accountId + namespaceId", () => {
219 expect(kvHttpNamespaceFromEnv({})).toBeUndefined();
220 expect(
221 kvHttpNamespaceFromEnv({
222 VINEXT_KV_CACHE: { namespaceId: "ns" },
223 }),
224 ).toBeUndefined();
225 expect(
226 kvHttpNamespaceFromEnv({
227 VINEXT_KV_CACHE: { namespaceId: "ns", accountId: "acct" },
228 }),
229 ).toEqual({ namespaceId: "ns", accountId: "acct" });
230 });
231
232 it("seedPrerenderTo writes through putText regardless of backend", async () => {
233 const written: Array<{ key: string; value: string; ttlMs?: number }> = [];
234 const sink: SeedSink = {
235 putText: (key, value, ttlMs) =>
236 Effect.sync(() => {
237 written.push({ key, value, ttlMs });
238 }),
239 };
240 const result = await Effect.runPromise(
241 seedPrerenderTo(sink, {
242 rootDir: "/tmp/vinext-cache-missing",
243 label: "memory",
244 }),
245 );
246 expect(result).toEqual({ count: 0, routeCount: 0 });
247 expect(written).toEqual([]);
248 });
249});