Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: detect Typescript typings for NPM modules and reference them from barrel files #505

Merged
merged 19 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions node/npm_dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { promises as fs } from 'fs'
import { builtinModules } from 'module'
import path from 'path'
import path, { join } 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'

Expand All @@ -14,6 +15,39 @@ import { Logger } from './logger.js'

const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.cts', '.mts'])

/**
* Turns @netlify/functions into @types/netlify__functions.
*/
const inferDefinitelyTypedPackage = (specifier: string) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would call this getTypesPackageName. "Definitely typed" doesn't mean a lot to people who don't know that the repo where the types live have that name, so I think it could be a bit more descriptive.

Also you're calling this "types package" below, so it would make things a bit more consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed in a0a27f0

if (!specifier.startsWith('@')) return `@types/${specifier}`
const [scope, pkg] = specifier.split('/')
return `@types/${scope.replace('@', '')}__${pkg}`
}

const detectTypes = async (filePath: string): Promise<string | undefined> => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we add a JSDoc comment here to explain what this does?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 59d60b4

try {
const packageJson = await findUp('package.json', { cwd: filePath })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to find the path to package.json, because NFT gives us this information. Every module's package.json is part of the reasons object, which you can identify with type: ["resolve"] or basename(path) === "package.json".

In fact, since every npm module will have a package.json, we can just discard any file that isn't a package.json for the purpose of finding direct dependencies (we still want to look at them for the purpose of finding extraneous files).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not so sure about this. Have we verified that NFT also includes the package.json for CJS modules? Or does it only include package.json if that contains "type": "module"?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we verified that NFT also includes the package.json for CJS modules?

Yes.

Copy link
Contributor Author

@Skn0tt Skn0tt Oct 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried implementing this, but I don't see how we can identify direct dependencies based on package.json. In the test case, here's how the reason for @pt-committee/identidade/package.json looks:
Screenshot 2023-10-20 at 11 29 03
It references its internals, but not the user code that imports it. Since that's what we use to identify direct deps, we'd have to first detect the @pt-committee/identidade/index.js reason, and then find @pt-committee/identidade/package.json from that. That's tricky, and I prefer doing a (not strictly necessary) findUp instead. It's not very costly, only executed in local dev, and makes this easier to reason about.

If you seen an easier way of implementing this that i'm missing, please let me know / push a commit to this!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's tricky, and I prefer doing a (not strictly necessary) findUp instead. It's not very costly, only executed in local dev, and makes this easier to reason about.

My issue with that is that if you have a module with 10,000 files, you'll be repeating this operation 10,000 times, with no caching whatsoever:

  1. Traverse the module tree to find the package.json
  2. Read the package.json from disk
  3. Parse it
  4. Look for types
  5. Potentially traverse the module tree again to find @types/

It's not costly in the context of the synthetic test case we've put together here, but I don't think we can say the same when we're dealing with real modules.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the flip side, you'd do:

  1. Look at file: if it's not package.json, discard
  2. Look up parent of package.json, if it's not a direct dependency, discard
  3. Look up types (steps 2 to 5 above)

But only once for every node module that is a direct dependency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look up parent of package.json, if it's not a direct dependency, discard

In order to properly detect direct dependencies, this means we need to look up the parents of package.json within NFT's reason object. Added a comment as to why in 444a78f. Assuming that's what you intend?

if (!packageJson) return
const packageJsonContents = JSON.parse(await fs.readFile(packageJson, '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 packageJsonTypes = packageJsonContents.types ?? packageJsonContents.typings
if (packageJsonTypes) return join(packageJson, '..', packageJsonTypes)

const nodeModulesFolder = await findUp('node_modules', { cwd: packageJson, type: 'directory' })
if (!nodeModulesFolder) return

const typesPackageJson = join(
nodeModulesFolder,
inferDefinitelyTypedPackage(packageJsonContents.name),
'package.json',
)
const typesPackageContents = JSON.parse(await fs.readFile(typesPackageJson, 'utf8'))
const typesPackageTypes = typesPackageContents.types ?? typesPackageContents.typings
if (typesPackageContents) return join(typesPackageJson, '..', typesPackageTypes)
} catch {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having the entire function wrapped in a try with nothing in the catch is a bit of an anti-pattern, because we're essentially swallowing every possible error that might occur regardless of who calls the function.

I understand we don't want to throw an error if anything goes wrong in this process, but I think it would be nicer if we left that decision up to the caller, because then it can also decide how to handle the failure (e.g. log, do nothing, etc.).

Have you considered making this function throw and move the try/catch to getNPMSpecifiers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did that in 59d60b4. ESLint forced me to introduce a safelyDetectTypes function so that the logic isn't nested too deep.

}

// Workaround for https://github.com/evanw/esbuild/issues/1921.
const banner = {
js: `
Expand Down Expand Up @@ -72,14 +106,14 @@ const getNPMSpecifiers = async (basePath: string, functions: string[], importMap
return nftResolve(specifier, ...args)
},
})
const npmSpecifiers = new Set<string>()
const npmSpecifiers: Record<string, { types?: string }> = {}
const npmSpecifiersWithExtraneousFiles = new Set<string>()

reasons.forEach((reason, filePath) => {
for (const [filePath, reason] of reasons.entries()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making a for-loop out of this, so we can use await down below

const packageName = getPackageName(filePath)

if (packageName === undefined) {
return
continue
}

const parents = [...reason.parents]
Expand All @@ -91,7 +125,9 @@ const getNPMSpecifiers = async (basePath: string, functions: string[], importMap
if (isDirectDependency) {
const specifier = getPackageName(filePath)

npmSpecifiers.add(specifier)
npmSpecifiers[specifier] = {
types: await detectTypes(join(basePath, filePath)),
}
}

const isExtraneousFile = reason.type.every((type) => type === 'asset')
Expand All @@ -109,14 +145,19 @@ const getNPMSpecifiers = async (basePath: string, functions: string[], importMap
}
})
}
})
}

return {
npmSpecifiers: [...npmSpecifiers],
npmSpecifiers,
npmSpecifiersWithExtraneousFiles: [...npmSpecifiersWithExtraneousFiles],
}
}

const prependFile = async (path: string, prefix: string) => {
const existingContent = await fs.readFile(path, 'utf8')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like that we are writing a file to disk, then loading it again into memory, and then writing it back to disk.

I think in the future it might be nice to set write: false in esbuild, so that we get the file contents directly and write to disk just once.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we create an issue so we do this as a follow-up?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a8880e0

await fs.writeFile(path, prefix + existingContent)
}

interface VendorNPMSpecifiersOptions {
basePath: string
directory?: string
Expand Down Expand Up @@ -149,21 +190,22 @@ export const vendorNPMSpecifiers = async ({
)

// If we found no specifiers, there's nothing left to do here.
if (npmSpecifiers.length === 0) {
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, index) => {
Object.entries(npmSpecifiers).map(async ([specifier, { types }], index) => {
const code = `import * as mod from "${specifier}"; export default mod.default; export * from "${specifier}";`
const filePath = path.join(temporaryDirectory.path, `barrel-${index}.js`)
const barrelName = `barrel-${index}.js`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stuff for a future PR: it doesn't make a lot of sense to call these barrel files. At this point, when we create them, they are indeed barrel files, but further below after esbuild runs they will become the actual modules. It'd be great to name these with a slug of the module name and version.

const filePath = path.join(temporaryDirectory.path, barrelName)

await fs.writeFile(filePath, code)

return { filePath, specifier }
return { filePath, specifier, barrelName, types }
}),
)
const entryPoints = ops.map(({ filePath }) => filePath)
Expand All @@ -185,6 +227,13 @@ export const vendorNPMSpecifiers = async ({
target: 'es2020',
})

for (const { barrelName, types } of ops) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do this only when we're building for local development? We don't get any value from doing it for production builds and we'll pay the price of performance + adding a point of failure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 59d60b4

if (!types) continue
// we're updating the output instead of adding this to the input,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's really annoying that esbuild doesn't make it possible to preserve certain comments in the output (only if they follow the pattern of legal comments).

Otherwise it'd be super easy for us to add the comment to the barrel file and avoid getting in the business of re-writing files.

😞

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, also checked legal comments :/ The current way is pretty much the easiest, sadly ...

// because esbuild will erase the directive while bundling
await prependFile(path.join(temporaryDirectory.path, barrelName), `/// <reference types="${types}" />`)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These will be absolute URLs. Should we change those to relative URLs instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be nicer, yes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e4a82a7. Had to change servePath to a repo-local directory to test this.

}

// 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.
Expand Down
14 changes: 13 additions & 1 deletion node/server/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFile } from 'fs/promises'
import { join } from 'path'

import getPort from 'get-port'
Expand All @@ -24,6 +25,9 @@ test('Starts a server and serves requests for edge functions', async () => {
importMapPaths,
port,
servePath,
featureFlags: {
edge_functions_npm_modules: true,
},
})

const functions = [
Expand Down Expand Up @@ -51,7 +55,7 @@ test('Starts a server and serves requests for edge functions', async () => {
},
options,
)
expect(features).toEqual({})
expect(features).toEqual({ npmModules: true })
expect(success).toBe(true)
expect(functionsConfig).toEqual([{ path: '/my-function' }, {}, { path: '/global-netlify' }])

Expand Down Expand Up @@ -95,4 +99,12 @@ test('Starts a server and serves requests for edge functions', async () => {
global: 'i love netlify',
local: 'i love netlify',
})

const idBarrelFile = await readFile(join(servePath, 'barrel-0.js'), 'utf-8')
expect(idBarrelFile).toContain(`/// <reference types="${join(basePath, 'node_modules', 'id', 'types.d.ts')}" />`)

const identidadeBarrelFile = await readFile(join(servePath, 'barrel-1.js'), 'utf-8')
expect(identidadeBarrelFile).toContain(
`/// <reference types="${join(basePath, 'node_modules', '@types', 'pt-committee__identidade', 'index.d.ts')}" />`,
)
})
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Config } from 'https://edge.netlify.com'

import { yell } from 'helper'
import id from 'id'
import identidade from '@pt-committee/identidade'

export default () => new Response(yell(Deno.env.get('very_secret_secret') ?? ''))
export default () => {
return new Response(yell(identidade(id(Deno.env.get('very_secret_secret'))) ?? ''))
}

export const config: Config = {
path: '/my-function',
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/serve_test/node_modules/id/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions test/fixtures/serve_test/node_modules/id/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/fixtures/serve_test/node_modules/id/types.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.