@netlify/edge-bundler 8.9.0 → 8.11.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.
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'buffer';
2
- import fs from 'fs';
2
+ import { rm } from 'fs/promises';
3
3
  import { createRequire } from 'module';
4
4
  import { platform, env } from 'process';
5
5
  import { PassThrough } from 'stream';
@@ -53,7 +53,7 @@ test('Does not inherit environment variables if `extendEnv` is false', async ()
53
53
  });
54
54
  output = output.trim().replace(/\n+/g, '\n');
55
55
  expect(output).toBe('LULU=LALA');
56
- await fs.promises.rmdir(tmpDir.path, { recursive: true });
56
+ await rm(tmpDir.path, { force: true, recursive: true });
57
57
  });
58
58
  test('Does inherit environment variables if `extendEnv` is true', async () => {
59
59
  var _a;
@@ -78,7 +78,7 @@ test('Does inherit environment variables if `extendEnv` is true', async () => {
78
78
  // lets remove holes, split lines and sort lines by name, as different OSes might order them different
79
79
  const environmentVariables = output.trim().replace(/\n+/g, '\n').split('\n').sort();
80
80
  expect(environmentVariables).toEqual(['LULU=LALA', 'TADA=TUDU']);
81
- await fs.promises.rmdir(tmpDir.path, { recursive: true });
81
+ await rm(tmpDir.path, { force: true, recursive: true });
82
82
  });
83
83
  test('Does inherit environment variables if `extendEnv` is not set', async () => {
84
84
  var _a;
@@ -103,5 +103,5 @@ test('Does inherit environment variables if `extendEnv` is not set', async () =>
103
103
  // lets remove holes, split lines and sort lines by name, as different OSes might order them different
104
104
  const environmentVariables = output.trim().replace(/\n+/g, '\n').split('\n').sort();
105
105
  expect(environmentVariables).toEqual(['LULU=LALA', 'TADA=TUDU']);
106
- await fs.promises.rmdir(tmpDir.path, { recursive: true });
106
+ await rm(tmpDir.path, { force: true, recursive: true });
107
107
  });
@@ -1,5 +1,6 @@
1
1
  import { OnAfterDownloadHook, OnBeforeDownloadHook } from './bridge.js';
2
2
  import { Declaration } from './declaration.js';
3
+ import { EdgeFunction } from './edge_function.js';
3
4
  import { FeatureFlags } from './feature_flags.js';
4
5
  import { LogFunction } from './logger.js';
5
6
  interface BundleOptions {
@@ -13,10 +14,26 @@ interface BundleOptions {
13
14
  onAfterDownload?: OnAfterDownloadHook;
14
15
  onBeforeDownload?: OnBeforeDownloadHook;
15
16
  systemLogger?: LogFunction;
17
+ internalSrcFolder?: string;
16
18
  }
17
- declare const bundle: (sourceDirectories: string[], distDirectory: string, tomlDeclarations?: Declaration[], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths, onAfterDownload, onBeforeDownload, systemLogger, }?: BundleOptions) => Promise<{
18
- functions: import("./edge_function.js").EdgeFunction[];
19
+ declare const bundle: (sourceDirectories: string[], distDirectory: string, tomlDeclarations?: Declaration[], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths, onAfterDownload, onBeforeDownload, systemLogger, internalSrcFolder, }?: BundleOptions) => Promise<{
20
+ functions: EdgeFunction[];
19
21
  manifest: import("./manifest.js").Manifest;
20
22
  }>;
23
+ export declare const addGeneratorFieldIfMissing: (declaration: Declaration, functions: EdgeFunction[], internalFunctionsPath?: string) => {
24
+ generator: string | undefined;
25
+ cache?: string | undefined;
26
+ function: string;
27
+ name?: string | undefined;
28
+ path: string;
29
+ excludedPath?: string | undefined;
30
+ } | {
31
+ generator: string | undefined;
32
+ cache?: string | undefined;
33
+ function: string;
34
+ name?: string | undefined;
35
+ pattern: string;
36
+ excludedPattern?: string | undefined;
37
+ };
21
38
  export { bundle };
22
39
  export type { BundleOptions };
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from 'fs';
2
- import { join } from 'path';
2
+ import { join, resolve } from 'path';
3
3
  import commonPathPrefix from 'common-path-prefix';
4
+ import isPathInside from 'is-path-inside';
4
5
  import { v4 as uuidv4 } from 'uuid';
5
6
  import { importMapSpecifier } from '../shared/consts.js';
6
7
  import { DenoBridge } from './bridge.js';
@@ -14,7 +15,7 @@ import { ImportMap } from './import_map.js';
14
15
  import { getLogger } from './logger.js';
15
16
  import { writeManifest } from './manifest.js';
16
17
  import { ensureLatestTypes } from './types.js';
17
- const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths = [], onAfterDownload, onBeforeDownload, systemLogger, } = {}) => {
18
+ const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths = [], onAfterDownload, onBeforeDownload, systemLogger, internalSrcFolder, } = {}) => {
18
19
  const logger = getLogger(systemLogger, debug);
19
20
  const featureFlags = getFlags(inputFeatureFlags);
20
21
  const options = {
@@ -24,7 +25,8 @@ const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], {
24
25
  onAfterDownload,
25
26
  onBeforeDownload,
26
27
  };
27
- if (cacheDirectory !== undefined && featureFlags.edge_functions_cache_deno_dir) {
28
+ const internalFunctionsPath = internalSrcFolder && resolve(internalSrcFolder);
29
+ if (cacheDirectory !== undefined) {
28
30
  options.denoDir = join(cacheDirectory, 'deno_dir');
29
31
  }
30
32
  const deno = new DenoBridge(options);
@@ -59,17 +61,17 @@ const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], {
59
61
  // rename the bundles to their permanent names.
60
62
  await createFinalBundles([functionBundle], distDirectory, buildID);
61
63
  // Retrieving a configuration object for each function.
62
- const functionsConfig = await Promise.all(functions.map((func) => {
63
- if (!featureFlags.edge_functions_config_export) {
64
- return {};
65
- }
66
- return getFunctionConfig(func, importMap, deno, logger);
67
- }));
64
+ const functionsConfig = await Promise.all(functions.map((func) => getFunctionConfig(func, importMap, deno, logger, featureFlags)));
68
65
  // Creating a hash of function names to configuration objects.
69
66
  const functionsWithConfig = functions.reduce((acc, func, index) => ({ ...acc, [func.name]: functionsConfig[index] }), {});
70
67
  // Creating a final declarations array by combining the TOML file with the
71
68
  // deploy configuration API and the in-source configuration.
72
- const declarations = getDeclarationsFromConfig(tomlDeclarations, functionsWithConfig, deployConfig);
69
+ const declarationsFromConfig = getDeclarationsFromConfig(tomlDeclarations, functionsWithConfig, deployConfig);
70
+ // If any declarations are autogenerated and are missing the generator field
71
+ // add a default string.
72
+ const declarations = internalFunctionsPath
73
+ ? declarationsFromConfig.map((declaration) => addGeneratorFieldIfMissing(declaration, functions, internalFunctionsPath))
74
+ : declarationsFromConfig;
73
75
  const manifest = await writeManifest({
74
76
  bundles: [functionBundle],
75
77
  declarations,
@@ -105,4 +107,12 @@ const getBasePath = (sourceDirectories, inputBasePath) => {
105
107
  }
106
108
  return commonPathPrefix(sourceDirectories);
107
109
  };
110
+ export const addGeneratorFieldIfMissing = (declaration, functions, internalFunctionsPath) => {
111
+ var _a;
112
+ const fullFuncPath = (_a = functions === null || functions === void 0 ? void 0 : functions.find((func) => func.name === declaration.function)) === null || _a === void 0 ? void 0 : _a.path;
113
+ // If function path is in the internalFunctionsPath, we assume it is autogenerated.
114
+ const isInternal = Boolean(internalFunctionsPath && fullFuncPath && isPathInside(fullFuncPath, internalFunctionsPath));
115
+ const generatorFallback = isInternal ? 'internalFunc' : undefined;
116
+ return { ...declaration, generator: declaration.generator || generatorFallback };
117
+ };
108
118
  export { bundle };
@@ -147,8 +147,8 @@ test('Does not add a custom error property to system errors during bundling', as
147
147
  expect(error).not.toBeInstanceOf(BundleError);
148
148
  }
149
149
  });
150
- test('Uses the cache directory as the `DENO_DIR` value if the `edge_functions_cache_deno_dir` feature flag is set', async () => {
151
- expect.assertions(6);
150
+ test('Uses the cache directory as the `DENO_DIR` value', async () => {
151
+ expect.assertions(3);
152
152
  const { basePath, cleanup, distPath } = await useFixture('with_import_maps');
153
153
  const sourceDirectory = join(basePath, 'functions');
154
154
  const cacheDir = await tmp.dir();
@@ -163,29 +163,12 @@ test('Uses the cache directory as the `DENO_DIR` value if the `edge_functions_ca
163
163
  cacheDirectory: cacheDir.path,
164
164
  configPath: join(sourceDirectory, 'config.json'),
165
165
  };
166
- // Run #1, feature flag off: The directory should not be populated.
167
- const result1 = await bundle([sourceDirectory], distPath, declarations, options);
168
- const outFiles1 = await fs.readdir(distPath);
169
- expect(result1.functions.length).toBe(1);
170
- expect(outFiles1.length).toBe(2);
171
- try {
172
- await fs.readdir(join(cacheDir.path, 'deno_dir'));
173
- }
174
- catch (error) {
175
- expect(error).toBeInstanceOf(Error);
176
- }
177
- // Run #2, feature flag on: The directory should be populated.
178
- const result2 = await bundle([sourceDirectory], distPath, declarations, {
179
- ...options,
180
- featureFlags: {
181
- edge_functions_cache_deno_dir: true,
182
- },
183
- });
184
- const outFiles2 = await fs.readdir(distPath);
185
- expect(result2.functions.length).toBe(1);
186
- expect(outFiles2.length).toBe(2);
187
- const denoDir2 = await fs.readdir(join(cacheDir.path, 'deno_dir'));
188
- expect(denoDir2.includes('gen')).toBe(true);
166
+ const result = await bundle([sourceDirectory], distPath, declarations, options);
167
+ const outFiles = await fs.readdir(distPath);
168
+ expect(result.functions.length).toBe(1);
169
+ expect(outFiles.length).toBe(2);
170
+ const denoDir = await fs.readdir(join(cacheDir.path, 'deno_dir'));
171
+ expect(denoDir.includes('gen')).toBe(true);
189
172
  await cleanup();
190
173
  });
191
174
  test('Supports import maps with relative paths', async () => {
@@ -264,7 +247,7 @@ test('Processes a function that imports a custom layer', async () => {
264
247
  path: '/func1',
265
248
  },
266
249
  ];
267
- const layer = { name: 'layer:test', flag: 'edge-functions-layer-test' };
250
+ const layer = { name: 'https://edge-function-layer-template.netlify.app/mod.ts', flag: 'edge-functions-layer-test' };
268
251
  const result = await bundle([sourceDirectory], distPath, declarations, {
269
252
  basePath,
270
253
  configPath: join(sourceDirectory, 'config.json'),
@@ -292,17 +275,22 @@ test('Loads declarations and import maps from the deploy configuration', async (
292
275
  const directories = [join(basePath, 'netlify', 'edge-functions'), join(basePath, '.netlify', 'edge-functions')];
293
276
  const result = await bundle(directories, distPath, declarations, {
294
277
  basePath,
295
- configPath: join(basePath, '.netlify', 'edge-functions', 'config.json'),
278
+ configPath: join(basePath, '.netlify', 'edge-functions', 'manifest.json'),
279
+ internalSrcFolder: directories[1],
296
280
  });
297
281
  const generatedFiles = await fs.readdir(distPath);
298
- expect(result.functions.length).toBe(2);
282
+ expect(result.functions.length).toBe(3);
299
283
  expect(generatedFiles.length).toBe(2);
300
284
  const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8');
301
285
  const manifest = JSON.parse(manifestFile);
302
- const { bundles, function_config: functionConfig } = manifest;
286
+ const { routes, bundles, function_config: functionConfig } = manifest;
303
287
  expect(bundles.length).toBe(1);
304
288
  expect(bundles[0].format).toBe('eszip2');
305
289
  expect(generatedFiles.includes(bundles[0].asset)).toBe(true);
290
+ expect(routes[0].generator).toBeUndefined();
291
+ expect(routes[1].name).toBe('Function two');
292
+ expect(routes[1].generator).toBe('@netlify/fake-plugin@1.0.0');
293
+ expect(routes[2].generator).toBe('internalFunc');
306
294
  // respects excludedPath from deploy config
307
295
  expect(functionConfig.func2).toEqual({ excluded_patterns: ['^/func2/skip/?$'] });
308
296
  await cleanup();
@@ -318,7 +306,7 @@ test("Ignores entries in `importMapPaths` that don't point to an existing import
318
306
  helper: pathToFileURL(join(basePath, 'helper.ts')).toString(),
319
307
  },
320
308
  scopes: {
321
- [pathToFileURL(join(sourceDirectory, 'func3')).toString()]: {
309
+ [pathToFileURL(join(sourceDirectory, 'func3/func3.ts')).toString()]: {
322
310
  helper: pathToFileURL(join(basePath, 'helper2.ts')).toString(),
323
311
  },
324
312
  },
@@ -1,14 +1,18 @@
1
1
  import { DenoBridge } from './bridge.js';
2
2
  import { EdgeFunction } from './edge_function.js';
3
+ import { FeatureFlags } from './feature_flags.js';
3
4
  import { ImportMap } from './import_map.js';
4
5
  import { Logger } from './logger.js';
5
6
  export declare const enum Cache {
6
7
  Off = "off",
7
8
  Manual = "manual"
8
9
  }
10
+ export type OnError = 'fail' | 'bypass' | `/${string}`;
11
+ export declare const isValidOnError: (value: unknown) => value is OnError;
9
12
  export interface FunctionConfig {
10
13
  cache?: Cache;
11
14
  path?: string | string[];
12
15
  excludedPath?: string | string[];
16
+ onError?: OnError;
13
17
  }
14
- export declare const getFunctionConfig: (func: EdgeFunction, importMap: ImportMap, deno: DenoBridge, log: Logger) => Promise<FunctionConfig>;
18
+ export declare const getFunctionConfig: (func: EdgeFunction, importMap: ImportMap, deno: DenoBridge, log: Logger, featureFlags: FeatureFlags) => Promise<FunctionConfig>;
@@ -14,12 +14,19 @@ var ConfigExitCode;
14
14
  ConfigExitCode[ConfigExitCode["SerializationError"] = 5] = "SerializationError";
15
15
  ConfigExitCode[ConfigExitCode["InvalidDefaultExport"] = 6] = "InvalidDefaultExport";
16
16
  })(ConfigExitCode || (ConfigExitCode = {}));
17
+ export const isValidOnError = (value) => {
18
+ if (typeof value === 'undefined')
19
+ return true;
20
+ if (typeof value !== 'string')
21
+ return false;
22
+ return value === 'fail' || value === 'bypass' || value.startsWith('/');
23
+ };
17
24
  const getConfigExtractor = () => {
18
25
  const packagePath = getPackagePath();
19
26
  const configExtractorPath = join(packagePath, 'deno', 'config.ts');
20
27
  return configExtractorPath;
21
28
  };
22
- export const getFunctionConfig = async (func, importMap, deno, log) => {
29
+ export const getFunctionConfig = async (func, importMap, deno, log, featureFlags) => {
23
30
  // The extractor is a Deno script that will import the function and run its
24
31
  // `config` export, if one exists.
25
32
  const extractorPath = getConfigExtractor();
@@ -46,38 +53,57 @@ export const getFunctionConfig = async (func, importMap, deno, log) => {
46
53
  JSON.stringify(ConfigExitCode),
47
54
  ], { rejectOnExitCode: false });
48
55
  if (exitCode !== ConfigExitCode.Success) {
49
- logConfigError(func, exitCode, stderr, log);
56
+ handleConfigError(func, exitCode, stderr, log, featureFlags);
50
57
  return {};
51
58
  }
52
59
  if (stdout !== '') {
53
60
  log.user(stdout);
54
61
  }
62
+ let collectorData = {};
55
63
  try {
56
- const collectorData = await fs.readFile(collector.path, 'utf8');
57
- return JSON.parse(collectorData);
64
+ const collectorDataJSON = await fs.readFile(collector.path, 'utf8');
65
+ collectorData = JSON.parse(collectorDataJSON);
58
66
  }
59
67
  catch {
60
- logConfigError(func, ConfigExitCode.UnhandledError, stderr, log);
61
- return {};
68
+ handleConfigError(func, ConfigExitCode.UnhandledError, stderr, log, featureFlags);
62
69
  }
63
70
  finally {
64
71
  await collector.cleanup();
65
72
  }
73
+ if (!isValidOnError(collectorData.onError)) {
74
+ throw new BundleError(new Error(`The 'onError' configuration property in edge function at '${func.path}' must be one of 'fail', 'bypass', or a path starting with '/'. Got '${collectorData.onError}'. More on the Edge Functions API at https://ntl.fyi/edge-api.`));
75
+ }
76
+ return collectorData;
66
77
  };
67
- const logConfigError = (func, exitCode, stderr, log) => {
78
+ const handleConfigError = (func, exitCode, stderr, log, featureFlags) => {
68
79
  switch (exitCode) {
69
80
  case ConfigExitCode.ImportError:
70
- log.user(`Could not load edge function at '${func.path}'`);
71
81
  log.user(stderr);
82
+ if (featureFlags.edge_functions_invalid_config_throw) {
83
+ throw new BundleError(new Error(`Could not load edge function at '${func.path}'. More on the Edge Functions API at https://ntl.fyi/edge-api.`));
84
+ }
85
+ else {
86
+ log.user(`Could not load edge function at '${func.path}'`);
87
+ }
72
88
  break;
73
89
  case ConfigExitCode.NoConfig:
74
90
  log.system(`No in-source config found for edge function at '${func.path}'`);
75
91
  break;
76
92
  case ConfigExitCode.InvalidExport:
77
- log.user(`'config' export in edge function at '${func.path}' must be an object`);
93
+ if (featureFlags.edge_functions_invalid_config_throw) {
94
+ throw new BundleError(new Error(`The 'config' export in edge function at '${func.path}' must be an object. More on the Edge Functions API at https://ntl.fyi/edge-api.`));
95
+ }
96
+ else {
97
+ log.user(`'config' export in edge function at '${func.path}' must be an object`);
98
+ }
78
99
  break;
79
100
  case ConfigExitCode.SerializationError:
80
- log.user(`'config' object in edge function at '${func.path}' must contain primitive values only`);
101
+ if (featureFlags.edge_functions_invalid_config_throw) {
102
+ throw new BundleError(new Error(`The 'config' object in the edge function at '${func.path}' must contain primitive values only. More on the Edge Functions API at https://ntl.fyi/edge-api.`));
103
+ }
104
+ else {
105
+ log.user(`'config' object in edge function at '${func.path}' must contain primitive values only`);
106
+ }
81
107
  break;
82
108
  case ConfigExitCode.InvalidDefaultExport:
83
109
  throw new BundleError(new Error(`Default export in '${func.path}' must be a function. More on the Edge Functions API at https://ntl.fyi/edge-api.`));
@@ -3,7 +3,7 @@ import { join, resolve } from 'path';
3
3
  import { pathToFileURL } from 'url';
4
4
  import { deleteAsync } from 'del';
5
5
  import tmp from 'tmp-promise';
6
- import { test, expect, vi } from 'vitest';
6
+ import { test, expect, vi, describe } from 'vitest';
7
7
  import { fixturesDir, useFixture } from '../test/util.js';
8
8
  import { DenoBridge } from './bridge.js';
9
9
  import { bundle } from './bundler.js';
@@ -16,102 +16,138 @@ const importMapFile = {
16
16
  },
17
17
  };
18
18
  const invalidDefaultExportErr = (path) => `Default export in '${path}' must be a function. More on the Edge Functions API at https://ntl.fyi/edge-api.`;
19
- test('`getFunctionConfig` extracts configuration properties from function file', async () => {
20
- const { path: tmpDir } = await tmp.dir();
21
- const deno = new DenoBridge({
22
- cacheDirectory: tmpDir,
23
- });
24
- const functions = [
25
- // No config
26
- {
27
- expectedConfig: {},
28
- name: 'func1',
29
- source: `export default async () => new Response("Hello from function one")`,
30
- },
31
- // Empty config
32
- {
33
- expectedConfig: {},
34
- name: 'func2',
35
- source: `
19
+ const functions = [
20
+ {
21
+ testName: 'no config',
22
+ expectedConfig: {},
23
+ name: 'func1',
24
+ source: `export default async () => new Response("Hello from function one")`,
25
+ },
26
+ {
27
+ testName: 'empty config',
28
+ expectedConfig: {},
29
+ name: 'func2',
30
+ source: `
36
31
  export default async () => new Response("Hello from function two")
37
32
 
38
33
  export const config = {}
39
34
  `,
35
+ },
36
+ {
37
+ testName: 'config with wrong type (throw)',
38
+ expectedConfig: {},
39
+ name: 'func3',
40
+ source: `
41
+ export default async () => new Response("Hello from function two")
42
+
43
+ export const config = () => ({})
44
+ `,
45
+ error: /^The 'config' export in edge function at '(.*)' must be an object\. More on the Edge Functions API at https:\/\/ntl\.fyi\/edge-api\.$/,
46
+ featureFlags: {
47
+ edge_functions_invalid_config_throw: true,
40
48
  },
41
- // Config with the wrong type
42
- {
43
- expectedConfig: {},
44
- name: 'func3',
45
- source: `
49
+ },
50
+ {
51
+ testName: 'config with wrong type (log)',
52
+ expectedConfig: {},
53
+ name: 'func3',
54
+ source: `
46
55
  export default async () => new Response("Hello from function two")
47
56
 
48
57
  export const config = () => ({})
49
58
  `,
50
- userLog: /^'config' export in edge function at '(.*)' must be an object$/,
59
+ userLog: /^'config' export in edge function at '(.*)' must be an object$/,
60
+ featureFlags: {
61
+ edge_functions_invalid_config_throw: false,
51
62
  },
52
- // Config with a syntax error
53
- {
54
- expectedConfig: {},
55
- name: 'func4',
56
- source: `
63
+ },
64
+ {
65
+ testName: 'config with syntax error (throw)',
66
+ expectedConfig: {},
67
+ name: 'func4',
68
+ source: `
69
+ export default async () => new Response("Hello from function two")
70
+
71
+ export const config
72
+ `,
73
+ error: /^Could not load edge function at '(.*)'\. More on the Edge Functions API at https:\/\/ntl\.fyi\/edge-api\.$/,
74
+ featureFlags: {
75
+ edge_functions_invalid_config_throw: true,
76
+ },
77
+ },
78
+ {
79
+ testName: 'config with syntax error (log)',
80
+ expectedConfig: {},
81
+ name: 'func4',
82
+ source: `
57
83
  export default async () => new Response("Hello from function two")
58
84
 
59
85
  export const config
60
86
  `,
61
- userLog: /^Could not load edge function at '(.*)'$/,
87
+ userLog: /^Could not load edge function at '(.*)'$/,
88
+ featureFlags: {
89
+ edge_functions_invalid_config_throw: false,
62
90
  },
63
- // Config with `path`
64
- {
65
- expectedConfig: { path: '/home' },
66
- name: 'func6',
67
- source: `
91
+ },
92
+ {
93
+ testName: 'config with correct onError',
94
+ expectedConfig: { onError: 'bypass' },
95
+ name: 'func5',
96
+ source: `
97
+ export default async () => new Response("Hello from function two")
98
+ export const config = { onError: "bypass" }
99
+ `,
100
+ },
101
+ {
102
+ testName: 'config with wrong onError',
103
+ name: 'func7',
104
+ source: `
105
+ export default async () => new Response("Hello from function two")
106
+ export const config = { onError: "foo" }
107
+ `,
108
+ error: /The 'onError' configuration property in edge function at .*/,
109
+ },
110
+ {
111
+ testName: 'config with `path`',
112
+ expectedConfig: { path: '/home' },
113
+ name: 'func6',
114
+ source: `
68
115
  export default async () => new Response("Hello from function three")
69
116
 
70
117
  export const config = { path: "/home" }
71
118
  `,
72
- },
73
- ];
74
- for (const func of functions) {
119
+ },
120
+ ];
121
+ describe('`getFunctionConfig` extracts configuration properties from function file', () => {
122
+ test.each(functions)('$testName', async (func) => {
123
+ const { path: tmpDir } = await tmp.dir();
124
+ const deno = new DenoBridge({
125
+ cacheDirectory: tmpDir,
126
+ });
75
127
  const logger = {
76
128
  user: vi.fn().mockResolvedValue(null),
77
129
  system: vi.fn().mockResolvedValue(null),
78
130
  };
79
131
  const path = join(tmpDir, `${func.name}.js`);
80
132
  await fs.writeFile(path, func.source);
81
- const config = await getFunctionConfig({
133
+ const funcCall = () => getFunctionConfig({
82
134
  name: func.name,
83
135
  path,
84
- }, new ImportMap([importMapFile]), deno, logger);
85
- expect(config).toEqual(func.expectedConfig);
86
- if (func.userLog) {
87
- expect(logger.user).toHaveBeenNthCalledWith(1, expect.stringMatching(func.userLog));
136
+ }, new ImportMap([importMapFile]), deno, logger, func.featureFlags || {});
137
+ if (func.error) {
138
+ await expect(funcCall()).rejects.toThrowError(func.error);
139
+ }
140
+ else if (func.userLog) {
141
+ await expect(funcCall()).resolves.not.toThrowError();
142
+ expect(logger.user).toHaveBeenCalledWith(expect.stringMatching(func.userLog));
88
143
  }
89
144
  else {
145
+ const config = await funcCall();
146
+ expect(config).toEqual(func.expectedConfig);
90
147
  expect(logger.user).not.toHaveBeenCalled();
91
148
  }
92
- }
93
- await deleteAsync(tmpDir, { force: true });
94
- });
95
- test('Ignores function paths from the in-source `config` function if the feature flag is off', async () => {
96
- const { basePath, cleanup, distPath } = await useFixture('with_config');
97
- const userDirectory = resolve(basePath, 'netlify', 'edge-functions');
98
- const internalDirectory = resolve(basePath, '.netlify', 'edge-functions');
99
- const declarations = [];
100
- const result = await bundle([internalDirectory, userDirectory], distPath, declarations, {
101
- basePath,
102
- configPath: join(internalDirectory, 'config.json'),
149
+ await deleteAsync(tmpDir, { force: true });
103
150
  });
104
- const generatedFiles = await fs.readdir(distPath);
105
- expect(result.functions.length).toBe(7);
106
- expect(generatedFiles.length).toBe(2);
107
- const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8');
108
- const manifest = JSON.parse(manifestFile);
109
- const { bundles, routes } = manifest;
110
- expect(bundles.length).toBe(1);
111
- expect(bundles[0].format).toBe('eszip2');
112
- expect(generatedFiles.includes(bundles[0].asset)).toBe(true);
113
- expect(routes.length).toBe(0);
114
- await cleanup();
115
151
  });
116
152
  test('Loads function paths from the in-source `config` function', async () => {
117
153
  const { basePath, cleanup, distPath } = await useFixture('with_config');
@@ -130,9 +166,6 @@ test('Loads function paths from the in-source `config` function', async () => {
130
166
  const result = await bundle([internalDirectory, userDirectory], distPath, declarations, {
131
167
  basePath,
132
168
  configPath: join(internalDirectory, 'config.json'),
133
- featureFlags: {
134
- edge_functions_config_export: true,
135
- },
136
169
  });
137
170
  const generatedFiles = await fs.readdir(distPath);
138
171
  expect(result.functions.length).toBe(7);
@@ -176,12 +209,10 @@ test('Passes validation if default export exists and is a function', async () =>
176
209
  };
177
210
  const path = join(tmpDir, `${func.name}.ts`);
178
211
  await fs.writeFile(path, func.source);
179
- expect(async () => {
180
- await getFunctionConfig({
181
- name: func.name,
182
- path,
183
- }, new ImportMap([importMapFile]), deno, logger);
184
- }).not.toThrow();
212
+ await expect(getFunctionConfig({
213
+ name: func.name,
214
+ path,
215
+ }, new ImportMap([importMapFile]), deno, logger, {})).resolves.not.toThrow();
185
216
  await deleteAsync(tmpDir, { force: true });
186
217
  });
187
218
  test('Fails validation if default export is not function', async () => {
@@ -205,7 +236,7 @@ test('Fails validation if default export is not function', async () => {
205
236
  const config = getFunctionConfig({
206
237
  name: func.name,
207
238
  path,
208
- }, new ImportMap([importMapFile]), deno, logger);
239
+ }, new ImportMap([importMapFile]), deno, logger, {});
209
240
  await expect(config).rejects.toThrowError(invalidDefaultExportErr(path));
210
241
  await deleteAsync(tmpDir, { force: true });
211
242
  });
@@ -229,7 +260,7 @@ test('Fails validation if default export is not present', async () => {
229
260
  const config = getFunctionConfig({
230
261
  name: func.name,
231
262
  path,
232
- }, new ImportMap([importMapFile]), deno, logger);
263
+ }, new ImportMap([importMapFile]), deno, logger, {});
233
264
  await expect(config).rejects.toThrowError(invalidDefaultExportErr(path));
234
265
  await deleteAsync(tmpDir, { force: true });
235
266
  });
@@ -26,6 +26,7 @@ test('Returns a config object with declarations, layers, and import map', async
26
26
  {
27
27
  function: 'func1',
28
28
  path: '/func1',
29
+ generator: 'internalFunc',
29
30
  },
30
31
  ],
31
32
  layers: [
@@ -1,4 +1,4 @@
1
- import { promises as fs } from 'fs';
1
+ import { rm } from 'fs/promises';
2
2
  import { platform } from 'process';
3
3
  import { PassThrough } from 'stream';
4
4
  import { execa } from 'execa';
@@ -20,7 +20,7 @@ beforeEach(async (ctx) => {
20
20
  ctx.tmpDir = tmpDir.path;
21
21
  });
22
22
  afterEach(async (ctx) => {
23
- await fs.rmdir(ctx.tmpDir, { recursive: true });
23
+ await rm(ctx.tmpDir, { force: true, recursive: true });
24
24
  });
25
25
  test('tries downloading binary up to 4 times', async (ctx) => {
26
26
  nock.disableNetConnect();
@@ -1,6 +1,12 @@
1
- declare const defaultFlags: Record<string, boolean>;
1
+ declare const defaultFlags: {
2
+ edge_functions_fail_unsupported_regex: boolean;
3
+ edge_functions_invalid_config_throw: boolean;
4
+ };
2
5
  type FeatureFlag = keyof typeof defaultFlags;
3
- type FeatureFlags = Record<FeatureFlag, boolean>;
4
- declare const getFlags: (input?: Record<string, boolean>, flags?: Record<string, boolean>) => FeatureFlags;
6
+ type FeatureFlags = Partial<Record<FeatureFlag, boolean>>;
7
+ declare const getFlags: (input?: Record<string, boolean>, flags?: {
8
+ edge_functions_fail_unsupported_regex: boolean;
9
+ edge_functions_invalid_config_throw: boolean;
10
+ }) => FeatureFlags;
5
11
  export { defaultFlags, getFlags };
6
12
  export type { FeatureFlag, FeatureFlags };
@@ -1,7 +1,6 @@
1
1
  const defaultFlags = {
2
- edge_functions_cache_deno_dir: false,
3
- edge_functions_config_export: false,
4
2
  edge_functions_fail_unsupported_regex: false,
3
+ edge_functions_invalid_config_throw: false,
5
4
  };
6
5
  const getFlags = (input = {}, flags = defaultFlags) => Object.entries(flags).reduce((result, [key, defaultValue]) => ({
7
6
  ...result,
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  import { env } from 'process';
4
4
  import { pathToFileURL } from 'url';
5
5
  import { deleteAsync } from 'del';
6
- const BOOTSTRAP_LATEST = 'https://6400da4c63e35700091e09d5--edge.netlify.com/bootstrap/index-combined.ts';
6
+ const BOOTSTRAP_LATEST = 'https://64071f3033a1800007cc20f8--edge.netlify.com/bootstrap/index-combined.ts';
7
7
  const defaultFormatExportTypeError = (name) => `The Edge Function "${name}" has failed to load. Does it have a function as the default export?`;
8
8
  const defaultFormatImpoortError = (name) => `There was an error with Edge Function "${name}".`;
9
9
  const generateStage2 = async ({ distDirectory, fileName, formatExportTypeError, formatImportError, functions, }) => {
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'buffer';
2
- import fs from 'fs';
2
+ import { rm } from 'fs/promises';
3
3
  import { createRequire } from 'module';
4
4
  import { platform } from 'process';
5
5
  import { PassThrough } from 'stream';
@@ -43,5 +43,5 @@ test('Downloads the Deno CLI on demand and caches it for subsequent calls', asyn
43
43
  expect((_d = output2 === null || output2 === void 0 ? void 0 : output2.stdout) !== null && _d !== void 0 ? _d : '').toMatch(expectedOutput);
44
44
  expect(beforeDownload).toHaveBeenCalledTimes(1);
45
45
  expect(afterDownload).toHaveBeenCalledTimes(1);
46
- await fs.promises.rmdir(tmpDir.path, { recursive: true });
46
+ await rm(tmpDir.path, { force: true, recursive: true });
47
47
  });
@@ -12,6 +12,7 @@ interface Route {
12
12
  }
13
13
  interface EdgeFunctionConfig {
14
14
  excluded_patterns: string[];
15
+ on_error?: string;
15
16
  }
16
17
  interface Manifest {
17
18
  bundler_version: string;
@@ -37,12 +38,6 @@ interface GenerateManifestOptions {
37
38
  importMap?: string;
38
39
  layers?: Layer[];
39
40
  }
40
- interface Route {
41
- function: string;
42
- name?: string;
43
- pattern: string;
44
- generator?: string;
45
- }
46
41
  declare const generateManifest: ({ bundles, declarations, featureFlags, functions, functionConfig, importMap, layers, }: GenerateManifestOptions) => Manifest;
47
42
  interface WriteManifestOptions extends GenerateManifestOptions {
48
43
  distDirectory: string;
@@ -12,7 +12,7 @@ const serializePattern = (pattern) => pattern.replace(/\\\//g, '/');
12
12
  const sanitizeEdgeFunctionConfig = (config) => {
13
13
  const newConfig = {};
14
14
  for (const [name, functionConfig] of Object.entries(config)) {
15
- if (functionConfig.excluded_patterns.length !== 0) {
15
+ if (functionConfig.excluded_patterns.length !== 0 || functionConfig.on_error) {
16
16
  newConfig[name] = functionConfig;
17
17
  }
18
18
  }
@@ -22,12 +22,15 @@ const generateManifest = ({ bundles = [], declarations = [], featureFlags, funct
22
22
  const preCacheRoutes = [];
23
23
  const postCacheRoutes = [];
24
24
  const manifestFunctionConfig = Object.fromEntries(functions.map(({ name }) => [name, { excluded_patterns: [] }]));
25
- for (const [name, { excludedPath }] of Object.entries(functionConfig)) {
25
+ for (const [name, { excludedPath, onError }] of Object.entries(functionConfig)) {
26
26
  if (excludedPath) {
27
27
  const paths = Array.isArray(excludedPath) ? excludedPath : [excludedPath];
28
28
  const excludedPatterns = paths.map(pathToRegularExpression).map(serializePattern);
29
29
  manifestFunctionConfig[name].excluded_patterns.push(...excludedPatterns);
30
30
  }
31
+ if (onError) {
32
+ manifestFunctionConfig[name].on_error = onError;
33
+ }
31
34
  }
32
35
  declarations.forEach((declaration) => {
33
36
  const func = functions.find(({ name }) => declaration.function === name);
@@ -84,6 +84,25 @@ test('Generates a manifest with excluded paths and patterns', () => {
84
84
  });
85
85
  expect(manifest.bundler_version).toBe(env.npm_package_version);
86
86
  });
87
+ test('Includes failure modes in manifest', () => {
88
+ const functions = [
89
+ { name: 'func-1', path: '/path/to/func-1.ts' },
90
+ { name: 'func-2', path: '/path/to/func-2.ts' },
91
+ ];
92
+ const declarations = [
93
+ { function: 'func-1', name: 'Display Name', path: '/f1/*' },
94
+ { function: 'func-2', pattern: '^/f2/.*/?$' },
95
+ ];
96
+ const functionConfig = {
97
+ 'func-1': {
98
+ onError: '/custom-error',
99
+ },
100
+ };
101
+ const manifest = generateManifest({ bundles: [], declarations, functions, functionConfig });
102
+ expect(manifest.function_config).toEqual({
103
+ 'func-1': { excluded_patterns: [], on_error: '/custom-error' },
104
+ });
105
+ });
87
106
  test('Excludes functions for which there are function files but no matching config declarations', () => {
88
107
  const bundle1 = {
89
108
  extension: '.ext2',
@@ -42,7 +42,7 @@ const prepareServer = ({ deno, distDirectory, flags: denoFlags, formatExportType
42
42
  });
43
43
  let functionsConfig = [];
44
44
  if (options.getFunctionsConfig) {
45
- functionsConfig = await Promise.all(functions.map((func) => getFunctionConfig(func, importMap, deno, logger)));
45
+ functionsConfig = await Promise.all(functions.map((func) => getFunctionConfig(func, importMap, deno, logger, {})));
46
46
  }
47
47
  const success = await waitForServer(port, processRef.ps);
48
48
  return {
@@ -1,4 +1,4 @@
1
- import { promises as fs } from 'fs';
1
+ import { readFile, rm, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import nock from 'nock';
4
4
  import tmp from 'tmp-promise';
@@ -18,20 +18,20 @@ test('`ensureLatestTypes` updates the Deno CLI cache if the local version of typ
18
18
  // @ts-expect-error return value not used
19
19
  const mock = vi.spyOn(deno, 'run').mockResolvedValue({});
20
20
  await ensureLatestTypes(deno, testLogger, mockURL);
21
- const versionFile = await fs.readFile(join(tmpDir.path, 'types-version.txt'), 'utf8');
21
+ const versionFile = await readFile(join(tmpDir.path, 'types-version.txt'), 'utf8');
22
22
  expect(latestVersionMock.isDone()).toBe(true);
23
23
  expect(mock).toHaveBeenCalledTimes(1);
24
24
  expect(mock).toHaveBeenCalledWith(['cache', '-r', mockURL]);
25
25
  expect(versionFile).toBe(mockVersion);
26
26
  mock.mockRestore();
27
- await fs.rmdir(tmpDir.path, { recursive: true });
27
+ await rm(tmpDir.path, { force: true, recursive: true });
28
28
  });
29
29
  test('`ensureLatestTypes` does not update the Deno CLI cache if the local version of types is up-to-date', async () => {
30
30
  const mockURL = 'https://edge.netlify';
31
31
  const mockVersion = '987654321';
32
32
  const tmpDir = await tmp.dir();
33
33
  const versionFilePath = join(tmpDir.path, 'types-version.txt');
34
- await fs.writeFile(versionFilePath, mockVersion);
34
+ await writeFile(versionFilePath, mockVersion);
35
35
  const latestVersionMock = nock(mockURL).get('/version.txt').reply(200, mockVersion);
36
36
  const deno = new DenoBridge({
37
37
  cacheDirectory: tmpDir.path,
@@ -43,7 +43,7 @@ test('`ensureLatestTypes` does not update the Deno CLI cache if the local versio
43
43
  expect(latestVersionMock.isDone()).toBe(true);
44
44
  expect(mock).not.toHaveBeenCalled();
45
45
  mock.mockRestore();
46
- await fs.rmdir(tmpDir.path, { recursive: true });
46
+ await rm(tmpDir.path, { force: true, recursive: true });
47
47
  });
48
48
  test('`ensureLatestTypes` does not throw if the types URL is not available', async () => {
49
49
  const mockURL = 'https://edge.netlify';
@@ -59,5 +59,5 @@ test('`ensureLatestTypes` does not throw if the types URL is not available', asy
59
59
  expect(latestVersionMock.isDone()).toBe(true);
60
60
  expect(mock).not.toHaveBeenCalled();
61
61
  mock.mockRestore();
62
- await fs.rmdir(tmpDir.path, { recursive: true });
62
+ await rm(tmpDir.path, { force: true, recursive: true });
63
63
  });
@@ -1,5 +1,9 @@
1
+ import chalk from 'chalk';
1
2
  import { test, expect, describe } from 'vitest';
2
3
  import { validateManifest, ManifestValidationError } from './index.js';
4
+ // We need to disable all color outputs for the tests as they are different on different platforms, CI, etc.
5
+ // This only works if this is the same instance of chalk that better-ajv-errors uses
6
+ chalk.level = 0;
3
7
  // Factory so we have a new object per test
4
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
9
  const getBaseManifest = () => ({
@@ -106,6 +106,9 @@ declare const edgeManifestSchema: {
106
106
  errorMessage: string;
107
107
  };
108
108
  };
109
+ on_error: {
110
+ type: string;
111
+ };
109
112
  };
110
113
  };
111
114
  };
@@ -34,6 +34,7 @@ const functionConfigSchema = {
34
34
  errorMessage: 'excluded_patterns needs to be an array of regex that starts with ^ and ends with $ without any additional slashes before and afterwards',
35
35
  },
36
36
  },
37
+ on_error: { type: 'string' },
37
38
  },
38
39
  };
39
40
  const layersSchema = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/edge-bundler",
3
- "version": "8.9.0",
3
+ "version": "8.11.0",
4
4
  "description": "Intelligently prepare Netlify Edge Functions for deployment",
5
5
  "type": "module",
6
6
  "main": "./dist/node/index.js",
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsc",
17
+ "build:dev": "tsc -w",
17
18
  "prepare": "husky install node_modules/@netlify/eslint-config-node/.husky/",
18
19
  "prepublishOnly": "npm ci && npm test",
19
20
  "prepack": "npm run build",
@@ -28,10 +29,10 @@
28
29
  "format:fix:prettier": "cross-env-shell prettier --write $npm_package_config_prettier",
29
30
  "test:dev": "run-s test:dev:*",
30
31
  "test:ci": "run-s test:ci:*",
31
- "test:dev:vitest": "cross-env FORCE_COLOR=0 vitest run",
32
- "test:dev:vitest:watch": "cross-env FORCE_COLOR=0 vitest watch",
32
+ "test:dev:vitest": "vitest run",
33
+ "test:dev:vitest:watch": "vitest watch",
33
34
  "test:dev:deno": "deno test --allow-all deno",
34
- "test:ci:vitest": "cross-env FORCE_COLOR=0 vitest run",
35
+ "test:ci:vitest": "vitest run",
35
36
  "test:ci:deno": "deno test --allow-all deno",
36
37
  "test:integration": "node --experimental-modules test/integration/test.js"
37
38
  },
@@ -52,7 +53,7 @@
52
53
  "devDependencies": {
53
54
  "@commitlint/cli": "^17.0.0",
54
55
  "@commitlint/config-conventional": "^17.0.0",
55
- "@netlify/eslint-config-node": "^4.1.7",
56
+ "@netlify/eslint-config-node": "^7.0.1",
56
57
  "@types/glob-to-regexp": "^0.4.1",
57
58
  "@types/node": "^14.18.32",
58
59
  "@types/semver": "^7.3.9",
@@ -60,6 +61,7 @@
60
61
  "@types/uuid": "^9.0.0",
61
62
  "@vitest/coverage-c8": "^0.25.0",
62
63
  "archiver": "^5.3.1",
64
+ "chalk": "^4.1.2",
63
65
  "cpy": "^9.0.1",
64
66
  "cross-env": "^7.0.3",
65
67
  "husky": "^8.0.0",
@@ -84,6 +86,7 @@
84
86
  "find-up": "^6.3.0",
85
87
  "get-port": "^6.1.2",
86
88
  "glob-to-regexp": "^0.4.1",
89
+ "is-path-inside": "^4.0.0",
87
90
  "jsonc-parser": "^3.2.0",
88
91
  "node-fetch": "^3.1.1",
89
92
  "node-stream-zip": "^1.15.0",