mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
/**
|
|
* Build-time integration test: middleware (and any module reachable from it)
|
|
* must be allowed to `import 'server-only'`, even though vinext bundles
|
|
* middleware into the SSR environment.
|
|
*
|
|
* Ported from Next.js: test/e2e/module-layer/module-layer.test.ts
|
|
* https://github.com/vercel/next.js/blob/canary/test/e2e/module-layer/module-layer.test.ts
|
|
* The fixture's `middleware.js` contains a top-level `import 'server-only'`
|
|
* plus a `react` import — Next.js's module-layer rules let `server-only`
|
|
* through for middleware (`WEBPACK_LAYERS.neutralTarget`) while still
|
|
* blocking it from client code.
|
|
*
|
|
* Before this fix, `@vitejs/plugin-rsc`'s `rsc:validate-imports` rejected
|
|
* the import with:
|
|
*
|
|
* 'server-only' cannot be imported in client build ('ssr' environment):
|
|
* imported by middleware.js
|
|
* imported by virtual:vinext-server-entry
|
|
*
|
|
* That single build failure cascaded into 13 failures in the
|
|
* `module-layer.test.ts` deploy suite, which is the failure pattern that
|
|
* the upstream issue (#1344) tracks.
|
|
*/
|
|
import fsp from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
|
|
|
|
const FIXTURE_PREFIX = "vinext-mw-server-only-";
|
|
|
|
async function writeFile(file: string, source: string): Promise<void> {
|
|
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
await fsp.writeFile(file, source, "utf8");
|
|
}
|
|
|
|
async function buildFixture(): Promise<{ tmpDir: string }> {
|
|
const workspaceRoot = path.resolve(import.meta.dirname, "..");
|
|
const workspaceNodeModules = path.join(workspaceRoot, "node_modules");
|
|
|
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), FIXTURE_PREFIX));
|
|
|
|
// Hybrid App + Pages Router fixture. The Next.js module-layer test has
|
|
// both directories, and that combination is what makes the SSR entry
|
|
// (which contains the bare \`import 'server-only'\` statement coming from
|
|
// middleware) reachable from \`virtual:vinext-app-ssr-entry\` — see the
|
|
// hybrid re-export branch in src/entries/app-ssr-entry.ts. With only
|
|
// app/, the App Router SSR environment never imports the Pages Router
|
|
// virtual server entry, so plugin-rsc's validate-imports buildEnd pass
|
|
// never sees the \`server-only\` chain. The hybrid fixture mirrors what
|
|
// the deploy suite actually builds.
|
|
await writeFile(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
return <html lang="en"><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
await writeFile(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`export default function Home() { return <div>home</div>; }\n`,
|
|
);
|
|
// Pages Router companion route — its mere presence flips
|
|
// \`hasPagesDir\` and triggers the SSR-entry → server-entry re-export.
|
|
await writeFile(
|
|
path.join(tmpDir, "pages", "pages-ssr.tsx"),
|
|
`export default function PagesSsr() { return <div>pages-ssr</div>; }\n`,
|
|
);
|
|
await writeFile(
|
|
path.join(tmpDir, "lib", "api-server-only.ts"),
|
|
`import "server-only";
|
|
import * as React from "react";
|
|
|
|
export function getReactExports() {
|
|
return Object.keys(Object(React));
|
|
}
|
|
`,
|
|
);
|
|
await writeFile(
|
|
path.join(tmpDir, "pages", "api", "server-only-edge.ts"),
|
|
`import "server-only";
|
|
import { getReactExports } from "../../lib/api-server-only";
|
|
|
|
export default async function handler() {
|
|
return Response.json({ React: getReactExports() });
|
|
}
|
|
|
|
export const runtime = "edge";
|
|
`,
|
|
);
|
|
|
|
// A helper that the middleware imports. Mirrors the Next.js fixture's
|
|
// `lib/mixed-lib`-style transitive case: `server-only` must remain a
|
|
// no-op when reached two hops away from the middleware entry.
|
|
//
|
|
// Marked side-effectful via an exported sentinel so Rolldown does NOT
|
|
// tree-shake the module and its `import 'server-only'` out of the bundle.
|
|
// The Next.js fixture has the same property by virtue of `react` being
|
|
// an actual runtime dependency of the middleware; we keep ours minimal.
|
|
await writeFile(
|
|
path.join(tmpDir, "lib", "auth.ts"),
|
|
`import "server-only";
|
|
|
|
// Side-effectful top-level export to anchor the module against tree-shaking.
|
|
export const __AUTH_TAG = "vinext-test-tag-" + Date.now().toString(36);
|
|
export function getUserId(): string {
|
|
return __AUTH_TAG;
|
|
}
|
|
`,
|
|
);
|
|
|
|
// Middleware with a direct \`import 'server-only'\` (the failure surface
|
|
// reported in the issue) and a transitive one through ./lib/auth.
|
|
//
|
|
// Mirrors the Next.js module-layer fixture's middleware.js, which also
|
|
// imports \`import * as React from 'react'\` — React is what anchors the
|
|
// server-side module so DCE cannot drop the server-only chain. We keep
|
|
// the React assertion to match the upstream fixture's intent: even though
|
|
// React is bundled, its client-side hooks (\`useState\`) must NOT appear
|
|
// in the server-layer copy.
|
|
await writeFile(
|
|
path.join(tmpDir, "middleware.ts"),
|
|
`import "server-only";
|
|
import * as React from "react";
|
|
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
import { getUserId } from "./lib/auth";
|
|
|
|
export function middleware(request: NextRequest) {
|
|
// Match the Next.js fixture's React-in-server-layer assertion so the
|
|
// \`react\` import is genuinely live and Rolldown cannot tree-shake it.
|
|
const ReactObject = Object(React);
|
|
if (ReactObject.useState) {
|
|
throw new Error("React.useState should not be defined in server layer");
|
|
}
|
|
// Use the helper's result in a header so Rolldown's DCE pass cannot drop
|
|
// either the helper module or its top-level \`import 'server-only'\`.
|
|
const response = NextResponse.next();
|
|
response.headers.set("x-vinext-test-user", getUserId());
|
|
response.headers.set("x-vinext-test-react-keys", Object.keys(ReactObject).length.toString());
|
|
return response;
|
|
}
|
|
|
|
export const config = { matcher: ["/"] };
|
|
`,
|
|
);
|
|
|
|
// Symlink workspace node_modules so vinext, react, react-dom resolve.
|
|
await fsp.symlink(workspaceNodeModules, path.join(tmpDir, "node_modules"), "junction");
|
|
|
|
const { default: vinext } = await import(
|
|
pathToFileURL(path.join(workspaceRoot, "packages/vinext/src/index.ts")).href
|
|
);
|
|
const { createBuilder } = await import("vite");
|
|
const rscOutDir = path.join(tmpDir, "dist", "server");
|
|
const ssrOutDir = path.join(tmpDir, "dist", "server", "ssr");
|
|
const clientOutDir = path.join(tmpDir, "dist", "client");
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })],
|
|
logLevel: "error",
|
|
});
|
|
|
|
await builder.buildApp();
|
|
return { tmpDir };
|
|
}
|
|
|
|
async function buildPagesClientServerOnlyViolationFixture(): Promise<void> {
|
|
const workspaceRoot = path.resolve(import.meta.dirname, "..");
|
|
const workspaceNodeModules = path.join(workspaceRoot, "node_modules");
|
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-server-only-"));
|
|
|
|
await writeFile(
|
|
path.join(tmpDir, "pages", "index.tsx"),
|
|
`import Comp from "../components/Comp";
|
|
|
|
export default function Page() {
|
|
return <Comp />;
|
|
}
|
|
`,
|
|
);
|
|
await writeFile(
|
|
path.join(tmpDir, "components", "Comp.tsx"),
|
|
`import "server-only";
|
|
|
|
export default function Comp() {
|
|
return <p>hello world</p>;
|
|
}
|
|
`,
|
|
);
|
|
|
|
await fsp.symlink(workspaceNodeModules, path.join(tmpDir, "node_modules"), "junction");
|
|
|
|
const { default: vinext } = await import(
|
|
pathToFileURL(path.join(workspaceRoot, "packages/vinext/src/index.ts")).href
|
|
);
|
|
const { createBuilder } = await import("vite");
|
|
const rscOutDir = path.join(tmpDir, "dist", "server");
|
|
const ssrOutDir = path.join(tmpDir, "dist", "server", "ssr");
|
|
const clientOutDir = path.join(tmpDir, "dist", "client");
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })],
|
|
logLevel: "error",
|
|
});
|
|
|
|
await builder.buildApp();
|
|
}
|
|
|
|
async function buildAppServerActionServerOnlyFixture(): Promise<void> {
|
|
const workspaceRoot = path.resolve(import.meta.dirname, "..");
|
|
const workspaceNodeModules = path.join(workspaceRoot, "node_modules");
|
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-action-server-only-"));
|
|
|
|
await writeFile(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
return <html lang="en"><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
// A Server Actions module ("use server") that imports server-only. This
|
|
// mirrors Next.js's own fixture, which is valid because server actions run
|
|
// in the server/action layer — not the client layer that server-only guards.
|
|
await writeFile(
|
|
path.join(tmpDir, "app", "actions.ts"),
|
|
`'use server'
|
|
|
|
import 'server-only'
|
|
|
|
export async function inc(value: number) {
|
|
return value + 1;
|
|
}
|
|
`,
|
|
);
|
|
// A client component that references the action. This is what pulls the
|
|
// action module into the client environment graph, where the
|
|
// vinext:validate-server-only-client-imports guard runs.
|
|
await writeFile(
|
|
path.join(tmpDir, "app", "counter.tsx"),
|
|
`'use client'
|
|
|
|
import { useState } from "react";
|
|
import { inc } from "./actions";
|
|
|
|
export default function Counter() {
|
|
const [value, setValue] = useState(0);
|
|
return <button onClick={async () => setValue(await inc(value))}>{value}</button>;
|
|
}
|
|
`,
|
|
);
|
|
await writeFile(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`import Counter from "./counter";
|
|
|
|
export default function Home() {
|
|
return <Counter />;
|
|
}
|
|
`,
|
|
);
|
|
|
|
await fsp.symlink(workspaceNodeModules, path.join(tmpDir, "node_modules"), "junction");
|
|
|
|
const { default: vinext } = await import(
|
|
pathToFileURL(path.join(workspaceRoot, "packages/vinext/src/index.ts")).href
|
|
);
|
|
const { createBuilder } = await import("vite");
|
|
const rscOutDir = path.join(tmpDir, "dist", "server");
|
|
const ssrOutDir = path.join(tmpDir, "dist", "server", "ssr");
|
|
const clientOutDir = path.join(tmpDir, "dist", "client");
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })],
|
|
logLevel: "error",
|
|
});
|
|
|
|
await builder.buildApp();
|
|
}
|
|
|
|
describe("middleware can import server-only", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
const built = await buildFixture();
|
|
tmpDir = built.tmpDir;
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
// tmpdirs are left for post-mortem debugging; the test harness cleans
|
|
// os.tmpdir() periodically. Matching the pattern used by other build
|
|
// integration tests (build-time-classification-integration.test.ts).
|
|
});
|
|
|
|
async function collectServerJsFiles(): Promise<string[]> {
|
|
// App Router default output layout:
|
|
// dist/server/index.{js,mjs} ← RSC entry
|
|
// dist/server/ssr/*.{js,mjs} ← SSR pass for client components
|
|
// dist/client/assets/*.js ← browser bundle
|
|
const serverDir = path.join(tmpDir, "dist", "server");
|
|
const stack: string[] = [serverDir];
|
|
const seen: string[] = [];
|
|
while (stack.length) {
|
|
const current = stack.pop()!;
|
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) stack.push(full);
|
|
else if (/\.(m?js)$/.test(entry.name)) seen.push(full);
|
|
}
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
it("emits server bundles without an invalid-server-only stub", async () => {
|
|
// The validate-imports plugin replaces an invalid bare `server-only`
|
|
// specifier with a virtual module body of:
|
|
// throw new Error("invalid import of 'server-only'")
|
|
// If our taint-tracking plugin works, that string must not appear in any
|
|
// server-side artifact (it would appear in the SSR pass if middleware's
|
|
// chain were rejected). We check both the RSC entry and the SSR dir to
|
|
// catch regressions in either environment.
|
|
const files = await collectServerJsFiles();
|
|
expect(
|
|
files.length,
|
|
`expected JS artifacts under dist/server (got: ${files.length})`,
|
|
).toBeGreaterThan(0);
|
|
for (const file of files) {
|
|
const source = await fsp.readFile(file, "utf8");
|
|
expect(
|
|
source.includes("invalid import of 'server-only'"),
|
|
`${path.relative(tmpDir, file)} contains the rsc:validate-imports throw stub`,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("keeps the middleware (and its server-only chain) in the bundle", async () => {
|
|
// Sanity check: prove the fixture isn't trivially passing because Rolldown
|
|
// tree-shook the middleware out before validate-imports could see it. If
|
|
// \`getUserId\`'s body (the \`__AUTH_TAG\` constant) is reachable in the
|
|
// emitted artifacts, the middleware → lib/auth.ts → server-only chain
|
|
// really did survive into the build that validate-imports inspects.
|
|
const files = await collectServerJsFiles();
|
|
const found = await Promise.all(
|
|
files.map(async (f) => (await fsp.readFile(f, "utf8")).includes("vinext-test-tag-")),
|
|
);
|
|
expect(
|
|
found.some(Boolean),
|
|
"expected lib/auth's __AUTH_TAG sentinel to appear in at least one server artifact",
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Pages Router client builds reject server-only", () => {
|
|
it("errors when a Pages client graph imports server-only", async () => {
|
|
// Ported from Next.js: test/development/acceptance/server-component-compiler-errors-in-pages.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts
|
|
await expect(buildPagesClientServerOnlyViolationFixture()).rejects.toThrow(
|
|
/server-only.*Server Components in the App Router/s,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("App Router Server Actions can import server-only", () => {
|
|
it("builds when a 'use server' module imports server-only", async () => {
|
|
// Mirrors Next.js's test/e2e/app-dir/actions fixture, whose
|
|
// app/client/actions.js is a `'use server'` module with `import 'server-only'`.
|
|
// Server Actions run in the server/action layer, so server-only is allowed
|
|
// even though the client bundle references the actions to invoke them.
|
|
// The validate-server-only-client-imports guard (added in #1697) must not
|
|
// false-positive on these or it breaks the whole client build — regression
|
|
// test for the actions/app-basepath deploy-suite failures.
|
|
await expect(buildAppServerActionServerOnlyFixture()).resolves.toBeUndefined();
|
|
}, 120_000);
|
|
});
|