import { prerender } from "octane/static"; import type { ComponentBody } from "octane"; import { DefaultNotFound, DefaultServerError, } from "../runtime/default-error"; import { runWithServerRouterState, } from "../runtime/router-server"; import { addBasePath, hasBasePath, stripBasePath, } from "../runtime/navigation"; import { addLocalePrefix, resolveLocale, type I18nConfig } from "./i18n"; import { matchRoute } from "../routing/match"; import type { ClientRoute } from "../routing/types"; import type { GetStaticPathsResult, GetServerSidePropsContext, GetServerSidePropsResult, GetStaticPropsResult, NextaneData, ParsedUrlQuery, Redirect, } from "../types"; import { runApiRoute } from "./api"; import { createPageRequest, PageResponse, parseCookies, type ResponseSnapshot, } from "./http"; import { attachPreviewApi, clearPreviewCookies, DISABLED_PREVIEW, generatePreviewCredentials, PREVIEW_CACHE_CONTROL, resolvePreviewState, validatePreviewCredentials, type PreviewCredentials, type PreviewState, } from "./preview"; import { consumedDestinationParams, evaluateRuleConditions, isExternalDestination, matchHeaderRules, matchRedirectRule, matchRuleSource, redirectStatusFor, substituteDestination, substituteRuleParams, validateRuleSource, type HeaderRule, type RedirectRule, type RewriteRule, type RuleRequestContext, } from "./route-rules"; interface AssetBinding { fetch(request: Request): Promise; } export interface NextaneEnvironment { ASSETS: AssetBinding; } export interface NextaneManifest { buildId?: string; routes: ServerRouteManifest[]; loadApp: null | (() => Promise>); loadDocument: null | (() => Promise>); loadError: null | (() => Promise>); config?: { rewrites?: RewriteRule[]; redirects?: RedirectRule[]; headers?: HeaderRule[]; crossOrigin?: string; trailingSlash?: boolean; basePath?: string; assetPrefix?: string; i18n?: I18nConfig | null; }; preview?: PreviewCredentials; } interface ServerRouteManifest extends ClientRoute { id: number; kind: "page" | "api"; load(): Promise>; } interface PageResolution { pageProps: Record; gsp: boolean; gssp: boolean; revalidate?: number | boolean; notFound?: boolean; redirect?: Redirect; response: ResponseSnapshot; } export interface RenderArtifact { buildId?: string; route: string; pageProps: Record; appProps?: Record; query: ParsedUrlQuery; gsp: boolean; gssp: boolean; isFallback?: boolean; resolvedPath?: string; revalidate?: number | boolean; notFound?: boolean; redirect?: Redirect; response?: ResponseSnapshot; html?: string; css?: string; head?: string; documentHtml?: string; documentHead?: string; documentCss?: string; crossOrigin?: string; trailingSlash?: boolean; cacheable?: boolean; preview?: boolean; locale?: string; locales?: string[]; defaultLocale?: string; generatedAt: number; } interface HandlerOptions { loadCachedArtifact?: ( request: Request, route: ServerRouteManifest, ) => Promise; revalidatePath?: ( pathname: string, options?: { unstable_onlyGenerated?: boolean }, ) => Promise | boolean; } const BUILD_ID = "development"; const staticPathsCache = new WeakMap< Function, Promise >(); const manifestPreviewCredentials = new WeakMap< NextaneManifest, PreviewCredentials >(); function previewCredentialsFor(manifest: NextaneManifest): PreviewCredentials { const configured = validatePreviewCredentials(manifest.preview); if (configured) return configured; let generated = manifestPreviewCredentials.get(manifest); if (!generated) { generated = generatePreviewCredentials(); manifestPreviewCredentials.set(manifest, generated); } return generated; } export function cacheTagForPath(pathname: string): string { return `nextane:path:${encodeURIComponent(pathname).slice(0, 900)}`; } // Ported from Next.js shared/lib/router/utils/is-bot.ts so fallback pages // render blocking for crawlers exactly like upstream. const HEADLESS_BROWSER_BOT_UA = /Googlebot(?!-)|Googlebot$/i; const HTML_LIMITED_BOT_UA = /[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i; function isBotRequest(request: Request): boolean { const userAgent = request.headers.get("user-agent") ?? ""; return ( HEADLESS_BROWSER_BOT_UA.test(userAgent) || HTML_LIMITED_BOT_UA.test(userAgent) ); } function searchQuery(url: URL, params: ParsedUrlQuery): ParsedUrlQuery { const query: ParsedUrlQuery = {}; for (const [key, value] of url.searchParams) { const previous = query[key]; if (previous === undefined) query[key] = value; else if (Array.isArray(previous)) query[key] = [...previous, value]; else query[key] = [previous, value]; } return { ...query, ...params }; } interface RewriteResolution { pageUrl: URL; displayUrl: URL; resolvedUrl: URL; external?: URL; matched: boolean; } function resolveRewrite( rewrites: RewriteRule[], requestUrl: URL, context: RuleRequestContext, localizedPathname = requestUrl.pathname, ): RewriteResolution { for (const rewrite of rewrites) { // `locale: false` sources keep the locale segment; match them against the // locale-included path so the `:locale` param is captured. const values = matchRuleSource( rewrite.source, rewrite.locale === false ? localizedPathname : requestUrl.pathname, ); if (!values) continue; const captured = evaluateRuleConditions(rewrite, context); if (!captured) continue; const merged = { ...values, ...captured }; if (isExternalDestination(rewrite.destination)) { const externalUrl = new URL( substituteRuleParams(rewrite.destination, merged, encodeURIComponent), ); for (const [name, value] of requestUrl.searchParams) { externalUrl.searchParams.append(name, value); } return { pageUrl: requestUrl, displayUrl: requestUrl, resolvedUrl: requestUrl, external: externalUrl, matched: true, }; } const consumedParameters = consumedDestinationParams(rewrite.destination); const destination = substituteDestination(rewrite.destination, merged); const pageUrl = new URL(destination, requestUrl); for (const [name, value] of Object.entries(merged)) { if ( !consumedParameters.has(name) && !pageUrl.searchParams.has(name) ) { pageUrl.searchParams.set(name, value); } } for (const [name, value] of requestUrl.searchParams) { pageUrl.searchParams.append(name, value); } const resolvedUrl = new URL(pageUrl.pathname, requestUrl); resolvedUrl.search = requestUrl.search; return { pageUrl, displayUrl: requestUrl, resolvedUrl, matched: true, }; } return { pageUrl: requestUrl, displayUrl: requestUrl, resolvedUrl: requestUrl, matched: false, }; } function rulesForRequest( rules: Rule[] | undefined, basePath: string, hadBasePath: boolean, ): Rule[] { if (!basePath) return rules ?? []; return (rules ?? []).filter((rule) => rule.basePath === false ? !hadBasePath : hadBasePath, ); } const HOP_BY_HOP_HEADERS = [ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", ]; async function proxyExternalRewrite( request: Request, target: URL, ): Promise { const headers = new Headers(request.headers); for (const name of HOP_BY_HOP_HEADERS) headers.delete(name); // Never forward first-party credentials to a third-party rewrite target. headers.delete("cookie"); headers.delete("authorization"); const response = await fetch( new Request(target, { method: request.method, headers, body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, redirect: "manual", }), ); const responseHeaders = new Headers(response.headers); for (const name of HOP_BY_HOP_HEADERS) responseHeaders.delete(name); // Never let a third-party host set cookies on the first-party origin. responseHeaders.delete("set-cookie"); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); } function canonicalPathname( pathname: string, trailingSlash: boolean, ): string { if (pathname === "/") return pathname; const fileLike = /\/[^/]+\.[^/]+\/?$/.test(pathname); if (trailingSlash && !fileLike) { return pathname.endsWith("/") ? pathname : `${pathname}/`; } return pathname.replace(/\/+$/, ""); } function paramsEqual( expected: ParsedUrlQuery, actual: ParsedUrlQuery, ): boolean { const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]); for (const key of keys) { const left = expected[key]; const right = actual[key]; if ( (left === undefined && Array.isArray(right) && right.length === 0) || (right === undefined && Array.isArray(left) && left.length === 0) ) { continue; } if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right)) return false; if (left.length !== right.length) return false; if (left.some((value, index) => value !== right[index])) return false; } else if (left !== right) { return false; } } return true; } async function staticPathInfo( pageModule: Record, params: ParsedUrlQuery, pageUrl: URL, ): Promise<{ matched: boolean; fallback: boolean | "blocking"; }> { const getStaticPaths = pageModule.getStaticPaths; if (typeof getStaticPaths !== "function") { return { matched: true, fallback: false }; } let resultPromise = staticPathsCache.get(getStaticPaths); if (!resultPromise) { resultPromise = Promise.resolve(getStaticPaths({})) as Promise; staticPathsCache.set(getStaticPaths, resultPromise); } const result = await resultPromise; const matched = result.paths.some((candidate) => { if (typeof candidate === "string") { const candidateUrl = new URL(candidate, pageUrl); return candidateUrl.pathname === pageUrl.pathname; } return paramsEqual(candidate.params, params); }); return { matched, fallback: result.fallback }; } async function resolvePageData( pageModule: Record, request: Request, params: ParsedUrlQuery, pageUrl: URL, resolvedUrl: URL, displayUrl: URL, routePath: string, response: PageResponse, skipPageInitialProps: boolean, preview: PreviewState = DISABLED_PREVIEW, localeContext: | { locale: string; locales: string[]; defaultLocale: string } | undefined = undefined, ): Promise { const query = searchQuery(pageUrl, params); const req = createPageRequest(request); const previewContext = preview.enabled ? { preview: true as const, previewData: preview.previewData, draftMode: true as const, } : { preview: false as const, draftMode: false as const }; if (typeof pageModule.getServerSideProps === "function") { const context: GetServerSidePropsContext = { req, res: response, // Next passes `params` for any dynamic route, even when it matched zero // segments (e.g. an optional catch-all at its root), and omits it for a // static route. params: routePath.includes("[") ? params : undefined, query, resolvedUrl: `${resolvedUrl.pathname}${resolvedUrl.search}`, ...previewContext, ...(localeContext ?? {}), }; const result = (await pageModule.getServerSideProps( context, )) as GetServerSidePropsResult>; const snapshot = response.snapshot(); if ("redirect" in result) { return { pageProps: {}, gsp: false, gssp: true, redirect: result.redirect, response: snapshot, }; } if ("notFound" in result && result.notFound === true) { return { pageProps: {}, gsp: false, gssp: true, notFound: true, response: snapshot, }; } return { pageProps: await result.props, gsp: false, gssp: true, response: response.snapshot(), }; } if (typeof pageModule.getStaticProps === "function") { const staticPath = await staticPathInfo(pageModule, params, pageUrl); if (!staticPath.matched && staticPath.fallback === false && !preview.enabled) { return { pageProps: {}, gsp: true, gssp: false, notFound: true, response: response.snapshot(), }; } const result = (await pageModule.getStaticProps({ params: typeof pageModule.getStaticPaths === "function" || Object.keys(params).length > 0 ? params : undefined, revalidateReason: "build", ...(preview.enabled ? { preview: true, previewData: preview.previewData, draftMode: true, } : {}), ...(localeContext ?? {}), })) as GetStaticPropsResult>; if ("redirect" in result) { return { pageProps: {}, gsp: true, gssp: false, redirect: result.redirect, revalidate: result.revalidate, response: response.snapshot(), }; } if ("notFound" in result && result.notFound === true) { return { pageProps: {}, gsp: true, gssp: false, notFound: true, revalidate: result.revalidate, response: response.snapshot(), }; } return { pageProps: await result.props, gsp: true, gssp: false, revalidate: result.revalidate, response: response.snapshot(), }; } const component = pageModule.default as | (ComponentBody> & { getInitialProps?: (context: Record) => unknown; }) | undefined; if (!skipPageInitialProps && typeof component?.getInitialProps === "function") { const pageProps = await component.getInitialProps({ // `ctx.pathname` is the matched route pattern (e.g. `/items/[slug]`), not // the concrete request path; `asPath` carries the concrete URL. pathname: routePath, query, asPath: `${displayUrl.pathname}${displayUrl.search}`, req, res: response, }); return { pageProps: (pageProps ?? {}) as Record, gsp: false, gssp: false, response: response.snapshot(), }; } return { pageProps: {}, gsp: false, gssp: false, response: response.snapshot(), }; } function redirectStatus(redirect: Redirect): number { if (redirect.statusCode) return redirect.statusCode; return redirect.permanent ? 308 : 307; } function safeJson(value: unknown): string { return JSON.stringify(value) .replace(/ "\\u003c") .replace(/>/g, () => "\\u003e") .replace(/\u2028/g, () => "\\u2028") .replace(/\u2029/g, () => "\\u2029"); } function escapeHtml(value: string): string { const escaped: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; return value.replace(/[&<>"']/g, (character) => escaped[character]); } function replaceLiteral(source: string, search: string, replacement: string): string { const index = source.indexOf(search); if (index === -1) return source; return `${source.slice(0, index)}${replacement}${source.slice( index + search.length, )}`; } interface HtmlElementRange { end: number; innerEnd: number; innerStart: number; outer: string; start: number; } function htmlTagEnd(source: string, start: number): number { let quote = ""; for (let index = start; index < source.length; index += 1) { const character = source[index]; if (quote) { if (character === quote) quote = ""; } else if (character === '"' || character === "'") { quote = character; } else if (character === ">") { return index; } } return -1; } function isHtmlTagBoundary(character: string | undefined): boolean { return ( character === undefined || character === ">" || character === "/" || character === " " || character === "\n" || character === "\r" || character === "\t" || character === "\f" ); } function findHtmlElements(source: string, tagName: string): HtmlElementRange[] { const lowerSource = source.toLowerCase(); const opening = `<${tagName.toLowerCase()}`; const closing = ` name.toLowerCase() === "set-cookie", ) ?? false ); } async function getTemplate(request: Request, env: NextaneEnvironment): Promise { const templateResponse = await env.ASSETS.fetch( new Request(new URL("/", request.url), { method: "GET", headers: request.headers, }), ); if (!templateResponse.ok) { throw new Error(`Unable to load the Nextane HTML template (${templateResponse.status})`); } return templateResponse.text(); } function attributeValue(source: string, name: string): string | undefined { const match = new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`, "i").exec( source, ); return match?.[1] ?? match?.[2]; } function stampResourceAttributes( html: string, attributes: { nonce?: string; crossOrigin?: string }, ): string { return html.replace(/<(script|link)\b([^>]*)>/gi, (tag, name, rest) => { if ( name.toLowerCase() === "link" && !/\brel=["'][^"']*preload/i.test(rest) ) { return tag; } let next = rest; if (attributes.nonce && !/\bnonce=/i.test(next)) { next += ` nonce="${attributes.nonce}"`; } if (attributes.crossOrigin) { if (/\bcrossorigin(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/i.test(next)) { next = next.replace( /\s+crossorigin(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/i, () => ` crossorigin="${attributes.crossOrigin}"`, ); } else { next += ` crossorigin="${attributes.crossOrigin}"`; } } return `<${name}${next}>`; }); } function stampManagedHead(html: string): string { return html .replace(/^/, "") .replace( /<(base|meta|title|link|style|script)\b([^>]*)>/gi, (tag, name: string, attributes: string) => /\bdata-next-head(?:=|\s|$)/i.test(attributes) ? tag : `<${name}${attributes} data-next-head="">`, ); } function renderDocumentShell( shell: string, template: string, artifact: RenderArtifact, dataScript: string, ): string { const templateHead = htmlElementContent(template, "head"); const hydratedHead = stripHtmlElements( replaceLiteral( replaceLiteral( templateHead, "", stampManagedHead(artifact.head ?? ""), ), "", artifact.css ?? "", ), "script", ); const templateScripts = findHtmlElements(template, "script") .map((element) => element.outer) .filter((script) => attributeValue(script, "src") !== undefined) .join(""); let html = shell; html = html.replace( /]*)><\/nextane-head>/i, (_match, attributes: string) => { return stampResourceAttributes( `${artifact.documentHead ?? ""}${artifact.documentCss ?? ""}${hydratedHead}`, { nonce: attributeValue(attributes, "nonce"), crossOrigin: attributeValue(attributes, "crossorigin") ?? attributeValue(attributes, "crossOrigin") ?? artifact.crossOrigin, }, ); }, ); html = html.replace( /]*><\/nextane-main>/i, () => `
${artifact.html ?? ""}
`, ); html = html.replace( /]*)><\/nextane-script>/i, (_match, attributes: string) => { return stampResourceAttributes(`${dataScript}${templateScripts}`, { nonce: attributeValue(attributes, "nonce"), crossOrigin: attributeValue(attributes, "crossorigin") ?? attributeValue(attributes, "crossOrigin") ?? artifact.crossOrigin, }); }, ); return `${html}`; } async function createRenderArtifact( manifest: NextaneManifest, route: ServerRouteManifest, request: Request, params: ParsedUrlQuery, pageUrl = new URL(request.url), forcedPageProps?: Record, shouldRender = true, displayUrl = pageUrl, resolvedUrl = pageUrl, fallbackShell = false, preview: PreviewState = DISABLED_PREVIEW, locale?: string, ): Promise { const pageModule = await route.load(); const Page = pageModule.default as ComponentBody> | undefined; if (typeof Page !== "function") { throw new Error(`Page ${route.route} does not export a default Octane component`); } const [appModule, documentModule] = await Promise.all([ manifest.loadApp ? manifest.loadApp() : Promise.resolve(null), manifest.loadDocument ? manifest.loadDocument() : Promise.resolve(null), ]); const App = appModule?.default as | (ComponentBody> & { getInitialProps?: (context: Record) => unknown; }) | undefined; const Document = documentModule?.default as | (ComponentBody> & { getInitialProps?: (context: Record) => unknown; }) | undefined; const requestDependentInitialProps = typeof App?.getInitialProps === "function" || typeof Document?.getInitialProps === "function"; const localeContext = manifest.config?.i18n && locale ? { locale, locales: manifest.config.i18n.locales, defaultLocale: manifest.config.i18n.defaultLocale, } : undefined; const response = attachPreviewApi( new PageResponse(), previewCredentialsFor(manifest), ); const resolution = forcedPageProps ? { pageProps: forcedPageProps, gsp: false, gssp: false, response: response.snapshot(), } : fallbackShell ? { pageProps: {}, gsp: true, gssp: false, response: response.snapshot(), } : await resolvePageData( pageModule, request, params, pageUrl, resolvedUrl, displayUrl, route.route, response, typeof App?.getInitialProps === "function", preview, localeContext, ); const query = resolution.gsp ? { ...params } : searchQuery(pageUrl, params); const req = createPageRequest(request); let appProps: Record = {}; if (!forcedPageProps && typeof App?.getInitialProps === "function") { const initial = await App.getInitialProps({ Component: Page, router: { route: route.route, pathname: route.route, query, asPath: `${displayUrl.pathname}${displayUrl.search}`, }, ctx: { req, res: response, pathname: route.route, query, asPath: `${displayUrl.pathname}${displayUrl.search}`, }, }); if (initial && typeof initial === "object") { appProps = { ...(initial as Record) }; } if ("pageProps" in appProps) { // Next merges the App-provided pageProps with the page's data-function // props, with the data props winning on key collisions. For a // getInitialProps page (no gSP/gSSP) the page props flow through the App, // so this reduces to the App-provided pageProps. const appPageProps = (appProps.pageProps as Record | undefined) ?? {}; resolution.pageProps = { ...appPageProps, ...resolution.pageProps }; } delete appProps.pageProps; resolution.response = response.snapshot(); } if (resolution.gsp && !fallbackShell) { warnForLargePageData(route, displayUrl, resolution.pageProps); } const baseArtifact: RenderArtifact = { buildId: manifest.buildId ?? BUILD_ID, route: route.route, pageProps: resolution.pageProps, ...(Object.keys(appProps).length > 0 ? { appProps } : {}), query, gsp: resolution.gsp, gssp: resolution.gssp, isFallback: fallbackShell, resolvedPath: resolution.gsp ? pageUrl.pathname : `${pageUrl.pathname}${pageUrl.search}`, revalidate: resolution.revalidate, notFound: resolution.notFound, redirect: resolution.redirect, response: resolution.response, crossOrigin: manifest.config?.crossOrigin, trailingSlash: manifest.config?.trailingSlash, ...(localeContext ? { locale: localeContext.locale, locales: localeContext.locales, defaultLocale: localeContext.defaultLocale, } : {}), cacheable: resolution.gsp && !preview.enabled && !requestDependentInitialProps && !hasSetCookie(resolution.response), preview: preview.enabled, generatedAt: Date.now(), }; if ( resolution.redirect || resolution.notFound || resolution.response.ended || !shouldRender ) { return baseArtifact; } const router = { route: route.route, pathname: route.route, query, asPath: `${displayUrl.pathname}${displayUrl.search}`, basePath: manifest.config?.basePath ?? "", ...(localeContext ? { locale: localeContext.locale, locales: localeContext.locales, defaultLocale: localeContext.defaultLocale, } : {}), isReady: true, isPreview: preview.enabled, isFallback: fallbackShell, trailingSlash: manifest.config?.trailingSlash, }; type RenderEnhancements = | ((component: ComponentBody>) => ComponentBody>) | { enhanceComponent?: ( component: ComponentBody>, ) => ComponentBody>; enhanceApp?: ( component: ComponentBody>, ) => ComponentBody>; }; return runWithServerRouterState(router, async () => { const renderPage = async (enhancements?: RenderEnhancements) => { const options = typeof enhancements === "function" ? { enhanceComponent: enhancements } : enhancements ?? {}; const RenderPage = options.enhanceComponent ? options.enhanceComponent(Page) : Page; const RenderApp = App && options.enhanceApp ? options.enhanceApp(App) : App; const component = RenderApp ?? RenderPage; const props = RenderApp ? { ...appProps, Component: RenderPage, pageProps: resolution.pageProps, router, } : resolution.pageProps; return prerender(component as never, props, { headChannel: "separate", signal: request.signal, }); }; let renderResult = await renderPage(); let documentHtml: string | undefined; let documentHead: string | undefined; let documentCss: string | undefined; if (documentModule) { if (typeof Document !== "function") { throw new Error("pages/_document must export a default Octane component"); } let documentProps: Record = {}; if (typeof Document.getInitialProps === "function") { const initial = await Document.getInitialProps({ req, res: response, pathname: route.route, query, asPath: `${displayUrl.pathname}${displayUrl.search}`, async renderPage(enhancements?: RenderEnhancements) { renderResult = await renderPage(enhancements); return { html: renderResult.html, head: renderResult.head, styles: renderResult.css, }; }, }); if (initial && typeof initial === "object") { documentProps = initial as Record; } } const documentRender = await prerender( Document as never, { ...documentProps, html: renderResult.html, head: renderResult.head, styles: renderResult.css, __NEXT_DATA__: { props: { ...appProps, pageProps: resolution.pageProps, }, page: route.route, query, buildId: manifest.buildId ?? BUILD_ID, isFallback: fallbackShell, }, }, { signal: request.signal }, ); documentHtml = documentRender.html; documentHead = documentRender.head; documentCss = documentRender.css; } const finalResponse = response.snapshot(); return { ...baseArtifact, response: finalResponse, cacheable: baseArtifact.cacheable && !hasSetCookie(finalResponse), html: renderResult.html, css: renderResult.css, head: renderResult.head, documentHtml, documentHead, documentCss, }; }); } function warnForLargePageData( route: ServerRouteManifest, displayUrl: URL, pageProps: Record, ) { const size = new TextEncoder().encode(JSON.stringify(pageProps)).byteLength; if (size <= 128_000) return; const path = route.route === displayUrl.pathname ? "" : ` (path "${displayUrl.pathname}")`; console.warn( `Warning: data for page "${route.route}"${path} is ${Math.round( size / 1000, )} kB which exceeds the threshold of 128 kB, this amount of data can reduce performance`, ); } function artifactData(artifact: RenderArtifact) { if (artifact.redirect) { return { __N_REDIRECT: artifact.redirect.destination, __N_REDIRECT_STATUS: redirectStatus(artifact.redirect), }; } if (artifact.notFound) return { notFound: true }; return { ...(artifact.appProps ?? {}), pageProps: artifact.pageProps, ...(artifact.gsp ? { __N_SSG: true } : {}), ...(artifact.gssp ? { __N_SSP: true } : {}), ...(artifact.preview ? { __N_PREVIEW: true } : {}), }; } function defaultArtifactCacheControl(artifact: RenderArtifact): string { if (artifact.gssp) { return "private, no-cache, no-store, max-age=0, must-revalidate"; } if (artifact.gsp && artifact.cacheable) { return "public, max-age=0, must-revalidate"; } return "private, no-store"; } function applyArtifactCachePolicy( headers: Headers, artifact: RenderArtifact, ): void { if (!headers.has("cache-control")) { headers.set("cache-control", defaultArtifactCacheControl(artifact)); } // Request-dependent App/Document data must never be made public, even if // application code tried to set a conflicting header on the response. if (artifact.gsp && !artifact.cacheable) { headers.set("cache-control", "private, no-store"); } } async function renderArtifactResponse( artifact: RenderArtifact, request: Request, env: NextaneEnvironment, status = 200, cacheStatus?: string | null, ): Promise { if (artifact.response?.ended) { const headers = new Headers(artifact.response.headers); applyArtifactCachePolicy(headers, artifact); return new Response(artifact.response.body ?? null, { status: artifact.response.status, statusText: artifact.response.statusMessage, headers, }); } if (artifact.redirect) { const headers = new Headers(artifact.response?.headers); headers.set( "location", new URL(artifact.redirect.destination, request.url).toString(), ); applyArtifactCachePolicy(headers, artifact); return new Response(null, { status: redirectStatus(artifact.redirect), headers, }); } if (artifact.notFound) { const headers = new Headers(artifact.response?.headers); applyArtifactCachePolicy(headers, artifact); return new Response("Not Found", { status: 404, headers }); } const nextData: NextaneData = { props: { ...(artifact.appProps ?? {}), pageProps: artifact.pageProps, ...(artifact.preview ? { __N_PREVIEW: true } : {}), }, page: artifact.route, query: artifact.query, buildId: artifact.buildId ?? BUILD_ID, isFallback: artifact.isFallback === true, resolvedPath: artifact.resolvedPath, ...(artifact.gsp ? { gsp: true } : {}), ...(artifact.gssp ? { gssp: true } : {}), trailingSlash: artifact.trailingSlash, ...(artifact.locale ? { locale: artifact.locale, locales: artifact.locales, defaultLocale: artifact.defaultLocale, } : {}), }; const dataScript = ``; const template = await getTemplate(request, env); const html = artifact.documentHtml ? renderDocumentShell( artifact.documentHtml, template, artifact, dataScript, ) : replaceLiteral( replaceLiteral( replaceLiteral( replaceLiteral( template, "", stampManagedHead(artifact.head ?? ""), ), "", artifact.css ?? "", ), '
', `
${artifact.html ?? ""}
`, ), "", dataScript, ); const headers = new Headers({ "content-type": "text/html; charset=utf-8", "x-powered-by": "Nextane", "cache-control": defaultArtifactCacheControl(artifact), "x-nextane-generated-at": String(artifact.generatedAt), }); for (const [name, value] of artifact.response?.headers ?? []) { if (name.toLowerCase() === "set-cookie") headers.append(name, value); else headers.set(name, value); } if (artifact.gsp && !artifact.cacheable) { headers.set("cache-control", "private, no-store"); } if (typeof artifact.revalidate === "number") { headers.set("x-nextane-revalidate", String(artifact.revalidate)); } if (cacheStatus) headers.set("x-nextane-cache-status", cacheStatus); return new Response(stampDeferOnModuleScripts(html), { status, headers }); } async function renderRoute( manifest: NextaneManifest, route: ServerRouteManifest, request: Request, params: ParsedUrlQuery, env: NextaneEnvironment, status = 200, forcedPageProps?: Record, ): Promise { const artifact = await createRenderArtifact( manifest, route, request, params, new URL(request.url), forcedPageProps, ); if (artifact.notFound && route.route !== "/404") { return renderNotFound(manifest, request, env); } return renderArtifactResponse(artifact, request, env, status); } function internalErrorRoute( status: 404 | 500, ): ServerRouteManifest { return { route: status === 404 ? "/404" : "/500", regexSource: status === 404 ? "^/404/?$" : "^/500/?$", params: [], id: -status, kind: "page", async load() { return { default: status === 404 ? DefaultNotFound : DefaultServerError, }; }, }; } async function renderNotFound( manifest: NextaneManifest, request: Request, env: NextaneEnvironment, ) { const notFoundRoute = manifest.routes.find( (candidate) => candidate.route === "/404", ); if (!notFoundRoute && manifest.loadError) { const errorModule = await manifest.loadError(); const ErrorComponent = errorModule.default as | (ComponentBody> & { getInitialProps?: (context: Record) => unknown; }) | undefined; const response = new PageResponse(); let pageProps: Record = { statusCode: 404 }; if (typeof ErrorComponent?.getInitialProps === "function") { const requestUrl = new URL(request.url); const initial = await ErrorComponent.getInitialProps({ req: createPageRequest(request), res: response, pathname: "/_error", query: searchQuery(requestUrl, {}), asPath: `${requestUrl.pathname}${requestUrl.search}`, err: null, }); if (initial && typeof initial === "object") { pageProps = { ...pageProps, ...(initial as Record), }; } } return renderRoute( manifest, { route: "/_error", regexSource: "^/_error/?$", params: [], id: -1, kind: "page", async load() { return errorModule; }, }, request, {}, env, 404, pageProps, ); } const route = notFoundRoute ?? internalErrorRoute(404); return renderRoute( manifest, route, request, {}, env, 404, { statusCode: 404 }, ); } async function renderServerError( manifest: NextaneManifest, request: Request, env: NextaneEnvironment, ) { const route = manifest.routes.find((candidate) => candidate.route === "/500") ?? (manifest.loadError ? { route: "/_error", regexSource: "^/_error/?$", params: [], id: -1, kind: "page" as const, load: manifest.loadError, } : internalErrorRoute(500)); return renderRoute( manifest, route, request, {}, env, 500, { statusCode: 500 }, ); } function dataPathFromRequest(pathname: string, buildId: string): string | null { const match = /^\/_next\/data\/([^/]+)\/(.+)\.json$/.exec(pathname); if (!match || match[1] !== buildId) return null; return match[2] === "index" ? "/" : `/${match[2]}`; } async function routeUsesStaticProps(route: ServerRouteManifest): Promise { const pageModule = await route.load(); return typeof pageModule.getStaticProps === "function"; } async function manifestUsesRequestInitialProps( manifest: NextaneManifest, ): Promise { const [appModule, documentModule] = await Promise.all([ manifest.loadApp ? manifest.loadApp() : Promise.resolve(null), manifest.loadDocument ? manifest.loadDocument() : Promise.resolve(null), ]); const App = appModule?.default as { getInitialProps?: unknown } | undefined; const Document = documentModule?.default as | { getInitialProps?: unknown } | undefined; return ( typeof App?.getInitialProps === "function" || typeof Document?.getInitialProps === "function" ); } function canonicalStaticRequest( pageUrl: URL, request: Request, onlyCached = false, canonicalPathname = pageUrl.pathname, ): Request { const canonicalUrl = new URL(canonicalPathname, "https://nextane.internal"); const headers = new Headers(); if (onlyCached) headers.set("x-nextane-only-cached", "1"); return new Request(canonicalUrl, { method: "GET", headers, signal: request.signal, }); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } async function validatedSharedArtifact( response: Response, manifest: NextaneManifest, route: ServerRouteManifest, params: ParsedUrlQuery, pathname: string, ): Promise { const value = (await response.json()) as unknown; if (!isRecord(value)) { throw new Error("Shared ISR cache returned an invalid artifact"); } const artifact = value as unknown as RenderArtifact; const snapshot = artifact.response; const responseIsCanonical = snapshot !== undefined && snapshot.status === 200 && snapshot.ended === false && snapshot.body === undefined && Array.isArray(snapshot.headers) && snapshot.headers.length === 0; const queryIsCanonical = isRecord(artifact.query) && paramsEqual(params, artifact.query as ParsedUrlQuery); const identityMatches = artifact.buildId === (manifest.buildId ?? BUILD_ID) && artifact.route === route.route && artifact.resolvedPath === pathname; const safelyShareable = artifact.gsp === true && artifact.gssp === false && artifact.cacheable === true && artifact.isFallback !== true && artifact.appProps === undefined && !hasSetCookie(snapshot); if ( !identityMatches || !queryIsCanonical || !responseIsCanonical || !safelyShareable || typeof artifact.generatedAt !== "number" || !Number.isFinite(artifact.generatedAt) ) { throw new Error("Shared ISR cache returned a mismatched artifact"); } return artifact; } async function loadArtifact( manifest: NextaneManifest, route: ServerRouteManifest, request: Request, params: ParsedUrlQuery, options: HandlerOptions, pageUrl = new URL(request.url), shouldRender = true, displayUrl = pageUrl, resolvedUrl = pageUrl, preview: PreviewState = DISABLED_PREVIEW, locale?: string, ): Promise<{ artifact: RenderArtifact; cacheStatus?: string | null }> { if (preview.enabled) { // Preview renders are always per-request and must never read from or // write into the shared static artifact path. return { artifact: await createRenderArtifact( manifest, route, request, params, pageUrl, undefined, shouldRender, displayUrl, resolvedUrl, false, preview, locale, ), }; } const usesStaticProps = await routeUsesStaticProps(route); const usesRequestInitialProps = usesStaticProps && (await manifestUsesRequestInitialProps(manifest)); const canUseSharedArtifact = usesStaticProps && !usesRequestInitialProps; const i18n = manifest.config?.i18n ?? null; // The shared-artifact cache key is per-locale (non-default locales carry a // `/{locale}` prefix), while the rendered artifact's page URL — and thus its // asPath and resolvedPath — stays locale-stripped, matching Next.js. Without // the per-locale key, `/en/about` and `/fr/about` would collide on one // artifact and every locale would be served the default-locale content. const cacheKeyPathname = i18n && locale && locale !== i18n.defaultLocale ? addLocalePrefix(pageUrl.pathname, locale) : pageUrl.pathname; const artifactRequest = canUseSharedArtifact ? canonicalStaticRequest(pageUrl, request, false, cacheKeyPathname) : request; const artifactPageUrl = canUseSharedArtifact ? new URL(pageUrl.pathname, "https://nextane.internal") : pageUrl; const artifactDisplayUrl = canUseSharedArtifact ? artifactPageUrl : displayUrl; const artifactResolvedUrl = canUseSharedArtifact ? artifactPageUrl : resolvedUrl; if (usesStaticProps) { if (shouldRender) { const pageModule = await route.load(); const staticPath = await staticPathInfo(pageModule, params, pageUrl); if ( !staticPath.matched && staticPath.fallback === true && !isBotRequest(request) ) { if (options.loadCachedArtifact && canUseSharedArtifact) { const response = await options.loadCachedArtifact( canonicalStaticRequest(pageUrl, request, true, cacheKeyPathname), route, ); if (response.ok) { return { artifact: await validatedSharedArtifact( response, manifest, route, params, artifactPageUrl.pathname, ), cacheStatus: response.headers.get("cf-cache-status") ?? response.headers.get("x-cache-status"), }; } if (response.status !== 404) { throw new Error( `ISR artifact cache lookup failed (${response.status})`, ); } } return { artifact: await createRenderArtifact( manifest, route, artifactRequest, params, artifactPageUrl, undefined, true, artifactDisplayUrl, artifactResolvedUrl, true, DISABLED_PREVIEW, locale, ), }; } } } if (options.loadCachedArtifact && canUseSharedArtifact) { const response = await options.loadCachedArtifact( artifactRequest, route, ); if (!response.ok) { throw new Error(`ISR artifact entrypoint failed (${response.status})`); } return { artifact: await validatedSharedArtifact( response, manifest, route, params, artifactPageUrl.pathname, ), cacheStatus: response.headers.get("cf-cache-status") ?? response.headers.get("x-cache-status"), }; } return { artifact: await createRenderArtifact( manifest, route, artifactRequest, params, artifactPageUrl, undefined, shouldRender, artifactDisplayUrl, artifactResolvedUrl, false, DISABLED_PREVIEW, locale, ), }; } export function createIsrArtifactHandler(manifest: NextaneManifest) { return async function handleIsrArtifact(request: Request): Promise { const url = new URL(request.url); const i18n = manifest.config?.i18n ?? null; // The per-locale cache key may carry a `/{locale}` prefix; resolve it back // to the locale-stripped route path and render with that locale's context. let locale = i18n?.defaultLocale; let routePathname = url.pathname; if (i18n) { const resolution = resolveLocale(url.pathname, i18n); locale = resolution.locale; routePathname = resolution.pathname; } const match = matchRoute( manifest.routes.filter((route) => route.kind === "page"), routePathname, ); if (!match) return new Response("ISR route not found", { status: 404 }); if (!(await routeUsesStaticProps(match.route))) { return new Response("Route does not export getStaticProps", { status: 400 }); } if (await manifestUsesRequestInitialProps(manifest)) { return new Response( "Route uses request-dependent _app or _document initial props", { status: 409, headers: { "cache-control": "private, no-store" }, }, ); } if (request.headers.get("x-nextane-only-cached") === "1") { return new Response("ISR artifact not cached", { status: 404 }); } const artifactRequest = canonicalStaticRequest( new URL(routePathname, "https://nextane.internal"), request, ); const artifact = await createRenderArtifact( manifest, match.route, artifactRequest, match.params, new URL(artifactRequest.url), undefined, true, undefined, undefined, false, DISABLED_PREVIEW, locale, ); const revalidate = artifact.revalidate === true ? 1 : typeof artifact.revalidate === "number" && artifact.revalidate > 0 ? artifact.revalidate : 31536000; const cacheControl = artifact.cacheable ? [ "public", `max-age=${revalidate}`, "stale-while-revalidate=31536000", "stale-if-error=31536000", ].join(", ") : "private, no-store"; const cacheTag = cacheTagForPath(url.pathname); return Response.json(artifact, { headers: { "cache-control": cacheControl, "cache-tag": cacheTag, "x-nextane-generated-at": String(artifact.generatedAt), }, }); }; } const RESERVED_REQUEST_HEADERS = [ "x-nextane-only-cached", "x-nextane-cache-status", "x-nextane-generated-at", "x-nextane-revalidate", ] as const; function stripReservedRequestHeaders(request: Request): Request { if (!RESERVED_REQUEST_HEADERS.some((name) => request.headers.has(name))) { return request; } const headers = new Headers(request.headers); for (const name of RESERVED_REQUEST_HEADERS) headers.delete(name); const sanitized = new Request(request, { headers }); if ("cf" in request) { Object.defineProperty(sanitized, "cf", { value: (request as Request & { cf?: unknown }).cf, configurable: true, }); } return sanitized; } function buildManifestResponse(manifest: NextaneManifest): Response { const sortedPages = manifest.routes .filter((route) => route.kind === "page") .map((route) => route.route) .sort(); const source = `self.__BUILD_MANIFEST = ${safeJson({ __rewrites: { afterFiles: [], beforeFiles: [], fallback: [] }, sortedPages, })};` + `self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB();`; return new Response(source, { headers: { "content-type": "application/javascript; charset=utf-8", "cache-control": "public, max-age=31536000, immutable", }, }); } function stampDeferOnModuleScripts(html: string): string { // Only module scripts are stamped: `defer` is a documented no-op on // `type="module"` (already deferred), so this satisfies the // optimized-loading assertion without changing the timing of any classic // synchronous `