@netlify/edge-bundler 9.5.0 → 10.0.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.
@@ -0,0 +1,58 @@
1
+ import { assertEquals, assertStringIncludes } from 'https://deno.land/std@0.177.0/testing/asserts.ts'
2
+
3
+ import { join } from 'https://deno.land/std@0.177.0/path/mod.ts'
4
+ import { pathToFileURL } from 'https://deno.land/std@0.177.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
+ })
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { Buffer } from 'buffer';
2
+ import { env } from 'process';
3
+ import fetch, { Headers } from 'node-fetch';
4
+ import { test, expect } from 'vitest';
5
+ import { getBootstrapURL } from './formats/javascript.js';
6
+ test('Imports the bootstrap layer from a valid URL', async () => {
7
+ const importURL = new URL(getBootstrapURL());
8
+ const headers = new Headers();
9
+ // `node-fetch` doesn't let us send credentials as part of the URL, so we
10
+ // have to transform them into an `Authorization` header.
11
+ if (importURL.username) {
12
+ const auth = Buffer.from(`${importURL.username}:${importURL.password}`);
13
+ importURL.username = '';
14
+ importURL.password = '';
15
+ headers.set('Authorization', `Basic ${auth.toString('base64')}`);
16
+ }
17
+ const canonicalURL = importURL.toString();
18
+ const { status } = await fetch(canonicalURL, { headers });
19
+ expect(status).toBe(200);
20
+ });
21
+ test('Imports the bootstrap layer from the URL present in the `NETLIFY_EDGE_BOOTSTRAP` environment variable, if present', () => {
22
+ const mockURL = 'https://example.com/boot.ts';
23
+ env.NETLIFY_EDGE_BOOTSTRAP = mockURL;
24
+ expect(getBootstrapURL()).toBe(mockURL);
25
+ env.NETLIFY_EDGE_BOOTSTRAP = undefined;
26
+ });
@@ -15,11 +15,12 @@ export interface BundleOptions {
15
15
  internalSrcFolder?: string;
16
16
  onAfterDownload?: OnAfterDownloadHook;
17
17
  onBeforeDownload?: OnBeforeDownloadHook;
18
+ rootPath?: string;
18
19
  systemLogger?: LogFunction;
19
20
  userLogger?: LogFunction;
20
21
  vendorDirectory?: string;
21
22
  }
22
- export declare const bundle: (sourceDirectories: string[], distDirectory: string, tomlDeclarations?: Declaration[], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths, internalSrcFolder, onAfterDownload, onBeforeDownload, userLogger, systemLogger, vendorDirectory, }?: BundleOptions) => Promise<{
23
+ export declare const bundle: (sourceDirectories: string[], distDirectory: string, tomlDeclarations?: Declaration[], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths, internalSrcFolder, onAfterDownload, onBeforeDownload, rootPath, userLogger, systemLogger, vendorDirectory, }?: BundleOptions) => Promise<{
23
24
  functions: EdgeFunction[];
24
25
  manifest: import("./manifest.js").Manifest;
25
26
  }>;
@@ -15,7 +15,7 @@ import { getLogger } from './logger.js';
15
15
  import { writeManifest } from './manifest.js';
16
16
  import { vendorNPMSpecifiers } from './npm_dependencies.js';
17
17
  import { ensureLatestTypes } from './types.js';
18
- export const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths = [], internalSrcFolder, onAfterDownload, onBeforeDownload, userLogger, systemLogger, vendorDirectory, } = {}) => {
18
+ export const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], { basePath: inputBasePath, cacheDirectory, configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMapPaths = [], internalSrcFolder, onAfterDownload, onBeforeDownload, rootPath, userLogger, systemLogger, vendorDirectory, } = {}) => {
19
19
  const logger = getLogger(systemLogger, userLogger, debug);
20
20
  const featureFlags = getFlags(inputFeatureFlags);
21
21
  const options = {
@@ -53,6 +53,7 @@ export const bundle = async (sourceDirectories, distDirectory, tomlDeclarations
53
53
  functions,
54
54
  importMap,
55
55
  logger,
56
+ rootPath: rootPath !== null && rootPath !== void 0 ? rootPath : basePath,
56
57
  vendorDirectory,
57
58
  });
58
59
  if (vendor) {
@@ -147,7 +148,7 @@ const createFunctionConfig = ({ internalFunctionsWithConfig, declarations }) =>
147
148
  [functionName]: addGeneratorFallback(mergedConfigFields),
148
149
  };
149
150
  }, {});
