Skip to content
This repository was archived by the owner on Mar 20, 2025. It is now read-only.

Commit ca6962d

Browse files
feat: replace glob-to-regexp with URLPattern (#392)
* feat: replace `glob-to-regexp` with `URLPattern` * chore: fix tests * fix: pin version of `urlpattern-polyfill` * chore: put behind featureflag * fix: last missing test * fix: types --------- Co-authored-by: Simon Knott <[email protected]>
1 parent d9ae730 commit ca6962d

File tree

7 files changed

+111
-38
lines changed

7 files changed

+111
-38
lines changed

node/config.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ test('Loads function paths from the in-source `config` function', async () => {
178178
const result = await bundle([internalDirectory, userDirectory], distPath, declarations, {
179179
basePath,
180180
configPath: join(internalDirectory, 'config.json'),
181+
featureFlags: { edge_functions_path_urlpattern: true },
181182
})
182183
const generatedFiles = await fs.readdir(distPath)
183184

@@ -198,7 +199,7 @@ test('Loads function paths from the in-source `config` function', async () => {
198199
expect(routes[2]).toEqual({ function: 'framework-func1', pattern: '^/framework-func1/?$', excluded_patterns: [] })
199200
expect(routes[3]).toEqual({ function: 'user-func1', pattern: '^/user-func1/?$', excluded_patterns: [] })
200201
expect(routes[4]).toEqual({ function: 'user-func3', pattern: '^/user-func3/?$', excluded_patterns: [] })
201-
expect(routes[5]).toEqual({ function: 'user-func5', pattern: '^/user-func5/.*/?$', excluded_patterns: [] })
202+
expect(routes[5]).toEqual({ function: 'user-func5', pattern: '^/user-func5(?:/(.*))/?$', excluded_patterns: [] })
202203

203204
expect(postCacheRoutes.length).toBe(1)
204205
expect(postCacheRoutes[0]).toEqual({ function: 'user-func4', pattern: '^/user-func4/?$', excluded_patterns: [] })

node/feature_flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const defaultFlags = {
22
edge_functions_fail_unsupported_regex: false,
3+
edge_functions_path_urlpattern: false,
34
}
45

56
type FeatureFlag = keyof typeof defaultFlags

node/manifest.test.ts

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,15 @@ test('Generates a manifest with display names', () => {
4444
name: 'Display Name',
4545
},
4646
}
47-
const manifest = generateManifest({ bundles: [], declarations, functions, internalFunctionConfig })
47+
const manifest = generateManifest({
48+
bundles: [],
49+
declarations,
50+
functions,
51+
internalFunctionConfig,
52+
featureFlags: { edge_functions_path_urlpattern: true },
53+
})
4854

49-
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1/.*/?$', excluded_patterns: [] }]
55+
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [] }]
5056
expect(manifest.function_config).toEqual({
5157
'func-1': { name: 'Display Name' },
5258
})
@@ -63,9 +69,15 @@ test('Generates a manifest with a generator field', () => {
6369
generator: '@netlify/[email protected]',
6470
},
6571
}
66-
const manifest = generateManifest({ bundles: [], declarations, functions, internalFunctionConfig })
72+
const manifest = generateManifest({
73+
bundles: [],
74+
declarations,
75+
functions,
76+
internalFunctionConfig,
77+
featureFlags: { edge_functions_path_urlpattern: true },
78+
})
6779

68-
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1/.*/?$', excluded_patterns: [] }]
80+
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [] }]
6981
const expectedFunctionConfig = { 'func-1': { generator: '@netlify/[email protected]' } }
7082
expect(manifest.routes).toEqual(expectedRoutes)
7183
expect(manifest.function_config).toEqual(expectedFunctionConfig)
@@ -79,14 +91,23 @@ test('Generates a manifest with excluded paths and patterns', () => {
7991
]
8092
const declarations: Declaration[] = [
8193
{ function: 'func-1', path: '/f1/*', excludedPath: '/f1/exclude' },
82-
{ function: 'func-2', pattern: '^/f2/.*/?$', excludedPattern: ['^/f2/exclude$', '^/f2/exclude-as-well$'] },
94+
{ function: 'func-2', pattern: '^/f2(?:/(.*))/?$', excludedPattern: ['^/f2/exclude$', '^/f2/exclude-as-well$'] },
8395
{ function: 'func-3', path: '/*', excludedPath: '/**/*.html' },
8496
]
85-
const manifest = generateManifest({ bundles: [], declarations, functions })
97+
const manifest = generateManifest({
98+
bundles: [],
99+
declarations,
100+
functions,
101+
featureFlags: { edge_functions_path_urlpattern: true },
102+
})
86103
const expectedRoutes = [
87-
{ function: 'func-1', pattern: '^/f1/.*/?$', excluded_patterns: ['^/f1/exclude/?$'] },
88-
{ function: 'func-2', pattern: '^/f2/.*/?$', excluded_patterns: ['^/f2/exclude$', '^/f2/exclude-as-well$'] },
89-
{ function: 'func-3', pattern: '^/.*/?$', excluded_patterns: ['^/.*/.*\\.html/?$'] },
104+
{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: ['^/f1/exclude/?$'] },
105+
{ function: 'func-2', pattern: '^/f2(?:/(.*))/?$', excluded_patterns: ['^/f2/exclude$', '^/f2/exclude-as-well$'] },
106+
{
107+
function: 'func-3',
108+
pattern: '^(?:/(.*))/?$',
109+
excluded_patterns: ['^(?:/((?:.*)(?:/(?:.*))*))?(?:/(.*))\\.html/?$'],
110+
},
90111
]
91112

92113
expect(manifest.routes).toEqual(expectedRoutes)
@@ -105,9 +126,14 @@ test('TOML-defined paths can be combined with ISC-defined excluded paths', () =>
105126
const userFunctionConfig: Record<string, FunctionConfig> = {
106127
'func-1': { excludedPath: '/f1/exclude' },
107128
}
108-
const manifest = generateManifest({ bundles: [], declarations, functions, userFunctionConfig })
109-
110-
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1/.*/?$', excluded_patterns: [] }]
129+
const manifest = generateManifest({
130+
bundles: [],
131+
declarations,
132+
functions,
133+
userFunctionConfig,
134+
featureFlags: { edge_functions_path_urlpattern: true },
135+
})
136+
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [] }]
111137

112138
expect(manifest.routes).toEqual(expectedRoutes)
113139
expect(manifest.function_config).toEqual({
@@ -123,7 +149,7 @@ test('Filters out internal in-source configurations in user created functions',
123149
]
124150
const declarations: Declaration[] = [
125151
{ function: 'func-1', path: '/f1/*' },
126-
{ function: 'func-2', pattern: '^/f2/.*/?$' },
152+
{ function: 'func-2', pattern: '^/f2(?:/(.*))/?$' },
127153
]
128154
const userFunctionConfig: Record<string, FunctionConfig> = {
129155
'func-1': {
@@ -185,22 +211,23 @@ test('excludedPath from ISC goes into function_config, TOML goes into routes', (
185211
functions,
186212
userFunctionConfig,
187213
internalFunctionConfig,
214+
featureFlags: { edge_functions_path_urlpattern: true },
188215
})
189216
expect(manifest.routes).toEqual([
190217
{
191218
function: 'customisation',
192-
pattern: '^/showcases/.*/?$',
219+
pattern: '^/showcases(?:/(.*))/?$',
193220
excluded_patterns: [],
194221
},
195222
{
196223
function: 'customisation',
197-
pattern: '^/checkout/.*/?$',
198-
excluded_patterns: ['^/.*/terms-and-conditions/?$'],
224+
pattern: '^/checkout(?:/(.*))/?$',
225+
excluded_patterns: ['^(?:/(.*))/terms-and-conditions/?$'],
199226
},
200227
])
201228
expect(manifest.function_config).toEqual({
202229
customisation: {
203-
excluded_patterns: ['^/.*\\.css/?$', '^/.*\\.jpg/?$'],
230+
excluded_patterns: ['^(?:/(.*))\\.css/?$', '^(?:/(.*))\\.jpg/?$'],
204231
},
205232
})
206233

@@ -220,7 +247,7 @@ test('Includes failure modes in manifest', () => {
220247
]
221248
const declarations: Declaration[] = [
222249
{ function: 'func-1', path: '/f1/*' },
223-
{ function: 'func-2', pattern: '^/f2/.*/?$' },
250+
{ function: 'func-2', pattern: '^/f2(?:/(.*))/?$' },
224251
]
225252
const userFunctionConfig: Record<string, FunctionConfig> = {
226253
'func-1': {
@@ -330,17 +357,28 @@ test('Generates a manifest with layers', () => {
330357
{ function: 'func-2', path: '/f2/*' },
331358
]
332359
const expectedRoutes = [
333-
{ function: 'func-1', pattern: '^/f1/.*/?$', excluded_patterns: [] },
334-
{ function: 'func-2', pattern: '^/f2/.*/?$', excluded_patterns: [] },
360+
{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [] },
361+
{ function: 'func-2', pattern: '^/f2(?:/(.*))/?$', excluded_patterns: [] },
335362
]
336363
const layers = [
337364
{
338365
name: 'onion',
339366
flag: 'edge_functions_onion_layer',
340367
},
341368
]
342-
const manifest1 = generateManifest({ bundles: [], declarations, functions })
343-
const manifest2 = generateManifest({ bundles: [], declarations, functions, layers })
369+
const manifest1 = generateManifest({
370+
bundles: [],
371+
declarations,
372+
functions,
373+
featureFlags: { edge_functions_path_urlpattern: true },
374+
})
375+
const manifest2 = generateManifest({
376+
bundles: [],
377+
declarations,
378+
functions,
379+
layers,
380+
featureFlags: { edge_functions_path_urlpattern: true },
381+
})
344382

345383
expect(manifest1.routes).toEqual(expectedRoutes)
346384
expect(manifest1.layers).toEqual([])

node/manifest.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FeatureFlags } from './feature_flags.js'
1111
import { Layer } from './layer.js'
1212
import { getPackageVersion } from './package_json.js'
1313
import { nonNullable } from './utils/non_nullable.js'
14+
import { ExtendedURLPattern } from './utils/urlpattern.js'
1415

1516
interface Route {
1617
function: string
@@ -77,10 +78,11 @@ const addExcludedPatterns = (
7778
name: string,
7879
manifestFunctionConfig: Record<string, EdgeFunctionConfig>,
7980
excludedPath?: Path | Path[],
81+
featureFlags?: FeatureFlags,
8082
) => {
8183
if (excludedPath) {
8284
const paths = Array.isArray(excludedPath) ? excludedPath : [excludedPath]
83-
const excludedPatterns = paths.map(pathToRegularExpression).map(serializePattern)
85+
const excludedPatterns = paths.map((path) => pathToRegularExpression(path, featureFlags)).map(serializePattern)
8486

8587
manifestFunctionConfig[name].excluded_patterns.push(...excludedPatterns)
8688
}
@@ -107,7 +109,7 @@ const generateManifest = ({
107109
if (manifestFunctionConfig[name] === undefined) {
108110
continue
109111
}
110-
addExcludedPatterns(name, manifestFunctionConfig, excludedPath)
112+
addExcludedPatterns(name, manifestFunctionConfig, excludedPath, featureFlags)
111113

112114
manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError }
113115
}
@@ -117,7 +119,7 @@ const generateManifest = ({
117119
if (manifestFunctionConfig[name] === undefined) {
118120
continue
119121
}
120-
addExcludedPatterns(name, manifestFunctionConfig, excludedPath)
122+
addExcludedPatterns(name, manifestFunctionConfig, excludedPath, featureFlags)
121123

122124
manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError, ...rest }
123125
}
@@ -129,11 +131,8 @@ const generateManifest = ({
129131
return
130132
}
131133

132-
const pattern = getRegularExpression(declaration, featureFlags?.edge_functions_fail_unsupported_regex)
133-
const excludedPattern = getExcludedRegularExpressions(
134-
declaration,
135-
featureFlags?.edge_functions_fail_unsupported_regex,
136-
)
134+
const pattern = getRegularExpression(declaration, featureFlags)
135+
const excludedPattern = getExcludedRegularExpressions(declaration, featureFlags)
137136

138137
const route: Route = {
139138
function: func.name,
@@ -164,7 +163,22 @@ const generateManifest = ({
164163
return manifest
165164
}
166165

167-
const pathToRegularExpression = (path: string) => {
166+
const pathToRegularExpression = (path: string, featureFlags?: FeatureFlags) => {
167+
if (featureFlags?.edge_functions_path_urlpattern) {
168+
const pattern = new ExtendedURLPattern({ pathname: path })
169+
170+
// Removing the `^` and `$` delimiters because we'll need to modify what's
171+
// between them.
172+
const source = pattern.regexp.pathname.source.slice(1, -1)
173+
174+
// Wrapping the expression source with `^` and `$`. Also, adding an optional
175+
// trailing slash, so that a declaration of `path: "/foo"` matches requests
176+
// for both `/foo` and `/foo/`.
177+
const normalizedSource = `^${source}\\/?$`
178+
179+
return normalizedSource
180+
}
181+
168182
// We use the global flag so that `globToRegExp` will not wrap the expression
169183
// with `^` and `$`. We'll do that ourselves.
170184
const regularExpression = globToRegExp(path, { flags: 'g' })
@@ -177,13 +191,13 @@ const pathToRegularExpression = (path: string) => {
177191
return normalizedSource
178192
}
179193

180-
const getRegularExpression = (declaration: Declaration, failUnsupportedRegex = false): string => {
194+
const getRegularExpression = (declaration: Declaration, featureFlags?: FeatureFlags): string => {
181195
if ('pattern' in declaration) {
182196
try {
183197
return parsePattern(declaration.pattern)
184198
} catch (error: unknown) {
185199
// eslint-disable-next-line max-depth
186-
if (failUnsupportedRegex) {
200+
if (featureFlags?.edge_functions_fail_unsupported_regex) {
187201
throw new Error(
188202
`Could not parse path declaration of function '${declaration.function}': ${(error as Error).message}`,
189203
)
@@ -199,10 +213,10 @@ const getRegularExpression = (declaration: Declaration, failUnsupportedRegex = f
199213
}
200214
}
201215

202-
return pathToRegularExpression(declaration.path)
216+
return pathToRegularExpression(declaration.path, featureFlags)
203217
}
204218

205-
const getExcludedRegularExpressions = (declaration: Declaration, failUnsupportedRegex = false): string[] => {
219+
const getExcludedRegularExpressions = (declaration: Declaration, featureFlags?: FeatureFlags): string[] => {
206220
if ('excludedPattern' in declaration && declaration.excludedPattern) {
207221
const excludedPatterns: string[] = Array.isArray(declaration.excludedPattern)
208222
? declaration.excludedPattern
@@ -211,7 +225,7 @@ const getExcludedRegularExpressions = (declaration: Declaration, failUnsupported
211225
try {
212226
return parsePattern(excludedPattern)
213227
} catch (error: unknown) {
214-
if (failUnsupportedRegex) {
228+
if (featureFlags?.edge_functions_fail_unsupported_regex) {
215229
throw new Error(
216230
`Could not parse path declaration of function '${declaration.function}': ${(error as Error).message}`,
217231
)
@@ -230,7 +244,7 @@ const getExcludedRegularExpressions = (declaration: Declaration, failUnsupported
230244

231245
if ('path' in declaration && declaration.excludedPath) {
232246
const paths = Array.isArray(declaration.excludedPath) ? declaration.excludedPath : [declaration.excludedPath]
233-
return paths.map(pathToRegularExpression)
247+
return paths.map((path) => pathToRegularExpression(path, featureFlags))
234248
}
235249

236250
return []

node/utils/urlpattern.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { URLPattern } from 'urlpattern-polyfill'
2+
3+
export class ExtendedURLPattern extends URLPattern {
4+
// @ts-expect-error Internal property that the underlying class is using but
5+
// not exposing.
6+
regexp: Record<string, RegExp>
7+
}

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"regexp-tree": "^0.1.24",
9494
"semver": "^7.3.8",
9595
"tmp-promise": "^3.0.3",
96+
"urlpattern-polyfill": "8.0.2",
9697
"uuid": "^9.0.0"
9798
}
9899
}

0 commit comments

Comments
 (0)