@netlify/edge-bundler 15.1.0 → 16.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.
@@ -21,6 +21,9 @@ export interface BundleOptions {
21
21
  vendorDirectory?: string;
22
22
  }
23
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<{
24
+ functions: EdgeFunction[];
25
+ manifest: undefined;
26
+ } | {
24
27
  functions: EdgeFunction[];
25
28
  manifest: import("./manifest.js").Manifest;
26
29
  }>;
@@ -64,6 +64,79 @@ export const bundle = async (sourceDirectories, distDirectory, tomlDeclarations
64
64
  if (vendor) {
65
65
  importMap.add(vendor.importMap);
66
66
  }
67
+ // Extracts the in-source configuration for each function and derives the
68
+ // manifest's function config and routes from it.
69
+ const computeManifestData = async (log) => {
70
+ const { internalFunctions: internalFunctionsWithConfig, userFunctions: userFunctionsWithConfig } = await getFunctionConfigs({
71
+ deno,
72
+ importMap,
73
+ internalFunctions,
74
+ log,
75
+ userFunctions,
76
+ });
77
+ // Creating a final declarations array by combining the TOML file with the
78
+ // deploy configuration API and the in-source configuration.
79
+ const declarations = mergeDeclarations(tomlDeclarations, userFunctionsWithConfig, internalFunctionsWithConfig, deployConfig.declarations, featureFlags);
80
+ const internalFunctionConfig = createFunctionConfig({
81
+ internalFunctionsWithConfig,
82
+ declarations,
83
+ });
84
+ const manifestFunctionConfig = generateManifestFunctionConfig({
85
+ functions,
86
+ internalFunctionConfig,
87
+ userFunctionConfig: userFunctionsWithConfig,
88
+ });
89
+ const manifestRoutes = generateManifestRoutes({
90
+ functions,
91
+ declarations,
92
+ });
93
+ return { manifestFunctionConfig, manifestRoutes };
94
+ };
95
+ const excludeUnroutedFunctions = Boolean(featureFlags.edge_bundler_exclude_unrouted_functions);
96
+ // Excluding unrouted functions is the only reason to extract the config
97
+ // before bundling - we need the routes to know what to leave out. When we're
98
+ // not, we keep extracting it after bundling, as we always have, so that the
99
+ // extra Deno subprocess never runs ahead of the bundle and can't change what
100
+ // a build failure looks like.
101
+ //
102
+ // Config extraction reports the underlying failure to the user before it
103
+ // throws. When it runs up front we may end up discarding that error in favour
104
+ // of a bundling error (see below), and printing the detail of an error we
105
+ // then discard is just noise - so on that path we hold those logs back and
106
+ // only flush them if we do surface the config error.
107
+ const heldUserLogs = [];
108
+ const configLogger = {
109
+ system: logger.system,
110
+ user: (...args) => {
111
+ heldUserLogs.push(args);
112
+ },
113
+ };
114
+ const manifestDataPromise = excludeUnroutedFunctions ? computeManifestData(configLogger) : undefined;
115
+ // A function with no route is never invoked, so bundling it only serves to
116
+ // eagerly load code that can never run - which can surface import-time
117
+ // failures. The manifest is still generated from the full set of functions,
118
+ // but its routes exclude unrouted functions by definition. If config
119
+ // extraction failed, we have no routes to go on, so we bundle everything and
120
+ // let bundling report the failure.
121
+ const routes = manifestDataPromise ? (await manifestDataPromise.catch(() => undefined))?.manifestRoutes : undefined;
122
+ const bundledFunctions = excludeUnroutedFunctions && routes
123
+ ? functions.filter(({ name }) => !routes.unroutedFunctions.includes(name))
124
+ : functions;
125
+ // With nothing to bundle there is no code that can ever be invoked, so we
126
+ // produce neither a bundle nor a manifest. The deploy pipeline treats a
127
+ // manifest that exists but carries no ESZIP bundle as the deprecated JS format
128
+ // and can reject the deploy, so a missing manifest - the same path as a site
129
+ // with no edge functions at all - is safer than an empty one.
130
+ if (bundledFunctions.length === 0) {
131
+ // Config extraction may have produced function output that we were holding
132
+ // back; surface it before returning.
133
+ heldUserLogs.forEach((args) => {
134
+ logger.user(...args);
135
+ });
136
+ logger.system('Skipping bundling because no edge function has a route.');
137
+ await vendor?.cleanup();
138
+ return { functions, manifest: undefined };
139
+ }
67
140
  const bundles = [];
68
141
  let tarballBundleDurationMs;
69
142
  let tarballDryRunError;
@@ -78,7 +151,7 @@ export const bundle = async (sourceDirectories, distDirectory, tomlDeclarations
78
151
  debug,
79
152
  deno,
80
153
  distDirectory,
81
- functions,
154
+ functions: bundledFunctions,
82
155
  featureFlags,
83
156
  importMap: importMap.clone(),
84
157
  vendorDirectory: vendor?.directory,
@@ -109,34 +182,28 @@ export const bundle = async (sourceDirectories, distDirectory, tomlDeclarations
109
182
  deno,
110
183
  distDirectory,
111
184
  externals,
112
- functions,
185
+ functions: bundledFunctions,
113
186
  featureFlags,
114
187
  importMap,
115
188
  vendorDirectory: vendor?.directory,
116
189
  }));
