From 3e3c01272628d1315ecbc0c843f785436b18e943 Mon Sep 17 00:00:00 2001 From: Zack Tanner Date: Fri, 9 Feb 2024 10:30:58 -0800 Subject: [PATCH] provide interception rewrites to edge runtime (#61414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In #61794, the routes manifest is used to find the interception route rewrites in `next-server` and computed on the fly in `next-dev-server` based on `appPaths`. The edge runtime doesn't have access to the routes manifest nor a full list of app paths. This writes an entry for the edge runtime to make the interception routes readable, and adds plumbing to return them in the `getInterceptionRouteRewrites` handling in `web-server`. This is what we use to signal to the server whether to return ‘Next-URL’ in the Vary for RSC requests. This piggybacks on the existing interception routes test but adds an edge runtime case. Closes NEXT-2304 --- packages/next-swc/crates/next-api/src/app.rs | 1 + packages/next/src/build/index.ts | 3 +- .../next/src/build/templates/edge-ssr-app.ts | 4 + packages/next/src/build/webpack-config.ts | 1 + .../loaders/next-edge-ssr-loader/render.ts | 5 +- .../webpack/plugins/middleware-plugin.ts | 40 +++++++--- packages/next/src/client/route-loader.ts | 1 + .../generate-interception-routes-rewrites.ts | 3 +- .../src/server/lib/router-utils/filesystem.ts | 3 +- .../server/lib/router-utils/resolve-routes.ts | 9 ++- .../lib/router-utils/setup-dev-bundler.ts | 79 ++++++++++++------- packages/next/src/server/next-server.ts | 2 + packages/next/src/server/web-server.ts | 10 ++- packages/next/src/shared/lib/constants.ts | 3 + .../app/layout-edge.tsx | 17 ++++ .../interception-route-prefetch-cache.test.ts | 36 ++++++--- 16 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 test/e2e/app-dir/interception-route-prefetch-cache/app/layout-edge.tsx diff --git a/packages/next-swc/crates/next-api/src/app.rs b/packages/next-swc/crates/next-api/src/app.rs index 3bb0b0ac6e0f5..b2f945a191f5d 100644 --- a/packages/next-swc/crates/next-api/src/app.rs +++ b/packages/next-swc/crates/next-api/src/app.rs @@ -892,6 +892,7 @@ impl AppEndpoint { "server/middleware-build-manifest.js".to_string(), "server/middleware-react-loadable-manifest.js".to_string(), "server/next-font-manifest.js".to_string(), + "server/interception-route-rewrite-manifest.js".to_string(), ]; let mut wasm_paths_from_root = vec![]; diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 86f094d1767f9..28b5cf8edc4ec 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -720,7 +720,6 @@ export default async function build( .traceAsyncFn(() => loadCustomRoutes(config)) const { headers, rewrites, redirects } = customRoutes - NextBuildContext.rewrites = rewrites NextBuildContext.originalRewrites = config._originalRewrites NextBuildContext.originalRedirects = config._originalRedirects @@ -984,6 +983,8 @@ export default async function build( ...generateInterceptionRoutesRewrites(appPaths, config.basePath) ) + NextBuildContext.rewrites = rewrites + const totalAppPagesCount = appPaths.length const pageKeys = { diff --git a/packages/next/src/build/templates/edge-ssr-app.ts b/packages/next/src/build/templates/edge-ssr-app.ts index 8b7fe317537d5..b720e724f6fb4 100644 --- a/packages/next/src/build/templates/edge-ssr-app.ts +++ b/packages/next/src/build/templates/edge-ssr-app.ts @@ -44,6 +44,9 @@ const subresourceIntegrityManifest = sriEnabled : undefined const nextFontManifest = maybeJSONParse(self.__NEXT_FONT_MANIFEST) +const interceptionRouteRewrites = + maybeJSONParse(self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST) ?? [] + const render = getRender({ pagesType: PAGE_TYPES.APP, dev, @@ -65,6 +68,7 @@ const render = getRender({ buildId: 'VAR_BUILD_ID', nextFontManifest, incrementalCacheHandler, + interceptionRouteRewrites, }) export const ComponentMod = pageMod diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 5e304d3faa475..6341396081e51 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1803,6 +1803,7 @@ export default async function getBaseWebpackConfig( new MiddlewarePlugin({ dev, sriEnabled: !dev && !!config.experimental.sri?.algorithm, + rewrites, }), isClient && new BuildManifestPlugin({ diff --git a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts index 8be86cef4333a..67b96f6e41d85 100644 --- a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts +++ b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts @@ -13,7 +13,7 @@ import { WebNextResponse, } from '../../../../server/base-http/web' import { SERVER_RUNTIME } from '../../../../lib/constants' -import type { PrerenderManifest } from '../../..' +import type { ManifestRewriteRoute, PrerenderManifest } from '../../..' import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' import type { SizeLimit } from '../../../../../types' import { internal_getCurrentFunctionWaitUntil } from '../../../../server/web/internal-edge-wait-until' @@ -31,6 +31,7 @@ export function getRender({ buildManifest, prerenderManifest, reactLoadableManifest, + interceptionRouteRewrites, renderToHTML, clientReferenceManifest, subresourceIntegrityManifest, @@ -54,6 +55,7 @@ export function getRender({ prerenderManifest: PrerenderManifest reactLoadableManifest: ReactLoadableManifest subresourceIntegrityManifest?: Record + interceptionRouteRewrites?: ManifestRewriteRoute[] clientReferenceManifest?: ClientReferenceManifest serverActionsManifest?: any serverActions?: { @@ -85,6 +87,7 @@ export function getRender({ pathname: isAppPath ? normalizeAppPath(page) : page, pagesType, prerenderManifest, + interceptionRouteRewrites, extendRenderOpts: { buildId, runtime: SERVER_RUNTIME.experimentalEdge, diff --git a/packages/next/src/build/webpack/plugins/middleware-plugin.ts b/packages/next/src/build/webpack/plugins/middleware-plugin.ts index aea342d47a283..de43f8169f356 100644 --- a/packages/next/src/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/src/build/webpack/plugins/middleware-plugin.ts @@ -21,6 +21,7 @@ import { NEXT_FONT_MANIFEST, SERVER_REFERENCE_MANIFEST, PRERENDER_MANIFEST, + INTERCEPTION_ROUTE_REWRITE_MANIFEST, } from '../../../shared/lib/constants' import type { MiddlewareConfig } from '../../analysis/get-page-static-info' import type { Telemetry } from '../../../telemetry/storage' @@ -28,6 +29,8 @@ import { traceGlobals } from '../../../trace/shared' import { EVENT_BUILD_FEATURE_USAGE } from '../../../telemetry/events' import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' import { INSTRUMENTATION_HOOK_FILENAME } from '../../../lib/constants' +import type { CustomRoutes } from '../../../lib/load-custom-routes' +import { isInterceptionRouteRewrite } from '../../../lib/generate-interception-routes-rewrites' const KNOWN_SAFE_DYNAMIC_PACKAGES = require('../../../lib/known-edge-safe-packages.json') as string[] @@ -118,10 +121,10 @@ function getEntryFiles( files.push( `server/${MIDDLEWARE_BUILD_MANIFEST}.js`, - `server/${MIDDLEWARE_REACT_LOADABLE_MANIFEST}.js` + `server/${MIDDLEWARE_REACT_LOADABLE_MANIFEST}.js`, + `server/${NEXT_FONT_MANIFEST}.js`, + `server/${INTERCEPTION_ROUTE_REWRITE_MANIFEST}.js` ) - - files.push(`server/${NEXT_FONT_MANIFEST}.js`) } if (hasInstrumentationHook) { @@ -144,9 +147,7 @@ function getEntryFiles( function getCreateAssets(params: { compilation: webpack.Compilation metadataByEntry: Map - opts: { - sriEnabled: boolean - } + opts: Omit }) { const { compilation, metadataByEntry, opts } = params return (assets: any) => { @@ -161,6 +162,17 @@ function getCreateAssets(params: { INSTRUMENTATION_HOOK_FILENAME ) + // we only emit this entry for the edge runtime since it doesn't have access to a routes manifest + // and we don't need to provide the entire route manifest, just the interception routes. + const interceptionRewrites = JSON.stringify( + opts.rewrites.beforeFiles.filter(isInterceptionRouteRewrite) + ) + assets[`${INTERCEPTION_ROUTE_REWRITE_MANIFEST}.js`] = new sources.RawSource( + `self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST=${JSON.stringify( + interceptionRewrites + )}` + ) as unknown as webpack.sources.RawSource + for (const entrypoint of compilation.entrypoints.values()) { if (!entrypoint.name) { continue @@ -718,13 +730,22 @@ function getExtractMetadata(params: { } } } + +interface Options { + dev: boolean + sriEnabled: boolean + rewrites: CustomRoutes['rewrites'] +} + export default class MiddlewarePlugin { - private readonly dev: boolean - private readonly sriEnabled: boolean + private readonly dev: Options['dev'] + private readonly sriEnabled: Options['sriEnabled'] + private readonly rewrites: Options['rewrites'] - constructor({ dev, sriEnabled }: { dev: boolean; sriEnabled: boolean }) { + constructor({ dev, sriEnabled, rewrites }: Options) { this.dev = dev this.sriEnabled = sriEnabled + this.rewrites = rewrites } public apply(compiler: webpack.Compiler) { @@ -769,6 +790,7 @@ export default class MiddlewarePlugin { metadataByEntry, opts: { sriEnabled: this.sriEnabled, + rewrites: this.rewrites, }, }) ) diff --git a/packages/next/src/client/route-loader.ts b/packages/next/src/client/route-loader.ts index ca22834908464..ab0337eff033f 100644 --- a/packages/next/src/client/route-loader.ts +++ b/packages/next/src/client/route-loader.ts @@ -23,6 +23,7 @@ declare global { __RSC_SERVER_MANIFEST?: any __NEXT_FONT_MANIFEST?: any __SUBRESOURCE_INTEGRITY_MANIFEST?: string + __INTERCEPTION_ROUTE_REWRITE_MANIFEST?: string } } diff --git a/packages/next/src/lib/generate-interception-routes-rewrites.ts b/packages/next/src/lib/generate-interception-routes-rewrites.ts index 4de355fc51b3b..3876077ed7677 100644 --- a/packages/next/src/lib/generate-interception-routes-rewrites.ts +++ b/packages/next/src/lib/generate-interception-routes-rewrites.ts @@ -6,7 +6,6 @@ import { isInterceptionRouteAppPath, } from '../server/future/helpers/interception-routes' import type { Rewrite } from './load-custom-routes' -import type { ManifestRewriteRoute } from '../build' // a function that converts normalised paths (e.g. /foo/[bar]/[baz]) to the format expected by pathToRegexp (e.g. /foo/:bar/:baz) function toPathToRegexpPath(path: string): string { @@ -88,7 +87,7 @@ export function generateInterceptionRoutesRewrites( return rewrites } -export function isInterceptionRouteRewrite(route: ManifestRewriteRoute) { +export function isInterceptionRouteRewrite(route: Rewrite) { // When we generate interception rewrites in the above implementation, we always do so with only a single `has` condition. return route.has?.[0].key === NEXT_URL } diff --git a/packages/next/src/server/lib/router-utils/filesystem.ts b/packages/next/src/server/lib/router-utils/filesystem.ts index df0107524e388..937cd62d33991 100644 --- a/packages/next/src/server/lib/router-utils/filesystem.ts +++ b/packages/next/src/server/lib/router-utils/filesystem.ts @@ -325,7 +325,6 @@ export async function setupFsCheck(opts: { ) ) const rewrites = { - // TODO: add interception routes generateInterceptionRoutesRewrites() beforeFiles: customRoutes.rewrites.beforeFiles.map((item) => buildCustomRoute('before_files_rewrite', item) ), @@ -393,7 +392,7 @@ export async function setupFsCheck(opts: { dynamicRoutes, nextDataRoutes, - interceptionRoutes: undefined as + exportPathMapRoutes: undefined as | undefined | ReturnType>[], diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index e84beb96c512b..37d55112535e4 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -358,9 +358,12 @@ export function getResolveRoutes( } if (params) { - if (fsChecker.interceptionRoutes && route.name === 'before_files_end') { - for (const interceptionRoute of fsChecker.interceptionRoutes) { - const result = await handleRoute(interceptionRoute) + if ( + fsChecker.exportPathMapRoutes && + route.name === 'before_files_end' + ) { + for (const exportPathMapRoute of fsChecker.exportPathMapRoutes) { + const result = await handleRoute(exportPathMapRoute) if (result) { return result diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index bce38c0d6cfc8..f035e1ba404dc 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -68,7 +68,10 @@ import { getRouteMatcher } from '../../../shared/lib/router/utils/route-matcher' import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep' import { createClientRouterFilter } from '../../../lib/create-client-router-filter' import { absolutePathToPage } from '../../../shared/lib/page-path/absolute-path-to-page' -import { generateInterceptionRoutesRewrites } from '../../../lib/generate-interception-routes-rewrites' +import { + generateInterceptionRoutesRewrites, + isInterceptionRouteRewrite, +} from '../../../lib/generate-interception-routes-rewrites' import { store as consoleStore } from '../../../build/output/store' import { @@ -87,6 +90,7 @@ import { REACT_LOADABLE_MANIFEST, MIDDLEWARE_REACT_LOADABLE_MANIFEST, MIDDLEWARE_BUILD_MANIFEST, + INTERCEPTION_ROUTE_REWRITE_MANIFEST, } from '../../../shared/lib/constants' import { getMiddlewareRouteMatcher } from '../../../shared/lib/router/utils/middleware-route-matcher' @@ -868,15 +872,32 @@ async function startWatcher(opts: SetupOpts) { 'server', `${MIDDLEWARE_BUILD_MANIFEST}.js` ) + const interceptionRewriteManifestPath = path.join( + distDir, + 'server', + `${INTERCEPTION_ROUTE_REWRITE_MANIFEST}.js` + ) deleteCache(buildManifestPath) deleteCache(middlewareBuildManifestPath) + deleteCache(interceptionRewriteManifestPath) await writeFileAtomic( buildManifestPath, JSON.stringify(buildManifest, null, 2) ) await writeFileAtomic( middlewareBuildManifestPath, - `self.__BUILD_MANIFEST=${JSON.stringify(buildManifest)}` + `self.__BUILD_MANIFEST=${JSON.stringify(buildManifest)};` + ) + + const interceptionRewrites = JSON.stringify( + rewrites.beforeFiles.filter(isInterceptionRouteRewrite) + ) + + await writeFileAtomic( + interceptionRewriteManifestPath, + `self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST=${JSON.stringify( + interceptionRewrites + )};` ) const content: ClientBuildManifest = { @@ -2340,18 +2361,19 @@ async function startWatcher(opts: SetupOpts) { ? getMiddlewareRouteMatcher(serverFields.middleware?.matchers) : undefined - opts.fsChecker.interceptionRoutes = - generateInterceptionRoutesRewrites( - Object.keys(appPaths), - opts.nextConfig.basePath - )?.map((item) => - buildCustomRoute( - 'before_files_rewrite', - item, - opts.nextConfig.basePath, - opts.nextConfig.experimental.caseSensitiveRoutes - ) - ) || [] + const interceptionRoutes = generateInterceptionRoutesRewrites( + Object.keys(appPaths), + opts.nextConfig.basePath + ).map((item) => + buildCustomRoute( + 'before_files_rewrite', + item, + opts.nextConfig.basePath, + opts.nextConfig.experimental.caseSensitiveRoutes + ) + ) + + opts.fsChecker.rewrites.beforeFiles.push(...interceptionRoutes) const exportPathMap = (typeof nextConfig.exportPathMap === 'function' && @@ -2367,19 +2389,22 @@ async function startWatcher(opts: SetupOpts) { ))) || {} - for (const [key, value] of Object.entries(exportPathMap || {})) { - opts.fsChecker.interceptionRoutes.push( - buildCustomRoute( - 'before_files_rewrite', - { - source: key, - destination: `${value.page}${ - value.query ? '?' : '' - }${qs.stringify(value.query)}`, - }, - opts.nextConfig.basePath, - opts.nextConfig.experimental.caseSensitiveRoutes - ) + const exportPathMapEntries = Object.entries(exportPathMap || {}) + + if (exportPathMapEntries.length > 0) { + opts.fsChecker.exportPathMapRoutes = exportPathMapEntries.map( + ([key, value]) => + buildCustomRoute( + 'before_files_rewrite', + { + source: key, + destination: `${value.page}${ + value.query ? '?' : '' + }${qs.stringify(value.query)}`, + }, + opts.nextConfig.basePath, + opts.nextConfig.experimental.caseSensitiveRoutes + ) ) } diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 956c2604c7fa7..5c58adc385587 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -373,6 +373,8 @@ export default class NextNodeServer extends BaseServer { } protected getinterceptionRoutePatterns(): RegExp[] { + if (!this.enabledDirectories.app) return [] + const routesManifest = this.getRoutesManifest() return ( routesManifest?.rewrites.beforeFiles diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index cf067358896f9..ce3e9677250da 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -30,6 +30,8 @@ import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' import { IncrementalCache } from './lib/incremental-cache' import type { PAGE_TYPES } from '../lib/page-types' +import type { Rewrite } from '../lib/load-custom-routes' +import { buildCustomRoute } from '../lib/build-custom-route' interface WebServerOptions extends Options { webServerConfig: { @@ -44,6 +46,7 @@ interface WebServerOptions extends Options { | undefined incrementalCacheHandler?: any prerenderManifest: PrerenderManifest | undefined + interceptionRouteRewrites?: Rewrite[] } } @@ -395,7 +398,10 @@ export default class NextWebServer extends BaseServer { } protected getinterceptionRoutePatterns(): RegExp[] { - // TODO: This needs to be implemented. - return [] + return ( + this.serverOptions.webServerConfig.interceptionRouteRewrites?.map( + (rewrite) => new RegExp(buildCustomRoute('rewrite', rewrite).regex) + ) ?? [] + ) } } diff --git a/packages/next/src/shared/lib/constants.ts b/packages/next/src/shared/lib/constants.ts index f0e667feb1379..c3b3ed8563cc4 100644 --- a/packages/next/src/shared/lib/constants.ts +++ b/packages/next/src/shared/lib/constants.ts @@ -77,6 +77,9 @@ export const MIDDLEWARE_BUILD_MANIFEST = 'middleware-build-manifest' // server/middleware-react-loadable-manifest.js export const MIDDLEWARE_REACT_LOADABLE_MANIFEST = 'middleware-react-loadable-manifest' +// server/interception-route-rewrite-manifest.js +export const INTERCEPTION_ROUTE_REWRITE_MANIFEST = + 'interception-route-rewrite-manifest' // static/runtime/main.js export const CLIENT_STATIC_FILES_RUNTIME_MAIN = `main` diff --git a/test/e2e/app-dir/interception-route-prefetch-cache/app/layout-edge.tsx b/test/e2e/app-dir/interception-route-prefetch-cache/app/layout-edge.tsx new file mode 100644 index 0000000000000..9a4b0ee3ec326 --- /dev/null +++ b/test/e2e/app-dir/interception-route-prefetch-cache/app/layout-edge.tsx @@ -0,0 +1,17 @@ +// this file is swapped in for the normal layout file in the edge runtime test + +import Link from 'next/link' + +export const runtime = 'edge' + +export default function RootLayout({ children }) { + return ( + + + + home + {children} + + + ) +} diff --git a/test/e2e/app-dir/interception-route-prefetch-cache/interception-route-prefetch-cache.test.ts b/test/e2e/app-dir/interception-route-prefetch-cache/interception-route-prefetch-cache.test.ts index b830f64f4a9e5..8579b284fcfb9 100644 --- a/test/e2e/app-dir/interception-route-prefetch-cache/interception-route-prefetch-cache.test.ts +++ b/test/e2e/app-dir/interception-route-prefetch-cache/interception-route-prefetch-cache.test.ts @@ -1,13 +1,10 @@ -import { createNextDescribe } from 'e2e-utils' +import { nextTestSetup, FileRef } from 'e2e-utils' import { check } from 'next-test-utils' +import { join } from 'path' import { Response } from 'playwright-chromium' -createNextDescribe( - 'interception-route-prefetch-cache', - { - files: __dirname, - }, - ({ next, isNextStart }) => { +describe('interception-route-prefetch-cache', () => { + function runTests({ next }: ReturnType) { it('should render the correct interception when two distinct layouts share the same path structure', async () => { const browser = await next.browser('/') @@ -42,7 +39,17 @@ createNextDescribe( /Intercepted on Bar Page/ ) }) + } + + describe('runtime = nodejs', () => { + const testSetup = nextTestSetup({ + files: __dirname, + }) + runTests(testSetup) + + const { next, isNextStart } = testSetup + // this is a node runtime specific test as edge doesn't support static rendering if (isNextStart) { it('should not be a cache HIT when prefetching an interception route', async () => { const responses: { cacheStatus: string; pathname: string }[] = [] @@ -78,5 +85,16 @@ createNextDescribe( expect(interceptionPrefetchResponse.cacheStatus).toBeUndefined() }) } - } -) + }) + + describe('runtime = edge', () => { + runTests( + nextTestSetup({ + files: { + app: new FileRef(join(__dirname, 'app')), + 'app/layout.tsx': new FileRef(join(__dirname, 'app/layout-edge.tsx')), + }, + }) + ) + }) +})