150
- const safelyVendorNPMSpecifiers = async ({ basePath, functions, importMap, logger, vendorDirectory, }) => {
151
+ const safelyVendorNPMSpecifiers = async ({ basePath, functions, importMap, logger, rootPath, vendorDirectory, }) => {
151
152
  try {
152
153
  return await vendorNPMSpecifiers({
153
154
  basePath,
@@ -156,6 +157,7 @@ const safelyVendorNPMSpecifiers = async ({ basePath, functions, importMap, logge
156
157
  importMap,
157
158
  logger,
158
159
  referenceTypes: false,
160
+ rootPath,
159
161
  });
160
162
  }
161
163
  catch (error) {
@@ -380,6 +380,34 @@ test('Loads npm modules from bare specifiers', async () => {
380
380
  await cleanup();
381
381
  await rm(vendorDirectory.path, { force: true, recursive: true });
382
382
  });
383
+ test('Loads npm modules in a monorepo setup', async () => {
384
+ const systemLogger = vi.fn();
385
+ const { basePath: rootPath, cleanup, distPath } = await useFixture('monorepo_npm_module');
386
+ const basePath = join(rootPath, 'packages', 'frontend');
387
+ const sourceDirectory = join(basePath, 'functions');
388
+ const declarations = [
389
+ {
390
+ function: 'func1',
391
+ path: '/func1',
392
+ },
393
+ ];
394
+ const vendorDirectory = await tmp.dir();
395
+ await bundle([sourceDirectory], distPath, declarations, {
396
+ basePath,
397
+ importMapPaths: [join(basePath, 'import_map.json')],
398
+ rootPath,
399
+ vendorDirectory: vendorDirectory.path,
400
+ systemLogger,
401
+ });
402
+ expect(systemLogger.mock.calls.find((call) => call[0] === 'Could not track dependencies in edge function:')).toBeUndefined();
403
+ const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8');
404
+ const manifest = JSON.parse(manifestFile);
405
+ const bundlePath = join(distPath, manifest.bundles[0].asset);
406
+ const { func1 } = await runESZIP(bundlePath, vendorDirectory.path);
407
+ expect(func1).toBe(`<parent-1><child-1>JavaScript</child-1></parent-1>, <parent-2><child-2><grandchild-1>APIs<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-2>, <parent-3><child-2><grandchild-1>Markup<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-3>`);
408
+ await cleanup();
409
+ await rm(vendorDirectory.path, { force: true, recursive: true });
410
+ });
383
411
  test('Loads JSON modules', async () => {
384
412
  const { basePath, cleanup, distPath } = await useFixture('imports_json');
385
413
  const sourceDirectory = join(basePath, 'functions');
@@ -0,0 +1,5 @@
1
+ interface DenoConfigFile {
2
+ importMap?: string;
3
+ }
4
+ export declare const getConfig: (basePath?: string) => Promise<DenoConfigFile | undefined>;
5
+ export {};
@@ -0,0 +1,40 @@
1
+ import { promises as fs } from 'fs';
2
+ import { join, resolve } from 'path';
3
+ import { parse as parseJSONC } from 'jsonc-parser';
4
+ import { isNodeError } from './utils/error.js';
5
+ const filenames = ['deno.json', 'deno.jsonc'];
6
+ export const getConfig = async (basePath) => {
7
+ if (basePath === undefined) {
8
+ return;
9
+ }
10
+ for (const filename of filenames) {
11
+ const candidatePath = join(basePath, filename);
12
+ const config = await getConfigFromFile(candidatePath);
13
+ if (config !== undefined) {
14
+ return normalizeConfig(config, basePath);
15
+ }
16
+ }
17
+ };
18
+ const getConfigFromFile = async (filePath) => {
19
+ try {
20
+ const data = await fs.readFile(filePath, 'utf8');
21
+ const config = parseJSONC(data);
22
+ return config;
23
+ }
24
+ catch (error) {
25
+ if (isNodeError(error) && error.code === 'ENOENT') {
26
+ return;
27
+ }
28
+ return {};
29
+ }
30
+ };
31
+ const normalizeConfig = (rawConfig, basePath) => {
32
+ const config = {};
33
+ if (rawConfig.importMap) {
34
+ if (typeof rawConfig.importMap !== 'string') {
35
+ throw new TypeError(`'importMap' property in Deno config must be a string`);
36
+ }
37
+ config.importMap = resolve(basePath, rawConfig.importMap);
38
+ }
39
+ return config;
40
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { promises as fs } from 'fs';
2
+ import { join } from 'path';
3
+ import tmp from 'tmp-promise';
4
+ import { expect, test } from 'vitest';
5
+ import { getConfig } from './deno_config.js';
6
+ test('Returns `undefined` if no config file is found', async () => {
7
+ const { cleanup, path } = await tmp.dir({ unsafeCleanup: true });
8
+ const config = await getConfig(path);
9
+ expect(config).toBeUndefined();
10
+ await cleanup();
11
+ });
12
+ test('Returns an empty object if the config file cannot be parsed', async () => {
13
+ const { cleanup, path } = await tmp.dir({ unsafeCleanup: true });
14
+ const configPath = join(path, 'deno.json');
15
+ await fs.writeFile(configPath, '{');
16
+ const config = await getConfig(path);
17
+ expect(config).toEqual({});
18
+ await cleanup();
19
+ });
20
+ test('Resolves `importMap` into an absolute path', async () => {
21
+ const { cleanup, path } = await tmp.dir({ unsafeCleanup: true });
22
+ const configPath = join(path, 'deno.json');
23
+ const data = JSON.stringify({ importMap: 'import_map.json' });
24
+ await fs.writeFile(configPath, data);
25
+ const config = await getConfig(path);
26
+ expect(config).toEqual({ importMap: join(path, 'import_map.json') });
27
+ await cleanup();
28
+ });
29
+ test('Supports JSONC', async () => {
30
+ const { cleanup, path } = await tmp.dir({ unsafeCleanup: true });
31
+ const configPath = join(path, 'deno.jsonc');
32
+ const data = JSON.stringify({ importMap: 'import_map.json' });
33
+ await fs.writeFile(configPath, `// This is a comment\n${data}`);
34
+ const config = await getConfig(path);
35
+ expect(config).toEqual({ importMap: join(path, 'import_map.json') });
36
+ await cleanup();
37
+ });
@@ -5,5 +5,6 @@ export { Declaration, mergeDeclarations } from './declaration.js';
5
5
  export type { EdgeFunction } from './edge_function.js';
6
6
  export { findFunctions as find } from './finder.js';
7
7
  export { generateManifest } from './manifest.js';
8
+ export type { EdgeFunctionConfig, Manifest } from './manifest.js';
8
9
  export { serve } from './server/server.js';
9
10
  export { validateManifest, ManifestValidationError } from './validation/manifest/index.js';
@@ -11,7 +11,7 @@ interface Route {
11
11
  path?: string;
12
12
  methods?: string[];
13
13
  }
14
- interface EdgeFunctionConfig {
14
+ export interface EdgeFunctionConfig {
15
15
  excluded_patterns: string[];
16
16
  on_error?: string;
17
17
  generator?: string;
@@ -42,7 +42,11 @@ interface GenerateManifestOptions {
42
42
  layers?: Layer[];
43
43
  userFunctionConfig?: Record<string, FunctionConfig>;
44
44
  }
45
- declare const generateManifest: ({ bundles, declarations, functions, userFunctionConfig, internalFunctionConfig, importMap, layers, }: GenerateManifestOptions) => Manifest;
45
+ declare const generateManifest: ({ bundles, declarations, functions, userFunctionConfig, internalFunctionConfig, importMap, layers, }: GenerateManifestOptions) => {
46
+ declarationsWithoutFunction: string[];
47
+ manifest: Manifest;
48
+ unroutedFunctions: string[];
49
+ };
46
50
  interface WriteManifestOptions extends GenerateManifestOptions {
47
51
  distDirectory: string;
48
52
  }
@@ -29,7 +29,7 @@ const sanitizeEdgeFunctionConfig = (config) => {
29
29
  const addExcludedPatterns = (name, manifestFunctionConfig, excludedPath) => {
30
30
  if (excludedPath) {
31
31
  const paths = Array.isArray(excludedPath) ? excludedPath : [excludedPath];
32
- const excludedPatterns = paths.map((path) => pathToRegularExpression(path)).map(serializePattern);
32
+ const excludedPatterns = paths.map(pathToRegularExpression).filter(nonNullable).map(serializePattern);
33
33
  manifestFunctionConfig[name].excluded_patterns.push(...excludedPatterns);
34
34
  }
35
35
  };
@@ -50,6 +50,8 @@ const generateManifest = ({ bundles = [], declarations = [], functions, userFunc
50
50
  const preCacheRoutes = [];
51
51
  const postCacheRoutes = [];
52
52
  const manifestFunctionConfig = Object.fromEntries(functions.map(({ name }) => [name, { excluded_patterns: [] }]));
53
+ const routedFunctions = new Set();
54
+ const declarationsWithoutFunction = new Set();
53
55
  for (const [name, { excludedPath, onError }] of Object.entries(userFunctionConfig)) {
54
56
  // If the config block is for a function that is not defined, discard it.
55
57
  if (manifestFunctionConfig[name] === undefined) {
@@ -69,9 +71,16 @@ const generateManifest = ({ bundles = [], declarations = [], functions, userFunc
69
71
  declarations.forEach((declaration) => {
70
72
  const func = functions.find(({ name }) => declaration.function === name);
71
73
  if (func === undefined) {
74
+ declarationsWithoutFunction.add(declaration.function);
72
75
  return;
73
76
  }
74
77
  const pattern = getRegularExpression(declaration);
78
+ // If there is no `pattern`, the declaration will never be triggered, so we
79
+ // can discard it.
80
+ if (!pattern) {
81
+ return;
82
+ }
83
+ routedFunctions.add(declaration.function);
75
84
  const excludedPattern = getExcludedRegularExpressions(declaration);
76
85
  const route = {
77
86
  function: func.name,
@@ -104,9 +113,13 @@ const generateManifest = ({ bundles = [], declarations = [], functions, userFunc
104
113
  import_map: importMap,
105
114
  function_config: sanitizeEdgeFunctionConfig(manifestFunctionConfig),
106
115
  };
107
- return manifest;
116
+ const unroutedFunctions = functions.filter(({ name }) => !routedFunctions.has(name)).map(({ name }) => name);
117
+ return { declarationsWithoutFunction: [...declarationsWithoutFunction], manifest, unroutedFunctions };
108
118
  };
109
119
  const pathToRegularExpression = (path) => {
120
+ if (!path) {
121
+ return null;
122
+ }
110
123
  try {
111
124
  const pattern = new ExtendedURLPattern({ pathname: path });
112
125
  // Removing the `^` and `$` delimiters because we'll need to modify what's
@@ -149,12 +162,12 @@ const getExcludedRegularExpressions = (declaration) => {
149
162
  }
150
163
  if ('path' in declaration && declaration.excludedPath) {
151
164
  const paths = Array.isArray(declaration.excludedPath) ? declaration.excludedPath : [declaration.excludedPath];
152
- return paths.map((path) => pathToRegularExpression(path));
165
+ return paths.map(pathToRegularExpression).filter(nonNullable);
153
166
  }
154
167
  return [];
155
168
  };
156
169
  const writeManifest = async ({ distDirectory, ...rest }) => {
157
- const manifest = generateManifest(rest);
170
+ const { manifest } = generateManifest(rest);
158
171
  const manifestPath = join(distDirectory, 'manifest.json');
159
172
  await fs.writeFile(manifestPath, JSON.stringify(manifest));
160
173
  return manifest;
@@ -17,7 +17,7 @@ test('Generates a manifest with different bundles', () => {
17
17
  };
18
18
  const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }];
19
19
  const declarations = [{ function: 'func-1', path: '/f1' }];
20
- const manifest = generateManifest({ bundles: [bundle1, bundle2], declarations, functions });
20
+ const { manifest } = generateManifest({ bundles: [bundle1, bundle2], declarations, functions });
21
21
  const expectedBundles = [
22
22
  { asset: bundle1.hash + bundle1.extension, format: bundle1.format },
23
23
  { asset: bundle2.hash + bundle2.extension, format: bundle2.format },
@@ -35,7 +35,7 @@ test('Generates a manifest with display names', () => {
35
35
  name: 'Display Name',
36
36
  },
37
37
  };
38
- const manifest = generateManifest({
38
+ const { manifest } = generateManifest({
39
39
  bundles: [],
40
40
  declarations,
41
41
  functions,
@@ -56,7 +56,7 @@ test('Generates a manifest with a generator field', () => {
56
56
  generator: '@netlify/fake-plugin@1.0.0',
57
57
  },
58
58
  };
59
- const manifest = generateManifest({
59
+ const { manifest } = generateManifest({
60
60
  bundles: [],
61
61
  declarations,
62
62
  functions,
@@ -79,7 +79,7 @@ test('Generates a manifest with excluded paths and patterns', () => {
79
79
  { function: 'func-2', pattern: '^/f2(?:/(.*))/?$', excludedPattern: ['^/f2/exclude$', '^/f2/exclude-as-well$'] },
80
80
  { function: 'func-3', path: '/*', excludedPath: '/**/*.html' },
81
81
  ];
82
- const manifest = generateManifest({
82
+ const { manifest } = generateManifest({
83
83
  bundles: [],
84
84
  declarations,
85
85
  functions,
@@ -111,7 +111,7 @@ test('TOML-defined paths can be combined with ISC-defined excluded paths', () =>
111
111
  const userFunctionConfig = {
112
112
  'func-1': { excludedPath: '/f1/exclude' },
113
113
  };
114
- const manifest = generateManifest({
114
+ const { manifest } = generateManifest({
115
115
  bundles: [],
116
116
  declarations,
117
117
  functions,
@@ -153,7 +153,7 @@ test('Filters out internal in-source configurations in user created functions',
153
153
  generator: 'internal-generator',
154
154
  },
155
155
  };
156
- const manifest = generateManifest({
156
+ const { manifest } = generateManifest({
157
157
  bundles: [],
158
158
  declarations,
159
159
  functions,
@@ -186,7 +186,7 @@ test('excludedPath from ISC goes into function_config, TOML goes into routes', (
186
186
  },
187
187
  };
188
188
  const internalFunctionConfig = {};
189
- const manifest = generateManifest({
189
+ const { manifest } = generateManifest({
190
190
  bundles: [],
191
191
  declarations,
192
192
  functions,
@@ -224,7 +224,7 @@ test('URLPattern named groups are supported', () => {
224
224
  const declarations = [{ function: 'customisation', path: '/products/:productId' }];
225
225
  const userFunctionConfig = {};
226
226
  const internalFunctionConfig = {};
227
- const manifest = generateManifest({
227
+ const { manifest } = generateManifest({
228
228
  bundles: [],
229
229
  declarations,
230
230
  functions,
@@ -269,7 +269,7 @@ test('Includes failure modes in manifest', () => {
269
269
  onError: '/custom-error',
270
270
  },
271
271
  };
272
- const manifest = generateManifest({ bundles: [], declarations, functions, userFunctionConfig });
272
+ const { manifest } = generateManifest({ bundles: [], declarations, functions, userFunctionConfig });
273
273
  expect(manifest.function_config).toEqual({
274
274
  'func-1': { on_error: '/custom-error' },
275
275
  });
@@ -285,7 +285,7 @@ test('Excludes functions for which there are function files but no matching conf
285
285
  { name: 'func-2', path: '/path/to/func-2.ts' },
286
286
  ];
287
287
  const declarations = [{ function: 'func-1', path: '/f1' }];
288
- const manifest = generateManifest({ bundles: [bundle1], declarations, functions });
288
+ const { manifest } = generateManifest({ bundles: [bundle1], declarations, functions });
289
289
  const expectedRoutes = [{ function: 'func-1', pattern: '^/f1/?$', excluded_patterns: [], path: '/f1' }];
290
290
  expect(manifest.routes).toEqual(expectedRoutes);
291
291
  });
@@ -300,14 +300,14 @@ test('Excludes functions for which there are config declarations but no matching
300
300
  { function: 'func-1', path: '/f1' },
301
301
  { function: 'func-2', path: '/f2' },
302
302
  ];
303
- const manifest = generateManifest({ bundles: [bundle1], declarations, functions });
303
+ const { manifest } = generateManifest({ bundles: [bundle1], declarations, functions });
304
304
  const expectedRoutes = [{ function: 'func-2', pattern: '^/f2/?$', excluded_patterns: [], path: '/f2' }];
305
305
  expect(manifest.routes).toEqual(expectedRoutes);
306
306
  });
307
307
  test('Generates a manifest without bundles', () => {
308
308
  const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }];
309
309
  const declarations = [{ function: 'func-1', path: '/f1' }];
310
- const manifest = generateManifest({ bundles: [], declarations, functions });
310
+ const { manifest } = generateManifest({ bundles: [], declarations, functions });
311
311
  const expectedRoutes = [{ function: 'func-1', pattern: '^/f1/?$', excluded_patterns: [], path: '/f1' }];
312
312
  expect(manifest.bundles).toEqual([]);
313
313
  expect(manifest.routes).toEqual(expectedRoutes);
@@ -334,7 +334,7 @@ test('Generates a manifest with pre and post-cache routes', () => {
334
334
  { function: 'func-2', cache: 'not_a_supported_value', path: '/f2' },
335
335
  { function: 'func-3', cache: 'manual', path: '/f3' },
336
336
  ];
337
- const manifest = generateManifest({ bundles: [bundle1, bundle2], declarations, functions });
337
+ const { manifest } = generateManifest({ bundles: [bundle1, bundle2], declarations, functions });
338
338
  const expectedBundles = [
339
339
  { asset: bundle1.hash + bundle1.extension, format: bundle1.format },
340
340
  { asset: bundle2.hash + bundle2.extension, format: bundle2.format },
@@ -370,12 +370,12 @@ test('Generates a manifest with layers', () => {
370
370
  flag: 'edge_functions_onion_layer',
371
371
  },
372
372
  ];
373
- const manifest1 = generateManifest({
373
+ const { manifest: manifest1 } = generateManifest({
374
374
  bundles: [],
375
375
  declarations,
376
376
  functions,
377
377
  });
378
- const manifest2 = generateManifest({
378
+ const { manifest: manifest2 } = generateManifest({
379
379
  bundles: [],
380
380
  declarations,
381
381
  functions,
@@ -398,6 +398,34 @@ test('Throws an error if the regular expression contains a negative lookahead',
398
398
  test('Converts named capture groups to unnamed capture groups in regular expressions', () => {
399
399
  const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }];
400
400
  const declarations = [{ function: 'func-1', pattern: '^/(?<name>\\w+)$' }];
401
- const manifest = generateManifest({ bundles: [], declarations, functions });
401
+ const { manifest } = generateManifest({ bundles: [], declarations, functions });
402
402
  expect(manifest.routes).toEqual([{ function: 'func-1', pattern: '^/(\\w+)$', excluded_patterns: [] }]);
403
403
  });
404
+ test('Returns functions without a declaration and unrouted functions', () => {
405
+ const bundle = {
406
+ extension: '.ext1',
407
+ format: BundleFormat.ESZIP2,
408
+ hash: '123456',
409
+ };
410
+ const functions = [
411
+ { name: 'func-1', path: '/path/to/func-1.ts' },
412
+ { name: 'func-2', path: '/path/to/func-2.ts' },
413
+ { name: 'func-4', path: '/path/to/func-4.ts' },
414
+ ];
415
+ const declarations = [
416
+ { function: 'func-1', path: '/f1' },
417
+ { function: 'func-3', path: '/f3' },
418
+ // @ts-expect-error Error is expected due to neither `path` or `pattern`
419
+ // being present.
420
+ { function: 'func-4', name: 'Some name' },
421
+ ];
422
+ const { declarationsWithoutFunction, manifest, unroutedFunctions } = generateManifest({
423
+ bundles: [bundle],
424
+ declarations,
425
+ functions,
426
+ });
427
+ const expectedRoutes = [{ function: 'func-1', pattern: '^/f1/?$', excluded_patterns: [], path: '/f1' }];
428
+ expect(manifest.routes).toEqual(expectedRoutes);
429
+ expect(declarationsWithoutFunction).toEqual(['func-3']);
430
+ expect(unroutedFunctions).toEqual(['func-2', 'func-4']);
431
+ });
@@ -8,8 +8,9 @@ interface VendorNPMSpecifiersOptions {
8
8
  importMap: ImportMap;
9
9
  logger: Logger;
10
10
  referenceTypes: boolean;
11
+ rootPath?: string;
11
12
  }
12
- export declare const vendorNPMSpecifiers: ({ basePath, directory, functions, importMap, referenceTypes, }: VendorNPMSpecifiersOptions) => Promise<{
13
+ export declare const vendorNPMSpecifiers: ({ basePath, directory, functions, importMap, referenceTypes, rootPath, }: VendorNPMSpecifiersOptions) => Promise<{
13
14
  cleanup: () => Promise<void>;
14
15
  directory: string;
15
16
  importMap: {
@@ -8,6 +8,7 @@ import { build } from 'esbuild';
8
8
  import { findUp } from 'find-up';
9
9
  import getPackageName from 'get-package-name';
10
10
  import tmp from 'tmp-promise';
11
+ import { pathsBetween } from './utils/fs.js';
11
12
  const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.cts', '.mts']);
12
13
  const slugifyPackageName = (specifier) => {
13
14
  if (!specifier.startsWith('@'))
@@ -76,16 +77,12 @@ const banner = {
76
77
  /**
77
78
  * Parses a set of functions and returns a list of specifiers that correspond
78
79
  * to npm modules.
79
- *
80
- * @param basePath Root of the project
81
- * @param functions Functions to parse
82
- * @param importMap Import map to apply when resolving imports
83
- * @param referenceTypes Whether to detect typescript declarations and reference them in the output
84
80
  */
85
- const getNPMSpecifiers = async (basePath, functions, importMap, referenceTypes) => {
81
+ const getNPMSpecifiers = async ({ basePath, functions, importMap, referenceTypes, rootPath, }) => {
86
82
  const baseURL = pathToFileURL(basePath);
87
83
  const { reasons } = await nodeFileTrace(functions, {
88
- base: basePath,
84
+ base: rootPath,
85
+ processCwd: basePath,
89
86
  readFile: async (filePath) => {
90
87
  // If this is a TypeScript file, we need to compile in before we can
91
88
  // parse it.
@@ -165,17 +162,23 @@ const getNPMSpecifiers = async (basePath, functions, importMap, referenceTypes)
165
162
  npmSpecifiersWithExtraneousFiles: [...npmSpecifiersWithExtraneousFiles],
166
163
  };
167
164
  };
168
- export const vendorNPMSpecifiers = async ({ basePath, directory, functions, importMap, referenceTypes, }) => {
165
+ export const vendorNPMSpecifiers = async ({ basePath, directory, functions, importMap, referenceTypes, rootPath = basePath, }) => {
169
166
  // The directories that esbuild will use when resolving Node modules. We must
170
167
  // set these manually because esbuild will be operating from a temporary
171
168
  // directory that will not live inside the project root, so the normal
172
169
  // resolution logic won't work.
173
- const nodePaths = [path.join(basePath, 'node_modules')];
170
+ const nodePaths = pathsBetween(basePath, rootPath).map((directory) => path.join(directory, 'node_modules'));
174
171
  // We need to create some files on disk, which we don't want to write to the
175
172
  // project directory. If a custom directory has been specified, we use it.
176
173
  // Otherwise, create a random temporary directory.
177
174
  const temporaryDirectory = directory ? { path: directory } : await tmp.dir();
178
- const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers(basePath, functions, importMap.getContentsWithURLObjects(), referenceTypes);
175
+ const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers({
176
+ basePath,
177
+ functions,
178
+ importMap: importMap.getContentsWithURLObjects(),
179
+ referenceTypes,
180
+ rootPath,
181
+ });
179
182
  // If we found no specifiers, there's nothing left to do here.
180
183
  if (Object.keys(npmSpecifiers).length === 0) {
181
184
  return;
@@ -1,8 +1,3 @@
1
- /// <reference types="node" />
2
- /// <reference types="node" />
3
- /// <reference types="node" />
4
- /// <reference types="node" />
5
- /// <reference types="node" />
6
1
  import { OnAfterDownloadHook, OnBeforeDownloadHook } from '../bridge.js';
7
2
  import { FunctionConfig } from '../config.js';
8
3
  import type { EdgeFunction } from '../edge_function.js';
@@ -31,11 +26,12 @@ interface ServeOptions {
31
26
  formatExportTypeError?: FormatFunction;
32
27
  formatImportError?: FormatFunction;
33
28
  port: number;
29
+ rootPath?: string;
34
30
  servePath: string;
35
31
  userLogger?: LogFunction;
36
32
  systemLogger?: LogFunction;
37
33
  }
38
- export declare const serve: ({ basePath, bootstrapURL, certificatePath, debug, distImportMapPath, inspectSettings, featureFlags, formatExportTypeError, formatImportError, importMapPaths, onAfterDownload, onBeforeDownload, port, servePath, userLogger, systemLogger, }: ServeOptions) => Promise<(functions: EdgeFunction[], env?: NodeJS.ProcessEnv, options?: StartServerOptions) => Promise<{
34
+ export declare const serve: ({ basePath, bootstrapURL, certificatePath, debug, distImportMapPath, inspectSettings, featureFlags, formatExportTypeError, formatImportError, importMapPaths, onAfterDownload, onBeforeDownload, port, rootPath, servePath, userLogger, systemLogger, }: ServeOptions) => Promise<(functions: EdgeFunction[], env?: NodeJS.ProcessEnv, options?: StartServerOptions) => Promise<{
39
35
  features: Record<string, boolean>;
40
36
  functionsConfig: FunctionConfig[];
41
37
  graph: any;
@@ -18,7 +18,7 @@ const cleanDirectory = async (directory, except) => {
18
18
  const toBeDeleted = files.filter((file) => !except.includes(join(directory, file)));
19
19
  await Promise.all(toBeDeleted.map((file) => unlink(join(directory, file))));
20
20
  };
21
- const prepareServer = ({ basePath, bootstrapURL, deno, distDirectory, distImportMapPath, flags: denoFlags, formatExportTypeError, formatImportError, importMap: baseImportMap, logger, port, }) => {
21
+ const prepareServer = ({ basePath, bootstrapURL, deno, distDirectory, distImportMapPath, flags: denoFlags, formatExportTypeError, formatImportError, importMap: baseImportMap, logger, port, rootPath, }) => {
22
22
  const processRef = {};
23
23
  const startServer = async (functions, env = {}, options = {}) => {
24
24
  if ((processRef === null || processRef === void 0 ? void 0 : processRef.ps) !== undefined) {
@@ -45,6 +45,7 @@ const prepareServer = ({ basePath, bootstrapURL, deno, distDirectory, distImport
45
45
  importMap,
46
46
  logger,
47
47
  referenceTypes: true,
48
+ rootPath,
48
49
  });
49
50
  if (vendor) {
50
51
  features.npmModules = true;
@@ -92,7 +93,85 @@ const prepareServer = ({ basePath, bootstrapURL, deno, distDirectory, distImport
92
93
  };
93
94
  return startServer;
94
95
  };
95
- export const serve = async ({ basePath, bootstrapURL, certificatePath, debug, distImportMapPath, inspectSettings, featureFlags, formatExportTypeError, formatImportError, importMapPaths = [], onAfterDownload, onBeforeDownload, port, servePath, userLogger, systemLogger, }) => {
96
+ export const serve = async ({
97
+ /**
98
+ * Path that is common to all functions. Works as the root directory in the
99
+ * generated bundle.
100
+ */
101
+ basePath,
102
+ /**
103
+ * URL of the bootstrap layer to use.
104
+ */
105
+ bootstrapURL,
106
+ /**
107
+ * Path to an SSL certificate to run the Deno server with.
108
+ */
109
+ certificatePath,
110
+ /**
111
+ * Whether to print verbose information about the server process.
112
+ */
113
+ debug,
114
+ /**
115
+ * Path of an import map file to be generated using the built-in specifiers
116
+ * and any npm modules found during the bundling process.
117
+ */
118
+ distImportMapPath,
119
+ /**
120
+ * Debug settings to use with Deno's `--inspect` and `--inspect-brk` flags.
121
+ */
122
+ inspectSettings,
123
+ /**
124
+ * Map of feature flags.
125
+ */
126
+ featureFlags,
127
+ /**
128
+ * Callback function to be triggered whenever a function has a default export
129
+ * with the wrong type.
130
+ */
131
+ formatExportTypeError,
132
+ /**
133
+ * Callback function to be triggered whenever an error occurs while importing
134
+ * a function.
135
+ */
136
+ formatImportError,
137
+ /**
138
+ * Paths to any additional import map files.
139
+ */
140
+ importMapPaths = [],
141
+ /**
142
+ * Callback function to be triggered after the Deno CLI has been downloaded.
143
+ */
144
+ onAfterDownload,
145
+ /**
146
+ * Callback function to be triggered before we attempt to download the Deno
147
+ * CLI.
148
+ */
149
+ onBeforeDownload,
150
+ /**
151
+ * Port where the server should listen on.
152
+ */
153
+ port,
154
+ /**
155
+ * Root path of the project. Defines a boundary outside of which files or npm
156
+ * modules cannot be included from. This is usually the same as `basePath`,
157
+ * with monorepos being the main exception, where `basePath` maps to the
158
+ * package path and `rootPath` is the repository root.
159
+ */
160
+ rootPath,
161
+ /**
162
+ * Path to write ephemeral files that need to be generated for the server to
163
+ * operate.
164
+ */
165
+ servePath,
166
+ /**
167
+ * Custom logging function to be used for user-facing messages. Defaults to
168
+ * `console.log`.
169
+ */
170
+ userLogger,
171
+ /**
172
+ * Custom logging function to be used for system-level messages.
173
+ */
174
+ systemLogger, }) => {
96
175
  const logger = getLogger(systemLogger, userLogger, debug);
97
176
  const deno = new DenoBridge({
98
177
  debug,
@@ -137,6 +216,7 @@ export const serve = async ({ basePath, bootstrapURL, certificatePath, debug, di
137
216
  importMap,
138
217
  logger,
139
218
  port,
219
+ rootPath,
140
220
  });
141
221
  return server;
142
222
  };
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
+ import process from 'process';
3
4
  import getPort from 'get-port';
4
5
  import fetch from 'node-fetch';
5
6
  import { v4 as uuidv4 } from 'uuid';
@@ -86,3 +87,52 @@ test('Starts a server and serves requests for edge functions', async () => {
86
87
  const identidadeBarrelFile = await readFile(join(servePath, 'bundled-pt-committee__identidade.js'), 'utf-8');
87
88
  expect(identidadeBarrelFile).toContain(`/// <reference types="${join('..', '..', 'node_modules', '@types', 'pt-committee__identidade', 'index.d.ts')}" />`);
88
89
  });
90
+ test('Serves edge functions in a monorepo setup', async () => {
91
+ const rootPath = join(fixturesDir, 'monorepo_npm_module');
92
+ const basePath = join(rootPath, 'packages', 'frontend');
93
+ const paths = {
94
+ user: join(basePath, 'functions'),
95
+ };
96
+ const port = await getPort();
97
+ const importMapPaths = [join(basePath, 'import_map.json')];
98
+ const servePath = join(basePath, '.netlify', 'edge-functions-serve');
99
+ const server = await serve({
100
+ basePath,
101
+ bootstrapURL: 'https://edge.netlify.com/bootstrap/index-combined.ts',
102
+ importMapPaths,
103
+ port,
104
+ rootPath,
105
+ servePath,
106
+ });
107
+ const functions = [
108
+ {
109
+ name: 'func1',
110
+ path: join(paths.user, 'func1.ts'),
111
+ },
112
+ ];
113
+ const options = {
114
+ getFunctionsConfig: true,
115
+ };
116
+ const { features, functionsConfig, graph, success, npmSpecifiersWithExtraneousFiles } = await server(functions, {
117
+ very_secret_secret: 'i love netlify',
118
+ }, options);
119
+ expect(features).toEqual({ npmModules: true });
120
+ expect(success).toBe(true);
121
+ expect(functionsConfig).toEqual([{ path: '/func1' }]);
122
+ expect(npmSpecifiersWithExtraneousFiles).toEqual(['child-1']);
123
+ for (const key in functions) {
124
+ const graphEntry = graph === null || graph === void 0 ? void 0 : graph.modules.some(
125
+ // @ts-expect-error TODO: Module graph is currently not typed
126
+ ({ kind, mediaType, local }) => kind === 'esm' && mediaType === 'TypeScript' && local === functions[key].path);
127
+ expect(graphEntry).toBe(true);
128
+ }
129
+ const response1 = await fetch(`http://0.0.0.0:${port}/func1`, {
130
+ headers: {
131
+ 'x-nf-edge-functions': 'func1',
132
+ 'x-ef-passthrough': 'passthrough',
133
+ 'X-NF-Request-ID': uuidv4(),
134
+ },
135
+ });
136
+ expect(response1.status).toBe(200);
137
+ expect(await response1.text()).toBe(`<parent-1><child-1>JavaScript</child-1></parent-1>, <parent-2><child-2><grandchild-1>APIs<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-2>, <parent-3><child-2><grandchild-1>Markup<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-3>`);
138
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { join } from 'path';
2
+ import getPort from 'get-port';
3
+ import fetch from 'node-fetch';
4
+ import { test, expect } from 'vitest';
5
+ import { fixturesDir } from '../test/util.js';
6
+ import { serve } from './index.js';
7
+ test('bundler serving functionality', async () => {
8
+ const port = await getPort();
9
+ const server = await serve({
10
+ port,
11
+ });
12
+ const { success } = await server([
13
+ {
14
+ name: 'echo_env',
15
+ path: join(fixturesDir, 'serve_test', 'echo_env.ts'),
16
+ },
17
+ ], {
18
+ very_secret_secret: 'i love netlify',
19
+ });
20
+ expect(success).toBe(true);
21
+ const response = await fetch(`http://0.0.0.0:${port}/foo`, {
22
+ headers: {
23
+ 'x-deno-functions': 'echo_env',
24
+ 'x-deno-pass': 'passthrough',
25
+ 'X-NF-Request-ID': 'foo',
26
+ },
27
+ });
28
+ expect(response.status).toBe(200);
29
+ const body = (await response.json());
30
+ expect(body.very_secret_secret).toBe('i love netlify');
31
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns all the directories obtained by traversing `inner` and its parents
3
+ * all the way to `outer`, inclusive.
4
+ */
5
+ export declare const pathsBetween: (inner: string, outer: string, paths?: string[]) => string[];
@@ -0,0 +1,12 @@
1
+ import path from 'path';
2
+ /**
3
+ * Returns all the directories obtained by traversing `inner` and its parents
4
+ * all the way to `outer`, inclusive.
5
+ */
6
+ export const pathsBetween = (inner, outer, paths = []) => {
7
+ const parent = path.dirname(inner);
8
+ if (inner === outer || inner === parent) {
9
+ return [...paths, outer];
10
+ }
11
+ return [inner, ...pathsBetween(parent, outer)];
12
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/edge-bundler",
3
- "version": "9.5.0",
3
+ "version": "10.0.0",
4
4
  "description": "Intelligently prepare Netlify Edge Functions for deployment",
5
5
  "type": "module",
6
6
  "main": "./dist/node/index.js",
@@ -80,7 +80,7 @@
80
80
  "better-ajv-errors": "^1.2.0",
81
81
  "common-path-prefix": "^3.0.0",
82
82
  "env-paths": "^3.0.0",
83
- "esbuild": "0.19.4",
83
+ "esbuild": "0.19.5",
84
84
  "execa": "^6.0.0",
85
85
  "find-up": "^6.3.0",
86
86
  "get-package-name": "^2.2.0",