mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
bcd4e44d96
* feat(build): support prerender vite config * feat(init): offer prerender config opt-in * fix(cloudflare): export prerender deploy helper * fix(cloudflare): avoid redundant prerender config load * fix(build): quiet adapter fallback warnings
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
/**
|
|
* Config-driven cache adapter tests.
|
|
*
|
|
* Covers:
|
|
* - generateCacheAdaptersModule() codegen for the `virtual:vinext-cache-adapters`
|
|
* module across the no-config / data-only / cdn-only / both permutations,
|
|
* including inlined descriptor options.
|
|
* - The Cloudflare adapter modules: their config-time builders (kvDataAdapter,
|
|
* cdnAdapter) and their runtime factory default exports.
|
|
*/
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, it, expect } from "vite-plus/test";
|
|
import {
|
|
generateCacheAdaptersModule,
|
|
VIRTUAL_CACHE_ADAPTERS,
|
|
} from "../packages/vinext/src/cache/cache-adapters-virtual.js";
|
|
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
|
|
import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js";
|
|
import { readPagesRouterEntrySource } from "./worker-entry-source.js";
|
|
import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js";
|
|
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";
|
|
import { kvDataAdapter } from "../packages/cloudflare/src/cache/kv-data-adapter.js";
|
|
import { cdnAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.js";
|
|
import createKvDataCacheAdapter, {
|
|
KVCacheHandler,
|
|
} from "../packages/cloudflare/src/cache/kv-data-adapter.runtime.js";
|
|
import createCloudflareCdnCacheAdapter, {
|
|
CloudflareCdnCacheAdapter,
|
|
} from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
|
|
|
|
describe("generateCacheAdaptersModule", () => {
|
|
it("exposes the public virtual module id", () => {
|
|
expect(VIRTUAL_CACHE_ADAPTERS).toBe("virtual:vinext-cache-adapters");
|
|
});
|
|
|
|
it("emits a no-op registrar when no adapters are configured", () => {
|
|
for (const cache of [undefined, {}, { cdn: undefined, data: undefined }]) {
|
|
const code = generateCacheAdaptersModule(cache);
|
|
expect(code).toContain("export function registerConfiguredCacheAdapters() {}");
|
|
expect(code).not.toContain("import");
|
|
expect(code).not.toContain("setDataCacheHandler");
|
|
expect(code).not.toContain("setCdnCacheAdapter");
|
|
}
|
|
});
|
|
|
|
it("wires only the data adapter when only data is configured", () => {
|
|
const code = generateCacheAdaptersModule({ data: { adapter: "my-data-adapter" } });
|
|
expect(code).toContain(`import __vinextDataAdapterFactory from "my-data-adapter";`);
|
|
expect(code).toContain(`import { setDataCacheHandler } from "vinext/shims/cache-handler";`);
|
|
expect(code).toContain(
|
|
"setDataCacheHandler(__vinextDataAdapterFactory({ env, options: undefined }));",
|
|
);
|
|
expect(code).not.toContain("__vinextCdnAdapterFactory");
|
|
expect(code).not.toContain("setCdnCacheAdapter");
|
|
});
|
|
|
|
it("wires only the cdn adapter when only cdn is configured", () => {
|
|
const code = generateCacheAdaptersModule({ cdn: { adapter: "my-cdn-adapter" } });
|
|
expect(code).toContain(`import __vinextCdnAdapterFactory from "my-cdn-adapter";`);
|
|
expect(code).toContain(`import { setCdnCacheAdapter } from "vinext/shims/cdn-cache";`);
|
|
expect(code).toContain(
|
|
"setCdnCacheAdapter(__vinextCdnAdapterFactory({ env, options: undefined }));",
|
|
);
|
|
expect(code).not.toContain("__vinextDataAdapterFactory");
|
|
expect(code).not.toContain("setDataCacheHandler");
|
|
});
|
|
|
|
it("inlines descriptor options and forwards them to the factory", () => {
|
|
const code = generateCacheAdaptersModule({
|
|
data: { adapter: "@vinext/cloudflare/cache/kv-data-adapter", options: { binding: "MY_KV" } },
|
|
});
|
|
expect(code).toContain(
|
|
`setDataCacheHandler(__vinextDataAdapterFactory({ env, options: {"binding":"MY_KV"} }));`,
|
|
);
|
|
});
|
|
|
|
it("wires both adapters and guards against double registration", () => {
|
|
const code = generateCacheAdaptersModule({
|
|
cdn: { adapter: "@vinext/cloudflare/cache/cdn-adapter" },
|
|
data: { adapter: "@vinext/cloudflare/cache/kv-data-adapter" },
|
|
});
|
|
expect(code).toContain(`from "@vinext/cloudflare/cache/cdn-adapter";`);
|
|
expect(code).toContain(`from "@vinext/cloudflare/cache/kv-data-adapter";`);
|
|
expect(code).toContain("setDataCacheHandler(__vinextDataAdapterFactory(");
|
|
expect(code).toContain("setCdnCacheAdapter(__vinextCdnAdapterFactory(");
|
|
expect(code).toContain("if (__vinextCacheAdaptersRegistered) return;");
|
|
expect(code).toContain("__vinextCacheAdaptersRegistered = true;");
|
|
});
|
|
|
|
it("logs registration failures without printing raw Error stack traces", () => {
|
|
const code = generateCacheAdaptersModule({
|
|
cdn: { adapter: "@vinext/cloudflare/cache/cdn-adapter" },
|
|
data: { adapter: "@vinext/cloudflare/cache/kv-data-adapter" },
|
|
});
|
|
expect(code).toContain("function __vinextFormatAdapterError(error)");
|
|
expect(code).toContain(
|
|
'console.warn("[vinext] failed to initialize the configured data cache adapter; ' +
|
|
'using the default handler.\\n" + __vinextFormatAdapterError(error));',
|
|
);
|
|
expect(code).toContain(
|
|
'console.warn("[vinext] failed to initialize the configured CDN cache adapter; ' +
|
|
'using the default adapter.\\n" + __vinextFormatAdapterError(error));',
|
|
);
|
|
expect(code).not.toContain('", error);');
|
|
});
|
|
|
|
it("escapes adapter specifiers so absolute paths are safe", () => {
|
|
// require.resolve() yields an absolute path which may contain characters
|
|
// that must not break the generated import statement.
|
|
const weird = `/tmp/some path/with"quote/adapter.js`;
|
|
const code = generateCacheAdaptersModule({ data: { adapter: weird } });
|
|
expect(code).toContain(`import __vinextDataAdapterFactory from ${JSON.stringify(weird)};`);
|
|
});
|
|
});
|
|
|
|
describe("kvDataAdapter builder", () => {
|
|
it("resolves the runtime factory to an absolute path without touching the Workers runtime", () => {
|
|
const descriptor = kvDataAdapter({ binding: "MY_KV", ttlSeconds: 60 });
|
|
// `adapter` is an absolute path to the sibling runtime module (require.resolve),
|
|
// NOT a bare specifier — so it resolves regardless of package export wiring.
|
|
expect(path.isAbsolute(descriptor.adapter)).toBe(true);
|
|
expect(descriptor.adapter.endsWith("kv-data-adapter.runtime.js")).toBe(true);
|
|
expect(descriptor.options).toEqual({ binding: "MY_KV", ttlSeconds: 60 });
|
|
expect(kvDataAdapter().options).toBeUndefined();
|
|
});
|
|
|
|
it("validates the binding option at config time", () => {
|
|
// @ts-expect-error — binding must be a string
|
|
expect(() => kvDataAdapter({ binding: 123 })).toThrow(/binding/);
|
|
});
|
|
});
|
|
|
|
describe("Cloudflare kv-data-adapter factory", () => {
|
|
const namespace = { get: async () => null, put: async () => {}, delete: async () => {} };
|
|
|
|
it("returns a KVCacheHandler bound to the default VINEXT_KV_CACHE namespace", () => {
|
|
const handler = createKvDataCacheAdapter({
|
|
env: { VINEXT_KV_CACHE: namespace },
|
|
options: undefined,
|
|
});
|
|
expect(handler).toBeInstanceOf(KVCacheHandler);
|
|
});
|
|
|
|
it("honors a custom binding name from descriptor options", () => {
|
|
const handler = createKvDataCacheAdapter({
|
|
env: { MY_KV: namespace },
|
|
options: { binding: "MY_KV" },
|
|
});
|
|
expect(handler).toBeInstanceOf(KVCacheHandler);
|
|
});
|
|
|
|
it("throws a helpful error when the configured binding is missing", () => {
|
|
expect(() => createKvDataCacheAdapter({ env: {}, options: undefined })).toThrow(
|
|
/VINEXT_KV_CACHE/,
|
|
);
|
|
expect(() =>
|
|
createKvDataCacheAdapter({ env: { OTHER: namespace }, options: { binding: "MY_KV" } }),
|
|
).toThrow(/`MY_KV` KV namespace binding/);
|
|
expect(() => createKvDataCacheAdapter({ env: undefined, options: undefined })).toThrow(
|
|
/KV namespace binding/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("registration is wired into every router/runtime entry", () => {
|
|
const minimalAppRoutes = [
|
|
{
|
|
pattern: "/",
|
|
patternParts: [],
|
|
pagePath: "/tmp/test/app/page.tsx",
|
|
routePath: null,
|
|
layouts: ["/tmp/test/app/layout.tsx"],
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: [null],
|
|
notFoundPath: null,
|
|
notFoundPaths: [null],
|
|
forbiddenPaths: [null],
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: [null],
|
|
unauthorizedPath: null,
|
|
routeSegments: [],
|
|
templateTreePositions: [],
|
|
layoutTreePositions: [0],
|
|
isDynamic: false,
|
|
params: [],
|
|
},
|
|
] as unknown as Parameters<typeof generateRscEntry>[1];
|
|
|
|
it("App Router RSC entry imports and passes the registrar to the shared handler", () => {
|
|
// The RSC handler is the single chokepoint for App Router on Workers, Node,
|
|
// and dev — wiring registration here covers all three.
|
|
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
|
|
expect(code).toContain('from "virtual:vinext-cache-adapters"');
|
|
expect(code).toContain("registerCacheAdapters: __registerConfiguredCacheAdapters");
|
|
});
|
|
|
|
it("Pages Router server entry registers in renderPage and handleApiRoute", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cache-pages-entry-"));
|
|
try {
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
fs.mkdirSync(pagesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pagesDir, "index.tsx"),
|
|
"export default function Page() { return null; }",
|
|
);
|
|
const code = await generateServerEntry(
|
|
pagesDir,
|
|
await resolveNextConfig({}),
|
|
createValidFileMatcher(),
|
|
null,
|
|
null,
|
|
);
|
|
expect(code).toContain('from "virtual:vinext-cache-adapters"');
|
|
// Called from both request handlers (covers Node, dev, and Workers).
|
|
const calls = code.split("__registerConfiguredCacheAdapters();").length - 1;
|
|
expect(calls).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("Pages Router worker entry registers with env", () => {
|
|
const code = readPagesRouterEntrySource();
|
|
expect(code).toContain('from "virtual:vinext-cache-adapters"');
|
|
expect(code).toContain("registerConfiguredCacheAdapters(env)");
|
|
});
|
|
});
|
|
|
|
describe("cdnAdapter builder + factory", () => {
|
|
it("builder resolves the runtime factory to an absolute path", () => {
|
|
const descriptor = cdnAdapter();
|
|
expect(path.isAbsolute(descriptor.adapter)).toBe(true);
|
|
expect(descriptor.adapter.endsWith("cdn-adapter.runtime.js")).toBe(true);
|
|
expect(descriptor.options).toBeUndefined();
|
|
});
|
|
|
|
it("factory returns a CloudflareCdnCacheAdapter", () => {
|
|
const adapter = createCloudflareCdnCacheAdapter();
|
|
expect(adapter).toBeInstanceOf(CloudflareCdnCacheAdapter);
|
|
// Edge adapter does not own in-process background regeneration.
|
|
expect(adapter.ownsBackgroundRevalidation).toBe(false);
|
|
});
|
|
});
|