mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
0c7ff57710
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.
216 lines
7.6 KiB
TypeScript
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);
|
|
});
|