mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
0b7721dba6
* fix(build): minify server build environments by default Vite only minifies the client environment; non-client (rsc/ssr and the Cloudflare worker) environments default build.minify to false, so the deployed worker and SSR renderer ship unminified. Add a build-only configEnvironment default that sets minify: true for server environments, yielding to any explicit user/plugin minify setting (including false). * fix(build): patch route classification before minify; build fixtures unminified for output introspection Minifying server environments by default exposed two problems: 1. Real bug: build-time route classification ran in `generateBundle`, but rolldown's minifier renames the top-level `__VINEXT_CLASS` function and mangles its `routeIdx` parameter before that hook runs, so the stub regex never matched and the patch silently no-op'd — every route fell back to the Layer 3 runtime probe in production. Move the injection to a `renderChunk` hook with `order: "pre"` so it runs before minification; the patched body is then minified along with the rest of the chunk (the runtime calls it by reference, not by name). 2. Test artifacts: three build tests introspect emitted server chunks for readable identifiers (registerConfiguredCacheAdapters, __VINEXT_CLASS) or depend on unminified chunking (MDX frontmatter leak check). These are test-only concerns — the runtime behavior is unchanged. Build their fixtures with `build.minify: false` (a user-overridable default) so the assertions stay deterministic. * fix(build): drop now-unused RouteClassificationChunk export (knip) The renderChunk refactor removed the external consumer of this type; keep it as a local type so knip passes. * test(build): guard minified classification path; fix stale generateBundle refs Re-add real regression coverage that the route-classification injector runs before minification (the production default). The prior fix made three tests pass by building with minify:false, which is exactly the config where the old post-minify generateBundle code also passed — so the tests no longer guarded the bug. - classification integration: add a production-default (minify on) suite that asserts minify-robust signals (injected switch dispatch shape + the module-graph reason literal, which is build-time-only). Verified it FAILS when the injector is reverted to a post-minify generateBundle hook. - cache adapters: assert the minify-safe LOCAL_ADAPTER_MARKER under the default minified build instead of grepping the renamed registerConfiguredCacheAdapters symbol. - tsconfig path alias (#659): run minified; the old not.toContain('title: "Second Post"') was a quote-escaping artifact (frontmatter renders as MDX content identically in both builds, not a minify leak) — replaced with a real parse-error/raw-YAML guard. - update stale generateBundle references to renderChunk in injector error strings, app-rsc-entry stubs, and index.ts comments; note the per-chunk patch assumption near the renderChunk hook. * test(build): run classification tests against minified output (no minify:false) Previously the dispatch-logic suites built the fixture with minify:false so they could regex-extract and eval the readable __VINEXT_CLASS function — but that is the one config where the original post-minify-hook bug also passes, so it masked the regression. Rewrite the extractors to be name-agnostic: derive the (mangled) dispatch function name from its property-keyed call site (__buildTimeClassifications/ __buildTimeReasons — property keys survive minification) and eval the body (string-literal values like "static" also survive). All suites now build with vinext's production defaults (minify on). Verified by reproducing the bug (disabling the patch): the minified tests fail with "was not patched". * refactor(build): rename buildGenerateBundleReplacement; fix stale generateBundle doc refs Address review nits: the classification dispatch builder still carried the old hook name (it now runs in renderChunk, not generateBundle). Rename it to buildClassificationReplacement (parallel to buildReasonsReplacement) and fix two stale 'generateBundle' doc comments (route-classification-manifest.ts:203, layout-classification.ts:130) to say renderChunk.
332 lines
9.1 KiB
TypeScript
332 lines
9.1 KiB
TypeScript
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;
|
|
}
|
|
|
|
async function loadCloudflarePlugin(): Promise<CloudflarePluginFactory> {
|
|
const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as {
|
|
cloudflare: CloudflarePluginFactory;
|
|
};
|
|
return cloudflare;
|
|
}
|
|
|
|
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"],
|
|
paths: {
|
|
"@/*": ["./*"],
|
|
},
|
|
},
|
|
include: ["app", "lib", "content", "*.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,
|
|
"mdx-components.tsx",
|
|
`export function useMDXComponents(components: Record<string, unknown>) {
|
|
return components;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"worker/index.ts",
|
|
`import handler from ${JSON.stringify(workerEntryPath)};
|
|
|
|
export default handler;
|
|
`,
|
|
);
|
|
}
|
|
|
|
async function buildCloudflareAppFixture(root: string) {
|
|
const cloudflare = await loadCloudflarePlugin();
|
|
const builder = await createBuilder({
|
|
root,
|
|
configFile: false,
|
|
plugins: [
|
|
vinext({ appDir: root }),
|
|
cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }),
|
|
],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
}
|
|
|
|
describe("App Router tsconfig path aliases in production builds", () => {
|
|
afterEach(() => {
|
|
for (const dir of tmpDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("transforms alias-based import.meta.glob and alias-based dynamic import in Cloudflare builds", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-tsconfig-alias-build-"));
|
|
tmpDirs.push(root);
|
|
writeCloudflareAppFixture(root, "vinext-tsconfig-alias-build");
|
|
writeFixtureFile(
|
|
root,
|
|
"app/page.tsx",
|
|
`import { getGlobPostCount } from "../lib/mdx-loader";
|
|
|
|
export default function HomePage() {
|
|
return <main>home {getGlobPostCount()}</main>;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"app/mdx-probe/page.mdx",
|
|
`# Probe
|
|
|
|
This file exists only to trigger vinext's MDX auto-detection in the fixture.
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"lib/mdx-loader.ts",
|
|
`type MdxModule = {
|
|
default: React.ComponentType;
|
|
};
|
|
|
|
export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", {
|
|
eager: true,
|
|
}) as Record<string, MdxModule>;
|
|
|
|
export function getGlobPostCount(): number {
|
|
return Object.keys(mdxModules).length;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"lib/mdx-dynamic.ts",
|
|
`export async function loadDynamicPost(year: string, month: string, day: string, slug: string) {
|
|
return await import(\`@/content/posts/\${year}/\${month}/\${day}/\${slug}/index.mdx\`);
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"app/dynamic-posts/[year]/[month]/[day]/[slug]/page.tsx",
|
|
`import { notFound } from "next/navigation";
|
|
import { loadDynamicPost } from "../../../../../../lib/mdx-dynamic";
|
|
|
|
export default async function DynamicPostPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ year: string; month: string; day: string; slug: string }>;
|
|
}) {
|
|
const { year, month, day, slug } = await params;
|
|
const mod = await loadDynamicPost(year, month, day, slug).catch(() => null);
|
|
|
|
if (!mod) {
|
|
notFound();
|
|
}
|
|
|
|
const Content = mod.default;
|
|
|
|
return (
|
|
<main data-testid="dynamic-post-page">
|
|
<h1>Dynamic Post</h1>
|
|
<Content />
|
|
</main>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"content/posts/2024/01/02/glob-post/index.mdx",
|
|
`# Globbed-Only MDX Post
|
|
|
|
This content came from an alias-based MDX import.
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"content/posts/2024/01/02/dynamic-post/index.mdx",
|
|
`# Dynamic MDX Post
|
|
|
|
This content came from an alias-based dynamic MDX import.
|
|
`,
|
|
);
|
|
|
|
await buildCloudflareAppFixture(root);
|
|
|
|
const buildOutput = readTextFilesRecursive(path.join(root, "dist"));
|
|
expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"');
|
|
expect(buildOutput).not.toContain("@/content/posts/");
|
|
}, 60_000);
|
|
|
|
it("import.meta.glob with MDX files containing frontmatter does not cause parse errors (issue #659)", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-mdx-frontmatter-build-"));
|
|
tmpDirs.push(root);
|
|
writeCloudflareAppFixture(root, "vinext-mdx-frontmatter-test");
|
|
writeFixtureFile(
|
|
root,
|
|
"app/page.tsx",
|
|
`import { getGlobPostCount } from "../lib/mdx-loader";
|
|
|
|
export default function HomePage() {
|
|
return <main>home {getGlobPostCount()}</main>;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"lib/mdx-loader.ts",
|
|
`type MdxModule = {
|
|
default: React.ComponentType;
|
|
frontmatter?: {
|
|
title: string;
|
|
date: string;
|
|
};
|
|
};
|
|
|
|
export const mdxModules = import.meta.glob("@/content/posts/**/*.mdx", {
|
|
eager: true,
|
|
}) as Record<string, MdxModule>;
|
|
|
|
export function getGlobPostCount(): number {
|
|
return Object.keys(mdxModules).length;
|
|
}
|
|
`,
|
|
);
|
|
writeFixtureFile(
|
|
root,
|
|
"content/posts/2025/08/20/second-post/index.mdx",
|
|
`---
|
|
title: "Second Post"
|
|
date: "2025-08-20"
|
|
---
|
|
|
|
<span className="text-red-500">This is a post with frontmatter and JSX.</span>
|
|
`,
|
|
);
|
|
|
|
await buildCloudflareAppFixture(root);
|
|
|
|
const buildOutput = readTextFilesRecursive(path.join(root, "dist"));
|
|
// Runs against the PRODUCTION DEFAULT (server minification ON).
|
|
expect(buildOutput).not.toContain('import.meta.glob("@/content/posts/**/*.mdx"');
|
|
expect(buildOutput).not.toContain("@/content/posts/");
|
|
// Issue #659 was a build-time *parse failure*: the MDX frontmatter `---`
|
|
// fence broke compilation, so the module never produced runnable JSX. The
|
|
// regression guard is therefore "the MDX compiled to JSX". The fixture
|
|
// configures no remark-frontmatter plugin, so MDX legitimately renders the
|
|
// `---` block as an `<hr>` + heading text — that rendered text (including
|
|
// `title: "Second Post"`) is EXPECTED content, present identically in
|
|
// minified and unminified output, and is NOT a leak. (The old
|
|
// `not.toContain('title: "Second Post"')` check only ever passed by accident:
|
|
// unminified JS escaped the inner quotes as `title: \"Second Post\"`, so the
|
|
// bare substring never matched. Minification emits the same string inside a
|
|
// backtick template without escaping, exposing that the assertion was a
|
|
// quote-style artifact rather than a real signal — hence its removal.)
|
|
//
|
|
// The real leak we still guard against is the RAW YAML frontmatter surviving
|
|
// verbatim as a top-of-module `---` fence (the unparsed #659 shape).
|
|
expect(buildOutput).toContain("text-red-500");
|
|
expect(buildOutput).not.toMatch(/^---\s*$[\s\S]*?title:/m);
|
|
}, 60_000);
|
|
});
|