mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
151f099089
* fix: CSS imports from node_modules crash SSR (#270) When a node_modules package imports a `.css` file, SSR crashes with "Unknown file extension .css" because Vite externalizes node_modules by default — causing Node's native ESM loader to handle the import, which it can't do for non-JS files. Set `noExternal: true` on all server environments (Pages Router SSR, App Router RSC, App Router SSR) to force node_modules through Vite's transform pipeline. This matches Next.js behavior of bundling everything for SSR. Explicitly listed `external` packages (react, satori, resvg, yoga-wasm-web) take precedence per Vite's rules. Skipped for Cloudflare/Nitro targets which already bundle everything. * fix: externalize better-sqlite3 and fix test hermiticity - Add better-sqlite3 to ssr.external in better-auth fixture config. Native addon packages using `bindings` break when Vite transforms them (stack trace introspection fails). This mirrors Next.js's serverExternalPackages — users with native addons must externalize them. - Fix Pages Router CSS test mutating real workspace node_modules. Previously symlinked tmpDir/node_modules → repo root, then created fake-css-lib inside the symlink target, leaking into the real workspace. Now creates a real node_modules dir and symlinks individual packages. * fix: propagate user ssr.external into RSC and SSR environments Vite 6+ treats `ssr.*` config as sugar for `environments.ssr.*` only — it does NOT propagate to custom environments like `rsc`. This means packages listed in `ssr.external` (e.g. better-sqlite3) were still being transformed by Vite in the RSC environment, breaking native addon resolution via the `bindings` package. Read user's `ssr.external` array and merge it into both `environments.rsc.resolve.external` and `environments.ssr.resolve.external` so native addons are properly externalized from all server environments. Fixes CI failures in better-auth ecosystem tests on Linux where the bindings package's stack trace introspection fails when the native module is transformed by Vite. * fix: address review comments on node-modules CSS SSR crash fix - Fix wrong repo URL in test comment (nicepkg/vinext -> cloudflare/vinext) - Propagate ssr.external: true into RSC and SSR environments correctly - Move fake test packages from committed node_modules to __test_packages__ with file: protocol dependencies, removing fragile force-tracked files - Add CSS module (fake-css-module-lib) coverage to Pages Router test - Narrow Pages Router test symlinks to only required packages * fix: address bonk review comments - Update stale comment on top-level ssr block to reflect both React externalization and noExternal: true behavior; document that user ssr.noExternal is intentionally superseded by vinext's setting - Remove now-redundant noExternal: ['better-auth'] from better-auth fixture (vinext's blanket noExternal: true already covers it) - Merge Pages Router test's two separate 'it' blocks (both hit same URL) and add missing fake-css-module-rendered assertion for .module.css - Guard symlink .catch() to only swallow EEXIST errors, not all failures - Add comment explaining appDir: tmpDir usage in Pages Router test --------- Co-authored-by: James <james@eli.cx>
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
/**
|
|
* Tests that CSS imports from node_modules packages don't crash SSR.
|
|
*
|
|
* When a node_modules package imports a `.css` file, Vite must process it
|
|
* through its transform pipeline (not Node's native ESM loader, which can't
|
|
* handle non-JS extensions). The `noExternal: true` config ensures this.
|
|
*
|
|
* Relates to: https://github.com/cloudflare/vinext/issues/270
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import { createServer, type ViteDevServer } from "vite";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
import path from "node:path";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "./helpers.js";
|
|
|
|
// ── App Router: node_modules CSS import ─────────────────────
|
|
|
|
describe("node_modules CSS import (App Router)", () => {
|
|
let server: ViteDevServer;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, {
|
|
appRouter: true,
|
|
}));
|
|
// Warm up
|
|
await fetch(`${baseUrl}/`).catch(() => {});
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await server?.close();
|
|
});
|
|
|
|
it("renders page that imports CSS from node_modules without crashing", async () => {
|
|
const { res, html } = await fetchHtml(baseUrl, "/nextjs-compat/node-modules-css");
|
|
expect(res.status).toBe(200);
|
|
expect(html).toContain("node-modules-css-works");
|
|
expect(html).toContain("fake-css-lib-rendered");
|
|
expect(html).toContain("fake-css-module-rendered");
|
|
});
|
|
});
|
|
|
|
// ── Pages Router: node_modules CSS import ────────────────────
|
|
|
|
describe("node_modules CSS import (Pages Router)", () => {
|
|
let server: ViteDevServer;
|
|
let baseUrl: string;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-nm-css-pages-"));
|
|
|
|
// Create a real node_modules dir (not a symlink, to avoid mutating workspace)
|
|
const nmDir = path.join(tmpDir, "node_modules");
|
|
await fs.mkdir(nmDir, { recursive: true });
|
|
|
|
// Symlink only the packages this test actually needs from the repo root.
|
|
// Symlinking only what's needed (rather than all entries) keeps the test
|
|
// hermetic and avoids touching unrelated packages.
|
|
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
|
|
const requiredPackages = [
|
|
"react",
|
|
"react-dom",
|
|
"vite",
|
|
"@vitejs",
|
|
"vinext",
|
|
];
|
|
for (const pkg of requiredPackages) {
|
|
const src = path.join(rootNodeModules, pkg);
|
|
const dest = path.join(nmDir, pkg);
|
|
await fs.symlink(src, dest, "junction").catch((err) => {
|
|
if (err.code !== "EEXIST") throw err;
|
|
});
|
|
}
|
|
|
|
// Create fake packages that import .css and .module.css
|
|
const fakeCssDir = path.join(nmDir, "fake-css-lib");
|
|
await fs.mkdir(fakeCssDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(fakeCssDir, "package.json"),
|
|
JSON.stringify({ name: "fake-css-lib", version: "1.0.0", type: "module", main: "index.js" }),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(fakeCssDir, "styles.css"),
|
|
`.fake-css-lib { color: red; }`,
|
|
);
|
|
await fs.writeFile(
|
|
path.join(fakeCssDir, "index.js"),
|
|
`import "./styles.css";\nexport function FakeComponent() { return "fake-css-lib-rendered"; }\n`,
|
|
);
|
|
|
|
const fakeCssModuleDir = path.join(nmDir, "fake-css-module-lib");
|
|
await fs.mkdir(fakeCssModuleDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(fakeCssModuleDir, "package.json"),
|
|
JSON.stringify({ name: "fake-css-module-lib", version: "1.0.0", type: "module", main: "index.js" }),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(fakeCssModuleDir, "styles.module.css"),
|
|
`.item { font-weight: bold; }`,
|
|
);
|
|
await fs.writeFile(
|
|
path.join(fakeCssModuleDir, "index.js"),
|
|
`import classes from "./styles.module.css";\nexport const styles = classes;\nexport function ModComponent() { return "fake-css-module-rendered"; }\n`,
|
|
);
|
|
|
|
// Pages Router structure
|
|
const pagesDir = path.join(tmpDir, "pages");
|
|
await fs.mkdir(pagesDir, { recursive: true });
|
|
|
|
await fs.writeFile(
|
|
path.join(pagesDir, "index.tsx"),
|
|
`import { FakeComponent } from "fake-css-lib";
|
|
import { ModComponent } from "fake-css-module-lib";
|
|
|
|
export default function Page() {
|
|
return (
|
|
<div>
|
|
<h1 id="nm-css-test">node-modules-css-pages-works</h1>
|
|
<p>{FakeComponent()}</p>
|
|
<p>{ModComponent()}</p>
|
|
</div>
|
|
);
|
|
}`,
|
|
);
|
|
|
|
// `appDir` points to the tmp project root. For a Pages Router test (no
|
|
// `app/` directory) this just gives vinext a root to resolve from; the
|
|
// plugin ignores the absence of `app/`.
|
|
const plugins: any[] = [vinext({ appDir: tmpDir })];
|
|
|
|
server = await createServer({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins,
|
|
optimizeDeps: { holdUntilCrawlEnd: true },
|
|
server: { port: 0, cors: false },
|
|
logLevel: "silent",
|
|
});
|
|
|
|
await server.listen();
|
|
const addr = server.httpServer?.address();
|
|
if (addr && typeof addr === "object") {
|
|
baseUrl = `http://localhost:${addr.port}`;
|
|
}
|
|
|
|
// Warm up
|
|
await fetch(`${baseUrl}/`).catch(() => {});
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await server?.close();
|
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
it("renders page that imports CSS and CSS modules from node_modules without crashing", async () => {
|
|
const res = await fetch(`${baseUrl}/`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
expect(html).toContain("node-modules-css-pages-works");
|
|
expect(html).toContain("fake-css-lib-rendered");
|
|
expect(html).toContain("fake-css-module-rendered");
|
|
});
|
|
});
|