From efa64541518531a524173ba98c35a75acee09f60 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:59:55 +1000 Subject: [PATCH] fix(build): externalize Next server packages --- .../src/config/server-external-packages.ts | 83 +++++ packages/vinext/src/index.ts | 93 +++--- tests/build-optimization.test.ts | 107 ++++++ .../prerender-native-module.test.ts | 314 ++++++++++++++++++ 4 files changed, 557 insertions(+), 40 deletions(-) create mode 100644 packages/vinext/src/config/server-external-packages.ts create mode 100644 tests/nextjs-compat/prerender-native-module.test.ts diff --git a/packages/vinext/src/config/server-external-packages.ts b/packages/vinext/src/config/server-external-packages.ts new file mode 100644 index 000000000..4dde1dbad --- /dev/null +++ b/packages/vinext/src/config/server-external-packages.ts @@ -0,0 +1,83 @@ +// Mirrors Next.js v16.2.6's default server-external package list. +// Source: packages/next/src/lib/server-external-packages.jsonc +export const NEXT_SERVER_EXTERNAL_PACKAGES = [ + "@alinea/generated", + "@appsignal/nodejs", + "@aws-sdk/client-s3", + "@aws-sdk/s3-presigned-post", + "@blockfrost/blockfrost-js", + "@highlight-run/node", + "@huggingface/transformers", + "@jpg-store/lucid-cardano", + "@libsql/client", + "@mikro-orm/core", + "@mikro-orm/knex", + "@node-rs/argon2", + "@node-rs/bcrypt", + "@prisma/client", + "@react-pdf/renderer", + "@sentry/profiling-node", + "@sparticuz/chromium", + "@sparticuz/chromium-min", + "@statsig/statsig-node-core", + "@swc/core", + "@xenova/transformers", + "@zenstackhq/runtime", + "argon2", + "autoprefixer", + "aws-crt", + "bcrypt", + "better-sqlite3", + "canvas", + "chromadb-default-embed", + "config", + "cpu-features", + "cypress", + "dd-trace", + "eslint", + "express", + "firebase-admin", + "htmlrewriter", + "import-in-the-middle", + "isolated-vm", + "jest", + "jsdom", + "keyv", + "libsql", + "mdx-bundler", + "mongodb", + "mongoose", + "newrelic", + "next-mdx-remote", + "next-seo", + "node-cron", + "node-pty", + "node-web-audio-api", + "onnxruntime-node", + "oslo", + "pg", + "pino", + "pino-pretty", + "pino-roll", + "playwright", + "playwright-core", + "postcss", + "prettier", + "prisma", + "puppeteer", + "puppeteer-core", + "ravendb", + "require-in-the-middle", + "rimraf", + "sharp", + "shiki", + "sqlite3", + "thread-stream", + "ts-morph", + "ts-node", + "typescript", + "vscode-oniguruma", + "webpack", + "websocket", + "zeromq", +] as const; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 916ca20db..8e4bfdb02 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -69,6 +69,7 @@ import { type NextConfigInput, type ResolvedNextConfig, } from "./config/next-config.js"; +import { NEXT_SERVER_EXTERNAL_PACKAGES } from "./config/server-external-packages.js"; import { findMiddlewareFile, runMiddleware } from "./server/middleware.js"; import { isNextDataPathname, parseNextDataPathname } from "./server/pages-data-route.js"; @@ -226,6 +227,10 @@ function isInsideDirectory(dir: string, filePath: string): boolean { return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); } +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} + function hasServerOnlyMarkerImport(code: string): boolean { if (!code.includes("server-only")) return false; @@ -1747,6 +1752,36 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Next emits CSS url() deps as files, not inlined data URLs. A user's // explicit `build.assetsInlineLimit` always wins. clientAssetsInlineLimit = config.build?.assetsInlineLimit ?? 0; + // Next.js skips bundling a default native/heavy package list plus + // `serverExternalPackages`. Apply that single policy to every Node + // server build boundary; Vite's top-level `ssr.*` does not cover + // custom RSC/SSR environments. + const configuredServerExternalPackages = nextConfig.serverExternalPackages; + const effectiveServerExternalPackages = uniqueStrings([ + ...NEXT_SERVER_EXTERNAL_PACKAGES, + ...configuredServerExternalPackages, + ]); + const userSsrExternal: string[] | true = Array.isArray(config.ssr?.external) + ? uniqueStrings([...config.ssr.external, ...effectiveServerExternalPackages]) + : config.ssr?.external === true + ? true + : effectiveServerExternalPackages; + const pagesNodeServerExternal: string[] | true = + userSsrExternal === true + ? true + : uniqueStrings([ + "react", + "react-dom", + "react-dom/server", + "ipaddr.js", + ...userSsrExternal, + ]); + const appRscServerExternal: string[] | true = + userSsrExternal === true + ? true + : uniqueStrings(["satori", "@resvg/resvg-js", "yoga-wasm-web", ...userSsrExternal]); + const appSsrServerExternal: string[] | true = + userSsrExternal === true ? true : uniqueStrings([...userSsrExternal, "ipaddr.js"]); const devHmrConfig = config.server?.hmr === false ? false @@ -1975,7 +2010,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? { ssr: { external: true as const } } : { ssr: { - external: ["react", "react-dom", "react-dom/server", "ipaddr.js"], + external: pagesNodeServerExternal, noExternal: true, }, }), @@ -2129,28 +2164,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; - // Collect user-provided ssr.external so we can propagate it into - // both the RSC and SSR environment configs. Vite's `ssr.*` config - // only applies to the default `ssr` environment, not custom ones - // like `rsc`. Native addon packages (e.g. better-sqlite3) listed - // in ssr.external must be externalized from ALL server environments. - // Vite's SSROptions.external is `string[] | true`; handle both forms. - // - // Also merge in `serverExternalPackages` from next.config (and the - // legacy `experimental.serverComponentsExternalPackages` alias). These - // are packages that Next.js intentionally skips bundling and loads - // natively — e.g. packages that import Node-specific entry points via - // conditional exports (like `file-type` which exports `fileTypeFromFile` - // only from its `node` condition, not from the universal `default` one). - // Without externalizing them, Vite's optimizer picks the wrong export - // condition and the build fails with MISSING_EXPORT errors. - const nextServerExternal: string[] = nextConfig?.serverExternalPackages ?? []; - const userSsrExternal: string[] | true = Array.isArray(config.ssr?.external) - ? [...config.ssr.external, ...nextServerExternal] - : config.ssr?.external === true - ? true - : nextServerExternal; - // Capture top-level optimizeDeps populated by earlier plugins // (e.g. @lingui/vite-plugin) so we merge rather than overwrite. // Moved above the hasAppDir branch so both Pages Router and App @@ -2237,10 +2250,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Note: Do NOT externalize react/react-dom here — they must // be bundled with the "react-server" condition for RSC. // Skip when targeting bundled runtimes (Cloudflare/Nitro). - external: - userSsrExternal === true - ? true - : ["satori", "@resvg/resvg-js", "yoga-wasm-web", ...userSsrExternal], + external: appRscServerExternal, // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. @@ -2280,7 +2290,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ? {} : { resolve: { - external: userSsrExternal === true ? true : [...userSsrExternal, "ipaddr.js"], + external: appSsrServerExternal, // Force all node_modules through Vite's transform pipeline // so non-JS imports (CSS, images) don't hit Node's native // ESM loader. Matches Next.js behavior of bundling everything. @@ -2332,17 +2342,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { consumer: "client", optimizeDeps: { // Exclude server-external packages from the client dep optimizer. - // These packages are server-only by design (listed in next.config's - // `serverExternalPackages`). If the client optimizer crawls into - // them through app/ entries, it will use browser export conditions - // and pick the wrong conditional export (e.g. `file-type` exports - // `fileTypeFromFile` only from its `node` condition via `index.js`, - // but the browser optimizer resolves to `core.js` which lacks it, - // causing MISSING_EXPORT build failures). + // These packages are server-only by design (Next.js defaults plus + // next.config's `serverExternalPackages`). If the client optimizer + // crawls into them through app/ entries, it will use browser export + // conditions and pick the wrong conditional export (e.g. `file-type` + // exports `fileTypeFromFile` only from its `node` condition via + // `index.js`, but the browser optimizer resolves to `core.js` which + // lacks it, causing MISSING_EXPORT build failures). exclude: mergeOptimizeDepsExclude( incomingExclude, VINEXT_OPTIMIZE_DEPS_EXCLUDE, - nextServerExternal, + effectiveServerExternalPackages, ), // Crawl app/ source files up front so client-only deps imported // by user components are discovered during startup instead of @@ -2429,10 +2439,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }, ssr: { - resolve: { - external: ["react", "react-dom", "react-dom/server", "ipaddr.js"], - noExternal: true as const, - }, + resolve: + pagesNodeServerExternal === true + ? { external: true as const } + : { + external: pagesNodeServerExternal, + noExternal: true as const, + }, optimizeDeps: { // `ipaddr.js` is imported by the next/image shim for // private-IP validation and is externalized via diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 4470fd2ce..b43c37ebd 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -34,6 +34,7 @@ import { collectAssetTags } from "../packages/vinext/src/server/pages-asset-tags import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; +import { NEXT_SERVER_EXTERNAL_PACKAGES } from "../packages/vinext/src/config/server-external-packages.js"; // Create a clientManualChunks instance with a test shims directory. // The exact path doesn't matter for the node_modules-focused tests; @@ -474,6 +475,112 @@ describe("optimizeDeps.exclude for vinext", () => { } }, 15000); + it("externalizes Next.js default server packages in App Router Node environments", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const plugins = vinext(); + const mainPlugin = plugins.find( + (p: any) => p.name === "vinext:config" && typeof p.config === "function", + ); + expect(mainPlugin).toBeDefined(); + + const os = await import("node:os"); + const fsp = await import("node:fs/promises"); + const path = await import("node:path"); + + const tmpDir = await fsp.mkdtemp( + path.join(os.tmpdir(), "vinext-ts-test-default-server-externals-app-"), + ); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { return

