diff --git a/.github/workflows/deno-test.yml b/.github/workflows/deno-test.yml
new file mode 100644
index 0000000000..6c15856206
--- /dev/null
+++ b/.github/workflows/deno-test.yml
@@ -0,0 +1,21 @@
+name: Deno tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Git Checkout Deno Module
+ uses: actions/checkout@v2
+ - name: Use Deno Version ${{ matrix.deno-version }}
+ uses: denolib/setup-deno@v2
+ with:
+ deno-version: vx.x.x
+ - name: Test Deno
+ run: deno test packages/runtime/src/templates/edge-shared/
diff --git a/.github/workflows/e2e-routes.yml b/.github/workflows/e2e-routes.yml
new file mode 100644
index 0000000000..fd08d33c98
--- /dev/null
+++ b/.github/workflows/e2e-routes.yml
@@ -0,0 +1,60 @@
+name: Run e2e (edge router)
+on:
+ pull_request:
+ types: [opened, labeled, unlabeled, synchronize]
+ push:
+ branches:
+ - main
+ paths:
+ - 'test/e2e/router/**/*.{js,jsx,ts,tsx}'
+ - 'demos/custom-routes/**/*'
+ - 'packages/**/*.{ts,js}'
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Generate Github token
+ uses: navikt/github-app-token-generator@v1
+ id: get-token
+ with:
+ private-key: ${{ secrets.TOKENS_PRIVATE_KEY }}
+ app-id: ${{ secrets.TOKENS_APP_ID }}
+
+ - name: Checkout @netlify/wait-for-deploy-action
+ uses: actions/checkout@v2
+ with:
+ repository: netlify/wait-for-deploy-action
+ token: ${{ steps.get-token.outputs.token }}
+ path: ./.github/actions/wait-for-netlify-deploy
+
+ - name: Node
+ uses: actions/setup-node@v2
+ with:
+ node-version: 16
+ cache: 'npm'
+
+ - name: Install npm dependencies
+ run: npm install
+
+ - name: Install Playwright dependencies
+ run: npm run playwright:install
+
+ - name: Wait for Netlify Deploy
+ id: deploy
+ uses: ./.github/actions/wait-for-netlify-deploy
+ with:
+ site-name: nextjs-plugin-custom-routes-demo
+ timeout: 300
+
+ - name: Deploy successful
+ if: ${{ steps.deploy.outputs.origin-url }}
+ run: echo ${{ steps.deploy.outputs.origin-url }}
+
+ - name: Run e2e tests
+ if: ${{ steps.deploy.outputs.origin-url }}
+ run: npx jest -c test/e2e/jest.config.router.js
+ env:
+ SITE_URL: ${{ steps.deploy.outputs.origin-url }}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 5e1acb3317..70103135ae 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,6 +3,7 @@
"packages/runtime/src/templates/edge",
"packages/runtime/src/templates/edge-shared",
"demos/middleware/.netlify/edge-functions",
+ "demos/custom-routes/.netlify/edge-functions",
"demos/server-components/.netlify/edge-functions",
],
"deno.unstable": true
diff --git a/demos/canary/netlify.toml b/demos/canary/netlify.toml
index 86b1877202..29fe998364 100644
--- a/demos/canary/netlify.toml
+++ b/demos/canary/netlify.toml
@@ -3,6 +3,9 @@ command = "next build"
publish = ".next"
ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;"
+[build.environment]
+NETLIFY_NEXT_EDGE_ROUTER = "true"
+
[[plugins]]
package = "@netlify/plugin-nextjs"
diff --git a/demos/canary/package-lock.json b/demos/canary/package-lock.json
index f8fd25606c..2feb025dee 100644
--- a/demos/canary/package-lock.json
+++ b/demos/canary/package-lock.json
@@ -2497,4 +2497,4 @@
"dev": true
}
}
-}
+}
\ No newline at end of file
diff --git a/demos/canary/package.json b/demos/canary/package.json
index ac603620de..2ee4788dec 100644
--- a/demos/canary/package.json
+++ b/demos/canary/package.json
@@ -25,4 +25,4 @@
"npm-run-all": "^4.1.5",
"typescript": "^4.6.3"
}
-}
+}
\ No newline at end of file
diff --git a/demos/custom-routes/middleware.ts b/demos/custom-routes/middleware.ts
new file mode 100644
index 0000000000..339b66dcf0
--- /dev/null
+++ b/demos/custom-routes/middleware.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+
+export async function middleware(req: NextRequest) {
+ const res = NextResponse.rewrite(new URL('/', req.url))
+ res.headers.set('x-response-header', 'set in middleware')
+ res.headers.set('x-is-deno', 'Deno' in globalThis ? 'true' : 'false')
+ return res
+}
+
+export const config = {
+ matcher: [
+ '/foo',
+ { source: '/bar' },
+ {
+ source: '/baz',
+ has: [
+ {
+ type: 'header',
+ key: 'x-my-header',
+ value: 'my-value',
+ },
+ ],
+ },
+ {
+ source: '/en/asdf',
+ locale: false,
+ },
+ ],
+}
diff --git a/demos/custom-routes/netlify.toml b/demos/custom-routes/netlify.toml
index 86b1877202..e931c8e7be 100644
--- a/demos/custom-routes/netlify.toml
+++ b/demos/custom-routes/netlify.toml
@@ -3,6 +3,12 @@ command = "next build"
publish = ".next"
ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;"
+[build.environment]
+NETLIFY_NEXT_EDGE_ROUTER = "true"
+
+[dev]
+framework = "#static"
+
[[plugins]]
package = "@netlify/plugin-nextjs"
diff --git a/demos/custom-routes/next.config.js b/demos/custom-routes/next.config.js
index e69b8c393c..12197cb372 100644
--- a/demos/custom-routes/next.config.js
+++ b/demos/custom-routes/next.config.js
@@ -4,6 +4,7 @@ module.exports = {
// your project has ESLint errors.
ignoreDuringBuilds: true,
},
+ generateBuildId: () => 'build-id',
async rewrites() {
// no-rewrites comment
return {
@@ -86,7 +87,7 @@ module.exports = {
},
{
source: '/proxy-me/:path*',
- destination: 'http://localhost:__EXTERNAL_PORT__/:path*',
+ destination: 'http://localhost:8888/:path*',
},
{
source: '/api-hello',
@@ -233,6 +234,44 @@ module.exports = {
destination: '/_sport/nfl/:path*',
},
],
+ fallback: [
+ {
+ source: '/matchfallback',
+ destination: '/blog/1',
+ },
+ {
+ source: '/externalfallback',
+ destination: 'https://example.com',
+ },
+ {
+ source: '/fallbackloop',
+ destination: '/intermediatefallback',
+ },
+ {
+ source: '/intermediatefallback',
+ destination: '/fallbackloop',
+ },
+ {
+ source: '/chainedfallback',
+ destination: '/chain1',
+ },
+ {
+ source: '/chain1',
+ destination: '/chain2',
+ },
+ {
+ source: '/chain2',
+ destination: '/chain3',
+ },
+ {
+ source: '/chain3',
+ destination: '/chain4',
+ },
+ {
+ source: '/chain4',
+ destination: '/static/hello.txt',
+ },
+ ],
}
},
async redirects() {
diff --git a/demos/custom-routes/package.json b/demos/custom-routes/package.json
index 3dbb163d92..900651903a 100644
--- a/demos/custom-routes/package.json
+++ b/demos/custom-routes/package.json
@@ -18,8 +18,7 @@
"next": "^13.0.6"
},
"scripts": {
- "build": "next build",
- "test": "jest"
+ "build": "next build"
},
"repository": {
"type": "git",
diff --git a/demos/custom-routes/pages/[...slug].js b/demos/custom-routes/pages/[...slug].js
new file mode 100644
index 0000000000..26e5647304
--- /dev/null
+++ b/demos/custom-routes/pages/[...slug].js
@@ -0,0 +1,3 @@
+export default function Page() {
+ return
this is the fallback page to prevent 404s in tests
+}
diff --git a/demos/default/pages/api/shows/[id].js b/demos/default/pages/api/shows/[id].js
index 54f8a41b73..e059367af4 100644
--- a/demos/default/pages/api/shows/[id].js
+++ b/demos/default/pages/api/shows/[id].js
@@ -1,3 +1,6 @@
+/**
+ * @param {import('http').IncomingMessage} req
+ */
export default async (req, res) => {
// Respond with JSON
res.setHeader('Content-Type', 'application/json')
@@ -13,7 +16,15 @@ export default async (req, res) => {
// If show was found, return it
if (fetchRes.status == 200) {
res.status(200)
- res.json({ show: data })
+ res.json({
+ show: data,
+ headers: { ...req.headers },
+ url: req.url,
+ path: req.path,
+ query: req.query,
+ status: req.status,
+ id: req.id,
+ })
}
// If show was not found, return error
else {
diff --git a/demos/middleware/next.config.js b/demos/middleware/next.config.js
index 9ce274ced3..d4d87d430b 100644
--- a/demos/middleware/next.config.js
+++ b/demos/middleware/next.config.js
@@ -6,6 +6,22 @@ const nextConfig = {
// your project has ESLint errors.
ignoreDuringBuilds: true,
},
+ async rewrites() {
+ return {
+ afterFiles: [
+ {
+ source: '/old/:path*',
+ destination: '/:path*',
+ },
+ ],
+ beforeFiles: [
+ {
+ source: '/aa/:path*',
+ destination: '/:path*',
+ },
+ ],
+ }
+ },
generateBuildId: () => 'build-id',
i18n: {
defaultLocale: 'en',
diff --git a/package.json b/package.json
index 8237f9f8d4..13d66a1c47 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"test:next:disabled": "RUN_SKIPPED_TESTS=1 jest -c test/e2e/jest.config.disabled.js",
"test:next:all": "RUN_SKIPPED_TESTS=1 jest -c test/e2e/jest.config.all.js",
"test:next:appdir": "NEXT_TEST_VERSION=canary jest -c test/e2e/jest.config.appdir.js",
+ "test:next:router": "jest -c test/e2e/jest.config.router.js",
"test:jest": "jest",
"playwright:install": "playwright install --with-deps chromium",
"test:jest:update": "jest --updateSnapshot",
diff --git a/packages/runtime/src/helpers/edge.ts b/packages/runtime/src/helpers/edge.ts
index 927ac58783..f6cb79cde4 100644
--- a/packages/runtime/src/helpers/edge.ts
+++ b/packages/runtime/src/helpers/edge.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines-per-function */
import { promises as fs, existsSync } from 'fs'
import { resolve, join } from 'path'
@@ -32,7 +33,7 @@ export interface MiddlewareMatcher {
has?: RouteHas[]
}
-// This is the format after next@12.3.0
+// This is the format after next@12.2.6-canary.10
interface EdgeFunctionDefinitionV2 {
env: string[]
files: string[]
@@ -45,6 +46,8 @@ interface EdgeFunctionDefinitionV2 {
type EdgeFunctionDefinition = EdgeFunctionDefinitionV1 | EdgeFunctionDefinitionV2
+const EDGE_ROUTER_PRE_MIDDLEWARE = 'router-pre-middleware'
+const EDGE_ROUTER_POST_MIDDLEWARE = 'router-post-middleware'
export interface FunctionManifest {
version: 1
functions: Array<
@@ -182,6 +185,8 @@ const getMiddlewareBundle = async ({
const getEdgeTemplatePath = (file: string) => join(__dirname, '..', '..', 'src', 'templates', 'edge', file)
+export const usesEdgeRouter = destr(process.env.NETLIFY_NEXT_EDGE_ROUTER)
+
const copyEdgeSourceFile = ({
file,
target,
@@ -196,6 +201,7 @@ const writeEdgeFunction = async ({
edgeFunctionDefinition,
edgeFunctionRoot,
netlifyConfig,
+ edgeRouter,
pageRegexMap,
appPathRoutesManifest = {},
nextConfig,
@@ -204,6 +210,7 @@ const writeEdgeFunction = async ({
edgeFunctionDefinition: EdgeFunctionDefinition
edgeFunctionRoot: string
netlifyConfig: NetlifyConfig
+ edgeRouter: boolean
pageRegexMap?: Map
appPathRoutesManifest?: Record
nextConfig: NextConfig
@@ -261,7 +268,8 @@ const writeEdgeFunction = async ({
// We add a defintion for each matching path
return matchers.map((matcher) => {
- const pattern = stripLookahead(matcher.regexp)
+ // Edge router does its own routing, so gets a catch-all
+ const pattern = edgeRouter ? '^.*$' : stripLookahead(matcher.regexp)
return { function: name, pattern, name: edgeFunctionDefinition.name, cache }
})
}
@@ -292,6 +300,26 @@ export const writeDevEdgeFunction = async ({
await copyEdgeSourceFile({ edgeFunctionDir, file: 'next-dev.js', target: 'index.js' })
}
+export const writeEdgeRouter = async ({
+ edgeFunctionRoot = '.netlify/edge-functions',
+ publishDir = '.next',
+}: {
+ edgeFunctionRoot: string
+ publishDir: string
+}) => {
+ for (const file of [EDGE_ROUTER_PRE_MIDDLEWARE, EDGE_ROUTER_POST_MIDDLEWARE]) {
+ const edgeFunctionDir = join(edgeFunctionRoot, file)
+ await ensureDir(edgeFunctionDir)
+ await copyEdgeSourceFile({
+ file: `${file}.ts`,
+ edgeFunctionDir,
+ target: 'index.ts',
+ })
+ }
+ for (const file of ['routes-manifest.json', 'public-manifest.json']) {
+ await fs.copyFile(join(publishDir, file), join(edgeFunctionRoot, 'edge-shared', file))
+ }
+}
/**
* Writes Edge Functions for the Next middleware
*/
@@ -345,7 +373,7 @@ export const writeEdgeFunctions = async ({
return
}
- let usesEdge = false
+ let usesEdge = usesEdgeRouter
for (const middleware of middlewareManifest.sortedMiddleware) {
usesEdge = true
@@ -354,12 +382,28 @@ export const writeEdgeFunctions = async ({
edgeFunctionDefinition,
edgeFunctionRoot,
netlifyConfig,
+ edgeRouter: usesEdgeRouter,
nextConfig,
})
manifest.functions.push(...functionDefinitions)
}
- // Older versions of the manifest format don't have the functions field
- // No, the version field was not incremented
+
+ if (usesEdgeRouter) {
+ console.log('Using experimental Netlify Next.js Edge Router')
+ await writeEdgeRouter({ edgeFunctionRoot, publishDir: publish })
+ // Pre-middleware routing runs first...
+ manifest.functions.unshift({
+ function: EDGE_ROUTER_PRE_MIDDLEWARE,
+ path: '/*',
+ })
+
+ // ...and (you guessed it) post-middleware routing runs last
+ manifest.functions.push({
+ function: EDGE_ROUTER_POST_MIDDLEWARE,
+ path: '/*',
+ })
+ }
+
if (typeof middlewareManifest.functions === 'object') {
// When using the app dir, we also need to check if the EF matches a page
const appPathRoutesManifest = await loadAppPathRoutesManifest(netlifyConfig)
@@ -371,12 +415,14 @@ export const writeEdgeFunctions = async ({
]),
)
+ // Functions run after routing
for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) {
usesEdge = true
const functionDefinitions = await writeEdgeFunction({
edgeFunctionDefinition,
edgeFunctionRoot,
netlifyConfig,
+ edgeRouter: usesEdgeRouter,
pageRegexMap,
appPathRoutesManifest,
nextConfig,
@@ -386,6 +432,7 @@ export const writeEdgeFunctions = async ({
manifest.functions.push(...functionDefinitions)
}
}
+
if (usesEdge) {
console.log(outdent`
✨ Deploying middleware and functions to ${greenBright`Netlify Edge Functions`} ✨
@@ -395,3 +442,4 @@ export const writeEdgeFunctions = async ({
}
await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest)
}
+/* eslint-enable max-lines-per-function */
diff --git a/packages/runtime/src/helpers/files.ts b/packages/runtime/src/helpers/files.ts
index 02c397e4c1..427575bd4e 100644
--- a/packages/runtime/src/helpers/files.ts
+++ b/packages/runtime/src/helpers/files.ts
@@ -13,6 +13,7 @@ import slash from 'slash'
import { MINIMUM_REVALIDATE_SECONDS, DIVIDER } from '../constants'
import { NextConfig } from './config'
+import { usesEdgeRouter } from './edge'
import { Rewrites, RoutesManifest } from './types'
import { findModuleFromBase } from './utils'
@@ -95,10 +96,15 @@ export const moveStaticPages = async ({
const prerenderManifest: PrerenderManifest = await readJson(
join(netlifyConfig.build.publish, 'prerender-manifest.json'),
)
- const { redirects, rewrites }: RoutesManifest = await readJson(
+ let { redirects, rewrites }: RoutesManifest = await readJson(
join(netlifyConfig.build.publish, 'routes-manifest.json'),
)
+ if (usesEdgeRouter) {
+ redirects = []
+ rewrites = []
+ }
+
const isrFiles = new Set()
const shortRevalidateRoutes: Array<{ Route: string; Revalidate: number }> = []
@@ -450,3 +456,22 @@ export const movePublicFiles = async ({
await copy(publicDir, `${publish}/`)
}
}
+
+export const writeStaticRouteManifest = async ({
+ appDir,
+ outdir,
+ publish,
+}: {
+ appDir: string
+ outdir?: string
+ publish: string
+}): Promise => {
+ const publicDir = outdir ? join(appDir, outdir, 'public') : join(appDir, 'public')
+ const fileList = existsSync(publicDir) ? await globby(['**/*'], { cwd: publicDir }) : []
+
+ const pagesManifest = await readJson(join(publish, 'server', 'pages-manifest.json'))
+
+ const staticRoutes = Object.keys(pagesManifest).filter((route) => !isDynamicRoute(route))
+
+ await writeJson(join(publish, 'public-manifest.json'), [...fileList.map((file) => `/${file}`), ...staticRoutes])
+}
diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts
index f451abb321..3a69133e10 100644
--- a/packages/runtime/src/helpers/functions.ts
+++ b/packages/runtime/src/helpers/functions.ts
@@ -13,6 +13,7 @@ import { getHandler } from '../templates/getHandler'
import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver'
import { ApiConfig, ApiRouteType, extractConfigFromFile } from './analysis'
+import { usesEdgeRouter } from './edge'
import { getSourceFileForPage } from './files'
import { getFunctionNameForPage } from './utils'
@@ -42,6 +43,7 @@ export const generateFunctions = async (
config,
publishDir,
appDir: relative(functionDir, appDir),
+ minimalMode: usesEdgeRouter,
})
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND)
await ensureDir(join(functionsDir, functionName))
@@ -63,7 +65,12 @@ export const generateFunctions = async (
}
const writeHandler = async (functionName: string, isODB: boolean) => {
- const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) })
+ const handlerSource = await getHandler({
+ isODB,
+ publishDir,
+ appDir: relative(functionDir, appDir),
+ minimalMode: usesEdgeRouter,
+ })
await ensureDir(join(functionsDir, functionName))
await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource)
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
diff --git a/packages/runtime/src/helpers/redirects.ts b/packages/runtime/src/helpers/redirects.ts
index 3781712e95..a4e533bbae 100644
--- a/packages/runtime/src/helpers/redirects.ts
+++ b/packages/runtime/src/helpers/redirects.ts
@@ -8,6 +8,7 @@ import { join } from 'pathe'
import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../constants'
+import { usesEdgeRouter } from './edge'
import { getMiddleware } from './files'
import { ApiRouteConfig } from './functions'
import { RoutesManifest } from './types'
@@ -249,7 +250,7 @@ export const generateRedirects = async ({
netlifyConfig.redirects.push(...generateHiddenPathRedirects({ basePath }))
- if (i18n && i18n.localeDetection !== false) {
+ if (i18n && i18n.localeDetection !== false && !usesEdgeRouter) {
netlifyConfig.redirects.push(...generateLocaleRedirects({ i18n, basePath, trailingSlash }))
}
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 8806d686a9..290e8367b0 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -16,8 +16,8 @@ import {
generateCustomHeaders,
} from './helpers/config'
import { onPreDev } from './helpers/dev'
-import { writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge'
-import { moveStaticPages, movePublicFiles, patchNextFiles } from './helpers/files'
+import { usesEdgeRouter, writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge'
+import { moveStaticPages, movePublicFiles, patchNextFiles, writeStaticRouteManifest } from './helpers/files'
import {
generateFunctions,
setupImageFunction,
@@ -157,6 +157,10 @@ const plugin: NetlifyPlugin = {
await movePublicFiles({ appDir, outdir, publish })
+ if (usesEdgeRouter) {
+ await writeStaticRouteManifest({ appDir, outdir, publish })
+ }
+
await patchNextFiles(appDir)
if (!destr(process.env.SERVE_STATIC_FILES_FROM_ORIGIN)) {
@@ -219,10 +223,11 @@ const plugin: NetlifyPlugin = {
await checkZipSize(join(FUNCTIONS_DIST, `${ODB_FUNCTION_NAME}.zip`))
const nextConfig = await getNextConfig({ publish, failBuild })
+ if (!usesEdgeRouter) {
+ generateCustomHeaders(nextConfig, headers)
+ }
const { basePath, appDir, experimental } = nextConfig
- generateCustomHeaders(nextConfig, headers)
-
warnForProblematicUserRewrites({ basePath, redirects })
warnForRootRedirects({ appDir })
await warnOnApiRoutes({ FUNCTIONS_DIST })
diff --git a/packages/runtime/src/templates/edge-shared/next-utils.ts b/packages/runtime/src/templates/edge-shared/next-utils.ts
index 5fdc34e7ce..9726248cc3 100644
--- a/packages/runtime/src/templates/edge-shared/next-utils.ts
+++ b/packages/runtime/src/templates/edge-shared/next-utils.ts
@@ -36,7 +36,7 @@ export type RouteHas =
value: string
}
-export type Rewrite = {
+export type RewriteRule = {
source: string
destination: string
basePath?: false
@@ -45,7 +45,7 @@ export type Rewrite = {
regex: string
}
-export type Header = {
+export type HeaderRule = {
source: string
basePath?: false
locale?: false
@@ -53,7 +53,7 @@ export type Header = {
has?: RouteHas[]
regex: string
}
-export type Redirect = {
+export type RedirectRule = {
source: string
destination: string
basePath?: false
@@ -73,12 +73,12 @@ export type DynamicRoute = {
export type RoutesManifest = {
basePath: string
- redirects: Redirect[]
- headers: Header[]
+ redirects: RedirectRule[]
+ headers: HeaderRule[]
rewrites: {
- beforeFiles: Rewrite[]
- afterFiles: Rewrite[]
- fallback: Rewrite[]
+ beforeFiles: RewriteRule[]
+ afterFiles: RewriteRule[]
+ fallback: RewriteRule[]
}
dynamicRoutes: DynamicRoute[]
}
diff --git a/packages/runtime/src/templates/edge-shared/router.test.ts b/packages/runtime/src/templates/edge-shared/router.test.ts
new file mode 100644
index 0000000000..e8317406bd
--- /dev/null
+++ b/packages/runtime/src/templates/edge-shared/router.test.ts
@@ -0,0 +1,480 @@
+import { assert, assertEquals, assertFalse } from 'https://deno.land/std@0.148.0/testing/asserts.ts'
+import { HeaderRule, RedirectRule, RewriteRule, RoutesManifest } from './next-utils.ts'
+import {
+ applyHeaderRule,
+ applyHeaderRules,
+ applyRedirectRule,
+ applyRedirectRules,
+ applyRewriteRule,
+ applyRewriteRules,
+ matchesRule,
+ runPostMiddleware,
+} from './router.ts'
+import manifestImport from './test-routes-manifest.json' assert { type: 'json' }
+const manifest = manifestImport as unknown as RoutesManifest
+const staticRoutes = new Set([
+ '/blog/data.json',
+ '/static/hello.txt',
+ '/_error',
+ '/_app',
+ '/auto-export/another',
+ '/api/hello',
+ '/hello-again',
+ '/docs/v2/more/now-for-github',
+ '/hello',
+ '/nav',
+ '/multi-rewrites',
+ '/redirect-override',
+ '/with-params',
+ '/overridden',
+ '/_document',
+ '/404',
+])
+
+Deno.test('rewrites paths', () => {
+ const rule = {
+ source: '/catchall-query/:path*',
+ destination: '/with-params/:path*?foo=:path*',
+ regex: '/catchall-query(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$',
+ }
+ const result = applyRewriteRule({
+ request: new Request('http://n/catchall-query/something/else'),
+ rule,
+ })
+ assert(result)
+ assertEquals(result.url, 'http://n/with-params/something/else?foo=something%2Felse')
+})
+
+Deno.test('rewrite matches headers "has" rule', () => {
+ const rule: RewriteRule = {
+ source: '/has-rewrite-1',
+ regex: '/has-rewrite-1(?:/)?$',
+ has: [
+ {
+ type: 'header',
+ key: 'x-my-header',
+ value: '(?.*)',
+ },
+ ],
+ destination: '/with-params?myHeader=:myHeader',
+ }
+
+ const request = new Request('http://n/has-rewrite-1', {
+ headers: new Headers({
+ 'x-my-header': 'my-value',
+ }),
+ })
+
+ const result = matchesRule({ request, rule })
+ assert(result)
+ assertEquals(result.myHeader, 'my-value')
+})
+
+Deno.test('matches named regex param', () => {
+ const rule: RewriteRule = {
+ source: '/old-blog/:post(\\d{1,})',
+ destination: '/blog/:post', // Matched parameters can be used in the destination
+ regex: '/old-blog/(?\\d{1,})(?:/)?$',
+ }
+ const request = new Request('http://localhost/old-blog/123')
+ const result = matchesRule({ request, rule })
+ assert(result)
+ assertEquals(result.post, '123')
+})
+
+Deno.test('applies headers', () => {
+ const rule: HeaderRule = {
+ source: '/apply-header',
+ regex: '/apply-header(?:/)?$',
+ headers: [
+ {
+ key: 'x-my-header',
+ value: 'my-value',
+ },
+ ],
+ }
+
+ const request = new Request('http://n/apply-header')
+
+ const result = applyHeaderRule({ request, rule, headers: new Map() })
+ assert(result)
+ assertEquals(result.get('x-my-header'), 'my-value')
+})
+
+Deno.test('applies dynamic headers', () => {
+ const rule: HeaderRule = {
+ source: '/blog/:slug',
+ regex: '/blog/(?[^/]+?)(?:/)?$',
+ headers: [
+ {
+ key: 'x-slug',
+ value: ':slug', // Matched parameters can be used in the value
+ },
+ {
+ key: 'x-slug-:slug', // Matched parameters can be used in the key
+ value: 'my other custom header value',
+ },
+ ],
+ }
+
+ const request = new Request('http://n/blog/hello-world')
+
+ const result = applyHeaderRule({ request, rule, headers: new Map() })
+ assert(result)
+ assertEquals(result.get('x-slug'), 'hello-world')
+ assertEquals(result.get('x-slug-hello-world'), 'my other custom header value')
+})
+
+Deno.test('applies wildcard headers', () => {
+ const rule: HeaderRule = {
+ source: '/blog/:slug*',
+ regex: '/blog(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$',
+ headers: [
+ {
+ key: 'x-slug',
+ value: ':slug*', // Matched parameters can be used in the value
+ },
+ {
+ key: 'x-slug-:slug*', // Matched parameters can be used in the key
+ value: 'my other custom header value',
+ },
+ ],
+ }
+
+ const request = new Request('http://n/blog/a/b/c/d/hello-world')
+
+ const result = applyHeaderRule({ request, rule, headers: new Map() })
+ assert(result)
+ assertEquals(result.get('x-slug'), 'a/b/c/d/hello-world')
+ assertEquals(result.get('x-slug-abcdhello-world'), 'my other custom header value')
+})
+
+Deno.test('applies regex headers', () => {
+ const rule: HeaderRule = {
+ source: '/blog/:post(\\d{1,})',
+ regex: '/blog(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$',
+ headers: [
+ {
+ key: 'x-post',
+ value: ':post',
+ },
+ ],
+ }
+
+ const request = new Request('http://localhost/blog/123')
+
+ const result = applyHeaderRule({ request, rule, headers: new Map() })
+ assert(result)
+ assertEquals(result.get('x-post'), '123')
+})
+
+Deno.test('applies header based on value of a cookie', () => {
+ const rule: HeaderRule = {
+ source: '/specific/:path*',
+ regex: '/specific(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$',
+ has: [
+ {
+ type: 'query',
+ key: 'page',
+ // the page value will not be available in the
+ // header key/values since value is provided and
+ // doesn't use a named capture group e.g. (?home)
+ value: 'home',
+ },
+ {
+ type: 'cookie',
+ key: 'authorized',
+ value: '(?yes|true)',
+ },
+ ],
+ headers: [
+ {
+ key: 'x-authorized',
+ value: ':authorized',
+ },
+ ],
+ }
+
+ const request = new Request('http://localhost/specific/123?page=home', {
+ headers: new Headers({
+ cookie: 'authorized=true',
+ }),
+ })
+
+ const result = applyHeaderRule({ request, rule, headers: new Map() })
+ assert(result)
+ assertEquals(result.get('x-authorized'), 'true')
+})
+
+Deno.test('applies "has" host rule', () => {
+ const rule: RedirectRule = {
+ source: '/has-redirect-6',
+ regex: '/has-redirect-6(?:/)?$',
+ has: [
+ {
+ type: 'host',
+ value: '(?.*)-test.example.com',
+ },
+ ],
+ destination: 'https://:subdomain.example.com/some-path/end?a=:subdomain',
+ permanent: false,
+ }
+
+ const request = new Request('http://hello-test.example.com/has-redirect-6')
+
+ const result = applyRedirectRule({ request, rule })
+ assert(result)
+ assertEquals(result.headers.get('location'), 'https://hello.example.com/some-path/end?a=hello')
+})
+
+Deno.test('headers', async (t) => {
+ await t.step('has headers rule', () => {
+ const result = applyHeaderRules(
+ new Request('http://localhost/has-header-1', {
+ headers: {
+ 'x-my-header': 'hello world!!',
+ },
+ }),
+ manifest.headers as HeaderRule[],
+ )
+ assert(result)
+ assertEquals(new Headers(result).get('x-another'), 'header')
+
+ const result2 = applyHeaderRules(new Request('http://localhost/has-header-1'), manifest.headers as HeaderRule[])
+ assert(result2)
+ assertFalse(new Headers(result2).get('x-another'))
+ })
+
+ await t.step('has query rule', () => {
+ const result = applyHeaderRules(
+ new Request('http://localhost/has-header-2?my-query=hellooo'),
+ manifest.headers as HeaderRule[],
+ )
+ assert(result)
+ assertEquals(new Headers(result).get('x-added'), 'value')
+
+ const result2 = applyHeaderRules(new Request('http://localhost/has-header-2'), manifest.headers as HeaderRule[])
+ assert(result2)
+ assertFalse(new Headers(result2).get('x-added'))
+ })
+
+ await t.step('has cookie rule', () => {
+ const result = applyHeaderRules(
+ new Request('http://localhost/has-header-3', {
+ headers: {
+ cookie: 'loggedIn=true',
+ },
+ }),
+ manifest.headers as HeaderRule[],
+ )
+ assert(result)
+ assertEquals(new Headers(result).get('x-is-user'), 'yuuuup')
+
+ const result2 = applyHeaderRules(new Request('http://localhost/has-header-3'), manifest.headers as HeaderRule[])
+ assert(result2)
+ assertFalse(new Headers(result2).get('x-is-user'))
+ })
+
+ await t.step('has host rule', () => {
+ const result = applyHeaderRules(new Request('http://example.com/has-header-4'), manifest.headers as HeaderRule[])
+ assert(result)
+ assertEquals(new Headers(result).get('x-is-host'), 'yuuuup')
+
+ const result2 = applyHeaderRules(new Request('http://localhost/has-header-4'), manifest.headers as HeaderRule[])
+ assert(result2)
+ assertFalse(new Headers(result2).get('x-is-host'))
+ })
+})
+Deno.test('redirects', async (t) => {
+ await t.step('chained redirects', () => {
+ const result = applyRedirectRules(
+ new Request('http://localhost/redir-chain1'),
+ manifest.redirects as RedirectRule[],
+ )
+ assert(result)
+ assertEquals(result.headers.get('location'), 'http://localhost/redir-chain2')
+ assertEquals(result.status, 301)
+
+ const result2 = applyRedirectRules(
+ new Request('http://localhost/redir-chain2'),
+ manifest.redirects as RedirectRule[],
+ )
+ assert(result2)
+ assertEquals(result2.headers.get('location'), 'http://localhost/redir-chain3')
+ assertEquals(result2.status, 302)
+
+ const result3 = applyRedirectRules(
+ new Request('http://localhost/redir-chain3'),
+ manifest.redirects as RedirectRule[],
+ )
+ assert(result3)
+ assertEquals(result3.headers.get('location'), 'http://localhost/')
+ assertEquals(result3.status, 303)
+ })
+
+ await t.step('does not match _next', () => {
+ const result = applyRedirectRules(
+ new Request('http://localhost/_next/has-redirect-5', {
+ headers: {
+ 'x-test-next': 'true',
+ },
+ }),
+ manifest.redirects as RedirectRule[],
+ )
+ assertFalse(result)
+
+ const result2 = applyRedirectRules(
+ new Request('http://localhost/another/has-redirect-5', {
+ headers: {
+ 'x-test-next': 'true',
+ },
+ }),
+ manifest.redirects as RedirectRule[],
+ )
+
+ assert(result2)
+ assertEquals(result2.status, 307)
+ })
+
+ await t.step('with permanent: false', () => {
+ const result = applyRedirectRules(new Request('http://localhost/redirect1'), manifest.redirects as RedirectRule[])
+ assert(result)
+ assertEquals(result.headers.get('location'), 'http://localhost/')
+ assertEquals(result.status, 307)
+ })
+
+ await t.step('with params', () => {
+ const result = applyRedirectRules(
+ new Request('http://localhost/hello/123/another'),
+ manifest.redirects as RedirectRule[],
+ )
+ assert(result)
+ assertEquals(result.headers.get('location'), 'http://localhost/blog/123')
+ assertEquals(result.status, 307)
+ })
+
+ await t.step('to a URL with a hash', () => {
+ const result = applyRedirectRules(
+ new Request('http://localhost/docs/router-status/500'),
+ manifest.redirects as RedirectRule[],
+ )
+ assert(result)
+ const location = result.headers.get('location')
+ assert(location)
+ const { pathname, hash } = new URL(location)
+ assertEquals(pathname, '/docs/v2/network/status-codes')
+ assertEquals(hash, '#500')
+ assertEquals(result.status, 301)
+ })
+
+ await t.step('with provided statusCode', () => {
+ const result = applyRedirectRules(new Request('http://localhost/redirect2'), manifest.redirects as RedirectRule[])
+ assert(result)
+ const location = result.headers.get('location')
+ assert(location)
+ const { pathname, search } = new URL(location)
+ assertEquals(pathname, '/')
+ assertEquals(search, '')
+ assertEquals(result.status, 301)
+ })
+
+ await t.step('using a catchall', () => {
+ const result = applyRedirectRules(
+ new Request('http://localhost/catchall-redirect/hello/world'),
+ manifest.redirects as RedirectRule[],
+ )
+ assert(result)
+ const location = result.headers.get('location')
+ assert(location)
+ const { pathname, search } = new URL(location)
+ assertEquals(pathname, '/somewhere')
+ assertEquals(search, '')
+ assertEquals(result.status, 307)
+ })
+})
+
+Deno.test('rewrites', async (t) => {
+ await t.step('afterFiles rewrite', () => {
+ const result = applyRewriteRules({
+ request: new Request('http://localhost/to-another'),
+ rules: manifest.rewrites.afterFiles as RewriteRule[],
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+ assert(result)
+ assertEquals(result.url, 'http://localhost/another/one')
+ })
+ await t.step('afterFiles with params', () => {
+ const result = applyRewriteRules({
+ request: new Request('http://localhost/test/hello'),
+ rules: manifest.rewrites.afterFiles as RewriteRule[],
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+ assert(result)
+ assertEquals(result.url, 'http://localhost/hello')
+ })
+
+ await t.step('afterFiles matching static file', () => {
+ const result = applyRewriteRules({
+ request: new Request('http://localhost/hello-world'),
+ rules: manifest.rewrites.afterFiles as RewriteRule[],
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+ assert(result)
+ assertEquals(result.headers.get('x-matched-path'), '/static/hello.txt')
+ assertEquals(result.url, 'http://localhost/static/hello.txt')
+ })
+
+ await t.step('afterFiles matching dynamic route', () => {
+ const result = applyRewriteRules({
+ request: new Request('http://localhost/test/nothing'),
+ rules: manifest.rewrites.afterFiles as RewriteRule[],
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+ assert(result)
+ assertFalse(result.headers.get('x-matched-path'))
+ assertEquals(result.url, 'http://localhost/nothing')
+ })
+
+ await t.step('non-matching', () => {
+ const result = applyRewriteRules({
+ request: new Request('http://localhost/no-match'),
+ rules: manifest.rewrites.afterFiles as RewriteRule[],
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+ assertFalse(result)
+ })
+
+ await t.step('preserves query params', () => {
+ const result = applyRewriteRules({
+ request: new Request('http://localhost/proxy-me/first?keep=me&and=me'),
+ rules: manifest.rewrites.afterFiles as RewriteRule[],
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+ assert(result)
+ assertEquals(result.url, 'http://external.example.com/first?keep=me&and=me&this=me')
+ })
+})
+
+Deno.test('router', async (t) => {
+ await t.step('static route overrides afterFiles rewrite', () => {
+ const result = runPostMiddleware(new Request('http://localhost/nav'), manifest, staticRoutes)
+ assert(result)
+ assertEquals(result.url, 'http://localhost/nav')
+ })
+
+ await t.step('proxy to external resource', () => {
+ const result = runPostMiddleware(
+ new Request('http://localhost/proxy-me/first?keep=me&and=me'),
+ manifest,
+ staticRoutes,
+ )
+ assert(result)
+ assertEquals(result.url, 'http://external.example.com/first?keep=me&and=me&this=me')
+ })
+})
diff --git a/packages/runtime/src/templates/edge-shared/router.ts b/packages/runtime/src/templates/edge-shared/router.ts
new file mode 100644
index 0000000000..dc8ceaa7ec
--- /dev/null
+++ b/packages/runtime/src/templates/edge-shared/router.ts
@@ -0,0 +1,325 @@
+import { Match, match } from 'https://deno.land/x/path_to_regexp@v6.2.1/index.ts'
+
+import {
+ compileNonPath,
+ DynamicRoute,
+ HeaderRule,
+ matchHas as nextMatchHas,
+ prepareDestination,
+ RedirectRule,
+ RewriteRule,
+ RouteHas,
+ RoutesManifest,
+ searchParamsToUrlQuery,
+} from './next-utils.ts'
+
+type Params = Record
+export type Rule = RewriteRule | HeaderRule | RedirectRule
+export type NextHeaders = Map
+/**
+ * Converts Next.js's internal parsed URL response to a `URL` object.
+ */
+
+function preparedDestinationToUrl({ newUrl, parsedDestination }: ReturnType): URL {
+ const transformedUrl = new URL(newUrl, 'http://n')
+ transformedUrl.hostname = parsedDestination.hostname ?? ''
+ transformedUrl.port = parsedDestination.port ?? ''
+ transformedUrl.protocol = parsedDestination.protocol ?? ''
+ for (const [name, value] of Object.entries(parsedDestination.query)) {
+ if (Array.isArray(value)) {
+ // Arrays of values need to be appended as multiple values
+ for (const v of value) {
+ transformedUrl.searchParams.append(name, v)
+ }
+ } else {
+ transformedUrl.searchParams.set(name, value)
+ }
+ }
+ return transformedUrl
+}
+
+// regexp is based on https://github.com/sindresorhus/escape-string-regexp
+// const reHasRegExp = /[|\\{}()[\]^$+*?.-]/
+
+/**
+ * Checks if a patch matches a given path pattern. If it matches, then it returns
+ * the matched params. If it does not match, then it returns `false`.
+ */
+function matchPath(sourcePattern: string, path: string): Match | false {
+ // if (reHasRegExp.test(sourcePattern)) {
+ // const matches = new RegExp(sourcePattern).exec(path)
+ // if (!matches) {
+ // return false
+ // }
+ // }
+ const matcher = match(sourcePattern, { decode: decodeURIComponent })
+ return matcher(path)
+}
+
+/**
+ * Given a rule destination, and request params and query, generate a destination URL
+ * @returns destination The rewritten URL
+ */
+export function generateDestinationUrl({
+ destination,
+ query,
+ params,
+ appendParamsToQuery = false,
+}: {
+ query: URLSearchParams
+ destination: string
+ params: Params
+ appendParamsToQuery?: boolean
+}): URL {
+ const preparedDestination = prepareDestination({
+ destination,
+ params,
+ query: searchParamsToUrlQuery(query),
+ appendParamsToQuery,
+ })
+ return preparedDestinationToUrl(preparedDestination)
+}
+
+/**
+ * Checks if a request matches a list of `has` rules. If it matches (or there are no rules)
+ * then it returns any extracted params or an empty object. If there are rules that do not
+ * match then it returns `false`.
+ */
+function matchHas(request: Request, has?: RouteHas[]): Params | false {
+ const url = new URL(request.url)
+ // If there are no has rules then we do match, but with no params
+ if (!has?.length) {
+ return {}
+ }
+ return nextMatchHas(request, has, searchParamsToUrlQuery(url.searchParams))
+}
+
+/**
+ * Checks if the request matches the given Rewrite, Redirect or Headers rule.
+ * If it matches, returns the extracted params (e.g. path segments, query params).
+ */
+export function matchesRule({ request, rule }: { request: Request; rule: Rule }): Params | false {
+ const params: Params = {}
+ const url = new URL(request.url)
+ if (!new RegExp(rule.regex).test(url.pathname)) {
+ return false
+ }
+ const hasParams = matchHas(request, rule.has)
+ if (!hasParams) {
+ return false
+ }
+ Object.assign(params, hasParams)
+
+ const path = url.pathname
+ const result = matchPath(rule.source, path)
+ if (!result) {
+ return false
+ }
+ Object.assign(params, result.params)
+
+ return params
+}
+
+/**
+ * Applies a rewrite rule to a request if it matches. Returns the new request, or `false` if it does not match.
+ */
+
+export function applyRewriteRule({ request, rule }: { request: Request; rule: RewriteRule }): Request | false {
+ const params = matchesRule({ request, rule })
+ if (!params) {
+ return false
+ }
+ const destination = generateDestinationUrl({
+ query: new URL(request.url).searchParams,
+ destination: rule.destination,
+ params,
+ appendParamsToQuery: true,
+ })
+
+ if (destination.hostname === 'n') {
+ destination.host = new URL(request.url).host
+ }
+
+ return new Request(destination.href, request)
+}
+
+/**
+ * Applies a redirect rule to a request. If it matches, returns a redirect response, otherwise `false`.
+ */
+
+export function applyRedirectRule({ request, rule }: { request: Request; rule: RedirectRule }): Response | false {
+ const params = matchesRule({ request, rule })
+ if (!params) {
+ return false
+ }
+ const destination = generateDestinationUrl({
+ query: new URL(request.url).searchParams,
+ destination: rule.destination,
+ params,
+ })
+
+ if (destination.hostname === 'n') {
+ destination.host = new URL(request.url).host
+ }
+
+ return Response.redirect(destination.href, rule.statusCode)
+}
+
+/**
+ * Applies a header rule to a request. If it matches, returns a new request with the headers, otherwise `false`.
+ */
+
+export function applyHeaderRule({
+ request,
+ rule,
+ headers,
+}: {
+ request: Request
+ rule: HeaderRule
+ headers: NextHeaders
+}): NextHeaders | false {
+ const params = matchesRule({ request, rule })
+ if (!params) {
+ return false
+ }
+ const hasParams = Object.keys(params).length > 0
+ for (const { key, value } of rule.headers) {
+ if (hasParams) {
+ headers.set(compileNonPath(key, params), compileNonPath(value, params))
+ } else {
+ headers.set(key, value)
+ }
+ }
+ return headers
+}
+
+export function applyHeaderRules(request: Request, rules: HeaderRule[]): Array<[key: string, value: string]> {
+ const headers = rules.reduce((headers, rule) => {
+ return applyHeaderRule({ request, rule, headers }) || headers
+ }, new Map())
+ return [...headers.entries()]
+}
+
+export function applyRedirectRules(request: Request, rules: RedirectRule[]): Response | false {
+ // Redirects only apply the first matching rule
+ for (const rule of rules) {
+ const match = applyRedirectRule({ request, rule })
+ if (match) {
+ return match
+ }
+ }
+ return false
+}
+
+export function applyRewriteRules({
+ request,
+ rules,
+ staticRoutes,
+ checkStaticRoutes = false,
+}: {
+ request: Request
+ rules: RewriteRule[]
+ checkStaticRoutes?: boolean
+ staticRoutes?: Set
+}): Request | false {
+ let result: Request | false = false
+
+ if (checkStaticRoutes && !staticRoutes) {
+ throw new Error('staticRoutes must be provided if checkStaticRoutes is true')
+ }
+
+ // Apply all rewrite rules in order
+ for (const rule of rules) {
+ const rewritten = applyRewriteRule({ request: result || request, rule })
+ if (rewritten) {
+ result = rewritten
+ if (!checkStaticRoutes) {
+ continue
+ }
+ const { pathname } = new URL(rewritten.url)
+ // If a static route is matched, then we exit early
+ if (staticRoutes!.has(pathname) || pathname.startsWith('/_next/static/')) {
+ result.headers.set('x-matched-path', pathname)
+ return result
+ }
+ }
+ }
+ return result
+}
+
+export function matchDynamicRoute(request: Request, routes: DynamicRoute[]): string | false {
+ const { pathname } = new URL(request.url)
+ const match = routes.find((route) => {
+ return new RegExp(route.regex).test(pathname)
+ })
+ if (match) {
+ return match.page
+ }
+ return false
+}
+
+/**
+ * Run the rules that run after middleware
+ */
+export function runPostMiddleware(
+ request: Request,
+ manifest: RoutesManifest,
+ staticRoutes: Set,
+ skipBeforeFiles = false,
+ loopChecker = 10,
+): Request | Response {
+ // Everyone gets the beforeFiles rewrites, unless we're re-running after matching fallback
+ let result = skipBeforeFiles
+ ? request
+ : applyRewriteRules({
+ request,
+ rules: manifest.rewrites.beforeFiles,
+ }) || request
+
+ // Check if it matches a static route or file
+ const { pathname } = new URL(result.url)
+ if (staticRoutes.has(pathname) || pathname.startsWith('/_next/static/')) {
+ result.headers.set('x-matched-path', pathname)
+ return result
+ }
+
+ // afterFiles rewrites also check if it matches a static file after every match
+ const afterRewrite = applyRewriteRules({
+ request: result,
+ rules: manifest.rewrites.afterFiles,
+ checkStaticRoutes: true,
+ staticRoutes,
+ })
+
+ if (afterRewrite) {
+ result = afterRewrite
+ // If we match a rewrite, we check if it matches a static route or file
+ // If it does, we return right away
+ if (afterRewrite.headers.has('x-matched-path')) {
+ return afterRewrite
+ }
+ }
+
+ // Now we check dynamic routes
+ const dynamicRoute = matchDynamicRoute(result, manifest.dynamicRoutes)
+ if (dynamicRoute) {
+ result.headers.set('x-matched-path', dynamicRoute)
+ return result
+ }
+
+ // Finally, check for fallback rewrites
+ const fallbackRewrite = applyRewriteRules({
+ request: result,
+ rules: manifest.rewrites.fallback,
+ })
+
+ // If the fallback matched, we go right back to checking for static routes
+ if (fallbackRewrite) {
+ if (loopChecker <= 0) {
+ throw new Error('Fallback rewrite loop detected')
+ }
+ return runPostMiddleware(fallbackRewrite, manifest, staticRoutes, true, loopChecker - 1)
+ }
+ // 404
+ return result
+}
diff --git a/packages/runtime/src/templates/edge-shared/test-routes-manifest.json b/packages/runtime/src/templates/edge-shared/test-routes-manifest.json
new file mode 100644
index 0000000000..3ecb62af5a
--- /dev/null
+++ b/packages/runtime/src/templates/edge-shared/test-routes-manifest.json
@@ -0,0 +1,901 @@
+{
+ "version": 3,
+ "pages404": true,
+ "basePath": "",
+ "redirects": [
+ {
+ "source": "/:path+/",
+ "destination": "/:path+",
+ "internal": true,
+ "statusCode": 308,
+ "regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
+ },
+ {
+ "source": "/redirect/me/to-about/:lang",
+ "destination": "/:lang/about",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/redirect/me/to-about(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/docs/router-status/:code",
+ "destination": "/docs/v2/network/status-codes#:code",
+ "statusCode": 301,
+ "regex": "^(?!/_next)/docs/router-status(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/docs/github",
+ "destination": "/docs/v2/advanced/now-for-github",
+ "statusCode": 301,
+ "regex": "^(?!/_next)/docs/github(?:/)?$"
+ },
+ {
+ "source": "/docs/v2/advanced/:all(.*)",
+ "destination": "/docs/v2/more/:all",
+ "statusCode": 301,
+ "regex": "^(?!/_next)/docs/v2/advanced(?:/(.*))(?:/)?$"
+ },
+ {
+ "source": "/hello/:id/another",
+ "destination": "/blog/:id",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/hello(?:/([^/]+?))/another(?:/)?$"
+ },
+ {
+ "source": "/redirect1",
+ "destination": "/",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/redirect1(?:/)?$"
+ },
+ {
+ "source": "/redirect2",
+ "destination": "/",
+ "statusCode": 301,
+ "regex": "^(?!/_next)/redirect2(?:/)?$"
+ },
+ {
+ "source": "/redirect3",
+ "destination": "/another",
+ "statusCode": 302,
+ "regex": "^(?!/_next)/redirect3(?:/)?$"
+ },
+ {
+ "source": "/redirect4",
+ "destination": "/",
+ "statusCode": 308,
+ "regex": "^(?!/_next)/redirect4(?:/)?$"
+ },
+ {
+ "source": "/redir-chain1",
+ "destination": "/redir-chain2",
+ "statusCode": 301,
+ "regex": "^(?!/_next)/redir-chain1(?:/)?$"
+ },
+ {
+ "source": "/redir-chain2",
+ "destination": "/redir-chain3",
+ "statusCode": 302,
+ "regex": "^(?!/_next)/redir-chain2(?:/)?$"
+ },
+ {
+ "source": "/redir-chain3",
+ "destination": "/",
+ "statusCode": 303,
+ "regex": "^(?!/_next)/redir-chain3(?:/)?$"
+ },
+ {
+ "source": "/to-external",
+ "destination": "https://google.com",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/to-external(?:/)?$"
+ },
+ {
+ "source": "/query-redirect/:section/:name",
+ "destination": "/with-params?first=:section&second=:name",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/query-redirect(?:/([^/]+?))(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/unnamed/(first|second)/(.*)",
+ "destination": "/got-unnamed",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/unnamed(?:/(first|second))(?:/(.*))(?:/)?$"
+ },
+ {
+ "source": "/named-like-unnamed/:0",
+ "destination": "/:0",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/named-like-unnamed(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/redirect-override",
+ "destination": "/thank-you-next",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/redirect-override(?:/)?$"
+ },
+ {
+ "source": "/docs/:first(integrations|now-cli)/v2:second(.*)",
+ "destination": "/:first/:second",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/docs(?:/(integrations|now-cli))/v2(.*)(?:/)?$"
+ },
+ {
+ "source": "/catchall-redirect/:path*",
+ "destination": "/somewhere",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/catchall-redirect(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/to-external-with-query",
+ "destination": "https://authserver.example.com/set-password?returnUrl=https%3A%2F%2Fwww.example.com/login",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/to-external-with-query(?:/)?$"
+ },
+ {
+ "source": "/to-external-with-query-2",
+ "destination": "https://authserver.example.com/set-password?returnUrl=https://www.example.com/login",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/to-external-with-query-2(?:/)?$"
+ },
+ {
+ "source": "/has-redirect-1",
+ "has": [
+ {
+ "type": "header",
+ "key": "x-my-header",
+ "value": "(?.*)"
+ }
+ ],
+ "destination": "/another?myHeader=:myHeader",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/has-redirect-1(?:/)?$"
+ },
+ {
+ "source": "/has-redirect-2",
+ "has": [
+ {
+ "type": "query",
+ "key": "my-query"
+ }
+ ],
+ "destination": "/another?value=:myquery",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/has-redirect-2(?:/)?$"
+ },
+ {
+ "source": "/has-redirect-3",
+ "has": [
+ {
+ "type": "cookie",
+ "key": "loggedIn",
+ "value": "true"
+ }
+ ],
+ "destination": "/another?authorized=1",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/has-redirect-3(?:/)?$"
+ },
+ {
+ "source": "/has-redirect-4",
+ "has": [
+ {
+ "type": "host",
+ "value": "example.com"
+ }
+ ],
+ "destination": "/another?host=1",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/has-redirect-4(?:/)?$"
+ },
+ {
+ "source": "/:path/has-redirect-5",
+ "has": [
+ {
+ "type": "header",
+ "key": "x-test-next"
+ }
+ ],
+ "destination": "/somewhere",
+ "statusCode": 307,
+ "regex": "^(?!/_next)(?:/([^/]+?))/has-redirect-5(?:/)?$"
+ },
+ {
+ "source": "/has-redirect-6",
+ "has": [
+ {
+ "type": "host",
+ "value": "(?.*)-test.example.com"
+ }
+ ],
+ "destination": "https://:subdomain.example.com/some-path/end?a=b",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/has-redirect-6(?:/)?$"
+ },
+ {
+ "source": "/has-redirect-7",
+ "has": [
+ {
+ "type": "query",
+ "key": "hello",
+ "value": "(?.*)"
+ }
+ ],
+ "destination": "/somewhere?value=:hello",
+ "statusCode": 307,
+ "regex": "^(?!/_next)/has-redirect-7(?:/)?$"
+ }
+ ],
+ "headers": [
+ {
+ "source": "/add-header",
+ "headers": [
+ {
+ "key": "x-custom-header",
+ "value": "hello world"
+ },
+ {
+ "key": "x-another-header",
+ "value": "hello again"
+ }
+ ],
+ "regex": "^/add-header(?:/)?$"
+ },
+ {
+ "source": "/my-headers/(.*)",
+ "headers": [
+ {
+ "key": "x-first-header",
+ "value": "first"
+ },
+ {
+ "key": "x-second-header",
+ "value": "second"
+ }
+ ],
+ "regex": "^/my-headers(?:/(.*))(?:/)?$"
+ },
+ {
+ "source": "/my-other-header/:path",
+ "headers": [
+ {
+ "key": "x-path",
+ "value": ":path"
+ },
+ {
+ "key": "some:path",
+ "value": "hi"
+ },
+ {
+ "key": "x-test",
+ "value": "some:value*"
+ },
+ {
+ "key": "x-test-2",
+ "value": "value*"
+ },
+ {
+ "key": "x-test-3",
+ "value": ":value?"
+ },
+ {
+ "key": "x-test-4",
+ "value": ":value+"
+ },
+ {
+ "key": "x-test-5",
+ "value": "something https:"
+ },
+ {
+ "key": "x-test-6",
+ "value": ":hello(world)"
+ },
+ {
+ "key": "x-test-7",
+ "value": "hello(world)"
+ },
+ {
+ "key": "x-test-8",
+ "value": "hello{1,}"
+ },
+ {
+ "key": "x-test-9",
+ "value": ":hello{1,2}"
+ },
+ {
+ "key": "content-security-policy",
+ "value": "default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com/:path"
+ }
+ ],
+ "regex": "^/my-other-header(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/without-params/url",
+ "headers": [
+ {
+ "key": "x-origin",
+ "value": "https://example.com"
+ }
+ ],
+ "regex": "^/without-params/url(?:/)?$"
+ },
+ {
+ "source": "/with-params/url/:path*",
+ "headers": [
+ {
+ "key": "x-url",
+ "value": "https://example.com/:path*"
+ }
+ ],
+ "regex": "^/with-params/url(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/with-params/url2/:path*",
+ "headers": [
+ {
+ "key": "x-url",
+ "value": "https://example.com:8080?hello=:path*"
+ }
+ ],
+ "regex": "^/with-params/url2(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/:path*",
+ "headers": [
+ {
+ "key": "x-something",
+ "value": "applied-everywhere"
+ }
+ ],
+ "regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/named-pattern/:path(.*)",
+ "headers": [
+ {
+ "key": "x-something",
+ "value": "value=:path"
+ },
+ {
+ "key": "path-:path",
+ "value": "end"
+ }
+ ],
+ "regex": "^/named-pattern(?:/(.*))(?:/)?$"
+ },
+ {
+ "source": "/catchall-header/:path*",
+ "headers": [
+ {
+ "key": "x-value",
+ "value": ":path*"
+ }
+ ],
+ "regex": "^/catchall-header(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/has-header-1",
+ "has": [
+ {
+ "type": "header",
+ "key": "x-my-header",
+ "value": "(?.*)"
+ }
+ ],
+ "headers": [
+ {
+ "key": "x-another",
+ "value": "header"
+ }
+ ],
+ "regex": "^/has-header-1(?:/)?$"
+ },
+ {
+ "source": "/has-header-2",
+ "has": [
+ {
+ "type": "query",
+ "key": "my-query"
+ }
+ ],
+ "headers": [
+ {
+ "key": "x-added",
+ "value": "value"
+ }
+ ],
+ "regex": "^/has-header-2(?:/)?$"
+ },
+ {
+ "source": "/has-header-3",
+ "has": [
+ {
+ "type": "cookie",
+ "key": "loggedIn",
+ "value": "true"
+ }
+ ],
+ "headers": [
+ {
+ "key": "x-is-user",
+ "value": "yuuuup"
+ }
+ ],
+ "regex": "^/has-header-3(?:/)?$"
+ },
+ {
+ "source": "/has-header-4",
+ "has": [
+ {
+ "type": "host",
+ "value": "example.com"
+ }
+ ],
+ "headers": [
+ {
+ "key": "x-is-host",
+ "value": "yuuuup"
+ }
+ ],
+ "regex": "^/has-header-4(?:/)?$"
+ }
+ ],
+ "dynamicRoutes": [
+ {
+ "page": "/_sport/[slug]",
+ "regex": "^/_sport/([^/]+?)(?:/)?$",
+ "routeKeys": {
+ "slug": "slug"
+ },
+ "namedRegex": "^/_sport/(?[^/]+?)(?:/)?$"
+ },
+ {
+ "page": "/_sport/[slug]/test",
+ "regex": "^/_sport/([^/]+?)/test(?:/)?$",
+ "routeKeys": {
+ "slug": "slug"
+ },
+ "namedRegex": "^/_sport/(?[^/]+?)/test(?:/)?$"
+ },
+ {
+ "page": "/another/[id]",
+ "regex": "^/another/([^/]+?)(?:/)?$",
+ "routeKeys": {
+ "id": "id"
+ },
+ "namedRegex": "^/another/(?[^/]+?)(?:/)?$"
+ },
+ {
+ "page": "/api/dynamic/[slug]",
+ "regex": "^/api/dynamic/([^/]+?)(?:/)?$",
+ "routeKeys": {
+ "slug": "slug"
+ },
+ "namedRegex": "^/api/dynamic/(?[^/]+?)(?:/)?$"
+ },
+ {
+ "page": "/auto-export/[slug]",
+ "regex": "^/auto\\-export/([^/]+?)(?:/)?$",
+ "routeKeys": {
+ "slug": "slug"
+ },
+ "namedRegex": "^/auto\\-export/(?[^/]+?)(?:/)?$"
+ },
+ {
+ "page": "/blog/[post]",
+ "regex": "^/blog/([^/]+?)(?:/)?$",
+ "routeKeys": {
+ "post": "post"
+ },
+ "namedRegex": "^/blog/(?[^/]+?)(?:/)?$"
+ },
+ {
+ "page": "/blog-catchall/[...slug]",
+ "regex": "^/blog\\-catchall/(.+?)(?:/)?$",
+ "routeKeys": {
+ "slug": "slug"
+ },
+ "namedRegex": "^/blog\\-catchall/(?.+?)(?:/)?$"
+ }
+ ],
+ "staticRoutes": [
+ {
+ "page": "/auto-export/another",
+ "regex": "^/auto\\-export/another(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/auto\\-export/another(?:/)?$"
+ },
+ {
+ "page": "/docs/v2/more/now-for-github",
+ "regex": "^/docs/v2/more/now\\-for\\-github(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/docs/v2/more/now\\-for\\-github(?:/)?$"
+ },
+ {
+ "page": "/hello",
+ "regex": "^/hello(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/hello(?:/)?$"
+ },
+ {
+ "page": "/hello-again",
+ "regex": "^/hello\\-again(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/hello\\-again(?:/)?$"
+ },
+ {
+ "page": "/multi-rewrites",
+ "regex": "^/multi\\-rewrites(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/multi\\-rewrites(?:/)?$"
+ },
+ {
+ "page": "/nav",
+ "regex": "^/nav(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/nav(?:/)?$"
+ },
+ {
+ "page": "/overridden",
+ "regex": "^/overridden(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/overridden(?:/)?$"
+ },
+ {
+ "page": "/redirect-override",
+ "regex": "^/redirect\\-override(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/redirect\\-override(?:/)?$"
+ },
+ {
+ "page": "/with-params",
+ "regex": "^/with\\-params(?:/)?$",
+ "routeKeys": {},
+ "namedRegex": "^/with\\-params(?:/)?$"
+ }
+ ],
+ "dataRoutes": [
+ {
+ "page": "/blog-catchall/[...slug]",
+ "routeKeys": {
+ "slug": "slug"
+ },
+ "dataRouteRegex": "^/_next/data/7ddkxfkyV8tKXNdN\\-kG4A/blog\\-catchall/(.+?)\\.json$",
+ "namedDataRouteRegex": "^/_next/data/7ddkxfkyV8tKXNdN\\-kG4A/blog\\-catchall/(?.+?)\\.json$"
+ }
+ ],
+ "rewrites": {
+ "beforeFiles": [
+ {
+ "source": "/hello",
+ "has": [
+ {
+ "type": "query",
+ "key": "overrideMe"
+ }
+ ],
+ "destination": "/with-params?overridden=1",
+ "regex": "^/hello(?:/)?$"
+ },
+ {
+ "source": "/old-blog/:path*",
+ "destination": "/blog/:path*",
+ "regex": "^/old-blog(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/overridden",
+ "destination": "https://example.vercel.sh",
+ "regex": "^/overridden(?:/)?$"
+ },
+ {
+ "source": "/nfl/:path*",
+ "destination": "/_sport/nfl/:path*",
+ "regex": "^/nfl(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ }
+ ],
+ "afterFiles": [
+ {
+ "source": "/to-nowhere",
+ "destination": "http://localhost:12233",
+ "regex": "^/to-nowhere(?:/)?$"
+ },
+ {
+ "source": "/rewriting-to-auto-export",
+ "destination": "/auto-export/hello?rewrite=1",
+ "regex": "^/rewriting-to-auto-export(?:/)?$"
+ },
+ {
+ "source": "/rewriting-to-another-auto-export/:path*",
+ "destination": "/auto-export/another?rewrite=1",
+ "regex": "^/rewriting-to-another-auto-export(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/to-another",
+ "destination": "/another/one",
+ "regex": "^/to-another(?:/)?$"
+ },
+ {
+ "source": "/nav",
+ "destination": "/404",
+ "regex": "^/nav(?:/)?$"
+ },
+ {
+ "source": "/hello-world",
+ "destination": "/static/hello.txt",
+ "regex": "^/hello-world(?:/)?$"
+ },
+ {
+ "source": "/",
+ "destination": "/another",
+ "regex": "^/(?:/)?$"
+ },
+ {
+ "source": "/another",
+ "destination": "/multi-rewrites",
+ "regex": "^/another(?:/)?$"
+ },
+ {
+ "source": "/first",
+ "destination": "/hello",
+ "regex": "^/first(?:/)?$"
+ },
+ {
+ "source": "/second",
+ "destination": "/hello-again",
+ "regex": "^/second(?:/)?$"
+ },
+ {
+ "source": "/to-hello",
+ "destination": "/hello",
+ "regex": "^/to-hello(?:/)?$"
+ },
+ {
+ "source": "/blog/post-1",
+ "destination": "/blog/post-2",
+ "regex": "^/blog/post-1(?:/)?$"
+ },
+ {
+ "source": "/test/:path",
+ "destination": "/:path",
+ "regex": "^/test(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/test-overwrite/:something/:another",
+ "destination": "/params/this-should-be-the-value",
+ "regex": "^/test-overwrite(?:/([^/]+?))(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/params/:something",
+ "destination": "/with-params",
+ "regex": "^/params(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/query-rewrite/:section/:name",
+ "destination": "/with-params?first=:section&second=:name",
+ "regex": "^/query-rewrite(?:/([^/]+?))(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/hidden/_next/:path*",
+ "destination": "/_next/:path*",
+ "regex": "^/hidden/_next(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/proxy-me/:path*",
+ "destination": "http://external.example.com/:path*?this=me",
+ "regex": "^/proxy-me(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/api-hello",
+ "destination": "/api/hello",
+ "regex": "^/api-hello(?:/)?$"
+ },
+ {
+ "source": "/api-hello-regex/:first(.*)",
+ "destination": "/api/hello?name=:first*",
+ "regex": "^/api-hello-regex(?:/(.*))(?:/)?$"
+ },
+ {
+ "source": "/api-hello-param/:name",
+ "destination": "/api/hello?hello=:name",
+ "regex": "^/api-hello-param(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/api-dynamic-param/:name",
+ "destination": "/api/dynamic/:name?hello=:name",
+ "regex": "^/api-dynamic-param(?:/([^/]+?))(?:/)?$"
+ },
+ {
+ "source": "/:path/post-321",
+ "destination": "/with-params",
+ "regex": "^(?:/([^/]+?))/post-321(?:/)?$"
+ },
+ {
+ "source": "/unnamed-params/nested/(.*)/:test/(.*)",
+ "destination": "/with-params",
+ "regex": "^/unnamed-params/nested(?:/(.*))(?:/([^/]+?))(?:/(.*))(?:/)?$"
+ },
+ {
+ "source": "/catchall-rewrite/:path*",
+ "destination": "/with-params",
+ "regex": "^/catchall-rewrite(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/catchall-query/:path*",
+ "destination": "/with-params?another=:path*",
+ "regex": "^/catchall-query(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))?(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-1",
+ "has": [
+ {
+ "type": "header",
+ "key": "x-my-header",
+ "value": "(?.*)"
+ }
+ ],
+ "destination": "/with-params?myHeader=:myHeader",
+ "regex": "^/has-rewrite-1(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-2",
+ "has": [
+ {
+ "type": "query",
+ "key": "my-query"
+ }
+ ],
+ "destination": "/with-params?value=:myquery",
+ "regex": "^/has-rewrite-2(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-3",
+ "has": [
+ {
+ "type": "cookie",
+ "key": "loggedIn",
+ "value": "(?true)"
+ }
+ ],
+ "destination": "/with-params?authorized=1",
+ "regex": "^/has-rewrite-3(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-4",
+ "has": [
+ {
+ "type": "host",
+ "value": "example.com"
+ }
+ ],
+ "destination": "/with-params?host=1",
+ "regex": "^/has-rewrite-4(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-5",
+ "has": [
+ {
+ "type": "query",
+ "key": "hasParam"
+ }
+ ],
+ "destination": "/:hasParam",
+ "regex": "^/has-rewrite-5(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-6",
+ "has": [
+ {
+ "type": "header",
+ "key": "hasParam",
+ "value": "with-params"
+ }
+ ],
+ "destination": "/with-params",
+ "regex": "^/has-rewrite-6(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-7",
+ "has": [
+ {
+ "type": "query",
+ "key": "hasParam",
+ "value": "(?with-params|hello)"
+ }
+ ],
+ "destination": "/with-params?idk=:idk",
+ "regex": "^/has-rewrite-7(?:/)?$"
+ },
+ {
+ "source": "/has-rewrite-8",
+ "has": [
+ {
+ "type": "query",
+ "key": "post"
+ }
+ ],
+ "destination": "/blog-catchall/:post",
+ "regex": "^/has-rewrite-8(?:/)?$"
+ },
+ {
+ "source": "/blog/about",
+ "destination": "/hello",
+ "regex": "^/blog/about(?:/)?$"
+ }
+ ],
+ "fallback": [
+ {
+ "source": "/matchfallback",
+ "destination": "/blog/1",
+ "regex": "^/matchfallback(?:/)?$"
+ },
+ {
+ "source": "/externalfallback",
+ "destination": "https://example.com",
+ "regex": "^/externalfallback(?:/)?$"
+ },
+ {
+ "source": "/fallbackloop",
+ "destination": "/intermediatefallback",
+ "regex": "^/fallbackloop(?:/)?$"
+ },
+ {
+ "source": "/intermediatefallback",
+ "destination": "/fallbackloop",
+ "regex": "^/intermediatefallback(?:/)?$"
+ },
+ {
+ "source": "/chainedfallback",
+ "destination": "/chain1",
+ "regex": "^/chainedfallback(?:/)?$"
+ },
+ {
+ "source": "/chain1",
+ "destination": "/chain2",
+ "regex": "^/chain1(?:/)?$"
+ },
+ {
+ "source": "/chain2",
+ "destination": "/chain3",
+ "regex": "^/chain2(?:/)?$"
+ },
+ {
+ "source": "/chain3",
+ "destination": "/chain4",
+ "regex": "^/chain3(?:/)?$"
+ },
+ {
+ "source": "/chain4",
+ "destination": "/chain5",
+ "regex": "^/chain4(?:/)?$"
+ },
+ {
+ "source": "/chain5",
+ "destination": "/chain6",
+ "regex": "^/chain5(?:/)?$"
+ },
+ {
+ "source": "/chain6",
+ "destination": "/chain7",
+ "regex": "^/chain6(?:/)?$"
+ },
+ {
+ "source": "/chain7",
+ "destination": "/chain8",
+ "regex": "^/chain7(?:/)?$"
+ },
+ {
+ "source": "/chain8",
+ "destination": "/chain9",
+ "regex": "^/chain8(?:/)?$"
+ },
+ {
+ "source": "/chain9",
+ "destination": "/chain10",
+ "regex": "^/chain9(?:/)?$"
+ },
+ {
+ "source": "/chain10",
+ "destination": "/static/hello.txt",
+ "regex": "^/chain10(?:/)?$"
+ }
+ ]
+ }
+}
diff --git a/packages/runtime/src/templates/edge-shared/utils.ts b/packages/runtime/src/templates/edge-shared/utils.ts
index 4d9b95cde6..90089b4b93 100644
--- a/packages/runtime/src/templates/edge-shared/utils.ts
+++ b/packages/runtime/src/templates/edge-shared/utils.ts
@@ -6,8 +6,15 @@ export interface FetchEventResult {
waitUntil: Promise
}
+declare global {
+ // deno-lint-ignore no-var
+ var NETLIFY_NEXT_EDGE_ROUTER: boolean
+}
+
type NextDataTransform = (data: T) => T
+export const useEdgeRouter = () => Boolean(globalThis.NETLIFY_NEXT_EDGE_ROUTER)
+
/**
* This is how Next handles rewritten URLs.
*/
@@ -140,19 +147,24 @@ export const buildResponse = async ({
if (isDataReq) {
res.headers.set('x-nextjs-rewrite', relativeUrl)
}
+ res.headers.set('x-middleware-rewrite', relativeUrl)
+
+ // The edge router handles rewrites itself, so we don't need to redirect or rewrite here
+ if (useEdgeRouter()) {
+ request.headers.set('x-middleware-rewrite', relativeUrl)
+ return addMiddlewareHeaders(context.next(), res)
+ }
if (rewriteUrl.hostname !== baseUrl.hostname) {
// Netlify Edge Functions don't support proxying to external domains, but Next middleware does
const proxied = fetch(new Request(rewriteUrl.toString(), request))
return addMiddlewareHeaders(proxied, res)
}
- res.headers.set('x-middleware-rewrite', relativeUrl)
-
return addMiddlewareHeaders(context.rewrite(rewrite), res)
}
const redirect = res.headers.get('Location')
- // Data requests shouldn;t automatically redirect in the browser (they might be HTML pages): they're handled by the router
+ // Data requests shouldn't automatically redirect in the browser (they might be HTML pages): they're handled by the router
if (redirect && isDataReq) {
res.headers.delete('location')
res.headers.set('x-nextjs-redirect', relativizeURL(redirect, request.url))
diff --git a/packages/runtime/src/templates/edge/router-post-middleware.ts b/packages/runtime/src/templates/edge/router-post-middleware.ts
new file mode 100644
index 0000000000..692e6bf5d1
--- /dev/null
+++ b/packages/runtime/src/templates/edge/router-post-middleware.ts
@@ -0,0 +1,42 @@
+import type { Context } from 'https://edge.netlify.com'
+// Available at build time
+import routesManifest from '../edge-shared/routes-manifest.json' assert { type: 'json' }
+import staticRoutes from '../edge-shared/public-manifest.json' assert { type: 'json' }
+import { runPostMiddleware } from '../edge-shared/router.ts'
+import type { RoutesManifest } from '../edge-shared/next-utils.ts'
+
+/**
+ * Stage 2 routing
+ */
+
+// deno-lint-ignore require-await
+const handler = async (request: Request, context: Context) => {
+ const rewrite = request.headers.get('x-middleware-rewrite')
+ const result = runPostMiddleware(
+ rewrite ? new Request(new URL(rewrite, request.url), request) : request,
+ routesManifest as unknown as RoutesManifest,
+ new Set(staticRoutes),
+ )
+ if (result instanceof Response) {
+ return result
+ }
+ for (const header of result.headers.entries()) {
+ request.headers.set(...header)
+ }
+ if (result.url === request.url) {
+ return
+ }
+ const resultUrl = new URL(result.url)
+ const requestUrl = new URL(request.url)
+
+ if (resultUrl.hostname === 'n') {
+ resultUrl.hostname = requestUrl.hostname
+ }
+ // External rewrite
+ if (resultUrl.hostname !== requestUrl.hostname) {
+ return fetch(result, request)
+ }
+
+ return context.rewrite(resultUrl)
+}
+export default handler
diff --git a/packages/runtime/src/templates/edge/router-pre-middleware.ts b/packages/runtime/src/templates/edge/router-pre-middleware.ts
new file mode 100644
index 0000000000..0c33366964
--- /dev/null
+++ b/packages/runtime/src/templates/edge/router-pre-middleware.ts
@@ -0,0 +1,52 @@
+// Available at build time
+import routesManifest from '../edge-shared/routes-manifest.json' assert { type: 'json' }
+import type { RoutesManifest } from '../edge-shared/next-utils.ts'
+import { applyHeaderRules, applyRedirectRules } from '../edge-shared/router.ts'
+
+import { Context } from 'https://edge.netlify.com/'
+declare global {
+ // deno-lint-ignore no-var
+ var NETLIFY_NEXT_EDGE_ROUTER: boolean
+}
+
+globalThis.NETLIFY_NEXT_EDGE_ROUTER = true
+
+/**
+ * Stage 1 routing
+ */
+
+const handler = async (request: Request, context: Context) => {
+ // We add this ourselves. Don't allow users to forge it.
+ request.headers.delete('x-matched-path')
+
+ const manifest: RoutesManifest = routesManifest as unknown as RoutesManifest
+
+ // Get changed response headers
+ const extraHeaders = applyHeaderRules(request, manifest.headers)
+
+ const redirect = applyRedirectRules(request, manifest.redirects)
+ let response: Response
+ if (redirect) {
+ response = redirect
+ } else if (extraHeaders.length === 0) {
+ // No redirect and no new headers, so we can skip to the next function
+ return
+ } else {
+ // No redirect, but there are new headers to apply, so we need to fetch the response
+ response = await context.next()
+ }
+
+ // Redirect with no extra headers
+ if (extraHeaders.length === 0) {
+ return response
+ }
+
+ // Clone the response, because we need to add headers and reponse headers are immutable
+ return new Response(response.body, {
+ headers: new Headers([...response.headers.entries(), ...extraHeaders]),
+ status: response.status,
+ statusText: response.statusText,
+ })
+}
+
+export default handler
diff --git a/packages/runtime/src/templates/getApiHandler.ts b/packages/runtime/src/templates/getApiHandler.ts
index b0b82bd76c..4ca3f337a8 100644
--- a/packages/runtime/src/templates/getApiHandler.ts
+++ b/packages/runtime/src/templates/getApiHandler.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-params */
import { HandlerContext, HandlerEvent } from '@netlify/functions'
import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge'
// Aliasing like this means the editor may be able to syntax-highlight the string
@@ -26,7 +27,7 @@ type Mutable = {
// We return a function and then call `toString()` on it to serialise it as the launcher function
-const makeHandler = (conf: NextConfig, app, pageRoot, page) => {
+const makeHandler = (conf: NextConfig, app, pageRoot, page, minimalMode) => {
// Change working directory into the site root, unless using Nx, which moves the
// dist directory and handles this itself
const dir = path.resolve(__dirname, app)
@@ -71,6 +72,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, page) => {
customServer: false,
hostname: url.hostname,
port,
+ minimalMode,
})
const requestHandler = nextServer.getRequestHandler()
const server = new Server(async (req, res) => {
@@ -118,14 +120,18 @@ export const getApiHandler = ({
config,
publishDir = '../../../.next',
appDir = '../../..',
+ minimalMode = false,
}: {
page: string
config: ApiConfig
publishDir?: string
appDir?: string
-}): string =>
+ minimalMode?: boolean
+}): string => {
+ const minimal = minimalMode ? 'true' : 'false'
+
// This is a string, but if you have the right editor plugin it should format as js
- javascript/* javascript */ `
+ return javascript/* javascript */ `
const { Server } = require("http");
// We copy the file here rather than requiring from the node module
const { Bridge } = require("./bridge");
@@ -138,8 +144,10 @@ export const getApiHandler = ({
let staticManifest
const path = require("path");
const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "server"));
- const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)})
+ const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)}, ${minimal})
exports.handler = ${
config.type === ApiRouteType.SCHEDULED ? `schedule(${JSON.stringify(config.schedule)}, handler);` : 'handler'
}
`
+}
+/* eslint-enable max-params */
diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts
index 0e62c09855..aa1f3f0081 100644
--- a/packages/runtime/src/templates/getHandler.ts
+++ b/packages/runtime/src/templates/getHandler.ts
@@ -31,8 +31,17 @@ type Mutable = {
}
// We return a function and then call `toString()` on it to serialise it as the launcher function
-// eslint-disable-next-line max-params
-const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[string, string]> = [], mode = 'ssr') => {
+
+// eslint-disable-next-line max-lines-per-function
+const makeHandler = (
+ conf: NextConfig,
+ app,
+ pageRoot,
+ staticManifest: Array<[string, string]> = [],
+ mode = 'ssr',
+ minimalMode = false,
+ // eslint-disable-next-line max-params
+) => {
// Change working directory into the site root, unless using Nx, which moves the
// dist directory and handles this itself
const dir = path.resolve(__dirname, app)
@@ -82,6 +91,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str
customServer: false,
hostname: url.hostname,
port,
+ minimalMode,
})
const requestHandler = nextServer.getRequestHandler()
const server = new Server(async (req, res) => {
@@ -170,9 +180,16 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str
}
}
-export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDir = '../../..' }): string =>
+export const getHandler = ({
+ isODB = false,
+ publishDir = '../../../.next',
+ appDir = '../../..',
+ minimalMode = false,
+}): string => {
+ const handlerSource = makeHandler.toString()
+ const minimal = minimalMode ? 'true' : 'false'
// This is a string, but if you have the right editor plugin it should format as js
- javascript/* javascript */ `
+ return javascript/* javascript */ `
const { Server } = require("http");
const { promises } = require("fs");
// We copy the file here rather than requiring from the node module
@@ -189,7 +206,8 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi
const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "server"));
exports.handler = ${
isODB
- ? `builder((${makeHandler.toString()})(config, "${appDir}", pageRoot, staticManifest, 'odb'));`
- : `(${makeHandler.toString()})(config, "${appDir}", pageRoot, staticManifest, 'ssr');`
+ ? `builder((${handlerSource})(config, "${appDir}", pageRoot, staticManifest, 'odb', ${minimal}));`
+ : `(${handlerSource})(config, "${appDir}", pageRoot, staticManifest, 'ssr', ${minimal});`
}
`
+}
diff --git a/test/e2e/jest.config.router.js b/test/e2e/jest.config.router.js
new file mode 100644
index 0000000000..e13e807517
--- /dev/null
+++ b/test/e2e/jest.config.router.js
@@ -0,0 +1,11 @@
+// @ts-check
+/** @type {import('@jest/types').Config.InitialOptions} */
+
+const parent = require('./jest.config')
+
+const config = {
+ ...parent,
+ testMatch: ['**/test/e2e/router/**/*.test.js', '**/test/e2e/router/**/*.test.ts'],
+}
+
+module.exports = config
diff --git a/test/e2e/next-test-lib/next-modes/base.ts b/test/e2e/next-test-lib/next-modes/base.ts
index e9a64d2730..9a0aad2662 100644
--- a/test/e2e/next-test-lib/next-modes/base.ts
+++ b/test/e2e/next-test-lib/next-modes/base.ts
@@ -141,7 +141,9 @@ export class NextInstance {
[build]
command = "yarn build"
publish = ".next"
-
+ [build.environment]
+ NETLIFY_NEXT_EDGE_ROUTER = "true"
+
[[plugins]]
package = "@netlify/plugin-nextjs"
`
diff --git a/test/e2e/router/custom-routes.test.ts b/test/e2e/router/custom-routes.test.ts
new file mode 100644
index 0000000000..f1977c01d2
--- /dev/null
+++ b/test/e2e/router/custom-routes.test.ts
@@ -0,0 +1,1064 @@
+import { check, fetchViaHTTP, getBrowserBodyText, renderViaHTTP, waitFor } from 'next-test-utils'
+import { load } from 'cheerio'
+import webdriver from 'next-webdriver'
+const nextUrl = process.env.SITE_URL || 'http://localhost:8888'
+import url from 'url'
+
+let buildId = 'build-id'
+
+describe('Custom routes', () => {
+ it('should not rewrite for _next/data route when a match is found', async () => {
+ const initial = await fetchViaHTTP(nextUrl, '/overridden/first')
+ expect(initial.status).toBe(200)
+ expect(await initial.text()).toContain('this page is overridden')
+
+ const nextData = await fetchViaHTTP(nextUrl, `/_next/data/${buildId}/overridden/first.json`)
+ expect(nextData.status).toBe(200)
+ expect(await nextData.json()).toEqual({
+ pageProps: { params: { slug: 'first' } },
+ __N_SSG: true,
+ })
+ })
+
+ it('should handle has query encoding correctly', async () => {
+ for (const expected of [
+ {
+ post: 'first',
+ slug: ['first'],
+ status: 200,
+ },
+ {
+ post: 'hello%20world',
+ slug: ['hello world'],
+ },
+ {
+ post: 'hello/world',
+ slug: ['hello', 'world'],
+ },
+ {
+ post: 'hello%2fworld',
+ slug: ['hello', 'world'],
+ },
+ ]) {
+ const { status = 200, post } = expected
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-8', `?post=${post}`, {
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(status)
+
+ if (status === 200) {
+ const $ = load(await res.text())
+ expect(JSON.parse($('#props').text())).toEqual({
+ params: {
+ slug: expected.slug,
+ },
+ })
+ }
+ }
+ })
+
+ it('should handle external beforeFiles rewrite correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/overridden')
+ const html = await res.text()
+
+ if (res.status !== 200) {
+ console.error('Invalid response', html)
+ }
+ expect(res.status).toBe(200)
+ expect(html).toContain('Example Domain')
+
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.elementByCss('#to-before-files-overridden').click()
+ await check(() => browser.eval('document.documentElement.innerHTML'), /Example Domain/)
+ })
+
+ it('should handle beforeFiles rewrite to dynamic route correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/nfl')
+ const html = await res.text()
+
+ if (res.status !== 200) {
+ console.error('Invalid response', html)
+ }
+ expect(res.status).toBe(200)
+ expect(html).toContain('/_sport/[slug]')
+
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-before-files-dynamic').click()
+ await check(() => browser.eval('document.documentElement.innerHTML'), /_sport\/\[slug\]/)
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ slug: 'nfl',
+ })
+ expect(await browser.elementByCss('#pathname').text()).toBe('/_sport/[slug]')
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should handle beforeFiles rewrite to partly dynamic route correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/nfl')
+ const html = await res.text()
+
+ if (res.status !== 200) {
+ console.error('Invalid response', html)
+ }
+ expect(res.status).toBe(200)
+ expect(html).toContain('/_sport/[slug]')
+
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-before-files-dynamic-again').click()
+ await check(() => browser.eval('document.documentElement.innerHTML'), /_sport\/\[slug\]\/test/)
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ slug: 'nfl',
+ })
+ expect(await browser.elementByCss('#pathname').text()).toBe('/_sport/[slug]/test')
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should support long URLs for rewrites', async () => {
+ const res = await fetchViaHTTP(
+ nextUrl,
+ '/catchall-rewrite/a9btBxtHQALZ6cxfuj18X6OLGNSkJVzrOXz41HG4QwciZfn7ggRZzPx21dWqGiTBAqFRiWvVNm5ko2lpyso5jtVaXg88dC1jKfqI2qmIcdeyJat8xamrIh2LWnrYRrsBcoKfQU65KHod8DPANuzPS3fkVYWlmov05GQbc82HwR1exOvPVKUKb5gBRWiN0WOh7hN4QyezIuq3dJINAptFQ6m2bNGjYACBRk4MOSHdcQG58oq5Ch7luuqrl9EcbWSa',
+ )
+
+ const html = await res.text()
+ expect(res.status).toBe(200)
+ expect(html).toContain('/with-params')
+ })
+
+ it('should resolveHref correctly navigating through history', async () => {
+ const browser = await webdriver(nextUrl, '/')
+ await browser.eval('window.beforeNav = 1')
+
+ expect(await browser.eval('document.documentElement.innerHTML')).toContain('multi-rewrites')
+
+ await browser.eval('next.router.push("/rewriting-to-auto-export")')
+ await browser.waitForElementByCss('#auto-export')
+
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ slug: 'hello',
+ rewrite: '1',
+ })
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+
+ await browser.eval('next.router.push("/nav")')
+ await browser.waitForElementByCss('#nav')
+
+ expect(await browser.elementByCss('#nav').text()).toBe('Nav')
+
+ await browser.back()
+ await browser.waitForElementByCss('#auto-export')
+
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ slug: 'hello',
+ rewrite: '1',
+ })
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should continue in beforeFiles rewrites', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/old-blog/about')
+ expect(res.status).toBe(200)
+
+ const html = await res.text()
+ const $ = load(html)
+
+ expect($('#hello').text()).toContain('Hello')
+
+ const browser = await webdriver(nextUrl, '/nav')
+
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-old-blog').click().waitForElementByCss('#hello')
+ expect(await browser.elementByCss('#hello').text()).toContain('Hello')
+ })
+
+ it('should not hang when proxy rewrite fails', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/to-nowhere')
+
+ expect(res.status).toBe(500)
+ })
+
+ it('should parse params correctly for rewrite to auto-export dynamic page', async () => {
+ const browser = await webdriver(nextUrl, '/rewriting-to-auto-export')
+ await check(() => browser.eval(() => document.documentElement.innerHTML), /auto-export.*?hello/)
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ rewrite: '1',
+ slug: 'hello',
+ })
+ })
+
+ it('should provide params correctly for rewrite to auto-export non-dynamic page', async () => {
+ const browser = await webdriver(nextUrl, '/rewriting-to-another-auto-export/first')
+
+ expect(await browser.elementByCss('#auto-export-another').text()).toBe('auto-export another')
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ rewrite: '1',
+ path: ['first'],
+ })
+ })
+
+ it('should handle one-to-one rewrite successfully', async () => {
+ const html = await renderViaHTTP(nextUrl, '/first')
+ expect(html).toMatch(/hello/)
+ })
+
+ it('should handle chained rewrites successfully', async () => {
+ const html = await renderViaHTTP(nextUrl, '/')
+ expect(html).toMatch(/multi-rewrites/)
+ })
+
+ it('should handle param like headers properly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/my-other-header/my-path')
+ expect(res.headers.get('x-path')).toBe('my-path')
+ expect(res.headers.get('somemy-path')).toBe('hi')
+ expect(res.headers.get('x-test')).toBe('some:value*')
+ expect(res.headers.get('x-test-2')).toBe('value*')
+ expect(res.headers.get('x-test-3')).toBe(':value?')
+ expect(res.headers.get('x-test-4')).toBe(':value+')
+ expect(res.headers.get('x-test-5')).toBe('something https:')
+ expect(res.headers.get('x-test-6')).toBe(':hello(world)')
+ expect(res.headers.get('x-test-7')).toBe('hello(world)')
+ expect(res.headers.get('x-test-8')).toBe('hello{1,}')
+ expect(res.headers.get('x-test-9')).toBe(':hello{1,2}')
+ expect(res.headers.get('content-security-policy')).toBe(
+ "default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com/my-path",
+ )
+ })
+
+ it('should not match dynamic route immediately after applying header', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/blog/post-321')
+ expect(res.headers.get('x-something')).toBe('applied-everywhere')
+
+ const $ = load(await res.text())
+ expect(JSON.parse($('p').text()).path).toBe('blog')
+ })
+
+ it('should handle chained redirects successfully', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/redir-chain1', undefined, {
+ redirect: 'manual',
+ })
+ const res1location = url.parse(res1.headers.get('location')).pathname
+ expect(res1.status).toBe(301)
+ expect(res1location).toBe('/redir-chain2')
+
+ const res2 = await fetchViaHTTP(nextUrl, res1location, undefined, {
+ redirect: 'manual',
+ })
+ const res2location = url.parse(res2.headers.get('location')).pathname
+ expect(res2.status).toBe(302)
+ expect(res2location).toBe('/redir-chain3')
+
+ const res3 = await fetchViaHTTP(nextUrl, res2location, undefined, {
+ redirect: 'manual',
+ })
+ const res3location = url.parse(res3.headers.get('location')).pathname
+ expect(res3.status).toBe(303)
+ expect(res3location).toBe('/')
+ })
+
+ it('should not match redirect for /_next', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/_next/has-redirect-5', undefined, {
+ headers: {
+ 'x-test-next': 'true',
+ },
+ redirect: 'manual',
+ })
+ expect(res.status).toBe(404)
+
+ const res2 = await fetchViaHTTP(nextUrl, '/another/has-redirect-5', undefined, {
+ headers: {
+ 'x-test-next': 'true',
+ },
+ redirect: 'manual',
+ })
+ expect(res2.status).toBe(307)
+ })
+
+ it('should redirect successfully with permanent: false', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/redirect1', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname } = url.parse(res.headers.get('location'))
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/')
+ })
+
+ it('should redirect with params successfully', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/hello/123/another', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname } = url.parse(res.headers.get('location'))
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/blog/123')
+ })
+
+ it('should redirect with hash successfully', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/docs/router-status/500', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname, hash, query } = url.parse(res.headers.get('location'), true)
+ expect(res.status).toBe(301)
+ expect(pathname).toBe('/docs/v2/network/status-codes')
+ expect(hash).toBe('#500')
+ expect(query).toEqual({})
+ })
+
+ it('should redirect successfully with provided statusCode', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/redirect2', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname, query } = url.parse(res.headers.get('location'), true)
+ expect(res.status).toBe(301)
+ expect(pathname).toBe('/')
+ expect(query).toEqual({})
+ })
+
+ it('should redirect successfully with catchall', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/catchall-redirect/hello/world', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname, query } = url.parse(res.headers.get('location'), true)
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/somewhere')
+ expect(query).toEqual({})
+ })
+
+ it('should server static files through a rewrite', async () => {
+ const text = await renderViaHTTP(nextUrl, '/hello-world')
+ expect(text).toBe('hello world!')
+ })
+
+ it('should rewrite with params successfully', async () => {
+ const html = await renderViaHTTP(nextUrl, '/test/hello')
+ expect(html).toMatch(/Hello/)
+ })
+
+ it('should not append params when one is used in destination path', async () => {
+ const html = await renderViaHTTP(nextUrl, '/test/with-params?a=b')
+ const $ = load(html)
+ expect(JSON.parse($('p').text())).toEqual({ a: 'b' })
+ })
+
+ it('should double redirect successfully', async () => {
+ const html = await renderViaHTTP(nextUrl, '/docs/github')
+ expect(html).toMatch(/hi there/)
+ })
+
+ it('should allow params in query for rewrite', async () => {
+ const html = await renderViaHTTP(nextUrl, '/query-rewrite/hello/world?a=b')
+ const $ = load(html)
+ expect(JSON.parse($('#__NEXT_DATA__').html()).query).toEqual({
+ first: 'hello',
+ second: 'world',
+ a: 'b',
+ section: 'hello',
+ name: 'world',
+ })
+ })
+
+ it('should have correct params for catchall rewrite', async () => {
+ const html = await renderViaHTTP(nextUrl, '/catchall-rewrite/hello/world?a=b')
+ const $ = load(html)
+ expect(JSON.parse($('#__NEXT_DATA__').html()).query).toEqual({
+ a: 'b',
+ path: ['hello', 'world'],
+ })
+ })
+
+ it('should have correct encoding for params with catchall rewrite', async () => {
+ const html = await renderViaHTTP(nextUrl, '/catchall-rewrite/hello%20world%3Fw%3D24%26focalpoint%3Dcenter?a=b')
+ const $ = load(html)
+ expect(JSON.parse($('#__NEXT_DATA__').html()).query).toEqual({
+ a: 'b',
+ path: ['hello%20world%3Fw%3D24%26focalpoint%3Dcenter'],
+ })
+ })
+
+ it('should have correct query for catchall rewrite', async () => {
+ const html = await renderViaHTTP(nextUrl, '/catchall-query/hello/world?a=b')
+ const $ = load(html)
+ expect(JSON.parse($('#__NEXT_DATA__').html()).query).toEqual({
+ a: 'b',
+ another: 'hello/world',
+ path: ['hello', 'world'],
+ })
+ })
+
+ it('should have correct header for catchall rewrite', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/catchall-header/hello/world?a=b')
+ const headerValue = res.headers.get('x-value')
+ expect(headerValue).toBe('hello/world')
+ })
+
+ it('should allow params in query for redirect', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/query-redirect/hello/world?a=b', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname, query } = url.parse(res.headers.get('location'), true)
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/with-params')
+ expect(query).toEqual({
+ first: 'hello',
+ second: 'world',
+ a: 'b',
+ })
+ })
+
+ it('should have correctly encoded params in query for redirect', async () => {
+ const res = await fetchViaHTTP(
+ nextUrl,
+ '/query-redirect/hello%20world%3Fw%3D24%26focalpoint%3Dcenter/world?a=b',
+ undefined,
+ {
+ redirect: 'manual',
+ },
+ )
+ const { pathname, query } = url.parse(res.headers.get('location'), true)
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/with-params')
+ expect(query).toEqual({
+ // this should be decoded since url.parse decodes query values
+ first: 'hello world?w=24&focalpoint=center',
+ second: 'world',
+ a: 'b',
+ })
+ })
+
+ it('should overwrite param values correctly', async () => {
+ const html = await renderViaHTTP(nextUrl, '/test-overwrite/first/second')
+ expect(html).toMatch(/this-should-be-the-value/)
+ expect(html).not.toMatch(/first/)
+ expect(html).toMatch(/second/)
+ })
+
+ it('should handle query for rewrite correctly', async () => {
+ // query merge order lowest priority to highest
+ // 1. initial URL query values
+ // 2. path segment values
+ // 3. destination specified query values
+
+ const html = await renderViaHTTP(
+ nextUrl,
+ '/query-rewrite/first/second?section=overridden&name=overridden&first=overridden&second=overridden&keep=me',
+ )
+
+ const data = JSON.parse(load(html)('p').text())
+ expect(data).toEqual({
+ first: 'first',
+ second: 'second',
+ section: 'first',
+ name: 'second',
+ keep: 'me',
+ })
+ })
+
+ // current routes order do not allow rewrites to override page
+ // but allow redirects to
+ it('should not allow rewrite to override page file', async () => {
+ const html = await renderViaHTTP(nextUrl, '/nav')
+ expect(html).toContain('to-hello')
+ })
+
+ it('show allow redirect to override the page', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/redirect-override', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname } = url.parse(res.headers.get('location') || '')
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/thank-you-next')
+ })
+
+ it('should work successfully on the client', async () => {
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.elementByCss('#to-hello').click()
+ await browser.waitForElementByCss('#hello')
+
+ expect(await browser.eval('window.location.href')).toMatch(/\/first$/)
+ expect(await getBrowserBodyText(browser)).toMatch(/Hello/)
+
+ await browser.eval('window.location.href = window.location.href')
+ await waitFor(500)
+ expect(await browser.eval('window.location.href')).toMatch(/\/first$/)
+ expect(await getBrowserBodyText(browser)).toMatch(/Hello/)
+
+ await browser.elementByCss('#to-nav').click()
+ await browser.waitForElementByCss('#to-hello-again')
+ await browser.elementByCss('#to-hello-again').click()
+ await browser.waitForElementByCss('#hello-again')
+
+ expect(await browser.eval('window.location.href')).toMatch(/\/second$/)
+ expect(await getBrowserBodyText(browser)).toMatch(/Hello again/)
+
+ await browser.eval('window.location.href = window.location.href')
+ await waitFor(500)
+ expect(await browser.eval('window.location.href')).toMatch(/\/second$/)
+ expect(await getBrowserBodyText(browser)).toMatch(/Hello again/)
+ })
+
+ it('should work with rewrite when manually specifying href/as', async () => {
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-params-manual').click().waitForElementByCss('#query')
+
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ const query = JSON.parse(await browser.elementByCss('#query').text())
+ expect(query).toEqual({
+ something: '1',
+ another: 'value',
+ })
+ })
+
+ it('should work with rewrite when only specifying href', async () => {
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-params').click().waitForElementByCss('#query')
+
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ const query = JSON.parse(await browser.elementByCss('#query').text())
+ expect(query).toEqual({
+ something: '1',
+ another: 'value',
+ })
+ })
+
+ it('should work with rewrite when only specifying href and ends in dynamic route', async () => {
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-rewritten-dynamic').click().waitForElementByCss('#auto-export')
+
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+
+ const text = await browser.eval(() => document.documentElement.innerHTML)
+ expect(text).toContain('auto-export hello')
+ })
+
+ it('should match a page after a rewrite', async () => {
+ const html = await renderViaHTTP(nextUrl, '/to-hello')
+ expect(html).toContain('Hello')
+ })
+
+ it('should match dynamic route after rewrite', async () => {
+ const html = await renderViaHTTP(nextUrl, '/blog/post-1')
+ expect(html).toMatch(/post:.*?post-2/)
+ })
+
+ it('should match public file after rewrite', async () => {
+ const data = await renderViaHTTP(nextUrl, '/blog/data.json')
+ expect(JSON.parse(data)).toEqual({ hello: 'world' })
+ })
+
+ it('should match /_next file after rewrite', async () => {
+ await renderViaHTTP(nextUrl, '/hello')
+ const data = await renderViaHTTP(nextUrl, `/hidden/_next/static/${buildId}/_buildManifest.js`)
+ expect(data).toContain('/hello')
+ })
+
+ it('should allow redirecting to external resource', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/to-external', undefined, {
+ redirect: 'manual',
+ })
+ const location = res.headers.get('location')
+ expect(res.status).toBe(307)
+ expect(location).toBe('https://google.com/')
+ })
+
+ it('should apply headers for exact match', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/add-header')
+ expect(res.headers.get('x-custom-header')).toBe('hello world')
+ expect(res.headers.get('x-another-header')).toBe('hello again')
+ })
+
+ it('should apply headers for multi match', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/my-headers/first')
+ expect(res.headers.get('x-first-header')).toBe('first')
+ expect(res.headers.get('x-second-header')).toBe('second')
+ })
+
+ it('should apply params for header key/values', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/my-other-header/first')
+ expect(res.headers.get('x-path')).toBe('first')
+ expect(res.headers.get('somefirst')).toBe('hi')
+ })
+
+ it('should support URL for header key/values', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/without-params/url')
+ expect(res.headers.get('x-origin')).toBe('https://example.com')
+ })
+
+ it('should apply params header key/values with URL', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/with-params/url/first')
+ expect(res.headers.get('x-url')).toBe('https://example.com/first')
+ })
+
+ it('should apply params header key/values with URL that has port', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/with-params/url2/first')
+ expect(res.headers.get('x-url')).toBe('https://example.com:8080?hello=first')
+ })
+
+ it('should support named pattern for header key/values', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/named-pattern/hello')
+ expect(res.headers.get('x-something')).toBe('value=hello')
+ expect(res.headers.get('path-hello')).toBe('end')
+ })
+
+ it('should support unnamed parameters correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/unnamed/first/final', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname } = url.parse(res.headers.get('location') || '')
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/got-unnamed')
+ })
+
+ it('should support named like unnamed parameters correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/named-like-unnamed/first', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname } = url.parse(res.headers.get('location') || '')
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/first')
+ })
+
+ it('should add refresh header for 308 redirect', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/redirect4', undefined, {
+ redirect: 'manual',
+ })
+ expect(res.status).toBe(308)
+ expect(res.headers.get('refresh')).toBe(`0;url=/`)
+ })
+
+ it('should have correctly encoded query in location and refresh headers', async () => {
+ const res = await fetchViaHTTP(
+ nextUrl,
+ // Query unencoded is ?テスト=あ
+ '/redirect4?%E3%83%86%E3%82%B9%E3%83%88=%E3%81%82',
+ undefined,
+ {
+ redirect: 'manual',
+ },
+ )
+ expect(res.status).toBe(308)
+
+ expect(res.headers.get('location').split('?')[1]).toBe('%E3%83%86%E3%82%B9%E3%83%88=%E3%81%82')
+ expect(res.headers.get('refresh')).toBe('0;url=/?%E3%83%86%E3%82%B9%E3%83%88=%E3%81%82')
+ })
+
+ it('should handle basic api rewrite successfully', async () => {
+ const data = await renderViaHTTP(nextUrl, '/api-hello')
+ expect(JSON.parse(data)).toEqual({ query: {} })
+ })
+
+ it('should handle api rewrite with un-named param successfully', async () => {
+ const data = await renderViaHTTP(nextUrl, '/api-hello-regex/hello/world')
+ expect(JSON.parse(data)).toEqual({
+ query: { name: 'hello/world', first: 'hello/world' },
+ })
+ })
+
+ it('should handle api rewrite with param successfully', async () => {
+ const data = await renderViaHTTP(nextUrl, '/api-hello-param/hello')
+ expect(JSON.parse(data)).toEqual({
+ query: { name: 'hello', hello: 'hello' },
+ })
+ })
+
+ it('should handle encoded value in the pathname correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/redirect/me/to-about/' + encodeURI('\\google.com'), undefined, {
+ redirect: 'manual',
+ })
+
+ const { pathname, hostname, query } = url.parse(res.headers.get('location') || '', true)
+ expect(res.status).toBe(307)
+ expect(pathname).toBe(encodeURI('/\\google.com/about'))
+ expect(hostname).not.toBe('google.com')
+ expect(query).toEqual({})
+ })
+
+ it('should handle unnamed parameters with multi-match successfully', async () => {
+ const html = await renderViaHTTP(nextUrl, '/unnamed-params/nested/first/second/hello/world')
+ const params = JSON.parse(load(html)('p').text())
+ expect(params).toEqual({ test: 'hello' })
+ })
+
+ it('should handle named regex parameters with multi-match successfully', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/docs/integrations/v2-some/thing', undefined, {
+ redirect: 'manual',
+ })
+ const { pathname } = url.parse(res.headers.get('location') || '')
+ expect(res.status).toBe(307)
+ expect(pathname).toBe('/integrations/-some/thing')
+ })
+
+ it('should redirect with URL in query correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/to-external-with-query', undefined, {
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(307)
+ expect(res.headers.get('location')).toBe(
+ 'https://authserver.example.com/set-password?returnUrl=https://www.example.com/login',
+ )
+ })
+
+ it('should redirect with URL in query correctly non-encoded', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/to-external-with-query', undefined, {
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(307)
+ expect(res.headers.get('location')).toBe(
+ 'https://authserver.example.com/set-password?returnUrl=https://www.example.com/login',
+ )
+ })
+
+ it('should match has header rewrite correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-1', undefined, {
+ headers: {
+ 'x-my-header': 'hello world!!',
+ },
+ })
+
+ expect(res.status).toBe(200)
+ const $ = load(await res.text())
+
+ expect(JSON.parse($('#query').text())).toEqual({
+ myHeader: 'hello world!!',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-rewrite-1')
+ expect(res2.status).toBe(404)
+ })
+
+ it('should match has query rewrite correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-2', {
+ 'my-query': 'hellooo',
+ })
+
+ expect(res.status).toBe(200)
+ const $ = load(await res.text())
+
+ expect(JSON.parse($('#query').text())).toEqual({
+ 'my-query': 'hellooo',
+ myquery: 'hellooo',
+ value: 'hellooo',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-rewrite-2')
+ expect(res2.status).toBe(404)
+ })
+
+ it('should match has cookie rewrite correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-3', undefined, {
+ headers: {
+ cookie: 'loggedIn=true',
+ },
+ })
+
+ expect(res.status).toBe(200)
+ const $ = load(await res.text())
+
+ expect(JSON.parse($('#query').text())).toEqual({
+ loggedIn: 'true',
+ authorized: '1',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-rewrite-3')
+ expect(res2.status).toBe(404)
+ })
+
+ it('should match has host rewrite correctly', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/has-rewrite-4')
+ expect(res1.status).toBe(404)
+
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-4', undefined, {
+ headers: {
+ host: 'example.com',
+ },
+ })
+
+ expect(res.status).toBe(200)
+ const $ = load(await res.text())
+
+ expect(JSON.parse($('#query').text())).toEqual({
+ host: '1',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-rewrite-4')
+ expect(res2.status).toBe(404)
+ })
+
+ it('should pass has segment for rewrite correctly', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/has-rewrite-5')
+ expect(res1.status).toBe(404)
+
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-5', {
+ hasParam: 'with-params',
+ })
+
+ expect(res.status).toBe(200)
+ const $ = load(await res.text())
+
+ expect(JSON.parse($('#query').text())).toEqual({
+ hasParam: 'with-params',
+ })
+ })
+
+ it('should not pass non captured has value for rewrite correctly', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/has-rewrite-6')
+ expect(res1.status).toBe(404)
+
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-6', undefined, {
+ headers: {
+ hasParam: 'with-params',
+ },
+ })
+ expect(res.status).toBe(200)
+
+ const $ = load(await res.text())
+ expect(JSON.parse($('#query').text())).toEqual({})
+ })
+
+ it('should pass captured has value for rewrite correctly', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/has-rewrite-7')
+ expect(res1.status).toBe(404)
+
+ const res = await fetchViaHTTP(nextUrl, '/has-rewrite-7', {
+ hasParam: 'with-params',
+ })
+ expect(res.status).toBe(200)
+
+ const $ = load(await res.text())
+ expect(JSON.parse($('#query').text())).toEqual({
+ hasParam: 'with-params',
+ idk: 'with-params',
+ })
+ })
+
+ it('should match has rewrite correctly before files', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/hello')
+ expect(res1.status).toBe(200)
+ const $1 = load(await res1.text())
+ expect($1('#hello').text()).toBe('Hello')
+
+ const res = await fetchViaHTTP(nextUrl, '/hello', { overrideMe: '1' })
+
+ expect(res.status).toBe(200)
+ const $ = load(await res.text())
+
+ expect(JSON.parse($('#query').text())).toEqual({
+ overrideMe: '1',
+ overridden: '1',
+ })
+
+ const browser = await webdriver(nextUrl, '/nav')
+ await browser.eval('window.beforeNav = 1')
+ await browser.elementByCss('#to-overridden').click()
+ await browser.waitForElementByCss('#query')
+
+ expect(await browser.eval('window.next.router.pathname')).toBe('/with-params')
+ expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
+ overridden: '1',
+ overrideMe: '1',
+ })
+ expect(await browser.eval('window.beforeNav')).toBe(1)
+ })
+
+ it('should match has header redirect correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-redirect-1', undefined, {
+ headers: {
+ 'x-my-header': 'hello world!!',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(307)
+ const parsed = url.parse(res.headers.get('location'), true)
+
+ expect(parsed.pathname).toBe('/another')
+ expect(parsed.query).toEqual({
+ myHeader: 'hello world!!',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-redirect-1', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.status).toBe(404)
+ })
+
+ it('should match has query redirect correctly', async () => {
+ const res = await fetchViaHTTP(
+ nextUrl,
+ '/has-redirect-2',
+ {
+ 'my-query': 'hellooo',
+ },
+ {
+ redirect: 'manual',
+ },
+ )
+
+ expect(res.status).toBe(307)
+ const parsed = url.parse(res.headers.get('location'), true)
+
+ expect(parsed.pathname).toBe('/another')
+ expect(parsed.query).toEqual({
+ value: 'hellooo',
+ 'my-query': 'hellooo',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-redirect-2', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.status).toBe(404)
+ })
+
+ it('should match has cookie redirect correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-redirect-3', undefined, {
+ headers: {
+ cookie: 'loggedIn=true',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(307)
+ const parsed = url.parse(res.headers.get('location'), true)
+
+ expect(parsed.pathname).toBe('/another')
+ expect(parsed.query).toEqual({
+ authorized: '1',
+ })
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-redirect-3', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.status).toBe(404)
+ })
+
+ it('should match has host redirect correctly', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/has-redirect-4', undefined, {
+ redirect: 'manual',
+ })
+ expect(res1.status).toBe(404)
+
+ const res = await fetchViaHTTP(nextUrl, '/has-redirect-4', undefined, {
+ headers: {
+ host: 'example.com',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(307)
+ const parsed = url.parse(res.headers.get('location'), true)
+
+ expect(parsed.pathname).toBe('/another')
+ expect(parsed.query).toEqual({
+ host: '1',
+ })
+ })
+
+ it('should match has host redirect and insert in destination correctly', async () => {
+ const res1 = await fetchViaHTTP(nextUrl, '/has-redirect-6', undefined, {
+ redirect: 'manual',
+ })
+ expect(res1.status).toBe(404)
+
+ const res = await fetchViaHTTP(nextUrl, '/has-redirect-6', undefined, {
+ headers: {
+ host: 'hello-test.example.com',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.status).toBe(307)
+ const parsed = url.parse(res.headers.get('location'), true)
+
+ expect(parsed.protocol).toBe('https:')
+ expect(parsed.hostname).toBe('hello.example.com')
+ expect(parsed.pathname).toBe('/some-path/end')
+ expect(parsed.query).toEqual({
+ a: 'b',
+ })
+ })
+
+ it('should match has query redirect with duplicate query key', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-redirect-7', '?hello=world&hello=another', {
+ redirect: 'manual',
+ })
+ expect(res.status).toBe(307)
+ const parsed = url.parse(res.headers.get('location'), true)
+
+ expect(parsed.pathname).toBe('/somewhere')
+ expect(parsed.query).toEqual({
+ hello: ['world', 'another'],
+ value: 'another',
+ })
+ })
+
+ it('should match has header for header correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-header-1', undefined, {
+ headers: {
+ 'x-my-header': 'hello world!!',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.headers.get('x-another')).toBe('header')
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-header-1', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.headers.get('x-another')).toBe(null)
+ })
+
+ it('should match has query for header correctly', async () => {
+ const res = await fetchViaHTTP(
+ nextUrl,
+ '/has-header-2',
+ {
+ 'my-query': 'hellooo',
+ },
+ {
+ redirect: 'manual',
+ },
+ )
+
+ expect(res.headers.get('x-added')).toBe('value')
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-header-2', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.headers.get('x-another')).toBe(null)
+ })
+
+ it('should match has cookie for header correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-header-3', undefined, {
+ headers: {
+ cookie: 'loggedIn=true',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.headers.get('x-is-user')).toBe('yuuuup')
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-header-3', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.headers.get('x-is-user')).toBe(null)
+ })
+
+ it('should match has host for header correctly', async () => {
+ const res = await fetchViaHTTP(nextUrl, '/has-header-4', undefined, {
+ headers: {
+ host: 'example.com',
+ },
+ redirect: 'manual',
+ })
+
+ expect(res.headers.get('x-is-host')).toBe('yuuuup')
+
+ const res2 = await fetchViaHTTP(nextUrl, '/has-header-4', undefined, {
+ redirect: 'manual',
+ })
+ expect(res2.headers.get('x-is-host')).toBe(null)
+ })
+})
diff --git a/test/index.js b/test/index.js
index c442be43ce..a38ad73674 100644
--- a/test/index.js
+++ b/test/index.js
@@ -636,8 +636,8 @@ describe('onBuild()', () => {
expect(existsSync(handlerFile)).toBeTruthy()
expect(existsSync(odbHandlerFile)).toBeTruthy()
- expect(readFileSync(handlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'ssr')`)
- expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'odb')`)
+ expect(readFileSync(handlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'ssr', false)`)
+ expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`(config, "../../..", pageRoot, staticManifest, 'odb', false)`)
expect(readFileSync(handlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`)
expect(readFileSync(odbHandlerFile, 'utf8')).toMatch(`require("../../../.next/required-server-files.json")`)
})