@netlify/edge-bundler 9.3.0 → 9.4.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.
@@ -158,6 +158,7 @@ const safelyVendorNPMSpecifiers = async ({ basePath, featureFlags, functions, im
158
158
  functions: functions.map(({ path }) => path),
159
159
  importMap,
160
160
  logger,
161
+ referenceTypes: false,
161
162
  });
162
163
  }
163
164
  catch (error) {
@@ -7,8 +7,9 @@ interface VendorNPMSpecifiersOptions {
7
7
  functions: string[];
8
8
  importMap: ImportMap;
9
9
  logger: Logger;
10
+ referenceTypes: boolean;
10
11
  }
11
- export declare const vendorNPMSpecifiers: ({ basePath, directory, functions, importMap, }: VendorNPMSpecifiersOptions) => Promise<{
12
+ export declare const vendorNPMSpecifiers: ({ basePath, directory, functions, importMap, referenceTypes, }: VendorNPMSpecifiersOptions) => Promise<{
12
13
  cleanup: () => Promise<void>;
13
14
  directory: string;
14
15
  importMap: {
@@ -5,9 +5,63 @@ import { fileURLToPath, pathToFileURL } from 'url';
5
5
  import { resolve } from '@import-maps/resolve';
6
6
  import { nodeFileTrace, resolve as nftResolve } from '@vercel/nft';
7
7
  import { build } from 'esbuild';
8
+ import { findUp } from 'find-up';
8
9
  import getPackageName from 'get-package-name';
9
10
  import tmp from 'tmp-promise';
10
11
  const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.cts', '.mts']);
12
+ const slugifyPackageName = (specifier) => {
13
+ if (!specifier.startsWith('@'))
14
+ return specifier;
15
+ const [scope, pkg] = specifier.split('/');
16
+ return `${scope.replace('@', '')}__${pkg}`;
17
+ };
18
+ /**
19
+ * Returns the name of the `@types/` package used by a given specifier. Most of
20
+ * the times this is just the specifier itself, but scoped packages suffer a
21
+ * transformation (e.g. `@netlify/functions` -> `@types/netlify__functions`).
22
+ * https://github.com/DefinitelyTyped/DefinitelyTyped#what-about-scoped-packages
23
+ */
24
+ const getTypesPackageName = (specifier) => path.join('@types', slugifyPackageName(specifier));
25
+ /**
26
+ * Finds corresponding DefinitelyTyped packages (`@types/...`) and returns path to declaration file.
27
+ */
28
+ const getTypePathFromTypesPackage = async (packageName, packageJsonPath) => {
29
+ const typesPackagePath = await findUp(`node_modules/${getTypesPackageName(packageName)}/package.json`, {
30
+ cwd: packageJsonPath,
31
+ });
32
+ if (!typesPackagePath) {
33
+ return undefined;
34
+ }
35
+ const { types, typings } = JSON.parse(await fs.readFile(typesPackagePath, 'utf8'));
36
+ const declarationPath = types !== null && types !== void 0 ? types : typings;
37
+ if (typeof declarationPath === 'string') {
38
+ return path.join(typesPackagePath, '..', declarationPath);
39
+ }
40
+ return undefined;
41
+ };
42
+ /**
43
+ * Starting from a `package.json` file, this tries detecting a TypeScript declaration file.
44
+ * It first looks at the `types` and `typings` fields in `package.json`.
45
+ * If it doesn't find them, it falls back to DefinitelyTyped packages (`@types/...`).
46
+ */
47
+ const getTypesPath = async (packageJsonPath) => {
48
+ const { name, types, typings } = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
49
+ // this only looks at `.types` and `.typings` fields. there might also be data in `exports -> . -> types -> import/default`.
50
+ // we're ignoring that for now.
51
+ const declarationPath = types !== null && types !== void 0 ? types : typings;
52
+ if (typeof declarationPath === 'string') {
53
+ return path.join(packageJsonPath, '..', declarationPath);
54
+ }
55
+ return await getTypePathFromTypesPackage(name, packageJsonPath);
56
+ };
57
+ const safelyDetectTypes = async (packageJsonPath) => {
58
+ try {
59
+ return await getTypesPath(packageJsonPath);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ };
11
65
  // Workaround for https://github.com/evanw/esbuild/issues/1921.
12
66
  const banner = {
13
67
  js: `
@@ -26,8 +80,9 @@ const banner = {
26
80
  * @param basePath Root of the project
27
81
  * @param functions Functions to parse
28
82
  * @param importMap Import map to apply when resolving imports
83
+ * @param referenceTypes Whether to detect typescript declarations and reference them in the output
29
84
  */
30
- const getNPMSpecifiers = async (basePath, functions, importMap) => {
85
+ const getNPMSpecifiers = async (basePath, functions, importMap, referenceTypes) => {
31
86
  const baseURL = pathToFileURL(basePath);
32
87
  const { reasons } = await nodeFileTrace(functions, {
33
88
  base: basePath,
@@ -60,22 +115,10 @@ const getNPMSpecifiers = async (basePath, functions, importMap) => {
60
115
  return nftResolve(specifier, ...args);
61
116
  },
62
117
  });
63
- const npmSpecifiers = new Set();
118
+ const npmSpecifiers = [];
64
119
  const npmSpecifiersWithExtraneousFiles = new Set();
65
- reasons.forEach((reason, filePath) => {
66
- const packageName = getPackageName(filePath);
67
- if (packageName === undefined) {
68
- return;
69
- }
120
+ for (const [filePath, reason] of reasons.entries()) {
70
121
  const parents = [...reason.parents];
71
- const isDirectDependency = parents.some((parentPath) => !parentPath.startsWith(`node_modules${path.sep}`));
72
- // We're only interested in capturing the specifiers that are first-level
73
- // dependencies. Because we'll bundle all modules in a subsequent step,
74
- // any transitive dependencies will be handled then.
75
- if (isDirectDependency) {
76
- const specifier = getPackageName(filePath);
77
- npmSpecifiers.add(specifier);
78
- }
79
122
  const isExtraneousFile = reason.type.every((type) => type === 'asset');
80
123
  // An extraneous file is a dependency that was traced by NFT and marked
81
124
  // as not being statically imported. We can't process dynamic importing
@@ -89,13 +132,40 @@ const getNPMSpecifiers = async (basePath, functions, importMap) => {
89
132
  }
90
133
  });
91
134
  }
92
- });
135
+ // every dependency will have its `package.json` in `reasons` exactly once.
136
+ // by only looking at this file, we save us from doing duplicate work.
137
+ const isPackageJson = filePath.endsWith('package.json');
138
+ if (!isPackageJson)
139
+ continue;
140
+ const packageName = getPackageName(filePath);
141
+ if (packageName === undefined)
142
+ continue;
143
+ const isDirectDependency = parents.some((parentPath) => {
144
+ var _a, _b;
145
+ if (!parentPath.startsWith(`node_modules${path.sep}`))
146
+ return true;
147
+ // typically, edge functions have no direct dependency on the `package.json` of a module.
148
+ // it's the impl files that depend on `package.json`, so we need to check the parents of
149
+ // the `package.json` file as well to see if the module is a direct dependency.
150
+ const parents = [...((_b = (_a = reasons.get(parentPath)) === null || _a === void 0 ? void 0 : _a.parents) !== null && _b !== void 0 ? _b : [])];
151
+ return parents.some((parentPath) => !parentPath.startsWith(`node_modules${path.sep}`));
152
+ });
153
+ // We're only interested in capturing the specifiers that are first-level
154
+ // dependencies. Because we'll bundle all modules in a subsequent step,
155
+ // any transitive dependencies will be handled then.
156
+ if (isDirectDependency) {
157
+ npmSpecifiers.push({
158
+ specifier: packageName,
159
+ types: referenceTypes ? await safelyDetectTypes(path.join(basePath, filePath)) : undefined,
160
+ });
161
+ }
162
+ }
93
163
  return {
94
- npmSpecifiers: [...npmSpecifiers],
164
+ npmSpecifiers,
95
165
  npmSpecifiersWithExtraneousFiles: [...npmSpecifiersWithExtraneousFiles],
96
166
  };
97
167
  };
98
- export const vendorNPMSpecifiers = async ({ basePath, directory, functions, importMap, }) => {
168
+ export const vendorNPMSpecifiers = async ({ basePath, directory, functions, importMap, referenceTypes, }) => {
99
169
  // The directories that esbuild will use when resolving Node modules. We must
100
170
  // set these manually because esbuild will be operating from a temporary
101
171
  // directory that will not live inside the project root, so the normal
@@ -105,25 +175,25 @@ export const vendorNPMSpecifiers = async ({ basePath, directory, functions, impo
105
175
  // project directory. If a custom directory has been specified, we use it.
106
176
  // Otherwise, create a random temporary directory.
107
177
  const temporaryDirectory = directory ? { path: directory } : await tmp.dir();
108
- const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers(basePath, functions, importMap.getContentsWithURLObjects());
178
+ const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers(basePath, functions, importMap.getContentsWithURLObjects(), referenceTypes);
109
179
  // If we found no specifiers, there's nothing left to do here.
110
- if (npmSpecifiers.length === 0) {
180
+ if (Object.keys(npmSpecifiers).length === 0) {
111
181
  return;
112
182
  }
113
- // To bundle an entire module and all its dependencies, create a barrel file
183
+ // To bundle an entire module and all its dependencies, create a entrypoint file
114
184
  // where we re-export everything from that specifier. We do this for every
115
185
  // specifier, and each of these files will become entry points to esbuild.
116
- const ops = await Promise.all(npmSpecifiers.map(async (specifier, index) => {
186
+ const ops = await Promise.all(npmSpecifiers.map(async ({ specifier, types }) => {
117
187
  const code = `import * as mod from "${specifier}"; export default mod.default; export * from "${specifier}";`;
118
- const filePath = path.join(temporaryDirectory.path, `barrel-${index}.js`);
188
+ const filePath = path.join(temporaryDirectory.path, `bundled-${slugifyPackageName(specifier)}.js`);
119
189
  await fs.writeFile(filePath, code);
120
- return { filePath, specifier };
190
+ return { filePath, specifier, types };
121
191
  }));
122
192
  const entryPoints = ops.map(({ filePath }) => filePath);
123
- // Bundle each of the barrel files we created. We'll end up with a compiled
124
- // version of each of the barrel files, plus any chunks of shared code
193
+ // Bundle each of the entrypoints we created. We'll end up with a compiled
194
+ // version of each, plus any chunks of shared code
125
195
  // between them (such that a common module isn't bundled twice).
126
- await build({
196
+ const { outputFiles } = await build({
127
197
  allowOverwrite: true,
128
198
  banner,
129
199
  bundle: true,
@@ -135,7 +205,17 @@ export const vendorNPMSpecifiers = async ({ basePath, directory, functions, impo
135
205
  platform: 'node',
136
206
  splitting: true,
137
207
  target: 'es2020',
208
+ write: false,
138
209
  });
210
+ await Promise.all(outputFiles.map(async (file) => {
211
+ var _a;
212
+ const types = (_a = ops.find((op) => path.basename(file.path) === path.basename(op.filePath))) === null || _a === void 0 ? void 0 : _a.types;
213
+ let content = file.text;
214
+ if (types) {
215
+ content = `/// <reference types="${path.relative(file.path, types)}" />\n${content}`;
216
+ }
217
+ await fs.writeFile(file.path, content);
218
+ }));
139
219
  // Add all Node.js built-ins to the import map, so any unprefixed specifiers
140
220
  // (e.g. `process`) resolve to the prefixed versions (e.g. `node:prefix`),
141
221
  // which Deno can process.
@@ -31,6 +31,7 @@ const prepareServer = ({ basePath, bootstrapURL, deno, distDirectory, distImport
31
31
  functions: functions.map(({ path }) => path),
32
32
  importMap,
33
33
  logger,
34
+ referenceTypes: true,
34
35
  });
35
36
  if (vendor) {
36
37
  features.npmModules = true;
@@ -1,7 +1,7 @@
1
+ import { readFile } from 'fs/promises';
1
2
  import { join } from 'path';
2
3
  import getPort from 'get-port';
3
4
  import fetch from 'node-fetch';
4
- import { tmpName } from 'tmp-promise';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
6
  import { test, expect } from 'vitest';
7
7
  import { fixturesDir } from '../../test/util.js';
@@ -14,13 +14,16 @@ test('Starts a server and serves requests for edge functions', async () => {
14
14
  };
15
15
  const port = await getPort();
16
16
  const importMapPaths = [join(paths.internal, 'import_map.json'), join(paths.user, 'import-map.json')];
17
- const servePath = await tmpName();
17
+ const servePath = join(basePath, '.netlify', 'edge-functions-serve');
18
18
  const server = await serve({
19
19
  basePath,
20
20
  bootstrapURL: 'https://edge.netlify.com/bootstrap/index-combined.ts',
21
21
  importMapPaths,
22
22
  port,
23
23
  servePath,
24
+ featureFlags: {
25
+ edge_functions_npm_modules: true,
26
+ },
24
27
  });
25
28
  const functions = [
26
29
  {
@@ -39,12 +42,13 @@ test('Starts a server and serves requests for edge functions', async () => {
39
42
  const options = {
40
43
  getFunctionsConfig: true,
41
44
  };
42
- const { features, functionsConfig, graph, success } = await server(functions, {
45
+ const { features, functionsConfig, graph, success, npmSpecifiersWithExtraneousFiles } = await server(functions, {
43
46
  very_secret_secret: 'i love netlify',
44
47
  }, options);
45
- expect(features).toEqual({});
48
+ expect(features).toEqual({ npmModules: true });
46
49
  expect(success).toBe(true);
47
50
  expect(functionsConfig).toEqual([{ path: '/my-function' }, {}, { path: '/global-netlify' }]);
51
+ expect(npmSpecifiersWithExtraneousFiles).toEqual(['dictionary']);
48
52
  for (const key in functions) {
49
53
  const graphEntry = graph === null || graph === void 0 ? void 0 : graph.modules.some(
50
54
  // @ts-expect-error TODO: Module graph is currently not typed
@@ -80,4 +84,8 @@ test('Starts a server and serves requests for edge functions', async () => {
80
84
  global: 'i love netlify',
81
85
  local: 'i love netlify',
82
86
  });
87
+ const idBarrelFile = await readFile(join(servePath, 'bundled-id.js'), 'utf-8');
88
+ expect(idBarrelFile).toContain(`/// <reference types="${join('..', '..', '..', 'node_modules', 'id', 'types.d.ts')}" />`);
89
+ const identidadeBarrelFile = await readFile(join(servePath, 'bundled-pt-committee__identidade.js'), 'utf-8');
90
+ expect(identidadeBarrelFile).toContain(`/// <reference types="${join('..', '..', '..', 'node_modules', '@types', 'pt-committee__identidade', 'index.d.ts')}" />`);
83
91
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/edge-bundler",
3
- "version": "9.3.0",
3
+ "version": "9.4.0",
4
4
  "description": "Intelligently prepare Netlify Edge Functions for deployment",
5
5
  "type": "module",
6
6
  "main": "./dist/node/index.js",