Home

; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "next.config.mjs"), + `export default { serverExternalPackages: ["custom-native-package"] };`, + ); + + try { + const mockConfig = { root: tmpDir, build: {}, plugins: [] }; + const result = await (mainPlugin as any).config(mockConfig, { + command: "serve", + }); + + expect(NEXT_SERVER_EXTERNAL_PACKAGES).toContain("sqlite3"); + + const rscExternal = result.environments.rsc.resolve?.external ?? []; + expect(rscExternal).toContain("sqlite3"); + expect(rscExternal).toContain("custom-native-package"); + + const ssrExternal = result.environments.ssr.resolve?.external ?? []; + expect(ssrExternal).toContain("sqlite3"); + expect(ssrExternal).toContain("custom-native-package"); + expect(ssrExternal).toContain("ipaddr.js"); + expect(result.environments.ssr.resolve?.noExternal).toBe(true); + + const clientExclude = result.environments.client.optimizeDeps?.exclude ?? []; + expect(clientExclude).toContain("sqlite3"); + expect(clientExclude).toContain("custom-native-package"); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + + it("externalizes Next.js default server packages in plain Pages Router Node builds", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const plugins = vinext(); + const mainPlugin = plugins.find( + (p: any) => p.name === "vinext:config" && typeof p.config === "function", + ); + expect(mainPlugin).toBeDefined(); + + const os = await import("node:os"); + const fsp = await import("node:fs/promises"); + const path = await import("node:path"); + + const tmpDir = await fsp.mkdtemp( + path.join(os.tmpdir(), "vinext-ts-test-default-server-externals-pages-"), + ); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, "pages", "index.tsx"), + `export default function Home() { return

Home

; }`, + ); + await fsp.writeFile( + path.join(tmpDir, "next.config.mjs"), + `export default { serverExternalPackages: ["custom-native-package"] };`, + ); + + try { + const mockConfig = { root: tmpDir, build: {}, plugins: [] }; + const result = await (mainPlugin as any).config(mockConfig, { + command: "serve", + }); + + const topLevelExternal = result.ssr?.external ?? []; + expect(topLevelExternal).toContain("sqlite3"); + expect(topLevelExternal).toContain("custom-native-package"); + expect(topLevelExternal).toContain("react"); + expect(result.ssr?.noExternal).toBe(true); + + const pagesExternal = result.environments?.ssr?.resolve?.external ?? []; + expect(pagesExternal).toContain("sqlite3"); + expect(pagesExternal).toContain("custom-native-package"); + expect(pagesExternal).toContain("react"); + expect(result.environments?.ssr?.resolve?.noExternal).toBe(true); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 15000); + // Regression: `ipaddr.js` is imported by the next/image client shim for // server-side private-IP validation. It's already in ssr.resolve.external, // but the SSR dep optimizer would still pre-bundle it on first request, diff --git a/tests/nextjs-compat/prerender-native-module.test.ts b/tests/nextjs-compat/prerender-native-module.test.ts new file mode 100644 index 000000000..0c8b2b623 --- /dev/null +++ b/tests/nextjs-compat/prerender-native-module.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { build } from "vite-plus"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import vinext from "../../packages/vinext/src/index.js"; + +let tmpRoot: string | undefined; + +async function symlinkWorkspacePackage(nodeModulesDir: string, packageName: string): Promise { + const workspaceNodeModules = path.resolve(import.meta.dirname, "../../node_modules"); + const target = path.join(workspaceNodeModules, ...packageName.split("/")); + const link = path.join(nodeModulesDir, ...packageName.split("/")); + + await fsp.mkdir(path.dirname(link), { recursive: true }); + await fsp.symlink(target, link, "junction"); +} + +async function writePrerenderNativeModuleFixture(root: string): Promise { + const nodeModulesDir = path.join(root, "node_modules"); + await fsp.mkdir(path.join(root, "pages", "blog"), { recursive: true }); + await fsp.mkdir(nodeModulesDir, { recursive: true }); + + await Promise.all( + ["react", "react-dom", "scheduler", "next", "ipaddr.js", "vite"].map((packageName) => + symlinkWorkspacePackage(nodeModulesDir, packageName), + ), + ); + + await fsp.writeFile( + path.join(root, "package.json"), + JSON.stringify({ private: true, type: "module" }, null, 2) + "\n", + ); + await fsp.writeFile( + path.join(root, "data.sqlite"), + JSON.stringify({ + users: [ + { id: 1, first_name: "john", last_name: "deux" }, + { id: 2, first_name: "zeit", last_name: "geist" }, + ], + }), + ); + + const sqliteDir = path.join(nodeModulesDir, "sqlite"); + await fsp.mkdir(sqliteDir, { recursive: true }); + await fsp.writeFile( + path.join(sqliteDir, "package.json"), + JSON.stringify({ name: "sqlite", version: "0.0.0-test", type: "module", main: "index.js" }), + ); + await fsp.writeFile( + path.join(sqliteDir, "index.js"), + `import fs from "node:fs/promises"; + +export async function open({ filename, driver }) { + if (typeof driver !== "function") { + throw new Error("expected sqlite3.Database driver"); + } + if (driver.externalMarker !== "sqlite3-native-external-marker") { + throw new Error("expected sqlite3.Database from the sqlite3 package"); + } + const data = JSON.parse(await fs.readFile(filename, "utf8")); + return { + async all(sql) { + if (sql !== "SELECT * FROM users") { + throw new Error("unexpected SQL: " + sql); + } + return data.users; + }, + }; +} +`, + ); + + const sqlite3Dir = path.join(nodeModulesDir, "sqlite3"); + await fsp.mkdir(sqlite3Dir, { recursive: true }); + await fsp.writeFile( + path.join(sqlite3Dir, "package.json"), + JSON.stringify({ name: "sqlite3", version: "0.0.0-test", main: "index.js" }), + ); + await fsp.writeFile(path.join(sqlite3Dir, "binding.node"), "native placeholder\n"); + await fsp.writeFile( + path.join(sqlite3Dir, "index.js"), + `const path = require("node:path"); + +class Database {} +Database.nativeBindingPath = path.join(__dirname, "binding.node"); +Database.externalMarker = "sqlite3-native-external-marker"; + +module.exports = { + Database, + dir: __dirname, +}; +`, + ); + + await fsp.writeFile( + path.join(root, "pages", "index.tsx"), + `export const getStaticProps = () => { + return { + props: { + index: true, + }, + }; +}; + +export default function Page(props) { + return ( + <> +

