diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 97c4916d260299..481fcfa7ad3a24 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -160,6 +160,20 @@ export async function fetchServerResponse( : 'low' : 'auto' + if (process.env.NODE_ENV === 'production') { + if (process.env.__NEXT_CONFIG_OUTPUT === 'export') { + // In "output: export" mode, we can't rely on headers to distinguish + // between HTML and RSC requests. Instead, we append an extra prefix + // to the request. + url = new URL(url) + if (url.pathname.endsWith('/')) { + url.pathname += 'index.txt' + } else { + url.pathname += '.txt' + } + } + } + const res = await createFetch( url, headers, @@ -255,16 +269,9 @@ export function createFetch( ) { const fetchUrl = new URL(url) - if (process.env.NODE_ENV === 'production') { - if (process.env.__NEXT_CONFIG_OUTPUT === 'export') { - if (fetchUrl.pathname.endsWith('/')) { - fetchUrl.pathname += 'index.txt' - } else { - fetchUrl.pathname += '.txt' - } - } - } - + // TODO: In output: "export" mode, the headers do nothing. Omit them (and the + // cache busting search param) from the request so they're + // maximally cacheable. setCacheBustingSearchParam(fetchUrl, headers) if (process.env.__NEXT_TEST_MODE && fetchPriority !== null) { diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 9b9f083bd31e52..83429dcd5852a6 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -24,7 +24,6 @@ import { import { createFetch, createFromNextReadableStream, - urlToUrlWithoutFlightMarker, type RequestHeaders, } from '../router-reducer/fetch-server-response' import { @@ -42,6 +41,7 @@ import type { import { createTupleMap, type TupleMap, type Prefix } from './tuple-map' import { createLRU } from './lru' import { + convertSegmentPathToStaticExportFilename, encodeChildSegmentKey, encodeSegment, ROOT_SEGMENT_KEY, @@ -205,6 +205,10 @@ export type NonEmptySegmentCacheEntry = Exclude< EmptySegmentCacheEntry > +const isOutputExportMode = + process.env.NODE_ENV === 'production' && + process.env.__NEXT_CONFIG_OUTPUT === 'export' + // Route cache entries vary on multiple keys: the href and the Next-Url. Each of // these parts needs to be included in the internal cache key. Rather than // concatenate the keys into a single key, we use a multi-level map, where the @@ -805,8 +809,24 @@ export async function fetchRouteOnCacheMiss( const key = task.key const href = key.href const nextUrl = key.nextUrl + const segmentPath = '/_tree' + + const headers: RequestHeaders = { + [RSC_HEADER]: '1', + [NEXT_ROUTER_PREFETCH_HEADER]: '1', + [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: segmentPath, + } + if (nextUrl !== null) { + headers[NEXT_URL] = nextUrl + } + + // In output: "export" mode, we need to add the segment path to the URL. + const requestUrl = isOutputExportMode + ? addSegmentPathToUrlInOutputExportMode(href, segmentPath) + : href + try { - const response = await fetchSegmentPrefetchResponse(href, '/_tree', nextUrl) + const response = await fetchPrefetchResponse(requestUrl, headers) if ( !response || !response.ok || @@ -825,7 +845,15 @@ export async function fetchRouteOnCacheMiss( // This is a bit convoluted but it's taken from router-reducer and // fetch-server-response const canonicalUrl = response.redirected - ? createHrefFromUrl(urlToUrlWithoutFlightMarker(response.url)) + ? createHrefFromUrl( + new URL( + removeSegmentPathFromURLInOutputExportMode( + href, + requestUrl, + response.url + ) + ) + ) : href // Check whether the response varies based on the Next-Url header. @@ -839,9 +867,13 @@ export async function fetchRouteOnCacheMiss( // This checks whether the response was served from the per-segment cache, // rather than the old prefetching flow. If it fails, it implies that PPR // is disabled on this route. - // TODO: Add support for non-PPR routes. const routeIsPPREnabled = - response.headers.get(NEXT_DID_POSTPONE_HEADER) === '2' + response.headers.get(NEXT_DID_POSTPONE_HEADER) === '2' || + // In output: "export" mode, we can't rely on response headers. But if we + // receive a well-formed response, we can assume it's a static response, + // because all data is static in this mode. + isOutputExportMode + if (routeIsPPREnabled) { const prefetchStream = createPrefetchResponseStream( response.body, @@ -940,7 +972,7 @@ export async function fetchSegmentOnCacheMiss( route: FulfilledRouteCacheEntry, segmentCacheEntry: PendingSegmentCacheEntry, routeKey: RouteCacheKey, - segmentKeyPath: string + segmentPath: string ): Promise | null> { // This function is allowed to use async/await because it contains the actual // fetch that gets issued on a cache miss. Notice it writes the result to the @@ -949,21 +981,50 @@ export async function fetchSegmentOnCacheMiss( // // Segment fetches are non-blocking so we don't need to ping the scheduler // on completion. - const href = routeKey.href + const href = + route.canonicalUrl !== routeKey.href + ? // The route was redirected. If we request the segment data using the + // same URL, that request will be redirected, too. To avoid an extra + // waterfall on every segment request, pass the redirected URL instead + // of the original one. + // + // Since the redirected URL might be a relative path, we need to resolve + // it against the original href, which is a fully qualified URL. + // + // TODO: We should just store the fully qualified URL as canonical URL. + // There are other parts of the router that currently expect a relative + // path, so need to update those, too. + new URL(route.canonicalUrl, routeKey.href).href + : routeKey.href + const nextUrl = routeKey.nextUrl + + const normalizedSegmentPath = + segmentPath === ROOT_SEGMENT_KEY + ? // The root segment is a special case. To simplify the server-side + // handling of these requests, we encode the root segment path as + // `_index` instead of as an empty string. This should be treated as + // an implementation detail and not as a stable part of the protocol. + // It just needs to match the equivalent logic that happens when + // prerendering the responses. It should not leak outside of Next.js. + '/_index' + : segmentPath + + const headers: RequestHeaders = { + [RSC_HEADER]: '1', + [NEXT_ROUTER_PREFETCH_HEADER]: '1', + [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: normalizedSegmentPath, + } + if (nextUrl !== null) { + headers[NEXT_URL] = nextUrl + } + + // In output: "export" mode, we need to add the segment path to the URL. + const requestUrl = isOutputExportMode + ? addSegmentPathToUrlInOutputExportMode(href, normalizedSegmentPath) + : href + try { - const response = await fetchSegmentPrefetchResponse( - href, - segmentKeyPath === ROOT_SEGMENT_KEY - ? // The root segment is a special case. To simplify the server-side - // handling of these requests, we encode the root segment path as - // `_index` instead of as an empty string. This should be treated as - // an implementation detail and not as a stable part of the protocol. - // It just needs to match the equivalent logic that happens when - // prerendering the responses. It should not leak outside of Next.js. - '/_index' - : segmentKeyPath, - routeKey.nextUrl - ) + const response = await fetchPrefetchResponse(requestUrl, headers) if ( !response || !response.ok || @@ -973,7 +1034,11 @@ export async function fetchSegmentOnCacheMiss( // is disabled on this route. Theoretically this should never happen // because we only issue requests for segments once we've verified that // the route supports PPR. - response.headers.get(NEXT_DID_POSTPONE_HEADER) !== '2' || + (response.headers.get(NEXT_DID_POSTPONE_HEADER) !== '2' && + // In output: "export" mode, we can't rely on response headers. But if + // we receive a well-formed response, we can assume it's a static + // response, because all data is static in this mode. + !isOutputExportMode) || !response.body ) { // Server responded with an error, or with a miss. We should still cache @@ -1321,34 +1386,30 @@ function writeSeedDataIntoCache( } } -async function fetchSegmentPrefetchResponse( - href: NormalizedHref, - segmentPath: string, - nextUrl: NormalizedNextUrl | null -): Promise { - const headers: RequestHeaders = { - [RSC_HEADER]: '1', - [NEXT_ROUTER_PREFETCH_HEADER]: '1', - [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: segmentPath, - } - if (nextUrl !== null) { - headers[NEXT_URL] = nextUrl - } - return fetchPrefetchResponse(href, headers) -} - async function fetchPrefetchResponse( - href: NormalizedHref, + href: string, headers: RequestHeaders ): Promise { const fetchPriority = 'low' const response = await createFetch(new URL(href), headers, fetchPriority) - const contentType = response.headers.get('content-type') - const isFlightResponse = - contentType && contentType.startsWith(RSC_CONTENT_TYPE_HEADER) - if (!response.ok || !isFlightResponse) { + if (!response.ok) { return null } + + // Check the content type + if (isOutputExportMode) { + // In output: "export" mode, we relaxed about the content type, since it's + // not Next.js that's serving the response. If the status is OK, assume the + // response is valid. If it's not a valid response, the Flight client won't + // be able to decode it, and we'll treat it as a miss. + } else { + const contentType = response.headers.get('content-type') + const isFlightResponse = + contentType && contentType.startsWith(RSC_CONTENT_TYPE_HEADER) + if (!isFlightResponse) { + return null + } + } return response } @@ -1399,6 +1460,54 @@ function createPrefetchResponseStream( }) } +function addSegmentPathToUrlInOutputExportMode( + url: string, + segmentPath: string +) { + if (isOutputExportMode) { + // In output: "export" mode, we cannot use a header to encode the segment + // path. Instead, we append it to the end of the pathname. + const staticUrl = new URL(url) + const routeDir = staticUrl.pathname.endsWith('/') + ? staticUrl.pathname.substring(0, -1) + : staticUrl.pathname + const staticExportFilename = + convertSegmentPathToStaticExportFilename(segmentPath) + staticUrl.pathname = `${routeDir}/${staticExportFilename}` + return staticUrl.href + } + return url +} + +function removeSegmentPathFromURLInOutputExportMode( + href: string, + requestUrl: string, + redirectUrl: string +) { + if (isOutputExportMode) { + // Reverse of addSegmentPathToUrlInOutputExportMode. + // + // In output: "export" mode, we append an extra string to the URL that + // represents the segment path. If the server performs a redirect, it must + // include the segment path in new URL. + // + // This removes the segment path from the redirected URL to obtain the + // URL of the page. + const segmentPath = requestUrl.substring(href.length) + if (redirectUrl.endsWith(segmentPath)) { + // Remove the segment path from the redirect URL to get the page URL. + return redirectUrl.substring(0, redirectUrl.length - segmentPath.length) + } else { + // The server redirected to a URL that doesn't include the segment path. + // This suggests the server may not have been configured correctly, but + // we'll assume the redirected URL represents the page URL and continue. + // TODO: Consider printing a warning with a link to a page that explains + // how to configure redirects and rewrites correctly. + } + } + return redirectUrl +} + function createPromiseWithResolvers(): PromiseWithResolvers { // Shim of Stage 4 Promise.withResolvers proposal let resolve: (value: T | PromiseLike) => void diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 5c52899f645bf1..bf7ddb650bd2dd 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -12,11 +12,16 @@ import { existsSync, promises as fs } from 'fs' import '../server/require-hook' -import { dirname, join, resolve, sep } from 'path' +import { dirname, join, resolve, sep, relative } from 'path' import { formatAmpMessages } from '../build/output/index' import type { AmpPageStatus } from '../build/output/index' import * as Log from '../build/output/log' -import { RSC_SUFFIX, SSG_FALLBACK_EXPORT_ERROR } from '../lib/constants' +import { + RSC_SEGMENT_SUFFIX, + RSC_SEGMENTS_DIR_SUFFIX, + RSC_SUFFIX, + SSG_FALLBACK_EXPORT_ERROR, +} from '../lib/constants' import { recursiveCopy } from '../lib/recursive-copy' import { BUILD_ID_FILE, @@ -57,6 +62,7 @@ import type { DeepReadonly } from '../shared/lib/deep-readonly' import { isInterceptionRouteRewrite } from '../lib/generate-interception-routes-rewrites' import type { ActionManifest } from '../build/webpack/plugins/flight-client-entry-plugin' import { extractInfoFromServerReferenceId } from '../shared/lib/server-reference-info' +import { convertSegmentPathToStaticExportFilename } from '../server/app-render/segment-value-encoding' export class ExportError extends Error { code = 'NEXT_EXPORT_ERROR' @@ -680,19 +686,23 @@ async function exportAppImpl( // copy prerendered routes to outDir if (!options.buildExport && prerenderManifest) { await Promise.all( - Object.keys(prerenderManifest.routes).map(async (route) => { - const { srcRoute } = prerenderManifest!.routes[route] + Object.keys(prerenderManifest.routes).map(async (unnormalizedRoute) => { + const { srcRoute } = prerenderManifest!.routes[unnormalizedRoute] const appPageName = mapAppRouteToPage.get(srcRoute || '') - const pageName = appPageName || srcRoute || route + const pageName = appPageName || srcRoute || unnormalizedRoute const isAppPath = Boolean(appPageName) const isAppRouteHandler = appPageName && isAppRouteRoute(appPageName) // returning notFound: true from getStaticProps will not // output html/json files during the build - if (prerenderManifest!.notFoundRoutes.includes(route)) { + if (prerenderManifest!.notFoundRoutes.includes(unnormalizedRoute)) { return } - route = normalizePagePath(route) + // TODO: This rewrites /index/foo to /index/index/foo. Investigate and + // fix. I presume this was because normalizePagePath was designed for + // some other use case and then reused here for static exports without + // realizing the implications. + const route = normalizePagePath(unnormalizedRoute) const pagePath = getPagePath(pageName, distDir, undefined, isAppPath) const distPagesDir = join( @@ -748,6 +758,35 @@ async function exportAppImpl( await fs.mkdir(dirname(ampHtmlDest), { recursive: true }) await fs.copyFile(`${orig}.amp.html`, ampHtmlDest) } + + const segmentsDir = `${orig}${RSC_SEGMENTS_DIR_SUFFIX}` + if (isAppPath && existsSync(segmentsDir)) { + // Output a data file for each of this page's segments + // + // These files are requested by the client router's internal + // prefetcher, not the user directly. So we don't need to account for + // things like trailing slash handling. + // + // To keep the protocol simple, we can use the non-normalized route + // path instead of the normalized one (which, among other things, + // rewrites `/` to `/index`). + const segmentsDirDest = join(outDir, unnormalizedRoute) + const segmentPaths = await collectSegmentPaths(segmentsDir) + await Promise.all( + segmentPaths.map(async (segmentFileSrc) => { + const segmentPath = + '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length) + const segmentFilename = + convertSegmentPathToStaticExportFilename(segmentPath) + const segmentFileDest = join(segmentsDirDest, segmentFilename) + await fs.mkdir(dirname(segmentFileDest), { recursive: true }) + await fs.copyFile( + join(segmentsDir, segmentFileSrc), + segmentFileDest + ) + }) + ) + } }) ) } @@ -789,6 +828,40 @@ async function exportAppImpl( return collector } +async function collectSegmentPaths(segmentsDirectory: string) { + const results: Array = [] + await collectSegmentPathsImpl(segmentsDirectory, segmentsDirectory, results) + return results +} + +async function collectSegmentPathsImpl( + segmentsDirectory: string, + directory: string, + results: Array +) { + const segmentFiles = await fs.readdir(directory, { + withFileTypes: true, + }) + await Promise.all( + segmentFiles.map(async (segmentFile) => { + if (segmentFile.isDirectory()) { + await collectSegmentPathsImpl( + segmentsDirectory, + join(directory, segmentFile.name), + results + ) + return + } + if (!segmentFile.name.endsWith(RSC_SEGMENT_SUFFIX)) { + return + } + results.push( + relative(segmentsDirectory, join(directory, segmentFile.name)) + ) + }) + ) +} + export default async function exportApp( dir: string, options: ExportAppOptions, diff --git a/packages/next/src/server/app-render/segment-value-encoding.ts b/packages/next/src/server/app-render/segment-value-encoding.ts index 18dde3b00c1d84..09d47de9e7e02a 100644 --- a/packages/next/src/server/app-render/segment-value-encoding.ts +++ b/packages/next/src/server/app-render/segment-value-encoding.ts @@ -73,3 +73,9 @@ function encodeToFilesystemAndURLSafeString(value: string) { .replace(/=+$/, '') // Remove trailing '=' return '!' + base64url } + +export function convertSegmentPathToStaticExportFilename( + segmentPath: string +): string { + return `__next${segmentPath.replace(/\//g, '.')}.txt` +} diff --git a/test/e2e/app-dir/segment-cache/export/app/layout.tsx b/test/e2e/app-dir/segment-cache/export/app/layout.tsx new file mode 100644 index 00000000000000..dbce4ea8e3aeb6 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/export/app/page.tsx b/test/e2e/app-dir/segment-cache/export/app/page.tsx new file mode 100644 index 00000000000000..af0ea301ccc6d9 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/app/page.tsx @@ -0,0 +1,39 @@ +import { LinkAccordion } from '../components/link-accordion' + +export default function OutputExport() { + return ( + <> +

+ Demonstrates that per-segment prefetching works in{' '} + output: export mode. +

+
    +
  • + Target +
  • +
+

+ The following link is rewritten on the server to the same page as the + link above: +

+
    +
  • + + Rewrite to target page + +
  • +
+

+ The following link is redirected on the server to the same page as the + link above: +

+
    +
  • + + Redirect to target page + +
  • +
+ + ) +} diff --git a/test/e2e/app-dir/segment-cache/export/app/target-page/page.tsx b/test/e2e/app-dir/segment-cache/export/app/target-page/page.tsx new file mode 100644 index 00000000000000..b9392122d574e3 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/app/target-page/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function TargetPage() { + return ( + <> +
Target page
+ Back to home + + ) +} diff --git a/test/e2e/app-dir/segment-cache/export/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/export/components/link-accordion.tsx new file mode 100644 index 00000000000000..4b253eab3adf36 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/components/link-accordion.tsx @@ -0,0 +1,23 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ href, children }) { + const [isVisible, setIsVisible] = useState(false) + return ( + <> + setIsVisible(!isVisible)} + data-link-accordion={href} + /> + {isVisible ? ( + {children} + ) : ( + `${children} (link is hidden)` + )} + + ) +} diff --git a/test/e2e/app-dir/segment-cache/export/next.config.js b/test/e2e/app-dir/segment-cache/export/next.config.js new file mode 100644 index 00000000000000..e740e0c5233bea --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/next.config.js @@ -0,0 +1,13 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + output: 'export', + experimental: { + ppr: false, + dynamicIO: true, + clientSegmentCache: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/export/segment-cache-output-export.test.ts b/test/e2e/app-dir/segment-cache/export/segment-cache-output-export.test.ts new file mode 100644 index 00000000000000..e813a25a8db56e --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/segment-cache-output-export.test.ts @@ -0,0 +1,163 @@ +import type * as Playwright from 'playwright' +import webdriver from 'next-webdriver' +import { createRouterAct } from '../router-act' +import { findPort, nextBuild } from 'next-test-utils' +import { isNextStart } from 'e2e-utils' +import { server } from './server.mjs' + +describe('segment cache (output: "export")', () => { + if (!isNextStart) { + test('build test should not run during dev test run', () => {}) + return + } + + // To debug these tests locally, first build the app, then run: + // + // node start.mjs + // + // This will serve the static `/out` directory, and also set up a server-side + // rewrite, which some of the tests below rely on. + + let port: number + + beforeAll(async () => { + const appDir = __dirname + await nextBuild(appDir, undefined, { cwd: appDir }) + port = await findPort() + server.listen(port) + }) + + afterAll(() => { + server.close() + }) + + it('basic prefetch in output: "export" mode', async () => { + let act + const browser = await webdriver(port, '/', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Initiate a prefetch + await act( + async () => { + const checkbox = await browser.elementByCss( + '[data-link-accordion="/target-page"]' + ) + await checkbox.click() + }, + { + includes: 'Target page', + } + ) + + // Navigate to the prefetched target page. + await act( + async () => { + const link = await browser.elementByCss('a[href="/target-page"]') + await link.click() + + // The page was prefetched, so we're able to render the target + // page immediately. + const div = await browser.elementById('target-page') + expect(await div.text()).toBe('Target page') + + // The target page includes a link back to the home page + await browser.elementByCss('a[href="/"]') + }, + { + // Should have prefetched the home page + includes: 'Demonstrates that per-segment prefetching works', + } + ) + }) + + it('prefetch a link to a page that is rewritten server side', async () => { + let act + const browser = await webdriver(port, '/', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Initiate a prefetch + await act( + async () => { + const checkbox = await browser.elementByCss( + '[data-link-accordion="/rewrite-to-target-page"]' + ) + await checkbox.click() + }, + { + includes: 'Target page', + } + ) + + // Navigate to the prefetched page. + await act( + async () => { + const link = await browser.elementByCss( + 'a[href="/rewrite-to-target-page"]' + ) + await link.click() + + // The page was prefetched, so we're able to render the target + // page immediately. + const div = await browser.elementById('target-page') + expect(await div.text()).toBe('Target page') + + // The target page includes a link back to the home page + await browser.elementByCss('a[href="/"]') + }, + { + // Should have prefetched the home page + includes: 'Demonstrates that per-segment prefetching works', + } + ) + }) + + it('prefetch a link to a page that is redirected server side', async () => { + let act + const browser = await webdriver(port, '/', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + }, + }) + + // Initiate a prefetch + await act( + async () => { + const checkbox = await browser.elementByCss( + '[data-link-accordion="/redirect-to-target-page"]' + ) + await checkbox.click() + }, + { + includes: 'Target page', + } + ) + + // Navigate to the prefetched page. + await act( + async () => { + const link = await browser.elementByCss( + 'a[href="/redirect-to-target-page"]' + ) + await link.click() + + // The page was prefetched, so we're able to render the target + // page immediately. + const div = await browser.elementById('target-page') + expect(await div.text()).toBe('Target page') + + // The target page includes a link back to the home page + await browser.elementByCss('a[href="/"]') + }, + { + // Should have prefetched the home page + includes: 'Demonstrates that per-segment prefetching works', + } + ) + }) +}) diff --git a/test/e2e/app-dir/segment-cache/export/server.mjs b/test/e2e/app-dir/segment-cache/export/server.mjs new file mode 100644 index 00000000000000..74fa179fff52c2 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/server.mjs @@ -0,0 +1,37 @@ +import express from 'express' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'node:http' + +const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'out') + +const app = express() + +// Redirect /redirect-to-target-page/* to /target-page/* +app.get('/redirect-to-target-page/:file?', (req, res) => { + const { file } = req.params + const newUrl = file ? `/target-page/${file}` : '/target-page' + console.log(`Redirecting to ${newUrl}`) + res.redirect(302, newUrl) +}) + +// Rewrite /rewrite-to-target-page/* to /target-page/* +// NOTE: This intentionally uses `app.use` instead of `app.get` because +// the latter doesn't let you modify the `req.url` property. +app.use((req, res, next) => { + const url = req.originalUrl + if (/^\/rewrite-to-target-page\/?[^/]*$/.test(url)) { + const newUrl = req.originalUrl.replace( + '/rewrite-to-target-page', + '/target-page' + ) + console.log(`Rewriting to ${newUrl}`) + req.url = newUrl + } + next() +}) + +// Serve static files from the out directory +app.use(express.static(OUT_DIR, { extensions: ['html'] })) + +export const server = createServer(app) diff --git a/test/e2e/app-dir/segment-cache/export/start.mjs b/test/e2e/app-dir/segment-cache/export/start.mjs new file mode 100644 index 00000000000000..0a1f576295c8c4 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/export/start.mjs @@ -0,0 +1,6 @@ +import { server } from './server.mjs' + +const port = 3000 +server.listen(3000, () => { + console.log(`Server running at http://localhost:${port}/`) +})