mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
721 lines
29 KiB
TypeScript
721 lines
29 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 { afterAll, describe, expect, it, vi } from "vite-plus/test";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
import { runPrerender } from "../packages/vinext/src/build/run-prerender.js";
|
|
import { APP_FIXTURE_DIR } from "./helpers.js";
|
|
|
|
type BuiltAppHandler = (request: Request) => Promise<Response | string | null | undefined>;
|
|
|
|
type ClientManifestEntry = {
|
|
imports?: string[];
|
|
isEntry?: boolean;
|
|
name?: string;
|
|
src?: string;
|
|
};
|
|
|
|
function isBuiltAppHandler(value: unknown): value is BuiltAppHandler {
|
|
return typeof value === "function";
|
|
}
|
|
|
|
/** Concatenate every `.js` file under `dir` (recursively) for substring checks. */
|
|
function readAllJs(dir: string): string {
|
|
let out = "";
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) out += readAllJs(full);
|
|
else if (entry.name.endsWith(".js")) out += fs.readFileSync(full, "utf-8");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("App Router Production build", () => {
|
|
const outDir = path.resolve(APP_FIXTURE_DIR, "dist");
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("produces RSC/SSR/client bundles via vite build", async () => {
|
|
const builder = await createBuilder({
|
|
root: APP_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
// RSC entry should exist (at dist/server/index.js)
|
|
expect(fs.existsSync(path.join(outDir, "server", "index.js"))).toBe(true);
|
|
// SSR entry should exist (at dist/server/ssr/index.js)
|
|
expect(fs.existsSync(path.join(outDir, "server", "ssr", "index.js"))).toBe(true);
|
|
// Client bundle should exist
|
|
expect(fs.existsSync(path.join(outDir, "client"))).toBe(true);
|
|
|
|
// Client JS should land under Next.js's canonical `_next/static/chunks/`
|
|
// directory.
|
|
const clientAssets = fs.readdirSync(path.join(outDir, "client", "_next", "static", "chunks"));
|
|
expect(clientAssets.some((f: string) => f.endsWith(".js"))).toBe(true);
|
|
const clientJs = readAllJs(path.join(outDir, "client"));
|
|
|
|
// Ported from Next.js:
|
|
// test/production/app-dir/browser-chunks/browser-chunks.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/production/app-dir/browser-chunks/browser-chunks.test.ts
|
|
//
|
|
// Dev overlay and HMR plumbing must not create a production client chunk
|
|
// edge. Keep this list focused on symbols/module ids that only belong to
|
|
// vinext's App Router dev overlay path.
|
|
for (const devOnlyNeedle of [
|
|
"dev-error-overlay",
|
|
"vinext-dev-error-overlay",
|
|
"installDevErrorOverlay",
|
|
"installViteHmrErrorHandler",
|
|
"rsc:update",
|
|
]) {
|
|
expect(clientJs).not.toContain(devOnlyNeedle);
|
|
}
|
|
|
|
// Ported from the client-reference chunk ownership covered by Next.js:
|
|
// test/e2e/app-dir/client-reference-chunking/client-reference-chunking.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/client-reference-chunking/client-reference-chunking.test.ts
|
|
//
|
|
// `next/link` is a client reference used by selected routes. It must remain
|
|
// a lazy chunk instead of being pulled into the eager App Router bootstrap
|
|
// by vinext's manual chunk policy.
|
|
const clientManifest = JSON.parse(
|
|
fs.readFileSync(path.join(outDir, "client", ".vite", "manifest.json"), "utf-8"),
|
|
) as Record<string, ClientManifestEntry>;
|
|
const browserEntryKey = Object.keys(clientManifest).find(
|
|
(key) => clientManifest[key]?.isEntry === true,
|
|
);
|
|
const linkEntryKey = Object.keys(clientManifest).find((key) => {
|
|
const entry = clientManifest[key];
|
|
const source = entry?.src?.replaceAll("\\", "/") ?? key.replaceAll("\\", "/");
|
|
return entry?.name === "link" || /\/shims\/link\.(?:js|tsx)$/.test(source);
|
|
});
|
|
const serverActionClientKey = Object.keys(clientManifest).find((key) => {
|
|
const source = clientManifest[key]?.src?.replaceAll("\\", "/") ?? key.replaceAll("\\", "/");
|
|
return source.includes("/server/app-browser-server-action-client.");
|
|
});
|
|
expect(browserEntryKey).toBeDefined();
|
|
expect(linkEntryKey).toBeDefined();
|
|
expect(serverActionClientKey).toBeDefined();
|
|
|
|
const eagerKeys = new Set<string>();
|
|
const visitEagerImports = (key: string): void => {
|
|
if (eagerKeys.has(key)) return;
|
|
eagerKeys.add(key);
|
|
for (const importedKey of clientManifest[key]?.imports ?? []) {
|
|
visitEagerImports(importedKey);
|
|
}
|
|
};
|
|
if (browserEntryKey) visitEagerImports(browserEntryKey);
|
|
expect(linkEntryKey ? eagerKeys.has(linkEntryKey) : true).toBe(false);
|
|
|
|
// RSC bundle should contain route handling code
|
|
const rscEntry = fs.readFileSync(path.join(outDir, "server", "index.js"), "utf-8");
|
|
expect(rscEntry).toContain("handler");
|
|
|
|
// Asset manifest should be generated
|
|
expect(fs.existsSync(path.join(outDir, "server", "__vite_rsc_assets_manifest.js"))).toBe(true);
|
|
|
|
// BUILD_ID must be written to dist/server so post-build tools (TPR,
|
|
// seed-cache) and the e2e deploy harness can read the build identifier
|
|
// without parsing the (minified) server bundle. Regression guard: the
|
|
// vinext:build-id plugin previously used closeBundle, which does not fire
|
|
// during the multi-environment buildApp() pipeline, so the file was
|
|
// silently never written for pure App Router apps.
|
|
const buildIdPath = path.join(outDir, "server", "BUILD_ID");
|
|
expect(fs.existsSync(buildIdPath)).toBe(true);
|
|
const buildId = fs.readFileSync(buildIdPath, "utf-8").trim();
|
|
expect(buildId.length).toBeGreaterThan(0);
|
|
const rscBuildId = fs.readFileSync(path.join(outDir, "server", "RSC_BUILD_ID"), "utf-8").trim();
|
|
expect(rscBuildId).toMatch(/^[0-9a-f]{32}$/);
|
|
|
|
const warmupManifestPath = path.join(outDir, "server", "vinext-prerender-paths.json");
|
|
expect(fs.existsSync(warmupManifestPath)).toBe(false);
|
|
expect(fs.existsSync(path.join(outDir, "server", "prerendered-routes"))).toBe(false);
|
|
}, 30000);
|
|
|
|
it("omits the browser server-action client when the app has no server actions", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-action-free-client-"));
|
|
|
|
try {
|
|
fs.symlinkSync(
|
|
path.resolve(import.meta.dirname, "../node_modules"),
|
|
path.join(tmpDir, "node_modules"),
|
|
"junction",
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function Root({ children }: { children: React.ReactNode }) {
|
|
return <html><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`export default function Page() {
|
|
return <p>action-free</p>;
|
|
}
|
|
`,
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const clientDir = path.join(tmpDir, "dist", "client");
|
|
const manifest = fs.readFileSync(path.join(clientDir, ".vite", "manifest.json"), "utf-8");
|
|
expect(manifest).not.toContain("app-browser-server-action-client");
|
|
expect(readAllJs(clientDir)).not.toContain("UnrecognizedActionError");
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 30000);
|
|
|
|
it("adopts __VINEXT_SHARED_BUILD_ID so the runtime and BUILD_ID file agree", async () => {
|
|
// The `vinext build` CLI resolves the build ID once and shares it via
|
|
// __VINEXT_SHARED_BUILD_ID so that every plugin instance in a build (App
|
|
// Router buildApp + the separate hybrid Pages Router vite.build) uses the
|
|
// same ID. Without it, each instance mints its own random UUID and the
|
|
// runtime buildId, prerender manifest, and dist/server/BUILD_ID diverge.
|
|
const sharedBuildId = "shared-test-build-id-1234";
|
|
const previous = process.env.__VINEXT_SHARED_BUILD_ID;
|
|
process.env.__VINEXT_SHARED_BUILD_ID = sharedBuildId;
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: APP_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
// The emitted BUILD_ID file uses the shared ID.
|
|
expect(fs.readFileSync(path.join(outDir, "server", "BUILD_ID"), "utf-8").trim()).toBe(
|
|
sharedBuildId,
|
|
);
|
|
// The shared ID is baked into the App Router runtime bundle (the value
|
|
// process.env.__VINEXT_BUILD_ID is defined as), so cache keys and data
|
|
// routes line up with the BUILD_ID file.
|
|
const rscEntry = fs.readFileSync(path.join(outDir, "server", "index.js"), "utf-8");
|
|
expect(rscEntry).toContain(sharedBuildId);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.__VINEXT_SHARED_BUILD_ID;
|
|
else process.env.__VINEXT_SHARED_BUILD_ID = previous;
|
|
}
|
|
}, 30000);
|
|
|
|
it("adopts the shared prerender discovery secret in the Worker and server manifest", async () => {
|
|
// Hybrid builds instantiate vinext twice; the CLI-provided value must win
|
|
// so the later Pages build cannot invalidate the App Worker's capability.
|
|
const sharedSecret = "ab".repeat(32);
|
|
const previous = process.env.__VINEXT_SHARED_PRERENDER_SECRET;
|
|
process.env.__VINEXT_SHARED_PRERENDER_SECRET = sharedSecret;
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: APP_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
expect(
|
|
JSON.parse(fs.readFileSync(path.join(outDir, "server", "vinext-server.json"), "utf-8")),
|
|
).toEqual({ prerenderSecret: sharedSecret });
|
|
expect(fs.readFileSync(path.join(outDir, "server", "index.js"), "utf-8")).toContain(
|
|
sharedSecret,
|
|
);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.__VINEXT_SHARED_PRERENDER_SECRET;
|
|
else process.env.__VINEXT_SHARED_PRERENDER_SECRET = previous;
|
|
}
|
|
}, 30000);
|
|
|
|
it("adopts the shared build ID even when generateBuildId is set", async () => {
|
|
// The shared ID must win over a per-instance generateBuildId, because the
|
|
// CLI already resolved it through the user's generateBuildId once. A
|
|
// non-deterministic generateBuildId (e.g. returning null → a fresh random
|
|
// UUID per instance, per resolveBuildId()) would otherwise re-diverge across
|
|
// the buildApp() and hybrid Pages vite.build() instances — the exact bug
|
|
// this coordination exists to prevent.
|
|
const sharedBuildId = "shared-wins-over-generate-5678";
|
|
const previous = process.env.__VINEXT_SHARED_BUILD_ID;
|
|
process.env.__VINEXT_SHARED_BUILD_ID = sharedBuildId;
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: APP_FIXTURE_DIR,
|
|
configFile: false,
|
|
// generateBuildId returning null falls back to a random UUID per
|
|
// instance; the shared ID must still be adopted.
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR, nextConfig: { generateBuildId: () => null } })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
expect(fs.readFileSync(path.join(outDir, "server", "BUILD_ID"), "utf-8").trim()).toBe(
|
|
sharedBuildId,
|
|
);
|
|
const rscEntry = fs.readFileSync(path.join(outDir, "server", "index.js"), "utf-8");
|
|
expect(rscEntry).toContain(sharedBuildId);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.__VINEXT_SHARED_BUILD_ID;
|
|
else process.env.__VINEXT_SHARED_BUILD_ID = previous;
|
|
}
|
|
}, 30000);
|
|
|
|
it("adopts __VINEXT_SHARED_RSC_COMPATIBILITY_ID across the App Router build", async () => {
|
|
// Companion to the build-ID coordination: createRscCompatibilityId() mints a
|
|
// random UUID per plugin instance when no deploymentId is pinned, so a hybrid
|
|
// app+pages build would otherwise bake two different RSC-compat tokens. The
|
|
// CLI resolves it once and shares it via __VINEXT_SHARED_RSC_COMPATIBILITY_ID;
|
|
// the plugin always adopts it when set. Both the App Router server bundle and
|
|
// the client bundle (which compares its baked token against the server's
|
|
// X-Vinext-RSC-Compatibility-Id header) must carry the shared value.
|
|
const sharedCompatId = "shared-rsc-compat-id-9012";
|
|
const previous = process.env.__VINEXT_SHARED_RSC_COMPATIBILITY_ID;
|
|
process.env.__VINEXT_SHARED_RSC_COMPATIBILITY_ID = sharedCompatId;
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: APP_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
// The compat token is baked via Vite `define`; depending on chunking it
|
|
// can land in the RSC entry or a shared server/client chunk, so scan the
|
|
// whole server and client output trees. Both sides must carry the same
|
|
// adopted token — that is what lets the client reject mismatched RSC
|
|
// payloads (the X-Vinext-RSC-Compatibility-Id header check).
|
|
expect(readAllJs(path.join(outDir, "server"))).toContain(sharedCompatId);
|
|
expect(readAllJs(path.join(outDir, "client"))).toContain(sharedCompatId);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.__VINEXT_SHARED_RSC_COMPATIBILITY_ID;
|
|
else process.env.__VINEXT_SHARED_RSC_COMPATIBILITY_ID = previous;
|
|
}
|
|
}, 30000);
|
|
|
|
it("builds proxy.ts that reads __filename before redirecting", async () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/proxy-nfc-traced/proxy-nfc-traced.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-nfc-traced/proxy-nfc-traced.test.ts
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-proxy-cjs-globals-"));
|
|
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), `{"type":"module"}`);
|
|
fs.symlinkSync(
|
|
path.resolve(import.meta.dirname, "../node_modules"),
|
|
path.join(tmpDir, "node_modules"),
|
|
"junction",
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function Root({ children }: { children: React.ReactNode }) {
|
|
return <html><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`export default function Page() {
|
|
return <p>hello world</p>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "proxy.ts"),
|
|
`import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
|
|
export default function proxy(request: NextRequest) {
|
|
if (request.nextUrl.pathname === "/home") {
|
|
return NextResponse.redirect(new URL("/", request.url));
|
|
}
|
|
console.log(__filename);
|
|
return NextResponse.next();
|
|
}
|
|
`,
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const built: { default?: unknown } = await import(
|
|
pathToFileURL(path.join(tmpDir, "dist", "server", "index.js")).href
|
|
);
|
|
expect(isBuiltAppHandler(built.default)).toBe(true);
|
|
if (!isBuiltAppHandler(built.default)) return;
|
|
|
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
try {
|
|
const redirectResponse = await built.default(new Request("http://localhost/home"));
|
|
expect(redirectResponse).toBeInstanceOf(Response);
|
|
if (!(redirectResponse instanceof Response)) return;
|
|
expect(redirectResponse.status).toBe(307);
|
|
expect(redirectResponse.headers.get("location")).toBe("/");
|
|
|
|
const rootResponse = await built.default(new Request("http://localhost/"));
|
|
expect(rootResponse).toBeInstanceOf(Response);
|
|
if (!(rootResponse instanceof Response)) return;
|
|
expect(await rootResponse.text()).toContain("hello world");
|
|
expect(logSpy).toHaveBeenCalledWith(path.join(tmpDir, "dist", "server", "index.js"));
|
|
} finally {
|
|
logSpy.mockRestore();
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 120000);
|
|
|
|
it("serves TypeScript language service from a production route handler", async () => {
|
|
// Ported from Next.js: test/e2e/twoslash/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/twoslash/index.test.ts
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-twoslash-typescript-"));
|
|
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), `{"type":"module"}`);
|
|
fs.symlinkSync(
|
|
path.resolve(import.meta.dirname, "../node_modules"),
|
|
path.join(tmpDir, "node_modules"),
|
|
"junction",
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "route.ts"),
|
|
`import { API } from "typescript/unstable/sync";
|
|
import { createVirtualFileSystem } from "typescript/unstable/fs";
|
|
|
|
const code = "type X = Promise<number>;\\n'hello'.toUpperCase()";
|
|
|
|
export function GET(request) {
|
|
const root = "/vinext-typescript-api";
|
|
const fileName = root + "/input.ts";
|
|
const configFileName = root + "/tsconfig.json";
|
|
const compilerOptions = request.nextUrl.searchParams.has("esnext")
|
|
? { target: "ESNext", lib: ["ESNext", "DOM"] }
|
|
: {};
|
|
const fs = createVirtualFileSystem({
|
|
[fileName]: code,
|
|
[configFileName]: JSON.stringify({ compilerOptions, files: ["input.ts"] }),
|
|
});
|
|
const api = new API({ cwd: root, fs });
|
|
|
|
try {
|
|
const snapshot = api.updateSnapshot({ openProjects: [configFileName] });
|
|
const project = snapshot.getProjects()[0];
|
|
const promise = project.checker.getSymbolAtPosition(fileName, 9);
|
|
const upper = project.checker.getSymbolAtPosition(fileName, 34);
|
|
return Response.json({
|
|
promise:
|
|
promise && project.checker.typeToString(project.checker.getDeclaredTypeOfSymbol(promise)),
|
|
upper: upper && project.checker.typeToString(project.checker.getTypeOfSymbol(upper)),
|
|
});
|
|
} finally {
|
|
api.close();
|
|
}
|
|
}
|
|
`,
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const externals = JSON.parse(
|
|
fs.readFileSync(path.join(tmpDir, "dist", "server", "vinext-externals.json"), "utf-8"),
|
|
) as string[];
|
|
expect(externals).toContain("typescript");
|
|
|
|
const built: { default?: unknown } = await import(
|
|
`${pathToFileURL(path.join(tmpDir, "dist", "server", "index.js")).href}?t=${Date.now()}`
|
|
);
|
|
expect(isBuiltAppHandler(built.default)).toBe(true);
|
|
if (!isBuiltAppHandler(built.default)) return;
|
|
|
|
for (const mode of ["default", "esnext"]) {
|
|
const response = await built.default(new Request(`http://localhost/?${mode}`));
|
|
expect(response).toBeInstanceOf(Response);
|
|
if (!(response instanceof Response)) return;
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({
|
|
promise: "Promise<T>",
|
|
upper: "() => string",
|
|
});
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 120000);
|
|
|
|
it("fails the production build when proxy.ts has an invalid export", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-proxy-invalid-build-"));
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), `{"type":"module"}`);
|
|
fs.symlinkSync(
|
|
path.resolve(import.meta.dirname, "../node_modules"),
|
|
path.join(tmpDir, "node_modules"),
|
|
"junction",
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function Root({ children }: { children: React.ReactNode }) {
|
|
return <html><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`export default function Page() { return <p>hello world</p>; }\n`,
|
|
);
|
|
fs.writeFileSync(path.join(tmpDir, "proxy.ts"), `export function middleware() {}\n`);
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await expect(builder.buildApp()).rejects.toThrow(
|
|
'The file "./proxy.ts" must export a function, either as a default export or as a named "proxy" export.',
|
|
);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 120000);
|
|
|
|
// Next.js returns the value decoded from one branch of the callable-cache
|
|
// Flight stream and saves the other branch, so a serializer error rejects
|
|
// the call instead of persisting an error row.
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts
|
|
it('fails prerendering when a callable "use cache" result cannot be serialized', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-use-cache-serialization-"));
|
|
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), `{"type":"module"}`);
|
|
fs.symlinkSync(
|
|
path.resolve(import.meta.dirname, "../node_modules"),
|
|
path.join(tmpDir, "node_modules"),
|
|
"junction",
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function Root({ children }: { children: React.ReactNode }) {
|
|
return <html><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`export default function Page() { return <p>hello world</p>; }\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "opengraph-image.tsx"),
|
|
`import { ImageResponse } from "next/og";
|
|
|
|
export const size = { width: 1200, height: 630 };
|
|
|
|
export default async function OpenGraphImage() {
|
|
"use cache";
|
|
|
|
return new ImageResponse(
|
|
<div style={{ display: "flex", width: "100%", height: "100%" }}>
|
|
vinext use-cache serialization regression
|
|
</div>,
|
|
size,
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const built: { default?: unknown } = await import(
|
|
`${pathToFileURL(path.join(tmpDir, "dist", "server", "index.js")).href}?t=${Date.now()}`
|
|
);
|
|
expect(isBuiltAppHandler(built.default)).toBe(true);
|
|
if (!isBuiltAppHandler(built.default)) return;
|
|
|
|
await expect(built.default(new Request("http://localhost/opengraph-image"))).rejects.toThrow(
|
|
/Only plain objects/,
|
|
);
|
|
|
|
await expect(runPrerender({ root: tmpDir, concurrency: 1 })).rejects.toThrow(
|
|
/Metadata route returned 500/,
|
|
);
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 120000);
|
|
|
|
it("serves production build via preview server", async () => {
|
|
const { preview } = await import("vite");
|
|
|
|
const previewServer = await preview({
|
|
root: APP_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR })],
|
|
preview: { port: 0 },
|
|
logLevel: "silent",
|
|
});
|
|
|
|
const addr = previewServer.httpServer.address();
|
|
const previewUrl = addr && typeof addr === "object" ? `http://localhost:${addr.port}` : null;
|
|
expect(previewUrl).not.toBeNull();
|
|
|
|
try {
|
|
// Home page renders SSR HTML
|
|
const homeRes = await fetch(`${previewUrl}/`);
|
|
expect(homeRes.status).toBe(200);
|
|
const homeHtml = await homeRes.text();
|
|
expect(homeHtml).toContain("Welcome to App Router");
|
|
expect(homeHtml).toContain("<script");
|
|
expect(homeHtml).toMatch(
|
|
/<script[^>]+type="module"[^>]+src="\/_next\/static\/chunks\/[^"]+\.js"/,
|
|
);
|
|
|
|
// Dynamic route works
|
|
const blogRes = await fetch(`${previewUrl}/blog/test-post`);
|
|
expect(blogRes.status).toBe(200);
|
|
const blogHtml = await blogRes.text();
|
|
expect(blogHtml).toContain("Blog Post");
|
|
expect(blogHtml).toContain("test-post");
|
|
|
|
// Nested layout works
|
|
const dashRes = await fetch(`${previewUrl}/dashboard`);
|
|
expect(dashRes.status).toBe(200);
|
|
const dashHtml = await dashRes.text();
|
|
expect(dashHtml).toContain("Dashboard");
|
|
expect(dashHtml).toContain("dashboard-layout");
|
|
|
|
// 404 for nonexistent routes
|
|
const notFoundRes = await fetch(`${previewUrl}/no-such-page`);
|
|
expect(notFoundRes.status).toBe(404);
|
|
|
|
// RSC endpoint works
|
|
const rscRes = await fetch(`${previewUrl}/about.rsc`);
|
|
expect(rscRes.status).toBe(200);
|
|
expect(rscRes.headers.get("content-type")).toContain("text/x-component");
|
|
} finally {
|
|
previewServer.httpServer.close();
|
|
}
|
|
}, 30000);
|
|
|
|
it("emits and serves Pages client entry in hybrid builds with basePath + assetPrefix", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-hybrid-basepath-assetprefix-"));
|
|
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, "package.json"), `{"type":"module"}`);
|
|
fs.symlinkSync(
|
|
path.resolve(import.meta.dirname, "../node_modules"),
|
|
path.join(tmpDir, "node_modules"),
|
|
"junction",
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "layout.tsx"),
|
|
`export default function Root({ children }: { children: React.ReactNode }) {
|
|
return <html><body>{children}</body></html>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "app", "page.tsx"),
|
|
`export default function Page() {
|
|
return <p>App Router</p>;
|
|
}
|
|
`,
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, "pages"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "pages", "legacy.tsx"),
|
|
`export default function Legacy() {
|
|
return <p>Pages Router</p>;
|
|
}
|
|
`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, "next.config.mjs"),
|
|
`export default { basePath: "/app", assetPrefix: "/cdn" };`,
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: tmpDir })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const clientDir = path.join(tmpDir, "dist", "client");
|
|
const clientEntryManifestPath = path.join(clientDir, "vinext-client-entry-manifest.json");
|
|
expect(fs.existsSync(clientEntryManifestPath)).toBe(true);
|
|
|
|
const clientEntryManifest = JSON.parse(fs.readFileSync(clientEntryManifestPath, "utf-8"));
|
|
expect(clientEntryManifest.pagesClientEntry).toBeTruthy();
|
|
expect(clientEntryManifest.appBrowserEntry).toBeTruthy();
|
|
|
|
// The Pages client entry should be written under the assetPrefix path
|
|
// (cdn/_next/static/...) because assetPrefix is a path prefix.
|
|
const pagesEntryPath = clientEntryManifest.pagesClientEntry;
|
|
expect(pagesEntryPath.startsWith("cdn/_next/static/")).toBe(true);
|
|
expect(pagesEntryPath).toContain("vinext-client-entry");
|
|
|
|
// The App browser entry should also be under the assetPrefix path
|
|
const appEntryPath = clientEntryManifest.appBrowserEntry;
|
|
expect(appEntryPath.startsWith("cdn/_next/static/")).toBe(true);
|
|
expect(appEntryPath).toContain("index-");
|
|
|
|
// Import the RSC handler and verify the baked constants
|
|
const rscEntryPath = path.join(tmpDir, "dist", "server", "index.js");
|
|
const rscMtime = fs.statSync(rscEntryPath).mtimeMs;
|
|
const rscModule = await import(`${pathToFileURL(rscEntryPath).href}?t=${rscMtime}`);
|
|
expect(rscModule.__basePath).toBe("/app");
|
|
expect(rscModule.__assetPrefix).toBe("/cdn");
|
|
expect(rscModule.__hasPagesDir).toBe(true);
|
|
|
|
// Verify the RSC handler can serve the App route under basePath
|
|
const appResponse = await rscModule.default(new Request("http://localhost/app/"));
|
|
expect(appResponse.status).toBe(200);
|
|
const appHtml = await appResponse.text();
|
|
expect(appHtml).toContain("App Router");
|
|
|
|
// The App HTML should include the App browser entry script
|
|
// addressed through the assetPrefix (CDN) path, not basePath.
|
|
const appScriptMatch = appHtml.match(/<script[^>]+type="module"[^>]+src="\/cdn\/[^"]+\.js"/);
|
|
expect(appScriptMatch).toBeTruthy();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 120000);
|
|
});
|