index page

+

{JSON.stringify(props)}

+ + ); +} +`, + ); + + await fsp.writeFile( + path.join(root, "pages", "blog", "[slug].tsx"), + `import path from "node:path"; +import { open } from "sqlite"; +import sqlite3 from "sqlite3"; +import { useRouter } from "next/router"; + +export const getStaticProps = async ({ params }) => { + const dbPath = path.join(process.cwd(), "data.sqlite"); + + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const users = await db.all("SELECT * FROM users"); + + return { + props: { + users, + blog: true, + params: params || null, + }, + }; +}; + +export const getStaticPaths = () => { + return { + paths: ["/blog/first"], + fallback: true, + }; +}; + +export default function Page(props) { + const router = useRouter(); + + if (router.isFallback) { + return "Loading..."; + } + + return ( + <> +

blog page

+

{JSON.stringify(props)}

+ + ); +} +`, + ); +} + +async function buildPagesFixtureToOutDir(root: string, outDir: string): Promise { + await build({ + root, + configFile: false, + plugins: [vinext({ disableAppRouter: true })], + logLevel: "silent", + build: { + outDir: path.join(outDir, "server"), + ssr: "virtual:vinext-server-entry", + rollupOptions: { output: { entryFileNames: "entry.js" } }, + }, + }); + + await build({ + root, + configFile: false, + plugins: [vinext({ disableAppRouter: true })], + logLevel: "silent", + build: { + outDir: path.join(outDir, "client"), + manifest: true, + ssrManifest: true, + rollupOptions: { input: "virtual:vinext-client-entry" }, + }, + }); +} + +function decodeHtmlText(text: string): string { + return text.replaceAll("&", "&").replaceAll(""", '"'); +} + +function elementText(html: string, id: string): string { + const match = html.match(new RegExp(`

