Skip to content

Commit 4020ba3

Browse files
committed
feat: add dynamic route matching
1 parent 86889bf commit 4020ba3

File tree

5 files changed

+940
-799
lines changed

5 files changed

+940
-799
lines changed

demos/custom-routes/next.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ module.exports = {
8686
},
8787
{
8888
source: '/proxy-me/:path*',
89-
destination: 'http://localhost:__EXTERNAL_PORT__/:path*',
89+
destination: 'http://localhost:8888/:path*',
9090
},
9191
{
9292
source: '/api-hello',

plugin/src/templates/edge/next-utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ export type Redirect = {
6464
regex: string
6565
}
6666

67+
export type DynamicRoute = {
68+
page: string
69+
regex: string
70+
namedRegex?: string
71+
routeKeys?: { [key: string]: string }
72+
}
73+
74+
export type RoutesManifest = {
75+
basePath: string
76+
redirects: Redirect[]
77+
headers: Header[]
78+
rewrites: {
79+
beforeFiles: Rewrite[]
80+
afterFiles: Rewrite[]
81+
fallback: Rewrite[]
82+
}
83+
dynamicRoutes: DynamicRoute[]
84+
}
6785
// escape-regexp.ts
6886
// regexp is based on https://github.com/sindresorhus/escape-string-regexp
6987
const reHasRegExp = /[|\\{}()[\]^$+*?.-]/

plugin/src/templates/edge/router.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { assert, assertEquals, assertFalse } from 'https://deno.land/std@0.148.0/testing/asserts.ts'
2-
import { Header, Redirect, Rewrite } from './next-utils.ts'
2+
import { Header, Redirect, Rewrite, RoutesManifest } from './next-utils.ts'
33
import {
44
applyHeaderRule,
55
applyHeaders,
@@ -8,9 +8,10 @@ import {
88
applyRewriteRule,
99
applyRewrites,
1010
matchesRule,
11+
runPostMiddleware,
1112
} from './router.ts'
12-
import manifest from './test-routes-manifest.json' assert { type: 'json' }
13-
13+
import manifestImport from './test-routes-manifest.json' assert { type: 'json' }
14+
const manifest = manifestImport as unknown as RoutesManifest
1415
const staticRoutes = new Set([
1516
'/blog/data.json',
1617
'/static/hello.txt',
@@ -410,9 +411,22 @@ Deno.test('rewrites', async (t) => {
410411
staticRoutes,
411412
})
412413
assert(result)
414+
assertEquals(result.headers.get('x-matched-path'), '/static/hello.txt')
413415
assertEquals(result.url, 'http://localhost/static/hello.txt')
414416
})
415417

418+
await t.step('afterFiles matching dynamic route', () => {
419+
const result = applyRewrites({
420+
request: new Request('http://localhost/test/nothing'),
421+
rules: manifest.rewrites.afterFiles as Rewrite[],
422+
checkStaticRoutes: true,
423+
staticRoutes,
424+
})
425+
assert(result)
426+
assertFalse(result.headers.get('x-matched-path'))
427+
assertEquals(result.url, 'http://localhost/nothing')
428+
})
429+
416430
await t.step('non-matching', () => {
417431
const result = applyRewrites({
418432
request: new Request('http://localhost/no-match'),
@@ -422,4 +436,33 @@ Deno.test('rewrites', async (t) => {
422436
})
423437
assertFalse(result)
424438
})
439+
440+
await t.step('preserves query params', () => {
441+
const result = applyRewrites({
442+
request: new Request('http://localhost/proxy-me/first?keep=me&and=me'),
443+
rules: manifest.rewrites.afterFiles as Rewrite[],
444+
checkStaticRoutes: true,
445+
staticRoutes,
446+
})
447+
assert(result)
448+
assertEquals(result.url, 'http://external.example.com/first?keep=me&and=me&this=me')
449+
})
450+
})
451+
452+
Deno.test('router', async (t) => {
453+
await t.step('static route overrides afterFiles rewrite', () => {
454+
const result = runPostMiddleware(new Request('http://localhost/nav'), manifest, staticRoutes)
455+
assert(result)
456+
assertEquals(result.url, 'http://localhost/nav')
457+
})
458+
459+
await t.step('proxy to external resource', () => {
460+
const result = runPostMiddleware(
461+
new Request('http://localhost/proxy-me/first?keep=me&and=me'),
462+
manifest,
463+
staticRoutes,
464+
)
465+
assert(result)
466+
assertEquals(result.url, 'http://external.example.com/first?keep=me&and=me&this=me')
467+
})
425468
})

plugin/src/templates/edge/router.ts

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { Match, match } from 'https://deno.land/x/path_to_regexp@v6.2.1/index.ts
22

33
import {
44
compileNonPath,
5+
DynamicRoute,
56
Header,
67
matchHas as nextMatchHas,
78
prepareDestination,
89
Redirect,
910
Rewrite,
1011
RouteHas,
12+
RoutesManifest,
1113
searchParamsToUrlQuery,
1214
} from './next-utils.ts'
1315

@@ -18,17 +20,12 @@ export type Rule = Rewrite | Header | Redirect
1820
* Converts Next.js's internal parsed URL response to a `URL` object.
1921
*/
2022

21-
function preparedDestinationToUrl({
22-
newUrl,
23-
destQuery,
24-
parsedDestination,
25-
}: ReturnType<typeof prepareDestination>): URL {
23+
function preparedDestinationToUrl({ newUrl, parsedDestination }: ReturnType<typeof prepareDestination>): URL {
2624
const transformedUrl = new URL(newUrl, 'http://n')
2725
transformedUrl.hostname = parsedDestination.hostname ?? ''
2826
transformedUrl.port = parsedDestination.port ?? ''
2927
transformedUrl.protocol = parsedDestination.protocol ?? ''
30-
31-
for (const [name, value] of Object.entries(destQuery)) {
28+
for (const [name, value] of Object.entries(parsedDestination.query)) {
3229
transformedUrl.searchParams.set(name, String(value))
3330
}
3431
return transformedUrl
@@ -212,11 +209,11 @@ export function applyRewrites({
212209
request,
213210
rules,
214211
staticRoutes,
215-
checkStaticRoutes,
212+
checkStaticRoutes = false,
216213
}: {
217214
request: Request
218215
rules: Rewrite[]
219-
checkStaticRoutes: boolean
216+
checkStaticRoutes?: boolean
220217
staticRoutes?: Set<string>
221218
}): Request | false {
222219
let result: Request | false = false
@@ -230,18 +227,101 @@ export function applyRewrites({
230227
const rewritten = applyRewriteRule({ request, rule })
231228
if (rewritten) {
232229
result = rewritten
230+
if (!checkStaticRoutes) {
231+
continue
232+
}
233+
const { pathname } = new URL(rewritten.url)
233234
// If a static route is matched, then we exit early
234-
if (checkStaticRoutes && staticRoutes!.has(new URL(result.url).pathname)) {
235+
if (staticRoutes!.has(pathname) || pathname.startsWith('/_next/static/')) {
236+
result.headers.set('x-matched-path', pathname)
235237
return result
236238
}
237239
}
238240
}
239241
return result
240242
}
241243

242-
export function matchesStaticRoute(route: string, staticRoutes: Set<string>, staticFiles: Set<string>): boolean {
243-
if (staticFiles.has(route)) {
244-
return true
244+
export function matchDynamicRoute(request: Request, routes: DynamicRoute[]): string | false {
245+
const { pathname } = new URL(request.url)
246+
const match = routes.find((route) => {
247+
return new RegExp(route.regex).test(pathname)
248+
})
249+
if (match) {
250+
return match.page
251+
}
252+
return false
253+
}
254+
255+
/**
256+
* Run the rules that run before middleware
257+
*/
258+
export function runPreMiddleware(request: Request, manifest: RoutesManifest): Request | Response {
259+
const output: Request = applyHeaders(request, manifest.headers)
260+
const redirect = applyRedirects(output, manifest.redirects)
261+
if (redirect) {
262+
return Response.redirect(redirect.url, redirect.status)
245263
}
246-
return staticRoutes.has(route.endsWith('/') ? route.slice(0, -1) : route)
264+
return output
265+
}
266+
267+
/**
268+
* Run the rules that run after middleware
269+
*/
270+
export function runPostMiddleware(
271+
request: Request,
272+
manifest: RoutesManifest,
273+
staticRoutes: Set<string>,
274+
skipBeforeFiles = false,
275+
): Request | Response {
276+
// Everyone gets the beforeFiles rewrites, unless we're re-running after matching fallback
277+
let result = skipBeforeFiles
278+
? request
279+
: applyRewrites({
280+
request,
281+
rules: manifest.rewrites.beforeFiles,
282+
}) || request
283+
284+
// Check if it matches a static route or file
285+
const { pathname } = new URL(result.url)
286+
if (staticRoutes.has(pathname) || pathname.startsWith('/_next/static/')) {
287+
result.headers.set('x-matched-path', pathname)
288+
return result
289+
}
290+
291+
// afterFiles rewrites also check if it matches a static file after every match
292+
const afterRewrite = applyRewrites({
293+
request: result,
294+
rules: manifest.rewrites.afterFiles,
295+
checkStaticRoutes: true,
296+
staticRoutes,
297+
})
298+
299+
if (afterRewrite) {
300+
result = afterRewrite
301+
// If we match a rewrite, we check if it matches a static route or file
302+
// If it does, we return right away
303+
if (afterRewrite.headers.has('x-matched-path')) {
304+
return afterRewrite
305+
}
306+
}
307+
308+
// Now we check dynamic routes, so we can
309+
const dynamicRoute = matchDynamicRoute(result, manifest.dynamicRoutes)
310+
if (dynamicRoute) {
311+
result.headers.set('x-matched-path', dynamicRoute)
312+
return result
313+
}
314+
315+
// Finally, check for fallback rewrites
316+
const fallbackRewrite = applyRewrites({
317+
request: result,
318+
rules: manifest.rewrites.fallback,
319+
})
320+
321+
// If the fallback matched, we go right back to checking for static routes
322+
if (fallbackRewrite) {
323+
return runPostMiddleware(fallbackRewrite, manifest, staticRoutes, true)
324+
}
325+
// 404
326+
return result
247327
}

0 commit comments

Comments
 (0)