@netlify/edge-bundler 4.1.0 → 4.3.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.
package/deno/config.ts CHANGED
@@ -11,6 +11,10 @@ try {
11
11
  Deno.exit(exitCodes.ImportError)
12
12
  }
13
13
 
14
+ if (typeof func.default !== 'function') {
15
+ Deno.exit(exitCodes.InvalidDefaultExport)
16
+ }
17
+
14
18
  if (func.config === undefined) {
15
19
  Deno.exit(exitCodes.NoConfig)
16
20
  }
@@ -1,4 +1,5 @@
1
- export const PUBLIC_SPECIFIER = "netlify:edge";
2
- export const STAGE1_SPECIFIER = "netlify:bootstrap-stage1";
3
- export const STAGE2_SPECIFIER = "netlify:bootstrap-stage2";
4
- export const virtualRoot = "file:///root/";
1
+ export const CUSTOM_LAYER_PREFIX = 'layer:'
2
+ export const PUBLIC_SPECIFIER = 'netlify:edge'
3
+ export const STAGE1_SPECIFIER = 'netlify:bootstrap-stage1'
4
+ export const STAGE2_SPECIFIER = 'netlify:bootstrap-stage2'
5
+ export const virtualRoot = 'file:///root/'
@@ -3,7 +3,7 @@ import { build, LoadResponse } from 'https://deno.land/x/eszip@v0.28.0/mod.ts'
3
3
  import * as path from 'https://deno.land/std@0.127.0/path/mod.ts'
4
4
 
5
5
  import type { InputFunction, WriteStage2Options } from '../../shared/stage2.ts'
6
- import { PUBLIC_SPECIFIER, STAGE2_SPECIFIER, virtualRoot } from './consts.ts'
6
+ import { CUSTOM_LAYER_PREFIX, PUBLIC_SPECIFIER, STAGE2_SPECIFIER, virtualRoot } from './consts.ts'
7
7
  import { inlineModule, loadFromVirtualRoot, loadWithRetry } from './common.ts'
8
8
 
