@netlify/edge-bundler 8.4.0 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,6 +74,7 @@ const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], {
74
74
  bundles: [functionBundle],
75
75
  declarations,
76
76
  distDirectory,
77
+ featureFlags,
77
78
  functions,
78
79
  functionConfig: functionsWithConfig,
79
80
  importMap: importMapSpecifier,
@@ -15,4 +15,5 @@ type DeclarationWithPattern = BaseDeclaration & {
15
15
  };
16
16
  type Declaration = DeclarationWithPath | DeclarationWithPattern;
17
17
  export declare const getDeclarationsFromConfig: (tomlDeclarations: Declaration[], functionsConfig: Record<string, FunctionConfig>, deployConfig: DeployConfig) => Declaration[];
18
+ export declare const parsePattern: (pattern: string) => string;
18
19
  export { Declaration, DeclarationWithPath, DeclarationWithPattern };
@@ -1,3 +1,4 @@
1
+ import regexpAST from 'regexp-tree';
1
2
  export const getDeclarationsFromConfig = (tomlDeclarations, functionsConfig, deployConfig) => {
2
3
  var _a;
3
4
  const declarations = [];
@@ -42,3 +43,31 @@ export const getDeclarationsFromConfig = (tomlDeclarations, functionsConfig, dep
42
43
  }
43
44
  return declarations;
44
45
  };
46
+ // Validates and normalizes a pattern so that it's a valid regular expression
47
+ // in Go, which is the engine used by our edge nodes.
48
+ export const parsePattern = (pattern) => {
49
+ // Escaping forward slashes with back slashes.
50
+ const normalizedPattern = pattern.replace(/\//g, '\\/');
51
+ const regex = regexpAST.transform(`/${normalizedPattern}/`, {
52
+ Assertion(path) {
53
+ // Lookaheads are not supported. If we find one, throw an error.
54
+ if (path.node.kind === 'Lookahead') {
55
+ throw new Error('Regular expressions with lookaheads are not supported');
56
+ }
57
+ },
58
+ Group(path) {
59
+ // Named captured groups in JavaScript use a different syntax than in Go.
60
+ // If we find one, convert it to an unnamed capture group, which is valid
61
+ // in both engines.
62
+ if ('name' in path.node && path.node.name !== undefined) {
63
+ path.replace({
64
+ ...path.node,
65
+ name: undefined,
66
+ nameRaw: undefined,
67
+ });
68
+ }
69
+ },
70
+ });
71
+ // Strip leading and forward slashes.
72
+ return regex.toString().slice(1, -1);
73
+ };
@@ -1,6 +1,7 @@
1
1
  const defaultFlags = {
2
2
  edge_functions_cache_deno_dir: false,
3
3
  edge_functions_config_export: false,
4
+ edge_functions_fail_unsupported_regex: false,
4
5
  };
5
6
  const getFlags = (input = {}, flags = defaultFlags) => Object.entries(flags).reduce((result, [key, defaultValue]) => ({
6
7
  ...result,
@@ -1,7 +1,8 @@
1
1
  import type { Bundle } from './bundle.js';
2
2
  import { FunctionConfig } from './config.js';
3
- import type { Declaration } from './declaration.js';
3
+ import { Declaration } from './declaration.js';
4
4
  import { EdgeFunction } from './edge_function.js';
5
+ import { FeatureFlags } from './feature_flags.js';
5
6
  import { Layer } from './layer.js';
6
7
  interface Route {
7
8
  function: string;
@@ -29,6 +30,7 @@ interface Manifest {
29
30
  interface GenerateManifestOptions {
30
31
  bundles?: Bundle[];
31
32
  declarations?: Declaration[];
33
+ featureFlags?: FeatureFlags;
32
34
  functions: EdgeFunction[];
33
35
  functionConfig?: Record<string, FunctionConfig>;
34
36
  importMap?: string;
@@ -39,7 +41,7 @@ interface Route {
39
41
  name?: string;
40
42
  pattern: string;
41
43
  }
42
- declare const generateManifest: ({ bundles, declarations, functions, functionConfig, importMap, layers, }: GenerateManifestOptions) => Manifest;
44
+ declare const generateManifest: ({ bundles, declarations, featureFlags, functions, functionConfig, importMap, layers, }: GenerateManifestOptions) => Manifest;
43
45
  interface WriteManifestOptions extends GenerateManifestOptions {
44
46
  distDirectory: string;
45
47
  }
@@ -1,9 +1,14 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { join } from 'path';
3
3
  import globToRegExp from 'glob-to-regexp';
4
+ import { parsePattern } from './declaration.js';
4
5
  import { getPackageVersion } from './package_json.js';
5
6
  import { nonNullable } from './utils/non_nullable.js';
6
- const serializePattern = (regex) => regex.source.replace(/\\\//g, '/');
7
+ // JavaScript regular expressions are converted to strings with leading and
8
+ // trailing slashes, so any slashes inside the expression itself are escaped
9
+ // as `//`. This function deserializes that back into a single slash, which
10
+ // is the format we want to use in the manifest.
11
+ const serializePattern = (pattern) => pattern.replace(/\\\//g, '/');
7
12
  const sanitizeEdgeFunctionConfig = (config) => {
8
13
  const newConfig = {};
9
14
  for (const [name, functionConfig] of Object.entries(config)) {
@@ -13,7 +18,7 @@ const sanitizeEdgeFunctionConfig = (config) => {
13
18
  }
14
19
  return newConfig;
15
20
  };
16
- const generateManifest = ({ bundles = [], declarations = [], functions, functionConfig = {}, importMap, layers = [], }) => {
21
+ const generateManifest = ({ bundles = [], declarations = [], featureFlags, functions, functionConfig = {}, importMap, layers = [], }) => {
17
22
  const preCacheRoutes = [];
18
23
  const postCacheRoutes = [];
19
24
  const manifestFunctionConfig = Object.fromEntries(functions.map(({ name }) => [name, { excluded_patterns: [] }]));
@@ -29,13 +34,13 @@ const generateManifest = ({ bundles = [], declarations = [], functions, function
29
34
  if (func === undefined) {
30
35
  return;
31
36
  }
32
- const pattern = getRegularExpression(declaration);
37
+ const pattern = getRegularExpression(declaration, featureFlags === null || featureFlags === void 0 ? void 0 : featureFlags.edge_functions_fail_unsupported_regex);
33
38
  const route = {
34
39
  function: func.name,
35
40
  name: declaration.name,
36
41
  pattern: serializePattern(pattern),
37
42
  };
38
- const excludedPattern = getExcludedRegularExpression(declaration);
43
+ const excludedPattern = getExcludedRegularExpression(declaration, featureFlags === null || featureFlags === void 0 ? void 0 : featureFlags.edge_functions_fail_unsupported_regex);
39
44
  if (excludedPattern) {
40
45
  manifestFunctionConfig[func.name].excluded_patterns.push(serializePattern(excludedPattern));
41
46
  }
@@ -69,17 +74,37 @@ const pathToRegularExpression = (path) => {
69
74
  // trailing slash, so that a declaration of `path: "/foo"` matches requests
70
75
  // for both `/foo` and `/foo/`.
71
76
  const normalizedSource = `^${regularExpression.source}\\/?$`;
72
- return new RegExp(normalizedSource);
77
+ return normalizedSource;
73
78
  };
74
- const getRegularExpression = (declaration) => {
79
+ const getRegularExpression = (declaration, failUnsupportedRegex = false) => {
75
80
  if ('pattern' in declaration) {
76
- return new RegExp(declaration.pattern);
81
+ try {
82
+ return parsePattern(declaration.pattern);
83
+ }
84
+ catch (error) {
85
+ // eslint-disable-next-line max-depth
86
+ if (failUnsupportedRegex) {
87
+ throw new Error(`Could not parse path declaration of function '${declaration.function}': ${error.message}`);
88
+ }
89
+ console.warn(`Function '${declaration.function}' uses an unsupported regular expression and will not be invoked: ${error.message}`);
90
+ return declaration.pattern;
91
+ }
77
92
  }
78
93
  return pathToRegularExpression(declaration.path);
79
94
  };
80
- const getExcludedRegularExpression = (declaration) => {
81
- if ('pattern' in declaration && declaration.excludedPattern) {
82
- return new RegExp(declaration.excludedPattern);
95
+ const getExcludedRegularExpression = (declaration, failUnsupportedRegex = false) => {
96
+ if ('excludedPattern' in declaration && declaration.excludedPattern) {
97
+ try {
98
+ return parsePattern(declaration.excludedPattern);
99
+ }
100
+ catch (error) {
101
+ // eslint-disable-next-line max-depth
102
+ if (failUnsupportedRegex) {
103
+ throw new Error(`Could not parse path declaration of function '${declaration.function}': ${error.message}`);
104
+ }
105
+ console.warn(`Function '${declaration.function}' uses an unsupported regular expression and will therefore not be invoked: ${error.message}`);
106
+ return declaration.excludedPattern;
107
+ }
83
108
  }
84
109
  if ('path' in declaration && declaration.excludedPath) {
85
110
  return pathToRegularExpression(declaration.excludedPath);
@@ -1,5 +1,5 @@
1
1
  import { env } from 'process';
2
- import { test, expect } from 'vitest';
2
+ import { test, expect, vi } from 'vitest';
3
3
  import { BundleFormat } from './bundle.js';
4
4
  import { generateManifest } from './manifest.js';
5
5
  test('Generates a manifest with different bundles', () => {
@@ -164,3 +164,35 @@ test('Generates a manifest with layers', () => {
164
164
  expect(manifest2.routes).toEqual(expectedRoutes);
165
165
  expect(manifest2.layers).toEqual(layers);
166
166
  });
167
+ test('Shows a warning if the regular expression contains a negative lookahead', () => {
168
+ const mockConsoleWarn = vi.fn();
169
+ const consoleWarn = console.warn;
170
+ console.warn = mockConsoleWarn;
171
+ const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }];
172
+ const declarations = [{ function: 'func-1', pattern: '^/\\w+(?=\\d)$' }];
173
+ const manifest = generateManifest({
174
+ bundles: [],
175
+ declarations,
176
+ functions,
177
+ });
178
+ console.warn = consoleWarn;
179
+ expect(manifest.routes).toEqual([{ function: 'func-1', pattern: '^/\\w+(?=\\d)$' }]);
180
+ expect(mockConsoleWarn).toHaveBeenCalledOnce();
181
+ expect(mockConsoleWarn).toHaveBeenCalledWith("Function 'func-1' uses an unsupported regular expression and will not be invoked: Regular expressions with lookaheads are not supported");
182
+ });
183
+ test('Throws an error if the regular expression contains a negative lookahead and the `edge_functions_fail_unsupported_regex` flag is set', () => {
184
+ const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }];
185
+ const declarations = [{ function: 'func-1', pattern: '^/\\w+(?=\\d)$' }];
186
+ expect(() => generateManifest({
187
+ bundles: [],
188
+ declarations,
189
+ featureFlags: { edge_functions_fail_unsupported_regex: true },
190
+ functions,
191
+ })).toThrowError(/^Could not parse path declaration of function 'func-1': Regular expressions with lookaheads are not supported$/);
192
+ });
193
+ test('Converts named capture groups to unnamed capture groups in regular expressions', () => {
194
+ const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }];
195
+ const declarations = [{ function: 'func-1', pattern: '^/(?<name>\\w+)$' }];
196
+ const manifest = generateManifest({ bundles: [], declarations, functions });
197
+ expect(manifest.routes).toEqual([{ function: 'func-1', pattern: '^/(\\w+)$' }]);
198
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/edge-bundler",
3
- "version": "8.4.0",
3
+ "version": "8.6.0",
4
4
  "description": "Intelligently prepare Netlify Edge Functions for deployment",
5
5
  "type": "module",
6
6
  "main": "./dist/node/index.js",
@@ -90,6 +90,7 @@
90
90
  "p-retry": "^5.1.1",
91
91
  "p-wait-for": "^4.1.0",
92
92
  "path-key": "^4.0.0",
93
+ "regexp-tree": "^0.1.24",
93
94
  "semver": "^7.3.5",
94
95
  "tmp-promise": "^3.0.3",
95
96
  "uuid": "^9.0.0"
@@ -1,58 +0,0 @@
1
- import { assertEquals, assertStringIncludes } from 'https://deno.land/std@0.127.0/testing/asserts.ts'
2
-
3
- import { join } from 'https://deno.land/std@0.155.0/path/mod.ts'
4
- import { pathToFileURL } from 'https://deno.land/std@0.155.0/node/url.ts'
5
-
6
- import { getStage2Entry } from './stage2.ts'
7
- import { virtualRoot } from './consts.ts'
8
-
9
- Deno.test('`getStage2Entry` returns a valid stage 2 file', async () => {
10
- const directory = await Deno.makeTempDir()
11
- const functions = [
12
- {
13
- name: 'func1',
14
- path: join(directory, 'func1.ts'),
15
- response: 'Hello from function 1',
16
- },
17
- {
18
- name: 'func2',
19
- path: join(directory, 'func2.ts'),
20
- response: 'Hello from function 2',
21
- },
22
- ]
23
-
24
- for (const func of functions) {
25
- const contents = `export default async () => new Response(${JSON.stringify(func.response)})`
26
-
27
- await Deno.writeTextFile(func.path, contents)
28
- }
29
-
30
- const baseURL = pathToFileURL(directory)
31
- const stage2 = getStage2Entry(
32
- directory,
33
- functions.map(({ name, path }) => ({ name, path })),
34
- )
35
-
36
- // Ensuring that the stage 2 paths have the virtual root before we strip it.
37
- assertStringIncludes(stage2, virtualRoot)
38
-
39
- // Replacing the virtual root with the URL of the temporary directory so that
40
- // we can actually import the module.
41
- const normalizedStage2 = stage2.replaceAll(virtualRoot, `${baseURL.href}/`)
42
-
43
- const stage2Path = join(directory, 'stage2.ts')
44
- const stage2URL = pathToFileURL(stage2Path)
45
-
46
- await Deno.writeTextFile(stage2Path, normalizedStage2)
47
-
48
- const mod = await import(stage2URL.href)
49
-
50
- await Deno.remove(directory, { recursive: true })
51
-
52
- for (const func of functions) {
53
- const result = await mod.functions[func.name]()
54
-
55
- assertEquals(await result.text(), func.response)
56
- assertEquals(mod.metadata.functions[func.name].url, pathToFileURL(func.path).toString())
57
- }
58
- })