-
Notifications
You must be signed in to change notification settings - Fork 53
/
npm_dependencies.ts
328 lines (287 loc) · 11.2 KB
/
npm_dependencies.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
import { promises as fs } from 'fs'
import { builtinModules } from 'module'
import path from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { resolve, ParsedImportMap } from '@import-maps/resolve'
import { nodeFileTrace, resolve as nftResolve } from '@vercel/nft'
import { build } from 'esbuild'
import { findUp } from 'find-up'
import getPackageName from 'get-package-name'
import tmp from 'tmp-promise'
import { ImportMap } from './import_map.js'
import { Logger } from './logger.js'
const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.cts', '.mts'])
/**
* Returns the name of the `@types/` package used by a given specifier. Most of
* the times this is just the specifier itself, but scoped packages suffer a
* transformation (e.g. `@netlify/functions` -> `@types/netlify__functions`).
* https://github.com/DefinitelyTyped/DefinitelyTyped#what-about-scoped-packages
*/
const getTypesPackageName = (specifier: string) => {
if (!specifier.startsWith('@')) return path.join('@types', specifier)
const [scope, pkg] = specifier.split('/')
return path.join('@types', `${scope.replace('@', '')}__${pkg}`)
}
/**
* Finds corresponding DefinitelyTyped packages (`@types/...`) and returns path to declaration file.
*/
const getTypePathFromTypesPackage = async (
packageName: string,
packageJsonPath: string,
): Promise<string | undefined> => {
const typesPackagePath = await findUp(`node_modules/${getTypesPackageName(packageName)}/package.json`, {
cwd: packageJsonPath,
})
if (!typesPackagePath) {
return undefined
}
const { types, typings } = JSON.parse(await fs.readFile(typesPackagePath, 'utf8'))
const declarationPath = types ?? typings
if (typeof declarationPath === 'string') {
return path.join(typesPackagePath, '..', declarationPath)
}
return undefined
}
/**
* Starting from a `package.json` file, this tries detecting a TypeScript declaration file.
* It first looks at the `types` and `typings` fields in `package.json`.
* If it doesn't find them, it falls back to DefinitelyTyped packages (`@types/...`).
*/
const getTypesPath = async (packageJsonPath: string): Promise<string | undefined> => {
const { name, types, typings } = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'))
// this only looks at `.types` and `.typings` fields. there might also be data in `exports -> . -> types -> import/default`.
// we're ignoring that for now.
const declarationPath = types ?? typings
if (typeof declarationPath === 'string') {
return path.join(packageJsonPath, '..', declarationPath)
}
return await getTypePathFromTypesPackage(name, packageJsonPath)
}
const safelyDetectTypes = async (packageJsonPath: string): Promise<string | undefined> => {
try {
return await getTypesPath(packageJsonPath)
} catch {
return undefined
}
}
// Workaround for https://github.com/evanw/esbuild/issues/1921.
const banner = {
js: `
import {createRequire as ___nfyCreateRequire} from "node:module";
import {fileURLToPath as ___nfyFileURLToPath} from "node:url";
import {dirname as ___nfyPathDirname} from "node:path";
let __filename=___nfyFileURLToPath(import.meta.url);
let __dirname=___nfyPathDirname(___nfyFileURLToPath(import.meta.url));
let require=___nfyCreateRequire(import.meta.url);
`,
}
/**
* Parses a set of functions and returns a list of specifiers that correspond
* to npm modules.
*
* @param basePath Root of the project
* @param functions Functions to parse
* @param importMap Import map to apply when resolving imports
* @param referenceTypes Whether to detect typescript declarations and reference them in the output
*/
const getNPMSpecifiers = async (
basePath: string,
functions: string[],
importMap: ParsedImportMap,
referenceTypes: boolean,
) => {
const baseURL = pathToFileURL(basePath)
const { reasons } = await nodeFileTrace(functions, {
base: basePath,
readFile: async (filePath: string) => {
// If this is a TypeScript file, we need to compile in before we can
// parse it.
if (TYPESCRIPT_EXTENSIONS.has(path.extname(filePath))) {
const compiled = await build({
bundle: false,
entryPoints: [filePath],
logLevel: 'silent',
platform: 'node',
write: false,
})
return compiled.outputFiles[0].text
}
return fs.readFile(filePath, 'utf8')
},
// eslint-disable-next-line require-await
resolve: async (specifier, ...args) => {
// Start by checking whether the specifier matches any import map defined
// by the user.
const { matched, resolvedImport } = resolve(specifier, importMap, baseURL)
// If it does, the resolved import is the specifier we'll evaluate going
// forward.
if (matched && resolvedImport.protocol === 'file:') {
const newSpecifier = fileURLToPath(resolvedImport).replace(/\\/g, '/')
return nftResolve(newSpecifier, ...args)
}
return nftResolve(specifier, ...args)
},
})
const npmSpecifiers: { specifier: string; types?: string }[] = []
const npmSpecifiersWithExtraneousFiles = new Set<string>()
for (const [filePath, reason] of reasons.entries()) {
const parents = [...reason.parents]
const isExtraneousFile = reason.type.every((type) => type === 'asset')
// An extraneous file is a dependency that was traced by NFT and marked
// as not being statically imported. We can't process dynamic importing
// at runtime, so we gather the list of modules that may use these files
// so that we can warn users about this caveat.
if (isExtraneousFile) {
parents.forEach((path) => {
const specifier = getPackageName(path)
if (specifier) {
npmSpecifiersWithExtraneousFiles.add(specifier)
}
})
}
// every dependency will have its `package.json` in `reasons` exactly once.
// by only looking at this file, we save us from doing duplicate work.
const isPackageJson = filePath.endsWith('package.json')
if (!isPackageJson) continue
const packageName = getPackageName(filePath)
if (packageName === undefined) continue
const isDirectDependency = parents.some((parentPath) => {
if (!parentPath.startsWith(`node_modules${path.sep}`)) return true
// typically, edge functions have no direct dependency on the `package.json` of a module.
// it's the impl files that depend on `package.json`, so we need to check the parents of
// the `package.json` file as well to see if the module is a direct dependency.
const parents = [...(reasons.get(parentPath)?.parents ?? [])]
return parents.some((parentPath) => !parentPath.startsWith(`node_modules${path.sep}`))
})
// We're only interested in capturing the specifiers that are first-level
// dependencies. Because we'll bundle all modules in a subsequent step,
// any transitive dependencies will be handled then.
if (isDirectDependency) {
npmSpecifiers.push({
specifier: packageName,
types: referenceTypes ? await safelyDetectTypes(path.join(basePath, filePath)) : undefined,
})
}
}
return {
npmSpecifiers,
npmSpecifiersWithExtraneousFiles: [...npmSpecifiersWithExtraneousFiles],
}
}
interface VendorNPMSpecifiersOptions {
basePath: string
directory?: string
functions: string[]
importMap: ImportMap
logger: Logger
referenceTypes: boolean
}
export const vendorNPMSpecifiers = async ({
basePath,
directory,
functions,
importMap,
referenceTypes,
}: VendorNPMSpecifiersOptions) => {
// The directories that esbuild will use when resolving Node modules. We must
// set these manually because esbuild will be operating from a temporary
// directory that will not live inside the project root, so the normal
// resolution logic won't work.
const nodePaths = [path.join(basePath, 'node_modules')]
// We need to create some files on disk, which we don't want to write to the
// project directory. If a custom directory has been specified, we use it.
// Otherwise, create a random temporary directory.
const temporaryDirectory = directory ? { path: directory } : await tmp.dir()
const { npmSpecifiers, npmSpecifiersWithExtraneousFiles } = await getNPMSpecifiers(
basePath,
functions,
importMap.getContentsWithURLObjects(),
referenceTypes,
)
// If we found no specifiers, there's nothing left to do here.
if (Object.keys(npmSpecifiers).length === 0) {
return
}
// To bundle an entire module and all its dependencies, create a barrel file
// where we re-export everything from that specifier. We do this for every
// specifier, and each of these files will become entry points to esbuild.
const ops = await Promise.all(
npmSpecifiers.map(async ({ specifier, types }, index) => {
const code = `import * as mod from "${specifier}"; export default mod.default; export * from "${specifier}";`
const barrelName = `barrel-${index}.js`
const filePath = path.join(temporaryDirectory.path, barrelName)
await fs.writeFile(filePath, code)
return { filePath, specifier, barrelName, types }
}),
)
const entryPoints = ops.map(({ filePath }) => filePath)
// Bundle each of the barrel files we created. We'll end up with a compiled
// version of each of the barrel files, plus any chunks of shared code
// between them (such that a common module isn't bundled twice).
const { outputFiles } = await build({
allowOverwrite: true,
banner,
bundle: true,
entryPoints,
format: 'esm',
logLevel: 'error',
nodePaths,
outdir: temporaryDirectory.path,
platform: 'node',
splitting: true,
target: 'es2020',
write: false,
})
await Promise.all(
outputFiles.map(async (file) => {
const types = ops.find((op) => file.path.endsWith(op.barrelName))?.types
let content = file.text
if (types) {
content = `/// <reference types="${path.relative(file.path, types)}" />\n${content}`
}
await fs.writeFile(file.path, content)
}),
)
// Add all Node.js built-ins to the import map, so any unprefixed specifiers
// (e.g. `process`) resolve to the prefixed versions (e.g. `node:prefix`),
// which Deno can process.
const builtIns = builtinModules.reduce(
(acc, name) => ({
...acc,
[name]: `node:${name}`,
}),
{} as Record<string, string>,
)
// Creates an object that is compatible with the `imports` block of an import
// map, mapping specifiers to the paths of their bundled files on disk. Each
// specifier gets two entries in the import map, one with the `npm:` prefix
// and one without, such that both options are supported.
const newImportMap = {
baseURL: pathToFileURL(temporaryDirectory.path),
imports: ops.reduce((acc, op) => {
const url = pathToFileURL(op.filePath).toString()
return {
...acc,
[op.specifier]: url,
}
}, builtIns),
}
const cleanup = async () => {
// If a custom temporary directory was specified, we leave the cleanup job
// up to the caller.
if (directory) {
return
}
try {
await fs.rm(temporaryDirectory.path, { force: true, recursive: true })
} catch {
// no-op
}
}
return {
cleanup,
directory: temporaryDirectory.path,
importMap: newImportMap,
npmSpecifiersWithExtraneousFiles,
}
}