[next][routing-utils] Add missing matcher support (#8874)

### Related Issues

This adds handling to ensure we pass through the `missing` route field
correctly for custom routes and middleware matchers. Tests are also
added in the complimentary Next.js PR for this, example deployment can
be seen here
https://vtest314-e2e-tests-jj4-vtest314-next-e2e-tests.vercel.app/

x-ref: [slack
thread](https://vercel.slack.com/archives/C03S8ED1DKM/p1667935428788529?thread_ts=1667850697.542269&cid=C03S8ED1DKM)
x-ref: https://github.com/vercel/next.js/pull/42660

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with
tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a
reviewer
- [ ] Issue from task tracker has a link to this PR
This commit is contained in:
JJ Kasper
2022-11-08 16:17:34 -08:00
committed by GitHub
parent fbb8bba4cf
commit c2d0887b94
3 changed files with 180 additions and 4 deletions

View File

@@ -2278,6 +2278,7 @@ interface EdgeFunctionInfoV2 extends BaseEdgeFunctionInfo {
interface EdgeFunctionMatcher { interface EdgeFunctionMatcher {
regexp: string; regexp: string;
has?: HasField; has?: HasField;
missing?: HasField;
} }
export async function getMiddlewareBundle({ export async function getMiddlewareBundle({
@@ -2479,6 +2480,7 @@ export async function getMiddlewareBundle({
key: 'x-prerender-revalidate', key: 'x-prerender-revalidate',
value: prerenderBypassToken, value: prerenderBypassToken,
}, },
...(matcher.missing || []),
], ],
}; };
@@ -2609,6 +2611,9 @@ function getRouteMatchers(
if (matcher.has) { if (matcher.has) {
m.has = normalizeHas(matcher.has); m.has = normalizeHas(matcher.has);
} }
if (matcher.missing) {
m.missing = normalizeHas(matcher.missing);
}
return m; return m;
}); });
} }

View File