117
- const { internalFunctions: internalFunctionsWithConfig, userFunctions: userFunctionsWithConfig } = await getFunctionConfigs({
118
- deno,
119
- importMap,
120
- internalFunctions,
121
- log: logger,
122
- userFunctions,
123
- });
124
- // Creating a final declarations array by combining the TOML file with the
125
- // deploy configuration API and the in-source configuration.
126
- const declarations = mergeDeclarations(tomlDeclarations, userFunctionsWithConfig, internalFunctionsWithConfig, deployConfig.declarations, featureFlags);
127
- const internalFunctionConfig = createFunctionConfig({
128
- internalFunctionsWithConfig,
129
- declarations,
130
- });
131
- const manifestFunctionConfig = generateManifestFunctionConfig({
132
- functions,
133
- internalFunctionConfig,
134
- userFunctionConfig: userFunctionsWithConfig,
135
- });
136
- const manifestRoutes = generateManifestRoutes({
137
- functions,
138
- declarations,
139
- });
190
+ // When we extracted the config up front, bundling didn't fail, so no more
191
+ // precise error is coming - re-awaiting surfaces the config extraction error
192
+ // if there was one. When we didn't, we extract it now, after bundling, as we
193
+ // always have. Either way, we reach this point only once bundling has had its
194
+ // chance to throw, so any logs we held back are no longer masking a bundling
195
+ // error and should reach the user: on success they are the function's own
196
+ // output, on failure the detail of the error we are about to surface.
197
+ let manifestData;
198
+ try {
199
+ manifestData = await (manifestDataPromise ?? computeManifestData(logger));
200
+ }
201
+ finally {
202
+ heldUserLogs.forEach((args) => {
203
+ logger.user(...args);
204
+ });
205
+ }
206
+ const { manifestFunctionConfig, manifestRoutes } = manifestData;
140
207
  if (!tarballDryRunError && finalizeTarballBundle) {
141
208
  try {
142
209
  bundles.unshift(await finalizeTarballBundle({ manifestFunctionConfig, manifestRoutes }));
@@ -255,11 +322,16 @@ const getBasePath = (sourceDirectories, inputBasePath) => {
255
322
  // declaration level. We want these properties to live at the function level
256
323
  // in their config object, so we translate that for backwards-compatibility.
257
324
  const mergeWithDeclarationConfig = ({ functionName, config, declarations }) => {
258
- const declaration = declarations?.find((decl) => decl.function === functionName);
325
+ // A function may have several declarations for example, one synthesized
326
+ // from its in-source config and one coming from `netlify.toml` or the
327
+ // Frameworks API. Only the latter can carry `name` and `generator`, so we
328
+ // look for each field across all of the function's declarations rather than
329
+ // just the first one, which may well be the in-source one.
330
+ const functionDeclarations = declarations?.filter((decl) => decl.function === functionName) ?? [];
259
331
  return {
260
332
  ...config,
261
- name: declaration?.name || config.name,
262
- generator: declaration?.generator || config.generator,
333
+ name: functionDeclarations.find((decl) => decl.name)?.name || config.name,
334
+ generator: functionDeclarations.find((decl) => decl.generator)?.generator || config.generator,
263
335
  };
264
336
  };
265
337
  const addGeneratorFallback = (config) => ({
@@ -2,6 +2,15 @@ import { DenoBridge } from './bridge.js';
2
2
  import { ImportMap } from './import_map.js';
3
3
  import { Logger } from './logger.js';
4
4
  import { RateLimit } from './rate_limit.js';
5
+ export declare enum ConfigExitCode {
6
+ Success = 0,
7
+ UnhandledError = 1,
8
+ ImportError = 2,
9
+ NoConfig = 3,
10
+ InvalidExport = 4,
11
+ SerializationError = 5,
12
+ InvalidDefaultExport = 6
13
+ }
5
14
  export declare const enum Cache {
6
15
  Off = "off",
7
16
  Manual = "manual"
@@ -1,11 +1,13 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { pathToFileURL } from 'url';
4
+ import pRetry from 'p-retry';
4
5
  import { SemVer } from 'semver';
5
6
  import tmp from 'tmp-promise';
6
7
  import { BundleError } from './bundle_error.js';
7
8
  import { getPackagePath } from './package_json.js';
8
- var ConfigExitCode;
9
+ import { DENO_RETRIES, isTransientDenoError } from './utils/transient_error.js';
10
+ export var ConfigExitCode;
9
11
  (function (ConfigExitCode) {
10
12
  ConfigExitCode[ConfigExitCode["Success"] = 0] = "Success";
11
13
  ConfigExitCode[ConfigExitCode["UnhandledError"] = 1] = "UnhandledError";
@@ -43,7 +45,7 @@ export const getFunctionConfig = async ({ deno, functionPath, importMap, log, })
43
45
  // The extractor will use its exit code to signal different error scenarios,
44
46
  // based on the list of exit codes we send as an argument. We then capture
45
47
  // the exit code to know exactly what happened and guide people accordingly.
46
- const { exitCode, stderr, stdout } = await deno.run([
48
+ const runExtractor = () => deno.run([
47
49
  'run',
48
50
  '--allow-env',
49
51
  version.major >= 2 ? '--allow-import' : '',
@@ -59,6 +61,32 @@ export const getFunctionConfig = async ({ deno, functionPath, importMap, log, })
59
61
  pathToFileURL(collector.path).href,
60
62
  JSON.stringify(ConfigExitCode),
61
63
  ].filter(Boolean), { rejectOnExitCode: false });
64
+ // The extractor imports the function, so a dropped connection while fetching a remote module
65
+ // looks exactly like an import error. Because we're not rejecting on the exit code, we have to
66
+ // read the transient failure out of `stderr` ourselves and throw so that `pRetry` has another go.
67
+ // If every attempt fails we fall through with the last result, leaving the error handling below
68
+ // to report it as it would have without any retries.
69
+ let lastResult;
70
+ const { exitCode, stderr, stdout } = await pRetry(async () => {
71
+ lastResult = await runExtractor();
72
+ if (lastResult.exitCode !== ConfigExitCode.Success && isTransientDenoError(lastResult.stderr)) {
73
+ throw new Error(lastResult.stderr);
74
+ }
75
+ return lastResult;
76
+ }, {
77
+ retries: DENO_RETRIES,
78
+ onFailedAttempt: (error) => {
79
+ log.system(`Config extraction for '${functionPath}' hit a transient error, retrying: ${error.message}`);
80
+ },
81
+ }).catch((error) => {
82
+ // If we never captured a result, the extractor rejected outright rather than resolving with a
83
+ // non-zero exit code (e.g. the binary failed to spawn). Surface that error instead of falling
84
+ // through to destructure `undefined`.
85
+ if (lastResult === undefined) {
86
+ throw error;
87
+ }
88
+ return lastResult;
89
+ });
62
90
  if (exitCode !== ConfigExitCode.Success) {
63
91
  handleConfigError(functionPath, exitCode, stderr, log);
64
92
  return {};
@@ -1,12 +1,14 @@
1
1
  declare const defaultFlags: {
2
2
  edge_bundler_generate_tarball: boolean;
3
3
  edge_bundler_dry_run_generate_tarball: boolean;
4
+ edge_bundler_exclude_unrouted_functions: boolean;
4
5
  };
5
6
  type FeatureFlag = keyof typeof defaultFlags;
6
7
  type FeatureFlags = Partial<Record<FeatureFlag, boolean>>;
7
8
  declare const getFlags: (input?: Record<string, boolean>, flags?: {
8
9
  edge_bundler_generate_tarball: boolean;
9
10
  edge_bundler_dry_run_generate_tarball: boolean;
11
+ edge_bundler_exclude_unrouted_functions: boolean;
10
12
  }) => FeatureFlags;
11
13
  export { defaultFlags, getFlags };
12
14
  export type { FeatureFlag, FeatureFlags };
@@ -1,6 +1,7 @@
1
1
  const defaultFlags = {
2
2
  edge_bundler_generate_tarball: false,
3
3
  edge_bundler_dry_run_generate_tarball: false,
4
+ edge_bundler_exclude_unrouted_functions: false,
4
5
  };
5
6
  const getFlags = (input = {}, flags = defaultFlags) => Object.entries(flags).reduce((result, [key, defaultValue]) => ({
6
7
  ...result,
@@ -21,6 +21,12 @@ interface FinalizeTarballBundleOptions {
21
21
  manifestRoutes: ReturnType<typeof generateManifestRoutes>;
22
22
  }
23
23
  export declare const bundle: ({ buildID, deno, distDirectory, functions, importMap, vendorDirectory, }: BundleTarballOptions) => Promise<(arg: FinalizeTarballBundleOptions) => Promise<Bundle>>;
24
+ /**
25
+ * Uses deno info to get the module graph and extract only the local source files
26
+ * that are actually needed by the entry points. This avoids copying unnecessary
27
+ * files (like node_modules, .next, etc.) that may be under the common path.
28
+ */
29
+ export declare function getRequiredSourceFiles(deno: DenoBridge, entryPoints: string[], importMap: ImportMap): Promise<Set<string>>;
24
30
  /**
25
31
  * Rewrites import assert into import with in the bundle directory
26
32
  * Defaults to copying the file in its current form
@@ -2,11 +2,13 @@ import { promises as fs, existsSync } from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath, pathToFileURL } from 'url';
4
4
  import commonPathPrefix from 'common-path-prefix';
5
+ import pRetry, { AbortError } from 'p-retry';
5
6
  import * as tar from 'tar';
6
7
  import tmp from 'tmp-promise';
7
8
  import { BundleFormat } from '../bundle.js';
8
9
  import { listRecursively } from '../utils/fs.js';
9
10
  import { getFileHash } from '../utils/sha256.js';
11
+ import { DENO_RETRIES, isTransientDenoErrorObject } from '../utils/transient_error.js';
10
12
  import { rewriteSourceImportAssertions } from '../utils/import_attributes.js';
11
13
  const TARBALL_EXTENSION = '.tar.gz';
12
14
  const getUnixPath = (input) => input.split(path.sep).join('/');
@@ -154,19 +156,35 @@ const REWRITABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.m
154
156
  * that are actually needed by the entry points. This avoids copying unnecessary
155
157
  * files (like node_modules, .next, etc.) that may be under the common path.
156
158
  */
157
- async function getRequiredSourceFiles(deno, entryPoints, importMap) {
159
+ export async function getRequiredSourceFiles(deno, entryPoints, importMap) {
158
160
  const localFiles = new Set();
159
161
  const importMapDataUrl = importMap.withNodeBuiltins().toDataURL();
160
162
  // Run deno info for each entry point and combine the results
161
163
  for (const entryPoint of entryPoints) {
162
- const { stdout } = await deno.run([
163
- 'info',
164
- '--json',
165
- '--no-config',
166
- '--import-map',
167
- importMapDataUrl,
168
- pathToFileURL(entryPoint).href,
169
- ]);
164
+ const { stdout } = await pRetry(async () => {
165
+ try {
166
+ return await deno.run([
167
+ 'info',
168
+ '--json',
169
+ '--no-config',
170
+ '--import-map',
171
+ importMapDataUrl,
172
+ pathToFileURL(entryPoint).href,
173
+ ]);
174
+ }
175
+ catch (error) {
176
+ if (isTransientDenoErrorObject(error)) {
177
+ throw error;
178
+ }
179
+ // `AbortError` stops the retry loop and rethrows the error we pass it, unchanged.
180
+ throw new AbortError(error instanceof Error ? error : new Error(String(error)));
181
+ }
182
+ }, {
183
+ retries: DENO_RETRIES,
184
+ onFailedAttempt: (error) => {
185
+ deno.logger.system(`\`deno info\` failed with a transient error, retrying: ${error.message}`);
186
+ },
187
+ });
170
188
  const graph = JSON.parse(stdout);
171
189
  // Collect the specifiers of every module reachable through a *code* (runtime)
172
190
  // import edge. Deno's module graph classifies each dependency as either a `code`
@@ -0,0 +1,3 @@
1
+ export declare const isTransientDenoError: (output: string) => boolean;
2
+ export declare const isTransientDenoErrorObject: (error: unknown) => boolean;
3
+ export declare const DENO_RETRIES = 3;
@@ -0,0 +1,20 @@
1
+ // Deno subprocesses fetch remote modules and npm registry metadata, so they can fail for reasons
2
+ // that have nothing to do with the user's code: a dropped connection, a DNS blip, a registry
3
+ // timeout. These are the messages Deno surfaces for those failures. Anything else (a syntax error,
4
+ // an unresolvable import) is deterministic, and retrying it just delays the real error.
5
+ const TRANSIENT_DENO_ERRORS = [
6
+ 'error sending request',
7
+ 'error reading a body from connection',
8
+ 'connection closed before message completed',
9
+ 'connection reset',
10
+ 'operation timed out',
11
+ 'tcp connect error',
12
+ 'dns error',
13
+ ];
14
+ export const isTransientDenoError = (output) => {
15
+ const haystack = output.toLowerCase();
16
+ return TRANSIENT_DENO_ERRORS.some((pattern) => haystack.includes(pattern));
17
+ };
18
+ // execa folds stderr into `message`, which is where Deno writes the `Caused by:` detail.
19
+ export const isTransientDenoErrorObject = (error) => error instanceof Error && isTransientDenoError(error.message);
20
+ export const DENO_RETRIES = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/edge-bundler",
3
- "version": "15.1.0",
3
+ "version": "16.0.0",
4
4
  "description": "Intelligently prepare Netlify Edge Functions for deployment",
5
5
  "type": "module",
6
6
  "main": "./dist/node/index.js",
@@ -80,5 +80,5 @@
80
80
  "tmp-promise": "^3.0.3",
81
81
  "urlpattern-polyfill": "8.0.2"
82
82
  },
83
- "gitHead": "0a4aa4be3f9abbb49ebe00e82c64898148986715"
83
+ "gitHead": "af94ccf9fe17c7b2c653eaad23cc413283548d1a"
84
84
  }