9
9
  interface FunctionReference {
@@ -70,7 +70,7 @@ const stage2Loader = (basePath: string, functions: InputFunction[]) => {
70
70
  return inlineModule(specifier, stage2Entry)
71
71
  }
72
72
 
73
- if (specifier === PUBLIC_SPECIFIER) {
73
+ if (specifier === PUBLIC_SPECIFIER || specifier.startsWith(CUSTOM_LAYER_PREFIX)) {
74
74
  return {
75
75
  kind: 'external',
76
76
  specifier,
@@ -88,6 +88,9 @@ const stage2Loader = (basePath: string, functions: InputFunction[]) => {
88
88
  const writeStage2 = async ({ basePath, destPath, functions, importMapURL }: WriteStage2Options) => {
89
89
  const loader = stage2Loader(basePath, functions)
90
90
  const bytes = await build([STAGE2_SPECIFIER], loader, importMapURL)
91
+ const directory = path.dirname(destPath)
92
+
93
+ await Deno.mkdir(directory, { recursive: true })
91
94
 
92
95
  return await Deno.writeFile(destPath, bytes)
93
96
  }
@@ -12,5 +12,8 @@ declare class BundleError extends Error {
12
12
  customErrorInfo: ReturnType<typeof getCustomErrorInfo>;
13
13
  constructor(originalError: Error, options: BundleErrorOptions);
14
14
  }
15
+ /**
16
+ * BundleErrors are treated as user-error, so Netlify Team is not alerted about them.
17
+ */
15
18
  declare const wrapBundleError: (input: unknown, options: BundleErrorOptions) => unknown;
16
19
  export { BundleError, wrapBundleError };
@@ -15,6 +15,9 @@ class BundleError extends Error {
15
15
  Object.setPrototypeOf(this, BundleError.prototype);
16
16
  }
17
17
  }
18
+ /**
19
+ * BundleErrors are treated as user-error, so Netlify Team is not alerted about them.
20
+ */
18
21
  const wrapBundleError = (input, options) => {
19
22
  if (input instanceof Error) {
20
23
  return new BundleError(input, options);
@@ -17,7 +17,7 @@ interface BundleOptions {
17
17
  onBeforeDownload?: OnBeforeDownloadHook;
18
18
  systemLogger?: LogFunction;
19
19
  }
20
- declare const bundle: (sourceDirectories: string[], distDirectory: string, tomlDeclarations?: Declaration[], { basePath: inputBasePath, cacheDirectory, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMaps, onAfterDownload, onBeforeDownload, systemLogger, }?: BundleOptions) => Promise<{
20
+ declare const bundle: (sourceDirectories: string[], distDirectory: string, tomlDeclarations?: Declaration[], { basePath: inputBasePath, cacheDirectory, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMaps, layers, onAfterDownload, onBeforeDownload, systemLogger, }?: BundleOptions) => Promise<{
21
21
  functions: EdgeFunction[];
22
22
  manifest: import("./manifest.js").Manifest;
23
23
  }>;
@@ -35,7 +35,7 @@ const createBundle = ({ basePath, buildID, debug, deno, distDirectory, functions
35
35
  importMap,
36
36
  });
37
37
  };
38
- const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], { basePath: inputBasePath, cacheDirectory, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMaps, onAfterDownload, onBeforeDownload, systemLogger, } = {}) => {
38
+ const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], { basePath: inputBasePath, cacheDirectory, debug, distImportMapPath, featureFlags: inputFeatureFlags, importMaps, layers, onAfterDownload, onBeforeDownload, systemLogger, } = {}) => {
39
39
  const logger = getLogger(systemLogger, debug);
40
40
  const featureFlags = getFlags(inputFeatureFlags);
41
41
  const options = {
@@ -90,6 +90,7 @@ const bundle = async (sourceDirectories, distDirectory, tomlDeclarations = [], {
90
90
  declarations,
91
91
  distDirectory,
92
92
  functions,
93
+ layers,
93
94
  });
94
95
  if (distImportMapPath) {
95
96
  await importMap.writeToFile(distImportMapPath);
@@ -132,6 +132,28 @@ test('Adds a custom error property to user errors during bundling', async () =>
132
132
  });
133
133
  }
134
134
  });
135
+ test('Prints a nice error message when user tries importing NPM module', async () => {
136
+ expect.assertions(2);
137
+ const sourceDirectory = resolve(fixturesDir, 'imports_npm_module', 'functions');
138
+ const tmpDir = await tmp.dir();
139
+ const declarations = [
140
+ {
141
+ function: 'func1',
142
+ path: '/func1',
143
+ },
144
+ ];
145
+ try {
146
+ await bundle([sourceDirectory], tmpDir.path, declarations, {
147
+ featureFlags: {
148
+ edge_functions_produce_eszip: true,
149
+ },
150
+ });
151
+ }
152
+ catch (error) {
153
+ expect(error).toBeInstanceOf(BundleError);
154
+ expect(error.message).toEqual(`It seems like you're trying to import an npm module. This is only supported in Deno via CDNs like esm.sh. Have you tried 'import mod from "https://esm.sh/p-retry"'?`);
155
+ }
156
+ });
135
157
  test('Does not add a custom error property to system errors during bundling', async () => {
136
158
  expect.assertions(1);
137
159
  try {
@@ -278,3 +300,32 @@ test('Ignores any user-defined `deno.json` files', async () => {
278
300
  })).not.toThrow();
279
301
  await deleteAsync([tmpDir.path, denoConfigPath, importMapFile.path], { force: true });
280
302
  });
303
+ test('Processes a function that imports a custom layer', async () => {
304
+ const sourceDirectory = resolve(fixturesDir, 'with_layers', 'functions');
305
+ const tmpDir = await tmp.dir();
306
+ const declarations = [
307
+ {
308
+ function: 'func1',
309
+ path: '/func1',
310
+ },
311
+ ];
312
+ const layer = { name: 'test', flag: 'edge-functions-layer-test' };
313
+ const result = await bundle([sourceDirectory], tmpDir.path, declarations, {
314
+ basePath: fixturesDir,
315
+ featureFlags: {
316
+ edge_functions_produce_eszip: true,
317
+ },
318
+ layers: [layer],
319
+ });
320
+ const generatedFiles = await fs.readdir(tmpDir.path);
321
+ expect(result.functions.length).toBe(1);
322
+ expect(generatedFiles.length).toBe(2);
323
+ const manifestFile = await fs.readFile(resolve(tmpDir.path, 'manifest.json'), 'utf8');
324
+ const manifest = JSON.parse(manifestFile);
325
+ const { bundles, layers } = manifest;
326
+ expect(bundles.length).toBe(1);
327
+ expect(bundles[0].format).toBe('eszip2');
328
+ expect(generatedFiles.includes(bundles[0].asset)).toBe(true);
329
+ expect(layers).toEqual([layer]);
330
+ await fs.rmdir(tmpDir.path, { recursive: true });
331
+ });
@@ -12,6 +12,7 @@ var ConfigExitCode;
12
12
  ConfigExitCode[ConfigExitCode["InvalidExport"] = 4] = "InvalidExport";
13
13
  ConfigExitCode[ConfigExitCode["RuntimeError"] = 5] = "RuntimeError";
14
14
  ConfigExitCode[ConfigExitCode["SerializationError"] = 6] = "SerializationError";
15
+ ConfigExitCode[ConfigExitCode["InvalidDefaultExport"] = 7] = "InvalidDefaultExport";
15
16
  })(ConfigExitCode || (ConfigExitCode = {}));
