Files
cloudflare__vinext/tests/cache-adapters-build.test.ts
T
James Anderson 0c7ff57710 fix(build): exclude Vite build manifests from Cloudflare asset uploads (#1850)
On Cloudflare Workers the ASSETS binding serves any uploaded file whose
path matches the request BEFORE the Worker runs. Cloudflare builds enable
`build.manifest` (and `ssrManifest` for Pages Router) so the worker entry
can compute lazy chunks, which writes Vite's manifests to
`dist/client/.vite/`. Because nothing excluded that directory from the
asset bundle, `/.vite/manifest.json` and `/.vite/ssr-manifest.json` were
publicly fetchable, leaking the full source-file -> chunk mapping
(including the paths of routes that are never linked from the UI).

The Node production server already blocks `/.vite/` explicitly
(server/static-file-cache.ts); this adds the Cloudflare-side equivalent by
writing a `.assetsignore` into `dist/client` during the cloudflare-build
closeBundle hook (right after the existing `_headers` generation, after the
manifests have been read). wrangler matches `.assetsignore` with gitignore
semantics, so the bare `.vite` entry excludes the directory and its
contents from upload, and the file is never served.

The merge logic preserves any user-authored `.assetsignore` and only
appends missing patterns, so it is idempotent across rebuilds. The writer
lives in a small typed helper (build/assets-ignore.ts) with focused unit
tests; a real Cloudflare build test asserts the wiring emits `.assetsignore`
with `.vite` while the manifest still exists on disk.
2026-06-08 19:47:57 +01:00

216 lines
7.6 KiB
TypeScript

/**
* Verifies that a `cache.data` / `cache.cdn` adapter pointing at a LOCAL file by
* absolute path — i.e. what `require.resolve("./my-adapter")` yields in a user's
* vite config — resolves and bundles into the Cloudflare worker. A bare relative
* specifier would have no on-disk anchor (the registration module is virtual),
* so absolute paths are the supported way to reference local adapters.
*
* This is a real Cloudflare build, so it also proves nothing throws at build
* time: the descriptor is inert config data, and the adapter is only invoked at
* request time.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createBuilder } from "vite";
import { afterEach, describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";
const tmpDirs: string[] = [];
const workerEntryPath = path
.resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts")
.replace(/\\/g, "/");
const cfPluginPath = path.resolve(
import.meta.dirname,
"./fixtures/cf-app-basic/node_modules/@cloudflare/vite-plugin/dist/index.mjs",
);
type CloudflarePluginFactory = (opts?: {
viteEnvironment?: { name: string; childEnvironments?: string[] };
}) => import("vite").Plugin;
function writeFixtureFile(root: string, filePath: string, content: string) {
const absPath = path.join(root, filePath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content);
}
function readTextFilesRecursive(root: string): string {
let output = "";
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
output += readTextFilesRecursive(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
output += fs.readFileSync(entryPath, "utf-8");
}
return output;
}
function writeCloudflareAppFixture(root: string, name: string) {
fs.symlinkSync(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(root, "node_modules"),
"junction",
);
writeFixtureFile(
root,
"package.json",
JSON.stringify({ name, private: true, type: "module" }, null, 2),
);
writeFixtureFile(
root,
"wrangler.jsonc",
`{
"name": ${JSON.stringify(name)},
"compatibility_date": "2026-02-12",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker/index.ts",
"assets": { "not_found_handling": "none", "binding": "ASSETS" }
}
`,
);
writeFixtureFile(
root,
"tsconfig.json",
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "bundler",
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
types: ["vite/client", "@vitejs/plugin-rsc/types"],
},
include: ["app", "*.ts", "*.tsx"],
},
null,
2,
),
);
writeFixtureFile(
root,
"app/layout.tsx",
`export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`,
);
writeFixtureFile(
root,
"app/page.tsx",
`export default function HomePage() {
return <main>home</main>;
}
`,
);
writeFixtureFile(
root,
"mdx-components.tsx",
`export function useMDXComponents(components: Record<string, unknown>) {
return components;
}
`,
);
writeFixtureFile(
root,
"worker/index.ts",
`import handler from ${JSON.stringify(workerEntryPath)};\n\nexport default handler;\n`,
);
}
const LOCAL_ADAPTER_MARKER = "__VINEXT_LOCAL_DATA_ADAPTER_MARKER__";
describe("config-driven cache adapter — local file by absolute path", () => {
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("resolves and bundles a require.resolve-style absolute adapter path", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cache-adapter-build-"));
tmpDirs.push(root);
writeCloudflareAppFixture(root, "vinext-cache-adapter-build");
// A local adapter module the user would reference via
// `require.resolve("./cache/my-data-adapter")` → an ABSOLUTE path.
writeFixtureFile(
root,
"cache/my-data-adapter.ts",
`// A custom adapter module: default-exports a factory ({ env, options }) => CacheHandler.
const createAdapter = () => {
const store = new Map();
// The marker is a live property of the returned (escaping) handler, so it
// survives tree-shaking/minification and proves this module was bundled.
return {
adapterMarker: "${LOCAL_ADAPTER_MARKER}",
async get(key) { return store.get(key) ?? null; },
async set(key, data) { store.set(key, data); },
async revalidateTag() {},
};
};
export default createAdapter;
`,
);
// This absolute path is exactly what require.resolve("./cache/my-data-adapter")
// produces in a vite.config (modulo extension resolution).
const adapterAbsPath = path.join(root, "cache/my-data-adapter.ts").replace(/\\/g, "/");
const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as {
cloudflare: CloudflarePluginFactory;
};
const builder = await createBuilder({
root,
configFile: false,
plugins: [
vinext({ appDir: root, cache: { data: { adapter: adapterAbsPath } } }),
cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }),
],
logLevel: "silent",
// Build with vinext's production default (server minification ON) so this
// test covers the real shipping path. The assertion below keys off the
// adapter's `LOCAL_ADAPTER_MARKER` string literal, whose contents survive
// minification verbatim (only identifiers are mangled) — so it remains a
// valid "the adapter module was bundled" signal under minify. We
// intentionally do NOT grep for the readable `registerConfiguredCacheAdapters`
// function name: minify renames it (harmlessly — it is called by reference,
// never by name), so that grep would be a minify-off-only proxy that no
// longer reflects production.
});
// Build completing at all proves the absolute-path import resolved and that
// the inert descriptor did not require any Workers context at build time.
await builder.buildApp();
const buildOutput = readTextFilesRecursive(path.join(root, "dist"));
// Minify-safe: the marker is a string literal in the escaping handler, so
// its presence proves the local adapter module was bundled even though the
// build ran minified (the production default).
expect(buildOutput).toContain(LOCAL_ADAPTER_MARKER);
// Security regression: the Cloudflare build must emit a `.assetsignore` that
// excludes Vite's `.vite/` build metadata from the deployed asset bundle.
// The ASSETS binding serves matching files before the Worker runs, so
// without this `/.vite/manifest.json` would be publicly fetchable (it leaks
// the source-file → chunk mapping, including unlinked routes). The Node prod
// server blocks `/.vite/` for the same reason. Reuses this build to avoid a
// second expensive Cloudflare build in CI.
const assetsIgnore = fs.readFileSync(path.join(root, "dist/client/.assetsignore"), "utf-8");
expect(assetsIgnore.split("\n").map((l) => l.trim())).toContain(".vite");
// The manifest exists on disk (the build reads it) but is now excluded.
expect(fs.existsSync(path.join(root, "dist/client/.vite/manifest.json"))).toBe(true);
}, 60_000);
});