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")`) })