16
17
  const getConfigExtractor = () => {
17
18
  const packagePath = getPackagePath();
@@ -82,6 +83,8 @@ const logConfigError = (func, exitCode, stderr, log) => {
82
83
  case ConfigExitCode.SerializationError:
83
84
  log.user(`'config' function in edge function at '${func.path}' must return an object with primitive values only`);
84
85
  break;
86
+ case ConfigExitCode.InvalidDefaultExport:
87
+ throw new Error(`Default export in '${func.path}' must be a function. More on the Edge Functions API at https://ntl.fyi/edge-api.`);
85
88
  default:
86
89
  log.user(`Could not load configuration for edge function at '${func.path}'`);
87
90
  log.user(stderr);
@@ -15,6 +15,7 @@ const importMapFile = {
15
15
  'alias:helper': pathToFileURL(join(fixturesDir, 'helper.ts')).toString(),
16
16
  },
17
17
  };
18
+ const invalidDefaultExportErr = (path) => `Default export in '${path}' must be a function. More on the Edge Functions API at https://ntl.fyi/edge-api.`;
18
19
  test('`getFunctionConfig` extracts configuration properties from function file', async () => {
19
20
  const { path: tmpDir } = await tmp.dir();
20
21
  const deno = new DenoBridge({
@@ -184,3 +185,78 @@ test('Loads function paths from the in-source `config` function', async () => {
184
185
  expect(postCacheRoutes[0]).toEqual({ function: 'user-func4', pattern: '^/user-func4/?$' });
185
186
  await fs.rmdir(tmpDir.path, { recursive: true });
186
187
  });
188
+ test('Passes validation if default export exists and is a function', async () => {
189
+ const { path: tmpDir } = await tmp.dir();
190
+ const deno = new DenoBridge({
191
+ cacheDirectory: tmpDir,
192
+ });
193
+ const func = {
194
+ name: 'func1',
195
+ source: `
196
+ const func = () => new Response("Hello world!")
197
+ export default func
198
+ `,
199
+ };
200
+ const logger = {
201
+ user: vi.fn().mockResolvedValue(null),
202
+ system: vi.fn().mockResolvedValue(null),
203
+ };
204
+ const path = join(tmpDir, `${func.name}.ts`);
205
+ await fs.writeFile(path, func.source);
206
+ expect(async () => {
207
+ await getFunctionConfig({
208
+ name: func.name,
209
+ path,
210
+ }, new ImportMap([importMapFile]), deno, logger);
211
+ }).not.toThrow();
212
+ await deleteAsync(tmpDir, { force: true });
213
+ });
214
+ test('Fails validation if default export is not function', async () => {
215
+ const { path: tmpDir } = await tmp.dir();
216
+ const deno = new DenoBridge({
217
+ cacheDirectory: tmpDir,
218
+ });
219
+ const func = {
220
+ name: 'func2',
221
+ source: `
222
+ const func = new Response("Hello world!")
223
+ export default func
224
+ `,
225
+ };
226
+ const logger = {
227
+ user: vi.fn().mockResolvedValue(null),
228
+ system: vi.fn().mockResolvedValue(null),
229
+ };
230
+ const path = join(tmpDir, `${func.name}.ts`);
231
+ await fs.writeFile(path, func.source);
232
+ const config = getFunctionConfig({
233
+ name: func.name,
234
+ path,
235
+ }, new ImportMap([importMapFile]), deno, logger);
236
+ await expect(config).rejects.toThrowError(invalidDefaultExportErr(path));
237
+ await deleteAsync(tmpDir, { force: true });
238
+ });
239
+ test('Fails validation if default export is not present', async () => {
240
+ const { path: tmpDir } = await tmp.dir();
241
+ const deno = new DenoBridge({
242
+ cacheDirectory: tmpDir,
243
+ });
244
+ const func = {
245
+ name: 'func3',
246
+ source: `
247
+ export const func = () => new Response("Hello world!")
248
+ `,
249
+ };
250
+ const logger = {
251
+ user: vi.fn().mockResolvedValue(null),
252
+ system: vi.fn().mockResolvedValue(null),
253
+ };
254
+ const path = join(tmpDir, `${func.name}.ts`);
255
+ await fs.writeFile(path, func.source);
256
+ const config = getFunctionConfig({
257
+ name: func.name,
258
+ path,
259
+ }, new ImportMap([importMapFile]), deno, logger);
260
+ await expect(config).rejects.toThrowError(invalidDefaultExportErr(path));
261
+ await deleteAsync(tmpDir, { force: true });
262
+ });
@@ -1,5 +1,6 @@
1
1
  import { join } from 'path';
2
2
  import { wrapBundleError } from '../bundle_error.js';
3
+ import { wrapNpmImportError } from '../npm_import_error.js';
3
4
  import { getPackagePath } from '../package_json.js';
4
5
  import { getFileHash } from '../utils/sha256.js';
5
6
  const bundleESZIP = async ({ basePath, buildID, debug, deno, distDirectory, functions, importMap, }) => {
@@ -23,7 +24,7 @@ const bundleESZIP = async ({ basePath, buildID, debug, deno, distDirectory, func
23
24
  await deno.run(['run', ...flags, bundler, JSON.stringify(payload)], { pipeOutput: true });
24
25
  }
25
26
  catch (error) {
26
- throw wrapBundleError(error, { format: 'eszip' });
27
+ throw wrapBundleError(wrapNpmImportError(error), { format: 'eszip' });
27
28
  }
28
29
  const hash = await getFileHash(destPath);
29
30
  return { extension, format: 'eszip2', hash };
@@ -4,8 +4,9 @@ import { env } from 'process';
4
4
  import { pathToFileURL } from 'url';
5
5
  import { deleteAsync } from 'del';
6
6
  import { wrapBundleError } from '../bundle_error.js';
7
+ import { wrapNpmImportError } from '../npm_import_error.js';
7
8
  import { getFileHash } from '../utils/sha256.js';
8
- const BOOTSTRAP_LATEST = 'https://63735623f23a7a000841dfb9--edge.netlify.com/bootstrap/index-combined.ts';
9
+ const BOOTSTRAP_LATEST = 'https://63760359c1267a000900d7fb--edge.netlify.com/bootstrap/index-combined.ts';
9
10
  const bundleJS = async ({ buildID, debug, deno, distDirectory, functions, importMap, }) => {
10
11
  const stage2Path = await generateStage2({ distDirectory, functions, fileName: `${buildID}-pre.js` });
11
12
  const extension = '.js';
@@ -18,7 +19,7 @@ const bundleJS = async ({ buildID, debug, deno, distDirectory, functions, import
18
19
  await deno.run(['bundle', ...flags, stage2Path, jsBundlePath], { pipeOutput: true });
19
20
  }
20
21
  catch (error) {
21
- throw wrapBundleError(error, { format: 'javascript' });
22
+ throw wrapBundleError(wrapNpmImportError(error), { format: 'javascript' });
22
23
  }
23
24
  await fs.unlink(stage2Path);
24
25
  const hash = await getFileHash(jsBundlePath);
@@ -0,0 +1,5 @@
1
+ declare class NPMImportError extends Error {
2
+ constructor(originalError: Error, moduleName: string);
3
+ }
4
+ declare const wrapNpmImportError: (input: unknown) => unknown;
5
+ export { NPMImportError, wrapNpmImportError };
@@ -0,0 +1,20 @@
1
+ class NPMImportError extends Error {
2
+ constructor(originalError, moduleName) {
3
+ super(`It seems like you're trying to import an npm module. This is only supported in Deno via CDNs like esm.sh. Have you tried 'import mod from "https://esm.sh/${moduleName}"'?`);
4
+ this.name = 'NPMImportError';
5
+ this.stack = originalError.stack;
6
+ // https://github.com/microsoft/TypeScript-wiki/blob/8a66ecaf77118de456f7cd9c56848a40fe29b9b4/Breaking-Changes.md#implicit-any-error-raised-for-un-annotated-callback-arguments-with-no-matching-overload-arguments
7
+ Object.setPrototypeOf(this, NPMImportError.prototype);
8
+ }
9
+ }
10
+ const wrapNpmImportError = (input) => {
11
+ if (input instanceof Error) {
12
+ const match = input.message.match(/Relative import path "(.*)" not prefixed with/);
13
+ if (match !== null) {
14
+ const [, moduleName] = match;
15
+ return new NPMImportError(input, moduleName);
16
+ }
17
+ }
18
+ return input;
19
+ };
20
+ export { NPMImportError, wrapNpmImportError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/edge-bundler",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "description": "Intelligently prepare Netlify Edge Functions for deployment",
5
5
  "type": "module",
6
6
  "main": "./dist/node/index.js",