Skip to content

Commit da1494f

Browse files
committed
support dynamic routes
1 parent 73c6bb9 commit da1494f

File tree

5 files changed

+196
-44
lines changed

5 files changed

+196
-44
lines changed

src/adapter/build/netlify-adapter-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function createNetlifyAdapterContext(nextAdapterContext: OnBuildCompleteC
1616
frameworksAPIConfig: undefined as FrameworksAPIConfig | undefined,
1717
preparedOutputs: {
1818
staticAssets: [] as string[],
19+
staticAssetsAliases: {} as Record<string, string>,
1920
endpoints: [] as string[],
2021
middleware: false,
2122
},

src/adapter/build/pages-and-app-handlers.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const PAGES_AND_APP_FUNCTION_DIR = join(
2121
PAGES_AND_APP_FUNCTION_INTERNAL_NAME,
2222
)
2323

24+
// const abc: OneOfThePaths = 'asa(/abc/)dsa'
25+
2426
export async function onBuildComplete(
2527
nextAdapterContext: OnBuildCompleteContext,
2628
netlifyAdapterContext: NetlifyAdapterContext,
@@ -110,9 +112,13 @@ export async function onBuildComplete(
110112
return new Response('Not Found', { status: 404 })
111113
}
112114
113-
const nextHandler = require('./' + entry).handler
115+
const nextHandler = await require('./' + entry)
116+
117+
if (typeof nextHandler.handler !== 'function') {
118+
console.log('.handler is not a function', { nextHandler })
119+
}
114120
115-
return runNextHandler(request, context, nextHandler)
121+
return runNextHandler(request, context, nextHandler.handler)
116122
}
117123
118124
export const config = ${JSON.stringify(functionConfig, null, 2)}

src/adapter/build/routing.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,34 @@ import {
1313
} from './constants.js'
1414
import type { NetlifyAdapterContext, OnBuildCompleteContext } from './types.js'
1515

16+
function fixDestinationGroupReplacements(destination: string, sourceRegex: string): string {
17+
// convert $nxtPslug to $<nxtPslug> etc
18+
19+
// find all capturing groups in sourceRegex
20+
const segments = [...sourceRegex.matchAll(/\(\?<(?<segment_name>[^>]+)>/g)]
21+
22+
let adjustedDestination = destination
23+
for (const segment of segments) {
24+
if (segment.groups?.segment_name) {
25+
adjustedDestination = adjustedDestination.replaceAll(
26+
`$${segment.groups.segment_name}`,
27+
`$<${segment.groups.segment_name}>`,
28+
)
29+
}
30+
}
31+
32+
if (adjustedDestination !== destination) {
33+
console.log('fixing named captured group replacement', {
34+
sourceRegex,
35+
segments,
36+
destination,
37+
adjustedDestination,
38+
})
39+
}
40+
41+
return adjustedDestination
42+
}
43+
1644
export function convertRedirectToRoutingRule(
1745
redirect: Pick<
1846
OnBuildCompleteContext['routes']['redirects'][number],
@@ -27,11 +55,34 @@ export function convertRedirectToRoutingRule(
2755
},
2856
apply: {
2957
type: 'redirect',
30-
destination: redirect.destination,
58+
destination: fixDestinationGroupReplacements(redirect.destination, redirect.sourceRegex),
3159
},
3260
} satisfies RoutingRuleRedirect
3361
}
3462

63+
export function convertDynamicRouteToRoutingRule(
64+
dynamicRoute: Pick<
65+
OnBuildCompleteContext['routes']['dynamicRoutes'][number],
66+
'sourceRegex' | 'destination'
67+
>,
68+
description: string,
69+
): RoutingRuleRewrite {
70+
return {
71+
description,
72+
match: {
73+
path: dynamicRoute.sourceRegex,
74+
},
75+
apply: {
76+
type: 'rewrite',
77+
destination: fixDestinationGroupReplacements(
78+
dynamicRoute.destination,
79+
dynamicRoute.sourceRegex,
80+
),
81+
rerunRoutingPhases: ['filesystem', 'rewrite'], // this is attempt to mimic Vercel's check: true
82+
},
83+
} satisfies RoutingRuleRewrite
84+
}
85+
3586
export async function generateRoutingRules(
3687
nextAdapterContext: OnBuildCompleteContext,
3788
netlifyAdapterContext: NetlifyAdapterContext,
@@ -62,6 +113,34 @@ export async function generateRoutingRules(
62113
}
63114
}
64115

116+
const dynamicRoutes: RoutingRuleRewrite[] = []
117+
118+
for (const dynamicRoute of nextAdapterContext.routes.dynamicRoutes) {
119+
const isNextData = dynamicRoute.sourceRegex.includes('_next/data')
120+
121+
if (hasPages && !hasMiddleware) {
122+
// this was copied from Vercel adapter, not fully sure what it does - especially with the condition
123+
// not applying equavalent right now, but leaving it commented out
124+
// if (!route.sourceRegex.includes('_next/data') && !addedNextData404Route) {
125+
// addedNextData404Route = true
126+
// dynamicRoutes.push({
127+
// src: path.posix.join('/', config.basePath || '', '_next/data/(.*)'),
128+
// dest: path.posix.join('/', config.basePath || '', '404'),
129+
// status: 404,
130+
// check: true,
131+
// })
132+
// }
133+
}
134+
dynamicRoutes.push(
135+
convertDynamicRouteToRoutingRule(
136+
dynamicRoute,
137+
isNextData
138+
? `Mapping dynamic route _next/data to entrypoint: ${dynamicRoute.destination}`
139+
: `Mapping dynamic route to entrypoint: ${dynamicRoute.destination}`,
140+
),
141+
)
142+
}
143+
65144
const normalizeNextData: RoutingRuleRewrite[] = shouldDenormalizeJsonDataForMiddleware
66145
? [
67146
{
@@ -150,6 +229,12 @@ export async function generateRoutingRules(
150229
// - User rewrites
151230
// - Builder rewrites
152231

232+
{
233+
// this is no-op on its own, it's just marker to be able to run subset of routing rules
234+
description: "'entry' routing phase marker",
235+
routingPhase: 'entry',
236+
},
237+
153238
// priority redirects includes trailing slash redirect
154239
...priorityRedirects, // originally: ...convertedPriorityRedirects,
155240

@@ -260,7 +345,7 @@ export async function generateRoutingRules(
260345
// and we don't want to match a catch-all dynamic route
261346

262347
// apply normal dynamic routes
263-
// ...convertedDynamicRoutes,
348+
...dynamicRoutes, // originally: ...convertedDynamicRoutes,
264349

265350
// apply x-nextjs-matched-path header and __next_data_catchall rewrite
266351
// if middleware + pages

src/adapter/build/static-assets.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ export async function onBuildComplete(
3636
}
3737

3838
// register static asset for routing before applying .html extension for pretty urls
39-
netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
39+
const extensionLessPathname = distPathname
4040

4141
// if pathname is extension-less, but source file has an .html extension, preserve it
4242
distPathname += distPathname.endsWith('/') ? 'index.html' : '.html'
43+
44+
netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)
45+
netlifyAdapterContext.preparedOutputs.staticAssetsAliases[extensionLessPathname] =
46+
distPathname
4347
} else {
4448
// register static asset for routing
4549
netlifyAdapterContext.preparedOutputs.staticAssets.push(distPathname)

src/adapter/run/routing.ts

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { Context } from '@netlify/edge-functions'
44

55
import type { NetlifyAdapterContext } from '../build/types.js'
66

7+
export type RoutingPhase = 'entry' | 'filesystem' | 'rewrite'
8+
79
type RoutingRuleBase = {
810
/**
911
* Human readable description of the rule (for debugging purposes only)
@@ -41,6 +43,8 @@ export type RoutingRuleRewrite = RoutingRuleBase & {
4143
destination: string
4244
/** Forced status code for response, if not defined rewrite response status code will be used */
4345
statusCode?: 200 | 404 | 500
46+
/** Phases to re-run after matching this rewrite */
47+
rerunRoutingPhases?: RoutingPhase[]
4448
}
4549
}
4650

@@ -50,8 +54,6 @@ export type RoutingRuleMatchPrimitive = RoutingRuleBase & {
5054
}
5155
}
5256

53-
export type RoutingPhase = 'filesystem' | 'rewrite'
54-
5557
export type RoutingPhaseRule = RoutingRuleBase & {
5658
routingPhase: RoutingPhase
5759
}
@@ -62,62 +64,73 @@ export type RoutingRule =
6264
| RoutingPhaseRule
6365
| RoutingRuleMatchPrimitive
6466

65-
export function testRedirectRewriteRule(rule: RoutingRuleRedirect, request: Request) {
66-
const sourceRegexp = new RegExp(rule.match.path)
67-
const { pathname } = new URL(request.url)
68-
if (sourceRegexp.test(pathname)) {
69-
const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
70-
return { matched: true, replaced }
67+
function selectRoutingPhasesRules(routingRules: RoutingRule[], phases: RoutingPhase[]) {
68+
const selectedRules: RoutingRule[] = []
69+
let currentPhase: RoutingPhase | undefined
70+
for (const rule of routingRules) {
71+
if ('routingPhase' in rule) {
72+
currentPhase = rule.routingPhase
73+
} else if (currentPhase && phases.includes(currentPhase)) {
74+
selectedRules.push(rule)
75+
}
7176
}
72-
return { matched: false }
77+
78+
return selectedRules
7379
}
7480

7581
let requestCounter = 0
7682

77-
export async function runNextRouting(
83+
function normalizeIndex(request: Request, prefix: string) {
84+
const currentURL = new URL(request.url)
85+
const { pathname } = currentURL
86+
if (pathname === '/') {
87+
const destURL = new URL('/index', currentURL)
88+
console.log(prefix, 'Normalizing "/" to "/index" for routing purposes')
89+
return new Request(destURL, request)
90+
}
91+
return request
92+
}
93+
94+
// eslint-disable-next-line max-params
95+
async function match(
7896
request: Request,
7997
context: Context,
8098
routingRules: RoutingRule[],
8199
outputs: NetlifyAdapterContext['preparedOutputs'],
100+
prefix: string,
82101
) {
83-
if (request.headers.has('x-ntl-routing')) {
84-
// don't route multiple times for same request
85-
return
86-
}
87-
88-
const prefix = `[${
89-
request.headers.get('x-nf-request-id') ??
90-
// for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
91-
// eslint-disable-next-line no-plusplus
92-
`${Date.now()} - #${process.pid}:${++requestCounter}`
93-
}]`
94-
95-
console.log(prefix, 'Incoming request for routing:', request.url)
96-
97-
let currentRequest = new Request(request)
98-
currentRequest.headers.set('x-ntl-routing', '1')
102+
let currentRequest = normalizeIndex(request, prefix)
99103
let maybeResponse: Response | undefined
100104

105+
const currentURL = new URL(currentRequest.url)
106+
let { pathname } = currentURL
107+
101108
for (const rule of routingRules) {
102109
console.log(prefix, 'Evaluating rule:', rule.description ?? JSON.stringify(rule))
103110
if ('match' in rule) {
104-
const currentURL = new URL(currentRequest.url)
105-
const { pathname } = currentURL
106-
107111
if ('type' in rule.match) {
108-
const pathnameToMatch = pathname === '/' ? '/index' : pathname
109-
110112
if (rule.match.type === 'static-asset-or-function') {
111-
let matchedType: 'static-asset' | 'function' | null = null
113+
let matchedType: 'static-asset' | 'function' | 'static-asset-alias' | null = null
112114

113-
if (outputs.staticAssets.includes(pathnameToMatch)) {
115+
// below assumes no overlap between static assets (files and aliases) and functions
116+
if (outputs.staticAssets.includes(pathname)) {
114117
matchedType = 'static-asset'
115-
} else if (outputs.endpoints.includes(pathnameToMatch)) {
118+
} else if (outputs.endpoints.includes(pathname)) {
116119
matchedType = 'function'
120+
} else {
121+
const staticAlias = outputs.staticAssetsAliases[pathname]
122+
if (staticAlias) {
123+
matchedType = 'static-asset-alias'
124+
currentRequest = new Request(new URL(staticAlias, currentRequest.url), currentRequest)
125+
pathname = staticAlias
126+
}
117127
}
118128

119129
if (matchedType) {
120-
console.log(prefix, `Matched static asset or function (${matchedType}):`)
130+
console.log(
131+
prefix,
132+
`Matched static asset or function (${matchedType}): ${pathname} -> ${currentRequest.url}`,
133+
)
121134
maybeResponse = await context.next(currentRequest)
122135
}
123136
} else if (rule.match.type === 'image-cdn' && pathname.startsWith('/.netlify/image/')) {
@@ -146,9 +159,18 @@ export async function runNextRouting(
146159
const replaced = pathname.replace(sourceRegexp, rule.apply.destination)
147160

148161
if (rule.apply.type === 'rewrite') {
149-
console.log(prefix, `Rewriting ${pathname} to ${replaced}`)
150162
const destURL = new URL(replaced, currentURL)
151163
currentRequest = new Request(destURL, currentRequest)
164+
165+
if (rule.apply.rerunRoutingPhases) {
166+
maybeResponse = await match(
167+
currentRequest,
168+
context,
169+
selectRoutingPhasesRules(routingRules, rule.apply.rerunRoutingPhases),
170+
outputs,
171+
prefix,
172+
)
173+
}
152174
} else {
153175
console.log(prefix, `Redirecting ${pathname} to ${replaced}`)
154176
maybeResponse = new Response(null, {
@@ -163,10 +185,44 @@ export async function runNextRouting(
163185
}
164186

165187
if (maybeResponse) {
166-
// for debugging add log prefixes to response headers to make it easy to find logs for a given request
167-
maybeResponse.headers.set('x-ntl-log-prefix', prefix)
168-
console.log(prefix, 'Serving response', maybeResponse.status)
169188
return maybeResponse
170189
}
171190
}
172191
}
192+
193+
export async function runNextRouting(
194+
request: Request,
195+
context: Context,
196+
routingRules: RoutingRule[],
197+
outputs: NetlifyAdapterContext['preparedOutputs'],
198+
) {
199+
if (request.headers.has('x-ntl-routing')) {
200+
// don't route multiple times for same request
201+
return
202+
}
203+
204+
const prefix = `[${
205+
request.headers.get('x-nf-request-id') ??
206+
// for ntl serve, we use a combination of timestamp and pid to have a unique id per request as we don't have x-nf-request-id header then
207+
// eslint-disable-next-line no-plusplus
208+
`${Date.now()} - #${process.pid}:${++requestCounter}`
209+
}]`
210+
211+
console.log(prefix, 'Incoming request for routing:', request.url)
212+
213+
const currentRequest = new Request(request)
214+
currentRequest.headers.set('x-ntl-routing', '1')
215+
216+
let maybeResponse = await match(currentRequest, context, routingRules, outputs, prefix)
217+
218+
if (!maybeResponse) {
219+
console.log(prefix, 'No route matched - 404ing')
220+
maybeResponse = new Response('Not Found', { status: 404 })
221+
}
222+
223+
// for debugging add log prefixes to response headers to make it easy to find logs for a given request
224+
maybeResponse.headers.set('x-ntl-log-prefix', prefix)
225+
console.log(prefix, 'Serving response', maybeResponse.status)
226+
227+
return maybeResponse
228+
}

0 commit comments

Comments
 (0)