diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap new file mode 100644 index 00000000..ec9c517f --- /dev/null +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -0,0 +1,17146 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App Router entry templates > generateBrowserEntry snapshot 1`] = ` +" +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + encodeReply, + createTemporaryReferenceSet, +} from "@vitejs/plugin-rsc/browser"; +import { hydrateRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { setClientParams, setNavigationContext, toRscUrl, getPrefetchCache, getPrefetchedUrls, PREFETCH_CACHE_TTL } from "next/navigation"; + +let reactRoot; + +/** + * Convert the embedded RSC chunks back to a ReadableStream. + * Each chunk is a text string that needs to be encoded back to Uint8Array. + */ +function chunksToReadableStream(chunks) { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } + }); +} + +/** + * Create a ReadableStream from progressively-embedded RSC chunks. + * The server injects RSC data as + * + * Chunks are embedded as text strings (not byte arrays) since the RSC flight + * protocol is text-based. The browser entry encodes them back to Uint8Array. + * This is ~3x more compact than the previous byte-array format. + */ +function createRscEmbedTransform(embedStream) { + const reader = embedStream.getReader(); + const _decoder = new TextDecoder(); + let done = false; + let pendingChunks = []; + let reading = false; + + // Fix invalid preload "as" values in RSC Flight hint lines before + // they reach the client. React Flight emits HL hints with + // as="stylesheet" for CSS, but the HTML spec requires as="style" + // for . The fixPreloadAs() below only fixes the + // server-rendered HTML stream; this fixes the raw Flight data that + // gets embedded as __VINEXT_RSC_CHUNKS__ and processed client-side. + function fixFlightHints(text) { + // Flight hint format: :HL["url","stylesheet"] or with options + return text.replace(/(\\d+:HL\\[.*?),"stylesheet"(\\]|,)/g, '$1,"style"$2'); + } + + // Start reading RSC chunks in the background, accumulating them as text strings. + // The RSC flight protocol is text-based, so decoding to strings and embedding + // as JSON strings is ~3x more compact than the byte-array format. + async function pumpReader() { + if (reading) return; + reading = true; + try { + while (true) { + const result = await reader.read(); + if (result.done) { + done = true; + break; + } + const text = _decoder.decode(result.value, { stream: true }); + pendingChunks.push(fixFlightHints(text)); + } + } catch (err) { + if (process.env.NODE_ENV !== "production") { + console.warn("[vinext] RSC embed stream read error:", err); + } + done = true; + } + reading = false; + } + + // Fire off the background reader immediately + const pumpPromise = pumpReader(); + + return { + /** + * Flush any accumulated RSC chunks as "; + } + return scripts; + }, + + /** + * Wait for the RSC stream to fully complete and return any final + * script tags plus the closing signal. + */ + async finalize() { + await pumpPromise; + let scripts = this.flush(); + // Signal that all RSC chunks have been sent. + // Params are already embedded in — no need to include here. + scripts += ""; + return scripts; + }, + }; +} + +/** + * Render the RSC stream to HTML. + * + * @param rscStream - The RSC payload stream from the RSC environment + * @param navContext - Navigation context for client component SSR hooks. + * "use client" components like those using usePathname() need the current + * request URL during SSR, and they run in this SSR environment (separate + * from the RSC environment where the context was originally set). + * @param fontData - Font links and styles collected from the RSC environment. + * Fonts are loaded during RSC rendering (when layout calls Geist() etc.), + * and the data needs to be passed to SSR since they're separate module instances. + */ +export async function handleSsr(rscStream, navContext, fontData) { + // Wrap in a navigation ALS scope for per-request isolation in the SSR + // environment. The SSR environment has separate module instances from RSC, + // so it needs its own ALS scope. + return _runWithNavCtx(async () => { + // Set navigation context so hooks like usePathname() work during SSR + // of "use client" components + if (navContext) { + setNavigationContext(navContext); + } + + // Clear any stale callbacks from previous requests + const { clearServerInsertedHTML, flushServerInsertedHTML, useServerInsertedHTML: _addInsertedHTML } = await import("next/navigation"); + clearServerInsertedHTML(); + + try { + // Tee the RSC stream - one for SSR rendering, one for embedding in HTML. + // This ensures the browser uses the SAME RSC payload for hydration that + // was used to generate the HTML, avoiding hydration mismatches (React #418). + const [ssrStream, embedStream] = rscStream.tee(); + + // Create the progressive RSC embed helper — it reads the embed stream + // in the background and provides script tags to inject into the HTML stream. + const rscEmbed = createRscEmbedTransform(embedStream); + + // Deserialize RSC stream back to React VDOM. + // IMPORTANT: Do NOT await this — createFromReadableStream returns a thenable + // that React's renderToReadableStream can consume progressively. By passing + // the unresolved thenable, React will render Suspense fallbacks (loading.tsx) + // immediately in the HTML shell, then stream in resolved content as RSC + // chunks arrive. Awaiting here would block until all async server components + // complete, collapsing the streaming behavior. + // Lazily create the Flight root inside render so React's hook dispatcher is set + // (avoids React 19 dev-mode resolveErrorDev() crash). VinextFlightRoot returns + // a thenable (not a ReactNode), which React 19 consumes via its internal + // thenable-as-child suspend/resume behavior. This matches Next.js's approach. + let flightRoot; + function VinextFlightRoot() { + if (!flightRoot) { + flightRoot = createFromReadableStream(ssrStream); + } + return flightRoot; + } + const root = _ssrCE(VinextFlightRoot); + + // Wrap with ServerInsertedHTMLContext.Provider so libraries that use + // useContext(ServerInsertedHTMLContext) (Apollo Client, styled-components, + // etc.) get a working callback registration function during SSR. + // The provider value is useServerInsertedHTML — same function that direct + // callers use — so both paths push to the same ALS-backed callback array. + const ssrRoot = ServerInsertedHTMLContext + ? _ssrCE(ServerInsertedHTMLContext.Provider, { value: _addInsertedHTML }, root) + : root; + + // Get the bootstrap script content for the browser entry + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent("index"); + + // djb2 hash for digest generation in the SSR environment. + // Matches the RSC environment's __errorDigest function. + function ssrErrorDigest(str) { + let hash = 5381; + for (let i = str.length - 1; i >= 0; i--) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(); + } + + // Render HTML (streaming SSR) + // useServerInsertedHTML callbacks are registered during this render. + // The onError callback preserves the digest for Next.js navigation errors + // (redirect, notFound, forbidden, unauthorized) thrown inside Suspense + // boundaries during RSC streaming. Without this, React's default onError + // returns undefined and the digest is lost in the $RX() call, preventing + // client-side error boundaries from identifying the error type. + // In production, non-navigation errors also get a digest hash so they + // can be correlated with server logs without leaking details to clients. + const htmlStream = await renderToReadableStream(ssrRoot, { + bootstrapScriptContent, + onError(error) { + if (error && typeof error === "object" && "digest" in error) { + return String(error.digest); + } + // In production, generate a digest hash for non-navigation errors + if (process.env.NODE_ENV === "production" && error) { + const msg = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? (error.stack || "") : ""; + return ssrErrorDigest(msg + stack); + } + return undefined; + }, + }); + + + // Flush useServerInsertedHTML callbacks (CSS-in-JS style injection) + const insertedElements = flushServerInsertedHTML(); + + // Render the inserted elements to HTML strings + const { Fragment } = await import("react"); + let insertedHTML = ""; + for (const el of insertedElements) { + try { + insertedHTML += renderToStaticMarkup(_ssrCE(Fragment, null, el)); + } catch { + // Skip elements that can't be rendered + } + } + + // Escape HTML attribute values (defense-in-depth for font URLs/types). + function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } + + // Build font HTML from data passed from RSC environment + // (Fonts are loaded during RSC rendering, and RSC/SSR are separate module instances) + let fontHTML = ""; + if (fontData) { + if (fontData.links && fontData.links.length > 0) { + for (const url of fontData.links) { + fontHTML += '\\n'; + } + } + // Emit for local font files + if (fontData.preloads && fontData.preloads.length > 0) { + for (const preload of fontData.preloads) { + fontHTML += '\\n'; + } + } + if (fontData.styles && fontData.styles.length > 0) { + fontHTML += '\\n'; + } + } + + // Extract client entry module URL from bootstrapScriptContent to emit + // a hint. The RSC plugin formats bootstrap + // content as: import("URL") — we extract the URL so the browser can + // speculatively fetch and parse the JS module while still processing + // the HTML body, instead of waiting until it reaches the inline script. + let modulePreloadHTML = ""; + if (bootstrapScriptContent) { + const m = bootstrapScriptContent.match(/import\\("([^"]+)"\\)/); + if (m && m[1]) { + modulePreloadHTML = '\\n'; + } + } + + // Head-injected HTML: server-inserted HTML, font HTML, route params, + // and modulepreload hints. + // RSC payload is now embedded progressively via script tags in the body stream. + // Params are embedded eagerly in so they're available before client + // hydration starts, avoiding the need for polling on the client. + const paramsScript = ''; + // Embed the initial navigation context (pathname + searchParams) so the + // browser useSyncExternalStore getServerSnapshot can return the correct + // value during hydration. Without this, getServerSnapshot returns "/" and + // React detects a mismatch against the SSR-rendered HTML. + // Serialise searchParams as an array of [key, value] pairs to preserve + // duplicate keys (e.g. ?tag=a&tag=b). Object.fromEntries() would keep + // only the last value, causing a hydration mismatch for multi-value params. + const __navPayload = { pathname: navContext?.pathname ?? '/', searchParams: navContext?.searchParams ? [...navContext.searchParams.entries()] : [] }; + const navScript = ''; + const injectHTML = paramsScript + navScript + modulePreloadHTML + insertedHTML + fontHTML; + + // Inject the collected HTML before and progressively embed RSC + // chunks as script tags throughout the HTML body stream. + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let injected = false; + + // Fix invalid preload "as" values in server-rendered HTML. + // React Fizz emits for CSS, + // but the HTML spec requires as="style" for . + // Note: fixFlightHints() in createRscEmbedTransform handles the + // complementary case — fixing the raw Flight stream data before + // it's embedded as __VINEXT_RSC_CHUNKS__ for client-side processing. + // See: https://html.spec.whatwg.org/multipage/links.html#link-type-preload + function fixPreloadAs(html) { + // Match in any attribute order + return html.replace(/]*\\srel="preload")[^>]*>/g, function(tag) { + return tag.replace(' as="stylesheet"', ' as="style"'); + }); + } + + // Tick-buffered RSC script injection. + // + // React's renderToReadableStream (Fizz) flushes chunks synchronously + // within one microtask — all chunks from a single flushCompletedQueues + // call arrive in the same macrotask. We buffer HTML chunks as they + // arrive, then use setTimeout(0) to defer emitting them plus any + // accumulated RSC scripts to the next macrotask. This guarantees we + // never inject '); + } + if (m) { + // Always inject shared chunks (framework, vinext runtime, entry) and + // page-specific chunks. The manifest maps module file paths to their + // associated JS/CSS assets. + // + // For page-specific injection, the module IDs may be absolute paths + // while the manifest uses relative paths. Try both the original ID + // and a suffix match to find the correct manifest entry. + var allFiles = []; + + if (moduleIds && moduleIds.length > 0) { + // Collect assets for the requested page modules + for (var mi = 0; mi < moduleIds.length; mi++) { + var id = moduleIds[mi]; + var files = m[id]; + if (!files) { + // Absolute path didn't match — try matching by suffix. + // Manifest keys are relative (e.g. "pages/about.tsx") while + // moduleIds may be absolute (e.g. "/home/.../pages/about.tsx"). + for (var mk in m) { + if (id.endsWith("/" + mk) || id === mk) { + files = m[mk]; + break; + } + } + } + if (files) { + for (var fi = 0; fi < files.length; fi++) allFiles.push(files[fi]); + } + } + + // Also inject shared chunks that every page needs: framework, + // vinext runtime, and the entry bootstrap. These are identified + // by scanning all manifest values for chunk filenames containing + // known prefixes. + for (var key in m) { + var vals = m[key]; + if (!vals) continue; + for (var vi = 0; vi < vals.length; vi++) { + var file = vals[vi]; + var basename = file.split("/").pop() || ""; + if ( + basename.startsWith("framework-") || + basename.startsWith("vinext-") || + basename.includes("vinext-client-entry") || + basename.includes("vinext-app-browser-entry") + ) { + allFiles.push(file); + } + } + } + } else { + // No specific modules — include all assets from manifest + for (var akey in m) { + var avals = m[akey]; + if (avals) { + for (var ai = 0; ai < avals.length; ai++) allFiles.push(avals[ai]); + } + } + } + + for (var ti = 0; ti < allFiles.length; ti++) { + var tf = allFiles[ti]; + // Normalize: Vite's SSR manifest values include a leading '/' + // (from base path), but we prepend '/' ourselves when building + // href/src attributes. Strip any existing leading slash to avoid + // producing protocol-relative URLs like "//assets/chunk.js". + // This also ensures consistent keys for the seen-set dedup and + // lazySet.has() checks (which use values without leading slash). + if (tf.charAt(0) === '/') tf = tf.slice(1); + if (seen.has(tf)) continue; + seen.add(tf); + if (tf.endsWith(".css")) { + tags.push(''); + } else if (tf.endsWith(".js")) { + // Skip lazy chunks — they are behind dynamic import() boundaries + // (React.lazy, next/dynamic) and should only be fetched on demand. + if (lazySet && lazySet.has(tf)) continue; + tags.push(''); + tags.push(''); + } + } + } + return tags.join("\\n "); +} + +// i18n helpers +function extractLocale(url) { + if (!i18nConfig) return { locale: undefined, url, hadPrefix: false }; + const pathname = url.split("?")[0]; + const parts = pathname.split("/").filter(Boolean); + const query = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + if (parts.length > 0 && i18nConfig.locales.includes(parts[0])) { + const locale = parts[0]; + const rest = "/" + parts.slice(1).join("/"); + return { locale, url: (rest || "/") + query, hadPrefix: true }; + } + return { locale: i18nConfig.defaultLocale, url, hadPrefix: false }; +} + +function detectLocaleFromHeaders(headers) { + if (!i18nConfig) return null; + const acceptLang = headers.get("accept-language"); + if (!acceptLang) return null; + const langs = acceptLang.split(",").map(function(part) { + const pieces = part.trim().split(";"); + const q = pieces[1] ? parseFloat(pieces[1].replace("q=", "")) : 1; + return { lang: pieces[0].trim().toLowerCase(), q: q }; + }).sort(function(a, b) { return b.q - a.q; }); + for (let k = 0; k < langs.length; k++) { + const lang = langs[k].lang; + for (let j = 0; j < i18nConfig.locales.length; j++) { + if (i18nConfig.locales[j].toLowerCase() === lang) return i18nConfig.locales[j]; + } + const prefix = lang.split("-")[0]; + for (let j = 0; j < i18nConfig.locales.length; j++) { + const loc = i18nConfig.locales[j].toLowerCase(); + if (loc === prefix || loc.startsWith(prefix + "-")) return i18nConfig.locales[j]; + } + } + return null; +} + +function parseCookieLocaleFromHeader(cookieHeader) { + if (!i18nConfig || !cookieHeader) return null; + const match = cookieHeader.match(/(?:^|;\\s*)NEXT_LOCALE=([^;]*)/); + if (!match) return null; + var value; + try { value = decodeURIComponent(match[1].trim()); } catch (e) { return null; } + if (i18nConfig.locales.indexOf(value) !== -1) return value; + return null; +} + +function parseCookies(cookieHeader) { + const cookies = {}; + if (!cookieHeader) return cookies; + for (const part of cookieHeader.split(";")) { + const [key, ...rest] = part.split("="); + if (key) cookies[key.trim()] = rest.join("=").trim(); + } + return cookies; +} + +// Lightweight req/res facade for getServerSideProps and API routes. +// Next.js pages expect ctx.req/ctx.res with Node-like shapes. +function createReqRes(request, url, query, body) { + const headersObj = {}; + for (const [k, v] of request.headers) headersObj[k.toLowerCase()] = v; + + const req = { + method: request.method, + url: url, + headers: headersObj, + query: query, + body: body, + cookies: parseCookies(request.headers.get("cookie")), + }; + + let resStatusCode = 200; + const resHeaders = {}; + // set-cookie needs array support (multiple Set-Cookie headers are common) + const setCookieHeaders = []; + let resBody = null; + let ended = false; + let resolveResponse; + const responsePromise = new Promise(function(r) { resolveResponse = r; }); + + const res = { + get statusCode() { return resStatusCode; }, + set statusCode(code) { resStatusCode = code; }, + writeHead: function(code, headers) { + resStatusCode = code; + if (headers) { + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === "set-cookie") { + if (Array.isArray(v)) { for (const c of v) setCookieHeaders.push(c); } + else { setCookieHeaders.push(v); } + } else { + resHeaders[k] = v; + } + } + } + return res; + }, + setHeader: function(name, value) { + if (name.toLowerCase() === "set-cookie") { + if (Array.isArray(value)) { for (const c of value) setCookieHeaders.push(c); } + else { setCookieHeaders.push(value); } + } else { + resHeaders[name.toLowerCase()] = value; + } + return res; + }, + getHeader: function(name) { + if (name.toLowerCase() === "set-cookie") return setCookieHeaders.length > 0 ? setCookieHeaders : undefined; + return resHeaders[name.toLowerCase()]; + }, + end: function(data) { + if (ended) return; + ended = true; + if (data !== undefined && data !== null) resBody = data; + const h = new Headers(resHeaders); + for (const c of setCookieHeaders) h.append("set-cookie", c); + resolveResponse(new Response(resBody, { status: resStatusCode, headers: h })); + }, + status: function(code) { resStatusCode = code; return res; }, + json: function(data) { + resHeaders["content-type"] = "application/json"; + res.end(JSON.stringify(data)); + }, + send: function(data) { + if (typeof data === "object" && data !== null) { res.json(data); } + else { if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; res.end(String(data)); } + }, + redirect: function(statusOrUrl, url2) { + if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); } + else { res.writeHead(statusOrUrl, { Location: url2 }); } + res.end(); + }, + getHeaders: function() { + var h = Object.assign({}, resHeaders); + if (setCookieHeaders.length > 0) h["set-cookie"] = setCookieHeaders; + return h; + }, + get headersSent() { return ended; }, + }; + + return { req, res, responsePromise }; +} + +/** + * Read request body as text with a size limit. + * Throws if the body exceeds maxBytes. This prevents DoS via chunked + * transfer encoding where Content-Length is absent or spoofed. + */ +async function readBodyWithLimit(request, maxBytes) { + if (!request.body) return ""; + var reader = request.body.getReader(); + var decoder = new TextDecoder(); + var chunks = []; + var totalSize = 0; + for (;;) { + var result = await reader.read(); + if (result.done) break; + totalSize += result.value.byteLength; + if (totalSize > maxBytes) { + reader.cancel(); + throw new Error("Request body too large"); + } + chunks.push(decoder.decode(result.value, { stream: true })); + } + chunks.push(decoder.decode()); + return chunks.join(""); +} + +export async function renderPage(request, url, manifest) { + const localeInfo = extractLocale(url); + const locale = localeInfo.locale; + const routeUrl = localeInfo.url; + const cookieHeader = request.headers.get("cookie") || ""; + + // i18n redirect: check NEXT_LOCALE cookie first, then Accept-Language + if (i18nConfig && !localeInfo.hadPrefix) { + const cookieLocale = parseCookieLocaleFromHeader(cookieHeader); + if (cookieLocale && cookieLocale !== i18nConfig.defaultLocale) { + return new Response(null, { status: 307, headers: { Location: "/" + cookieLocale + routeUrl } }); + } + if (!cookieLocale && i18nConfig.localeDetection !== false) { + const detected = detectLocaleFromHeaders(request.headers); + if (detected && detected !== i18nConfig.defaultLocale) { + return new Response(null, { status: 307, headers: { Location: "/" + detected + routeUrl } }); + } + } + } + + const match = matchRoute(routeUrl, pageRoutes); + if (!match) { + return new Response("

404 - Page not found

", + { status: 404, headers: { "Content-Type": "text/html" } }); + } + + const { route, params } = match; + return runWithRouterState(() => + runWithHeadState(() => + _runWithCacheState(() => + runWithPrivateCache(() => + runWithFetchCache(async () => { + try { + if (typeof setSSRContext === "function") { + setSSRContext({ + pathname: routeUrl.split("?")[0], + query: { ...params, ...parseQuery(routeUrl) }, + asPath: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + }); + } + + if (i18nConfig) { + globalThis.__VINEXT_LOCALE__ = locale; + globalThis.__VINEXT_LOCALES__ = i18nConfig.locales; + globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale; + } + + const pageModule = route.module; + const PageComponent = pageModule.default; + if (!PageComponent) { + return new Response("Page has no default export", { status: 500 }); + } + + // Handle getStaticPaths for dynamic routes + if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) { + const pathsResult = await pageModule.getStaticPaths({ + locales: i18nConfig ? i18nConfig.locales : [], + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "", + }); + const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false; + + if (fallback === false) { + const paths = pathsResult && pathsResult.paths ? pathsResult.paths : []; + const isValidPath = paths.some(function(p) { + return Object.entries(p.params).every(function(entry) { + var key = entry[0], val = entry[1]; + var actual = params[key]; + if (Array.isArray(val)) { + return Array.isArray(actual) && val.join("/") === actual.join("/"); + } + return String(val) === String(actual); + }); + }); + if (!isValidPath) { + return new Response("

404 - Page not found

", + { status: 404, headers: { "Content-Type": "text/html" } }); + } + } + } + + let pageProps = {}; + var gsspRes = null; + if (typeof pageModule.getServerSideProps === "function") { + const { req, res, responsePromise } = createReqRes(request, routeUrl, parseQuery(routeUrl), undefined); + const ctx = { + params, req, res, + query: parseQuery(routeUrl), + resolvedUrl: routeUrl, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + }; + const result = await pageModule.getServerSideProps(ctx); + // If gSSP called res.end() directly (short-circuit), return that response. + if (res.headersSent) { + return await responsePromise; + } + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); + return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + // Preserve the res object so headers/status/cookies set by gSSP + // can be merged into the final HTML response. + gsspRes = res; + } + // Build font Link header early so it's available for ISR cached responses too. + // Font preloads are module-level state populated at import time and persist across requests. + var _fontLinkHeader = ""; + var _allFp = []; + try { + var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : []; + var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : []; + _allFp = _fpGoogle.concat(_fpLocal); + if (_allFp.length > 0) { + _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", "); + } + } catch (e) { /* font preloads not available */ } + + let isrRevalidateSeconds = null; + if (typeof pageModule.getStaticProps === "function") { + const pathname = routeUrl.split("?")[0]; + const cacheKey = "pages:" + (pathname === "/" ? "/" : pathname.replace(/\\/$/, "")); + const cached = await isrGet(cacheKey); + + if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + var _hitHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "HIT", + "Cache-Control": "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate", + }; + if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders }); + } + + if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") { + triggerBackgroundRegeneration(cacheKey, async function() { + const freshResult = await pageModule.getStaticProps({ params }); + if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) { + await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate); + } + }); + var _staleHeaders = { + "Content-Type": "text/html", "X-Vinext-Cache": "STALE", + "Cache-Control": "s-maxage=0, stale-while-revalidate", + }; + if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader; + return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders }); + } + + const ctx = { + params, + locale: locale, + locales: i18nConfig ? i18nConfig.locales : undefined, + defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined, + }; + const result = await pageModule.getStaticProps(ctx); + if (result && result.props) pageProps = result.props; + if (result && result.redirect) { + var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307); + return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); + } + if (result && result.notFound) { + return new Response("404", { status: 404 }); + } + if (typeof result.revalidate === "number" && result.revalidate > 0) { + isrRevalidateSeconds = result.revalidate; + } + } + + let element; + if (AppComponent) { + element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + element = React.createElement(PageComponent, pageProps); + } + element = wrapWithRouterContext(element); + + if (typeof resetSSRHead === "function") resetSSRHead(); + if (typeof flushPreloads === "function") await flushPreloads(); + + const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : ""; + + // Collect SSR font data (Google Font links, font preloads, font-face styles) + var fontHeadHTML = ""; + function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } + try { + var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : []; + for (var fl of fontLinks) { fontHeadHTML += '\\n '; } + } catch (e) { /* next/font/google not used */ } + // Emit for all font files (reuse _allFp collected earlier for Link header) + for (var fp of _allFp) { fontHeadHTML += '\\n '; } + try { + var allFontStyles = []; + if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle()); + if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal()); + if (allFontStyles.length > 0) { fontHeadHTML += '\\n '; } + } catch (e) { /* font styles not available */ } + + const pageModuleIds = route.filePath ? [route.filePath] : []; + const assetTags = collectAssetTags(manifest, pageModuleIds); + const nextDataPayload = { + props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, isFallback: false, + }; + if (i18nConfig) { + nextDataPayload.locale = locale; + nextDataPayload.locales = i18nConfig.locales; + nextDataPayload.defaultLocale = i18nConfig.defaultLocale; + } + const localeGlobals = i18nConfig + ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) + + ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) + + ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale) + : ""; + const nextDataScript = ""; + + // Build the document shell with a placeholder for the streamed body + var BODY_MARKER = ""; + var shellHtml; + if (DocumentComponent) { + const docElement = React.createElement(DocumentComponent); + shellHtml = await renderToStringAsync(docElement); + shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); + if (ssrHeadHTML || assetTags || fontHeadHTML) { + shellHtml = shellHtml.replace("", " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n"); + } + shellHtml = shellHtml.replace("", nextDataScript); + if (!shellHtml.includes("__NEXT_DATA__")) { + shellHtml = shellHtml.replace("", " " + nextDataScript + "\\n"); + } + } else { + shellHtml = "\\n\\n\\n \\n \\n " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n\\n\\n
" + BODY_MARKER + "
\\n " + nextDataScript + "\\n\\n"; + } + + if (typeof setSSRContext === "function") setSSRContext(null); + + // Split the shell at the body marker + var markerIdx = shellHtml.indexOf(BODY_MARKER); + var shellPrefix = shellHtml.slice(0, markerIdx); + var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); + + // Start the React body stream — progressive SSR (no allReady wait) + var bodyStream = await renderToReadableStream(element); + var encoder = new TextEncoder(); + + // Create a composite stream: prefix + body + suffix + var compositeStream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(shellPrefix)); + var reader = bodyStream.getReader(); + try { + for (;;) { + var chunk = await reader.read(); + if (chunk.done) break; + controller.enqueue(chunk.value); + } + } finally { + reader.releaseLock(); + } + controller.enqueue(encoder.encode(shellSuffix)); + controller.close(); + } + }); + + // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { + // Tee the stream so we can cache and respond simultaneously would be ideal, + // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. + var isrElement; + if (AppComponent) { + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + isrElement = React.createElement(PageComponent, pageProps); + } + isrElement = wrapWithRouterContext(isrElement); + var isrHtml = await renderToStringAsync(isrElement); + var fullHtml = shellPrefix + isrHtml + shellSuffix; + var isrPathname = url.split("?")[0]; + var isrCacheKey = "pages:" + (isrPathname === "/" ? "/" : isrPathname.replace(/\\/$/, "")); + await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + } + + // Merge headers/status/cookies set by getServerSideProps on the res object. + // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). + var finalStatus = 200; + const responseHeaders = new Headers({ "Content-Type": "text/html" }); + if (gsspRes) { + finalStatus = gsspRes.statusCode; + var gsspHeaders = gsspRes.getHeaders(); + for (var hk of Object.keys(gsspHeaders)) { + var hv = gsspHeaders[hk]; + if (hk === "set-cookie" && Array.isArray(hv)) { + for (var sc of hv) responseHeaders.append("set-cookie", sc); + } else if (hv != null) { + responseHeaders.set(hk, String(hv)); + } + } + // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) + responseHeaders.set("Content-Type", "text/html"); + } + if (isrRevalidateSeconds) { + responseHeaders.set("Cache-Control", "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate"); + responseHeaders.set("X-Vinext-Cache", "MISS"); + } + // Set HTTP Link header for font preloading + if (_fontLinkHeader) { + responseHeaders.set("Link", _fontLinkHeader); + } + return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + } catch (e) { + console.error("[vinext] SSR error:", e); + return new Response("Internal Server Error", { status: 500 }); + } + }) // end runWithFetchCache + ) // end runWithPrivateCache + ) // end _runWithCacheState + ) // end runWithHeadState + ); // end runWithRouterState +} + +export async function handleApiRoute(request, url) { + const match = matchRoute(url, apiRoutes); + if (!match) { + return new Response("404 - API route not found", { status: 404 }); + } + + const { route, params } = match; + const handler = route.module.default; + if (typeof handler !== "function") { + return new Response("API route does not export a default function", { status: 500 }); + } + + const query = { ...params }; + const qs = url.split("?")[1]; + if (qs) { + for (const [k, v] of new URLSearchParams(qs)) { + if (k in query) { + // Multi-value: promote to array (Next.js returns string[] for duplicate keys) + query[k] = Array.isArray(query[k]) ? query[k].concat(v) : [query[k], v]; + } else { + query[k] = v; + } + } + } + + // Parse request body (enforce 1MB limit to prevent memory exhaustion, + // matching Next.js default bodyParser sizeLimit). + // Check Content-Length first as a fast path, then enforce on the actual + // stream to prevent bypasses via chunked transfer encoding. + const contentLength = parseInt(request.headers.get("content-length") || "0", 10); + if (contentLength > 1 * 1024 * 1024) { + return new Response("Request body too large", { status: 413 }); + } + let body; + const ct = request.headers.get("content-type") || ""; + let rawBody; + try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); } + catch { return new Response("Request body too large", { status: 413 }); } + if (!rawBody) { + body = undefined; + } else if (ct.includes("application/json")) { + try { body = JSON.parse(rawBody); } catch { body = rawBody; } + } else { + body = rawBody; + } + + const { req, res, responsePromise } = createReqRes(request, url, query, body); + + try { + await handler(req, res); + // If handler didn't call res.end(), end it now. + // The end() method is idempotent — safe to call twice. + res.end(); + return await responsePromise; + } catch (e) { + console.error("[vinext] API error:", e); + return new Response("Internal Server Error", { status: 500 }); + } +} + + +// --- Middleware support (generated from middleware-codegen.ts) --- + +function __normalizePath(pathname) { + if ( + pathname === "/" || + (pathname.length > 1 && + pathname[0] === "/" && + !pathname.includes("//") && + !pathname.includes("/./") && + !pathname.includes("/../") && + !pathname.endsWith("/.") && + !pathname.endsWith("/..")) + ) { + return pathname; + } + var segments = pathname.split("/"); + var resolved = []; + for (var i = 0; i < segments.length; i++) { + var seg = segments[i]; + if (seg === "" || seg === ".") continue; + if (seg === "..") { resolved.pop(); } + else { resolved.push(seg); } + } + return "/" + resolved.join("/"); +} + +function __isSafeRegex(pattern) { + var quantifierAtDepth = []; + var depth = 0; + var i = 0; + while (i < pattern.length) { + var ch = pattern[i]; + if (ch === "\\\\") { i += 2; continue; } + if (ch === "[") { + i++; + while (i < pattern.length && pattern[i] !== "]") { + if (pattern[i] === "\\\\") i++; + i++; + } + i++; + continue; + } + if (ch === "(") { + depth++; + if (quantifierAtDepth.length <= depth) quantifierAtDepth.push(false); + else quantifierAtDepth[depth] = false; + i++; + continue; + } + if (ch === ")") { + var hadQ = depth > 0 && quantifierAtDepth[depth]; + if (depth > 0) depth--; + var next = pattern[i + 1]; + if (next === "+" || next === "*" || next === "{") { + if (hadQ) return false; + if (depth >= 0 && depth < quantifierAtDepth.length) quantifierAtDepth[depth] = true; + } + i++; + continue; + } + if (ch === "+" || ch === "*") { + if (depth > 0) quantifierAtDepth[depth] = true; + i++; + continue; + } + if (ch === "?") { + var prev = i > 0 ? pattern[i - 1] : ""; + if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") { + if (depth > 0) quantifierAtDepth[depth] = true; + } + i++; + continue; + } + if (ch === "{") { + var j = i + 1; + while (j < pattern.length && /[\\d,]/.test(pattern[j])) j++; + if (j < pattern.length && pattern[j] === "}" && j > i + 1) { + if (depth > 0) quantifierAtDepth[depth] = true; + i = j + 1; + continue; + } + } + i++; + } + return true; +} +function __safeRegExp(pattern, flags) { + if (!__isSafeRegex(pattern)) { + console.warn("[vinext] Ignoring potentially unsafe regex pattern (ReDoS risk): " + pattern); + return null; + } + try { return new RegExp(pattern, flags); } catch { return null; } +} + +function matchMiddlewarePattern(pathname, pattern) { + // Regex patterns: if the pattern contains "(" or "\\" it's a regex — + // pass it through to RegExp directly WITHOUT dot-escaping. + // This guard prevents regex pattern corruption from dot-escaping. + if (pattern.includes("(") || pattern.includes("\\\\")) { + var re = __safeRegExp("^" + pattern + "$"); + if (re) return re.test(pathname); + } + // Single-pass tokenizer (avoids chained .replace() flagged by CodeQL as + // incomplete sanitization — later passes could re-process earlier outputs). + var regexStr = ""; + var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; + var tok; + while ((tok = tokenRe.exec(pattern)) !== null) { + if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } + else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } + else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } + else if (tok[0] === ".") { regexStr += "\\\\."; } + else { regexStr += tok[0]; } + } + var re2 = __safeRegExp("^" + regexStr + "$"); + return re2 ? re2.test(pathname) : pathname === pattern; +} + +function matchesMiddleware(pathname, matcher) { + if (!matcher) { + return true; + } + var patterns = []; + if (typeof matcher === "string") { patterns.push(matcher); } + else if (Array.isArray(matcher)) { + for (var m of matcher) { + if (typeof m === "string") patterns.push(m); + else if (m && typeof m === "object" && "source" in m) patterns.push(m.source); + } + } + return patterns.some(function(p) { return matchMiddlewarePattern(pathname, p); }); +} + +export async function runMiddleware(request, ctx) { + var isProxy = false; + var middlewareFn = isProxy + ? (middlewareModule.proxy ?? middlewareModule.default) + : (middlewareModule.middleware ?? middlewareModule.default); + if (typeof middlewareFn !== "function") { + var fileType = isProxy ? "Proxy" : "Middleware"; + var expectedExport = isProxy ? "proxy" : "middleware"; + throw new Error("The " + fileType + " file must export a function named \`" + expectedExport + "\` or a \`default\` function."); + } + + var config = middlewareModule.config; + var matcher = config && config.matcher; + var url = new URL(request.url); + + // Normalize pathname before matching to prevent path-confusion bypasses + // (percent-encoding like /%61dmin, double slashes like /dashboard//settings). + var decodedPathname; + try { decodedPathname = decodeURIComponent(url.pathname); } catch (e) { + return { continue: false, response: new Response("Bad Request", { status: 400 }) }; + } + var normalizedPathname = __normalizePath(decodedPathname); + + if (!matchesMiddleware(normalizedPathname, matcher)) return { continue: true }; + + // Construct a new Request with the decoded + normalized pathname so middleware + // always sees the same canonical path that the router uses. + var mwRequest = request; + if (normalizedPathname !== url.pathname) { + var mwUrl = new URL(url); + mwUrl.pathname = normalizedPathname; + mwRequest = new Request(mwUrl, request); + } + var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); + var response; + try { response = await middlewareFn(nextRequest, fetchEvent); } + catch (e) { + console.error("[vinext] Middleware error:", e); + return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; + } + if (ctx && typeof ctx.waitUntil === "function") { ctx.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } + + if (!response) return { continue: true }; + + if (response.headers.get("x-middleware-next") === "1") { + var rHeaders = new Headers(); + for (var [key, value] of response.headers) { + // Keep x-middleware-request-* headers so the production server can + // apply middleware-request header overrides before stripping internals + // from the final client response. + if ( + !key.startsWith("x-middleware-") || + key.startsWith("x-middleware-request-") + ) rHeaders.append(key, value); + } + return { continue: true, responseHeaders: rHeaders }; + } + + if (response.status >= 300 && response.status < 400) { + var location = response.headers.get("Location") || response.headers.get("location"); + if (location) return { continue: false, redirectUrl: location, redirectStatus: response.status }; + } + + var rewriteUrl = response.headers.get("x-middleware-rewrite"); + if (rewriteUrl) { + var rwHeaders = new Headers(); + for (var [k, v] of response.headers) { + if (!k.startsWith("x-middleware-") || k.startsWith("x-middleware-request-")) rwHeaders.append(k, v); + } + var rewritePath; + try { var parsed = new URL(rewriteUrl, request.url); rewritePath = parsed.pathname + parsed.search; } + catch { rewritePath = rewriteUrl; } + return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders }; + } + + return { continue: false, response: response }; +} + +" +`; diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts new file mode 100644 index 00000000..321c62e3 --- /dev/null +++ b/tests/entry-templates.test.ts @@ -0,0 +1,289 @@ +/** + * Snapshot tests for the entry template code generators. + * + * These tests lock down the exact generated code for all virtual entry modules + * so that future refactoring (extracting generators into separate files, etc.) + * can be verified against a known baseline. + * + * - App Router generators are standalone exported functions → imported directly. + * - Pages Router generators are closures inside the plugin → tested via + * Vite's pluginContainer.load() on the virtual module IDs. + */ +import path from "node:path"; +import { describe, it, expect, afterAll } from "vitest"; +import { createServer, type ViteDevServer } from "vite"; +import { + generateRscEntry, + generateSsrEntry, + generateBrowserEntry, +} from "../packages/vinext/src/server/app-dev-server.js"; +import type { AppRouterConfig } from "../packages/vinext/src/server/app-dev-server.js"; +import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; +import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js"; +import vinext from "../packages/vinext/src/index.js"; + +// Workspace root (forward-slash normalised) used to replace absolute paths +// in generated code so snapshots are machine-independent. +const ROOT = path.resolve(import.meta.dirname, "..").replace(/\\/g, "/"); + +/** Replace all occurrences of the workspace root with ``. */ +function stabilize(code: string): string { + return code.replaceAll(ROOT, ""); +} + +// ── Minimal App Router route fixtures ───────────────────────────────── +// Use stable absolute paths so snapshots don't depend on the machine. +const minimalAppRoutes: AppRoute[] = [ + { + pattern: "/", + pagePath: "/tmp/test/app/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx"], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [null], + notFoundPath: null, + notFoundPaths: [null], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: [], + layoutTreePositions: [0], + isDynamic: false, + params: [], + }, + { + pattern: "/about", + pagePath: "/tmp/test/app/about/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx"], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [null], + notFoundPath: null, + notFoundPaths: [null], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: ["about"], + layoutTreePositions: [0], + isDynamic: false, + params: [], + }, + { + pattern: "/blog/:slug", + pagePath: "/tmp/test/app/blog/[slug]/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/blog/[slug]/layout.tsx"], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [null, null], + notFoundPath: null, + notFoundPaths: [null, null], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: ["blog", ":slug"], + layoutTreePositions: [0, 1], + isDynamic: true, + params: ["slug"], + }, + { + pattern: "/dashboard", + pagePath: "/tmp/test/app/dashboard/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/dashboard/layout.tsx"], + templates: ["/tmp/test/app/dashboard/template.tsx"], + parallelSlots: [], + loadingPath: "/tmp/test/app/dashboard/loading.tsx", + errorPath: "/tmp/test/app/dashboard/error.tsx", + layoutErrorPaths: [null, "/tmp/test/app/dashboard/error.tsx"], + notFoundPath: "/tmp/test/app/dashboard/not-found.tsx", + notFoundPaths: [null, "/tmp/test/app/dashboard/not-found.tsx"], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: ["dashboard"], + layoutTreePositions: [0, 1], + isDynamic: false, + params: [], + }, +]; + +// ── Pages Router fixture ────────────────────────────────────────────── +// NOTE: Adding, removing, or renaming pages in this fixture will break the +// Pages Router snapshots below. Run `pnpm test tests/entry-templates.test.ts -u` +// to update them after intentional fixture changes. +const PAGES_FIXTURE_DIR = path.resolve( + import.meta.dirname, + "./fixtures/pages-basic", +); + +// ── App Router entry templates ──────────────────────────────────────── + +describe("App Router entry templates", () => { + it("generateRscEntry snapshot (minimal routes)", () => { + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, // no middleware + [], // no metadata routes + null, // no global error + "", // no basePath + false, // no trailingSlash + ); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with middleware)", () => { + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + "/tmp/test/middleware.ts", + [], + null, + "", + false, + ); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with instrumentation)", () => { + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + [], + null, + "", + false, + undefined, + "/tmp/test/instrumentation.ts", + ); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with global error)", () => { + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + [], + "/tmp/test/app/global-error.tsx", + "", + false, + ); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with config)", () => { + const config: AppRouterConfig = { + redirects: [ + { source: "/old", destination: "/new", permanent: true }, + ], + rewrites: { + beforeFiles: [ + { source: "/api/:path*", destination: "/backend/:path*" }, + ], + afterFiles: [], + fallback: [], + }, + headers: [ + { + source: "/api/:path*", + headers: [{ key: "X-Custom", value: "test" }], + }, + ], + allowedOrigins: ["https://example.com"], + allowedDevOrigins: ["localhost:3001"], + }; + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + [], + null, + "/base", + true, + config, + ); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateRscEntry snapshot (with metadata routes)", () => { + const metadataRoutes: MetadataFileRoute[] = [ + { + type: "sitemap", + isDynamic: true, + filePath: "/tmp/test/app/sitemap.ts", + servedUrl: "/sitemap.xml", + contentType: "application/xml", + }, + ]; + const code = generateRscEntry( + "/tmp/test/app", + minimalAppRoutes, + null, + metadataRoutes, + null, + "", + false, + ); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateSsrEntry snapshot", () => { + const code = generateSsrEntry(); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("generateBrowserEntry snapshot", () => { + const code = generateBrowserEntry(); + expect(stabilize(code)).toMatchSnapshot(); + }); +}); + +// ── Pages Router entry templates ────────────────────────────────────── +// These are closure functions inside the vinext() plugin, so we test +// them via Vite's pluginContainer.load() on the virtual module IDs. + +describe("Pages Router entry templates", () => { + let server: ViteDevServer; + + afterAll(async () => { + if (server) await server.close(); + }); + + async function getVirtualModuleCode(moduleId: string): Promise { + if (!server) { + server = await createServer({ + root: PAGES_FIXTURE_DIR, + configFile: false, + plugins: [vinext()], + server: { port: 0 }, + logLevel: "silent", + }); + } + const resolved = await server.pluginContainer.resolveId(moduleId); + expect(resolved).toBeTruthy(); + const loaded = await server.pluginContainer.load(resolved!.id); + expect(loaded).toBeTruthy(); + return typeof loaded === "string" + ? loaded + : typeof loaded === "object" && loaded !== null && "code" in loaded + ? loaded.code + : ""; + } + + it("server entry snapshot", async () => { + const code = await getVirtualModuleCode("virtual:vinext-server-entry"); + expect(stabilize(code)).toMatchSnapshot(); + }); + + it("client entry snapshot", async () => { + const code = await getVirtualModuleCode("virtual:vinext-client-entry"); + expect(stabilize(code)).toMatchSnapshot(); + }); +});