Skip to content

Commit 0381cf0

Browse files
committed
Add missing matcher support
1 parent b9c7408 commit 0381cf0

File tree

11 files changed

+256
-140
lines changed

11 files changed

+256
-140
lines changed

packages/next/build/analysis/get-page-static-info.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface MiddlewareMatcher {
2525
regexp: string
2626
locale?: false
2727
has?: RouteHas[]
28+
missing?: RouteHas[]
2829
}
2930

3031
export interface PageStaticInfo {

packages/next/build/webpack/loaders/next-serverless-loader/utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,13 @@ export function getUtils({
157157
)
158158
let params = matcher(parsedUrl.pathname)
159159

160-
if (rewrite.has && params) {
161-
const hasParams = matchHas(req, rewrite.has, parsedUrl.query)
160+
if ((rewrite.has || rewrite.missing) && params) {
161+
const hasParams = matchHas(
162+
req,
163+
parsedUrl.query,
164+
rewrite.has,
165+
rewrite.missing
166+
)
162167

163168
if (hasParams) {
164169
Object.assign(params, hasParams)

packages/next/lib/load-custom-routes.ts

Lines changed: 71 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type Rewrite = {
2424
basePath?: false
2525
locale?: false
2626
has?: RouteHas[]
27+
missing?: RouteHas[]
2728
}
2829

2930
export type Header = {
@@ -32,6 +33,7 @@ export type Header = {
3233
locale?: false
3334
headers: Array<{ key: string; value: string }>
3435
has?: RouteHas[]
36+
missing?: RouteHas[]
3537
}
3638

3739
// internal type used for validation (not user facing)
@@ -41,6 +43,7 @@ export type Redirect = {
4143
basePath?: false
4244
locale?: false
4345
has?: RouteHas[]
46+
missing?: RouteHas[]
4447
} & (
4548
| {
4649
statusCode?: never
@@ -56,6 +59,7 @@ export type Middleware = {
5659
source: string
5760
locale?: false
5861
has?: RouteHas[]
62+
missing?: RouteHas[]
5963
}
6064

6165
const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host'])
@@ -132,8 +136,9 @@ export function checkCustomRoutes(
132136
let numInvalidRoutes = 0
133137
let hadInvalidStatus = false
134138
let hadInvalidHas = false
139+
let hadInvalidMissing = false
135140

136-
const allowedKeys = new Set<string>(['source', 'locale', 'has'])
141+
const allowedKeys = new Set<string>(['source', 'locale', 'has', 'missing'])
137142

138143
if (type === 'rewrite') {
139144
allowedKeys.add('basePath')
@@ -198,48 +203,65 @@ export function checkCustomRoutes(
198203
invalidParts.push('`locale` must be undefined or false')
199204
}
200205

201-
if (typeof route.has !== 'undefined' && !Array.isArray(route.has)) {
202-
invalidParts.push('`has` must be undefined or valid has object')
203-
hadInvalidHas = true
204-
} else if (route.has) {
205-
const invalidHasItems = []
206+
const checkInvalidHasMissing = (
207+
items: any,
208+
fieldName: 'has' | 'missing'
209+
) => {
210+
let hadInvalidItem = false
206211

207-
for (const hasItem of route.has) {
208-
let invalidHasParts = []
212+
if (typeof items !== 'undefined' && !Array.isArray(items)) {
213+
invalidParts.push(
214+
`\`${fieldName}\` must be undefined or valid has object`
215+
)
216+
hadInvalidItem = true
217+
} else if (items) {
218+
const invalidHasItems = []
209219

210-
if (!allowedHasTypes.has(hasItem.type)) {
211-
invalidHasParts.push(`invalid type "${hasItem.type}"`)
212-
}
213-
if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') {
214-
invalidHasParts.push(`invalid key "${hasItem.key}"`)
215-
}
216-
if (
217-
typeof hasItem.value !== 'undefined' &&
218-
typeof hasItem.value !== 'string'
219-
) {
220-
invalidHasParts.push(`invalid value "${hasItem.value}"`)
221-
}
222-
if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') {
223-
invalidHasParts.push(`value is required for "host" type`)
224-
}
220+
for (const hasItem of items) {
221+
let invalidHasParts = []
225222

226-
if (invalidHasParts.length > 0) {
227-
invalidHasItems.push(
228-
`${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}`
229-
)
223+
if (!allowedHasTypes.has(hasItem.type)) {
224+
invalidHasParts.push(`invalid type "${hasItem.type}"`)
225+
}
226+
if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') {
227+
invalidHasParts.push(`invalid key "${hasItem.key}"`)
228+
}
229+
if (
230+
typeof hasItem.value !== 'undefined' &&
231+
typeof hasItem.value !== 'string'
232+
) {
233+
invalidHasParts.push(`invalid value "${hasItem.value}"`)
234+
}
235+
if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') {
236+
invalidHasParts.push(`value is required for "host" type`)
237+
}
238+
239+
if (invalidHasParts.length > 0) {
240+
invalidHasItems.push(
241+
`${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}`
242+
)
243+
}
230244
}
231-
}
232245

233-
if (invalidHasItems.length > 0) {
234-
hadInvalidHas = true
235-
const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}`
246+
if (invalidHasItems.length > 0) {
247+
hadInvalidItem = true
248+
const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}`
236249

237-
console.error(
238-
`Invalid \`has\` ${itemStr}:\n` + invalidHasItems.join('\n')
239-
)
240-
console.error()
241-
invalidParts.push(`invalid \`has\` ${itemStr} found`)
250+
console.error(
251+
`Invalid \`${fieldName}\` ${itemStr}:\n` +
252+
invalidHasItems.join('\n')
253+
)
254+
console.error()
255+
invalidParts.push(`invalid \`${fieldName}\` ${itemStr} found`)
256+
}
242257
}
258+
return hadInvalidItem
259+
}
260+
if (checkInvalidHasMissing(route.has, 'has')) {
261+
hadInvalidHas = true
262+
}
263+
if (checkInvalidHasMissing(route.missing, 'missing')) {
264+
hadInvalidMissing = true
243265
}
244266

245267
if (!route.source) {
@@ -421,6 +443,19 @@ export function checkCustomRoutes(
421443
)}`
422444
)
423445
}
446+
if (hadInvalidMissing) {
447+
console.error(
448+
`\nValid \`missing\` object shape is ${JSON.stringify(
449+
{
450+
type: [...allowedHasTypes].join(', '),
451+
key: 'the key to check for',
452+
value: 'undefined or a value string to match against',
453+
},
454+
null,
455+
2
456+
)}`
457+
)
458+
}
424459
console.error()
425460
console.error(
426461
`Error: Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found`

packages/next/server/router.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type RouteResult = {
3030
export type Route = {
3131
match: RouteMatch
3232
has?: RouteHas[]
33+
missing?: RouteHas[]
3334
type: string
3435
check?: boolean
3536
statusCode?: number
@@ -416,8 +417,13 @@ export default class Router {
416417
})
417418

418419
let params = route.match(matchPathname)
419-
if (route.has && params) {
420-
const hasParams = matchHas(req, route.has, parsedUrlUpdated.query)
420+
if ((route.has || route.missing) && params) {
421+
const hasParams = matchHas(
422+
req,
423+
parsedUrlUpdated.query,
424+
route.has,
425+
route.missing
426+
)
421427
if (hasParams) {
422428
Object.assign(params, hasParams)
423429
} else {

packages/next/shared/lib/router/utils/middleware-route-matcher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export function getMiddlewareRouteMatcher(
2525
continue
2626
}
2727

28-
if (matcher.has) {
29-
const hasParams = matchHas(req, matcher.has, query)
28+
if (matcher.has || matcher.missing) {
29+
const hasParams = matchHas(req, query, matcher.has, matcher.missing)
3030
if (!hasParams) {
3131
continue
3232
}

packages/next/shared/lib/router/utils/prepare-destination.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ function unescapeSegments(str: string) {
4242

4343
export function matchHas(
4444
req: BaseNextRequest | IncomingMessage,
45-
has: RouteHas[],
46-
query: Params
45+
query: Params,
46+
has: RouteHas[] = [],
47+
missing: RouteHas[] = []
4748
): false | Params {
4849
const params: Params = {}
4950

50-
const allMatch = has.every((hasItem) => {
51+
const hasMatch = (hasItem: RouteHas) => {
5152
let value: undefined | string
5253
let key = hasItem.key
5354

@@ -100,7 +101,11 @@ export function matchHas(
100101
}
101102
}
102103
return false
103-
})
104+
}
105+
106+
const allMatch =
107+
has.every((item) => hasMatch(item)) &&
108+
!missing.some((item) => hasMatch(item))
104109

105110
if (allMatch) {
106111
return params

packages/next/shared/lib/router/utils/resolve-rewrites.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default function resolveRewrites(
4444

4545
let params = matcher(parsedAs.pathname)
4646

47-
if (rewrite.has && params) {
47+
if ((rewrite.has || rewrite.missing) && params) {
4848
const hasParams = matchHas(
4949
{
5050
headers: {
@@ -58,8 +58,9 @@ export default function resolveRewrites(
5858
return acc
5959
}, {}),
6060
} as any,
61+
parsedAs.query,
6162
rewrite.has,
62-
parsedAs.query
63+
rewrite.missing
6364
)
6465

6566
if (hasParams) {

test/e2e/middleware-custom-matchers/app/middleware.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,25 @@ export const config = {
5757
},
5858
],
5959
},
60+
{
61+
source: '/missing-match-1',
62+
missing: [
63+
{
64+
type: 'header',
65+
key: 'hello',
66+
value: '(.*)',
67+
},
68+
],
69+
},
70+
{
71+
source: '/missing-match-2',
72+
missing: [
73+
{
74+
type: 'query',
75+
key: 'test',
76+
value: 'value',
77+
},
78+
],
79+
},
6080
],
6181
}

test/e2e/middleware-custom-matchers/test/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@ describe('Middleware custom matchers', () => {
2121
afterAll(() => next.destroy())
2222

2323
const runTests = () => {
24+
it('should match missing header correctly', async () => {
25+
const res = await fetchViaHTTP(next.url, '/missing-match-1')
26+
expect(res.headers.get('x-from-middleware')).toBeDefined()
27+
28+
const res2 = await fetchViaHTTP(next.url, '/missing-match-1', undefined, {
29+
headers: {
30+
hello: 'world',
31+
},
32+
})
33+
expect(res2.headers.get('x-from-middleware')).toBeFalsy()
34+
})
35+
36+
it('should match missing query correctly', async () => {
37+
const res = await fetchViaHTTP(next.url, '/missing-match-2')
38+
expect(res.headers.get('x-from-middleware')).toBeDefined()
39+
40+
const res2 = await fetchViaHTTP(next.url, '/missing-match-2', {
41+
test: 'value',
42+
})
43+
expect(res2.headers.get('x-from-middleware')).toBeFalsy()
44+
})
45+
2446
it('should match source path', async () => {
2547
const res = await fetchViaHTTP(next.url, '/source-match')
2648
expect(res.status).toBe(200)

test/integration/custom-routes/next.config.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
module.exports = {
2-
// target: 'serverless',
32
async rewrites() {
43
// no-rewrites comment
54
return {
@@ -205,6 +204,38 @@ module.exports = {
205204
],
206205
destination: '/blog-catchall/:post',
207206
},
207+
{
208+
source: '/missing-rewrite-1',
209+
missing: [
210+
{
211+
type: 'header',
212+
key: 'x-my-header',
213+
value: '(?<myHeader>.*)',
214+
},
215+
],
216+
destination: '/with-params',
217+
},
218+
{
219+
source: '/missing-rewrite-2',
220+
missing: [
221+
{
222+
type: 'query',
223+
key: 'my-query',
224+
},
225+
],
226+
destination: '/with-params',
227+
},
228+
{
229+
source: '/missing-rewrite-3',
230+
missing: [
231+
{
232+
type: 'cookie',
233+
key: 'loggedIn',
234+
value: '(?<loggedIn>true)',
235+
},
236+
],
237+
destination: '/with-params?authorized=1',
238+
},
208239
{
209240
source: '/blog/about',
210241
destination: '/hello',

0 commit comments

Comments
 (0)