@@ -51,6 +51,9 @@ export function convertRedirects(
return redirects.map(r => { return redirects.map(r => {
const { src, segments } = sourceToRegex(r.source); const { src, segments } = sourceToRegex(r.source);
const hasSegments = collectHasSegments(r.has); const hasSegments = collectHasSegments(r.has);
normalizeHasKeys(r.has);
normalizeHasKeys(r.missing);
try { try {
const loc = replaceSegments(segments, hasSegments, r.destination, true); const loc = replaceSegments(segments, hasSegments, r.destination, true);
let status: number; let status: number;
@@ -70,6 +73,9 @@ export function convertRedirects(
if (r.has) { if (r.has) {
route.has = r.has; route.has = r.has;
} }
if (r.missing) {
route.missing = r.missing;
}
return route; return route;
} catch (e) { } catch (e) {
throw new Error(`Failed to parse redirect: ${JSON.stringify(r)}`); throw new Error(`Failed to parse redirect: ${JSON.stringify(r)}`);
@@ -84,6 +90,9 @@ export function convertRewrites(
return rewrites.map(r => { return rewrites.map(r => {
const { src, segments } = sourceToRegex(r.source); const { src, segments } = sourceToRegex(r.source);
const hasSegments = collectHasSegments(r.has); const hasSegments = collectHasSegments(r.has);
normalizeHasKeys(r.has);
normalizeHasKeys(r.missing);
try { try {
const dest = replaceSegments( const dest = replaceSegments(
segments, segments,
@@ -97,6 +106,9 @@ export function convertRewrites(
if (r.has) { if (r.has) {
route.has = r.has; route.has = r.has;
} }
if (r.missing) {
route.missing = r.missing;
}
return route; return route;
} catch (e) { } catch (e) {
throw new Error(`Failed to parse rewrite: ${JSON.stringify(r)}`); throw new Error(`Failed to parse rewrite: ${JSON.stringify(r)}`);
@@ -109,6 +121,9 @@ export function convertHeaders(headers: Header[]): Route[] {
const obj: { [key: string]: string } = {}; const obj: { [key: string]: string } = {};
const { src, segments } = sourceToRegex(h.source); const { src, segments } = sourceToRegex(h.source);
const hasSegments = collectHasSegments(h.has); const hasSegments = collectHasSegments(h.has);
normalizeHasKeys(h.has);
normalizeHasKeys(h.missing);
const namedSegments = segments.filter(name => name !== UN_NAMED_SEGMENT); const namedSegments = segments.filter(name => name !== UN_NAMED_SEGMENT);
const indexes: { [k: string]: string } = {}; const indexes: { [k: string]: string } = {};
@@ -140,6 +155,9 @@ export function convertHeaders(headers: Header[]): Route[] {
if (h.has) { if (h.has) {
route.has = h.has; route.has = h.has;
} }
if (h.missing) {
route.missing = h.missing;
}
return route; return route;
}); });
} }
@@ -193,14 +211,19 @@ export function sourceToRegex(source: string): {
const namedGroupsRegex = /\(\?<([a-zA-Z][a-zA-Z0-9]*)>/g; const namedGroupsRegex = /\(\?<([a-zA-Z][a-zA-Z0-9]*)>/g;
const normalizeHasKeys = (hasItems: HasField = []) => {
for (const hasItem of hasItems) {
if ('key' in hasItem && hasItem.type === 'header') {
hasItem.key = hasItem.key.toLowerCase();
}
}
return hasItems;
};
export function collectHasSegments(has?: HasField) { export function collectHasSegments(has?: HasField) {
const hasSegments = new Set<string>(); const hasSegments = new Set<string>();
for (const hasItem of has || []) { for (const hasItem of has || []) {
if ('key' in hasItem && hasItem.type === 'header') {
hasItem.key = hasItem.key.toLowerCase();
}
if (!hasItem.value && 'key' in hasItem) { if (!hasItem.value && 'key' in hasItem) {
hasSegments.add(hasItem.key); hasSegments.add(hasItem.key);
} }

View File

@@ -224,6 +224,17 @@ test('convertRedirects', () => {
], ],
permanent: false, permanent: false,
}, },
{
source: '/hello/:first',
destination: '/another',
missing: [
{
type: 'host',
value: '(?<a>.*)\\.(?<b>.*)',
},
],
permanent: false,
},
{ {
source: '/hello/:first', source: '/hello/:first',
destination: destination:
@@ -384,6 +395,19 @@ test('convertRedirects', () => {
src: '^\\/hello(?:\\/([^\\/]+?))$', src: '^\\/hello(?:\\/([^\\/]+?))$',
status: 307, status: 307,
}, },
{
missing: [
{
type: 'host',
value: '(?<a>.*)\\.(?<b>.*)',
},
],
headers: {
Location: '/another',
},
src: '^\\/hello(?:\\/([^\\/]+?))$',
status: 307,
},
{ {
has: [ has: [
{ {
@@ -453,6 +477,7 @@ test('convertRedirects', () => {
['/hello/world', '/hello/again'], ['/hello/world', '/hello/again'],
['/hello/world'], ['/hello/world'],
['/hello/world'], ['/hello/world'],
['/hello/world'],
]; ];
const mustNotMatch = [ const mustNotMatch = [
@@ -474,6 +499,7 @@ test('convertRedirects', () => {
['/feature/first', '/feature'], ['/feature/first', '/feature'],
['/hello', '/hello/another/one'], ['/hello', '/hello/another/one'],
['/hellooo'], ['/hellooo'],
['/hellooo'],
['/helloooo'], ['/helloooo'],
]; ];
@@ -586,6 +612,48 @@ test('convertRewrites', () => {
}, },
], ],
}, },
{
source: '/hello/:first',
destination: '/another',
missing: [
{
type: 'header',
key: 'x-rewrite',
},
{
type: 'cookie',
key: 'loggedIn',
value: '1',
},
{
type: 'host',
value: 'vercel.com',
},
{
type: 'host',
value: '(?<a>.*)\\.(?<b>.*)',
},
{
type: 'header',
key: 'host',
value: '(?<c>.*)\\.(?<d>.*)',
},
{
type: 'query',
key: 'username',
},
{
type: 'header',
key: 'x-pathname',
value: '(?<pathname>.*)',
},
{
type: 'header',
key: 'X-Pathname',
value: '(?<another>hello|world)',
},
],
},
{ {
source: '/array-query-string/:id/:name', source: '/array-query-string/:id/:name',
destination: 'https://example.com/?tag=1&tag=2', destination: 'https://example.com/?tag=1&tag=2',
@@ -740,6 +808,49 @@ test('convertRewrites', () => {
], ],
src: '^\\/hello(?:\\/([^\\/]+?))$', src: '^\\/hello(?:\\/([^\\/]+?))$',
}, },
{
check: true,
dest: '/another?first=$1',
missing: [
{
key: 'x-rewrite',
type: 'header',
},
{
key: 'loggedIn',
type: 'cookie',
value: '1',
},
{
type: 'host',
value: 'vercel.com',
},
{
type: 'host',
value: '(?<a>.*)\\.(?<b>.*)',
},
{
key: 'host',
type: 'header',
value: '(?<c>.*)\\.(?<d>.*)',
},
{
type: 'query',
key: 'username',
},
{
type: 'header',
key: 'x-pathname',
value: '(?<pathname>.*)',
},
{
type: 'header',
key: 'x-pathname',
value: '(?<another>hello|world)',
},
],
src: '^\\/hello(?:\\/([^\\/]+?))$',
},
{ {
src: '^\\/array-query-string(?:\\/([^\\/]+?))(?:\\/([^\\/]+?))$', src: '^\\/array-query-string(?:\\/([^\\/]+?))(?:\\/([^\\/]+?))$',
dest: 'https://example.com/?tag=1&tag=2&id=$1&name=$2', dest: 'https://example.com/?tag=1&tag=2&id=$1&name=$2',
@@ -776,6 +887,7 @@ test('convertRewrites', () => {
['/hello/world', '/hello/again'], ['/hello/world', '/hello/again'],
['/hello/world'], ['/hello/world'],
['/hello/world'], ['/hello/world'],
['/hello/world'],
['/array-query-string/10/email'], ['/array-query-string/10/email'],
['/en/hello'], ['/en/hello'],
]; ];
@@ -802,6 +914,7 @@ test('convertRewrites', () => {
['/hello', '/hello/another/one'], ['/hello', '/hello/another/one'],
['/hllooo'], ['/hllooo'],
['/hllooo'], ['/hllooo'],
['/hllooo'],
['/array-query-string/10'], ['/array-query-string/10'],
['/en/hello/world', '/en/hello/'], ['/en/hello/world', '/en/hello/'],
]; ];
@@ -919,6 +1032,25 @@ test('convertHeaders', () => {
}, },
], ],
}, },
{
source: '/hello/:first',
missing: [
{
type: 'host',
value: '(?<a>.*)\\.(?<b>.*)',
},
],
headers: [
{
key: 'x-a',
value: 'a',
},
{
key: 'x-b',
value: 'b',
},
],
},
{ {
source: '/hello/:first', source: '/hello/:first',
has: [ has: [
@@ -1057,6 +1189,20 @@ test('convertHeaders', () => {
}, },
src: '^\\/hello(?:\\/([^\\/]+?))$', src: '^\\/hello(?:\\/([^\\/]+?))$',
}, },
{
continue: true,
missing: [
{
type: 'host',
value: '(?<a>.*)\\.(?<b>.*)',
},
],
headers: {
'x-a': 'a',
'x-b': 'b',
},
src: '^\\/hello(?:\\/([^\\/]+?))$',
},
{ {
continue: true, continue: true,
has: [ has: [
@@ -1133,6 +1279,7 @@ test('convertHeaders', () => {
['/like/params/first', '/like/params/second'], ['/like/params/first', '/like/params/second'],
['/hello/world'], ['/hello/world'],
['/hello/world'], ['/hello/world'],
['/hello/world'],
['/hello'], ['/hello'],
]; ];
@@ -1143,6 +1290,7 @@ test('convertHeaders', () => {
['/non-match', '/like/params', '/like/params/'], ['/non-match', '/like/params', '/like/params/'],
['/hellooo'], ['/hellooo'],
['/hellooo'], ['/hellooo'],
['/hellooo'],
[], [],
]; ];