([^<]*)

`)); + expect(match).not.toBeNull(); + return decodeHtmlText(match![1]); +} + +async function fetchHtml(baseUrl: string, pathname: string): Promise { + const response = await fetch(`${baseUrl}${pathname}`); + expect(response.status).toBe(200); + return response.text(); +} + +async function fetchJson(baseUrl: string, pathname: string): Promise { + const response = await fetch(`${baseUrl}${pathname}`); + expect(response.status).toBe(200); + return response.json(); +} + +function extractNextData(html: string): Record { + const match = html.match( + /]*\bid=["']__NEXT_DATA__["'])(?=[^>]*\btype=["']application\/json["'])[^>]*>(.*?)<\/script>/s, + ); + expect(match).not.toBeNull(); + return JSON.parse(match![1]); +} + +function expectedBlogProps(slug: string) { + return { + params: { slug }, + blog: true, + users: [ + { id: 1, first_name: "john", last_name: "deux" }, + { id: 2, first_name: "zeit", last_name: "geist" }, + ], + }; +} + +function expectNextDataPageProps(value: unknown, pageProps: Record): void { + expect(value).toMatchObject({ pageProps }); +} + +afterEach(async () => { + if (tmpRoot) { + await fsp.rm(tmpRoot, { recursive: true, force: true }); + tmpRoot = undefined; + } +}); + +// Ported from Next.js: test/e2e/prerender-native-module.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/prerender-native-module.test.ts +describe("prerender native module", () => { + it("renders static Pages routes that read sqlite3 during getStaticProps", async () => { + tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-prerender-native-module-")); + const outDir = path.join(tmpRoot, "dist"); + const originalCwd = process.cwd(); + + try { + await writePrerenderNativeModuleFixture(tmpRoot); + process.chdir(tmpRoot); + await buildPagesFixtureToOutDir(tmpRoot, outDir); + const serverEntry = await fsp.readFile(path.join(outDir, "server", "entry.js"), "utf8"); + expect(serverEntry).toContain('from"sqlite3"'); + expect(serverEntry).not.toContain("nativeBindingPath"); + expect(serverEntry).not.toContain("binding.node"); + + const { runPrerender } = await import("../../packages/vinext/src/build/run-prerender.js"); + await runPrerender({ + root: tmpRoot, + pagesBundlePath: path.join(outDir, "server", "entry.js"), + concurrency: 1, + }); + + const { startProdServer } = await import("../../packages/vinext/src/server/prod-server.js"); + const started = await startProdServer({ port: 0, host: "127.0.0.1", outDir }); + const server = "server" in started ? started.server : started; + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected production server to listen on a TCP port"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + const indexHtml = await fetchHtml(baseUrl, "/"); + expect(elementText(indexHtml, "index")).toBe("index page"); + expect(JSON.parse(elementText(indexHtml, "props"))).toEqual({ + index: true, + }); + + const firstHtml = await fetchHtml(baseUrl, "/blog/first"); + expect(elementText(firstHtml, "blog")).toBe("blog page"); + expect(JSON.parse(elementText(firstHtml, "props"))).toEqual(expectedBlogProps("first")); + + const secondFallbackHtml = await fetchHtml(baseUrl, "/blog/second"); + expect(secondFallbackHtml).toContain("Loading..."); + const secondFallbackData = extractNextData(secondFallbackHtml); + expect(secondFallbackData.isFallback).toBe(true); + const buildId = secondFallbackData.buildId; + if (typeof buildId !== "string") { + throw new Error("Expected __NEXT_DATA__.buildId to be a string"); + } + + const secondJson = await fetchJson(baseUrl, `/_next/data/${buildId}/blog/second.json`); + expectNextDataPageProps(secondJson, expectedBlogProps("second")); + + const secondHtml = await fetchHtml(baseUrl, "/blog/second"); + expect(elementText(secondHtml, "blog")).toBe("blog page"); + expect(JSON.parse(elementText(secondHtml, "props"))).toEqual(expectedBlogProps("second")); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + } finally { + process.chdir(originalCwd); + } + }, 120_000); +});