diff --git a/docs/problems/NamedExports.md b/docs/problems/NamedExports.md new file mode 100644 index 0000000..a0571e4 --- /dev/null +++ b/docs/problems/NamedExports.md @@ -0,0 +1,21 @@ +# 🕵️ Named ESM exports + +Module advertises named exports which will not exist at runtime. + +## Explanation + +Static analysis of the distributed **JavaScript** files in this module found that the exports +declared in **TypeScript** do not exist when imported from ESM. + +## Consequences + +Consumers of this module will run into problems when importing named exports. + +```ts +import { utility } from "pkg"; +// SyntaxError: The requested module 'pkg' does not provide an export named 'utility' +``` + +## Common causes + +[...] diff --git a/packages/cli/src/problemUtils.ts b/packages/cli/src/problemUtils.ts index f9eefe8..94085de 100644 --- a/packages/cli/src/problemUtils.ts +++ b/packages/cli/src/problemUtils.ts @@ -9,6 +9,7 @@ export const problemFlags = { CJSResolvesToESM: "cjs-resolves-to-esm", FallbackCondition: "fallback-condition", CJSOnlyExportsDefault: "cjs-only-exports-default", + NamedExports: "named-exports", FalseExportDefault: "false-export-default", MissingExportEquals: "missing-export-equals", UnexpectedModuleSyntax: "unexpected-module-syntax", diff --git a/packages/core/package.json b/packages/core/package.json index b45eed0..6f56f7a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ }, "type": "module", "imports": { - "#internal/*": "./dist/internal/*" + "#*": "./dist/*" }, "exports": { ".": { @@ -51,6 +51,7 @@ }, "dependencies": { "@andrewbranch/untar.js": "^1.0.3", + "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.2", "semver": "^7.5.4", "typescript": "5.6.1-rc", diff --git a/packages/core/src/checkPackage.ts b/packages/core/src/checkPackage.ts index 028355b..03cd15f 100644 --- a/packages/core/src/checkPackage.ts +++ b/packages/core/src/checkPackage.ts @@ -1,4 +1,5 @@ import type { Package } from "./createPackage.js"; +import { init as initCjsLexer } from "cjs-module-lexer"; import checks from "./internal/checks/index.js"; import type { AnyCheck, CheckDependenciesContext } from "./internal/defineCheck.js"; import { createCompilerHosts } from "./internal/multiCompilerHost.js"; @@ -60,6 +61,7 @@ export async function checkPackage(pkg: Package, options?: CheckPackageOptions): bundler: {}, }; + await initCjsLexer(); const problems: Problem[] = []; const problemIdsToIndices = new Map(); visitResolutions(entrypointResolutions, (analysis, info) => { diff --git a/packages/core/src/internal/checks/index.ts b/packages/core/src/internal/checks/index.ts index b7ec801..3c0686d 100644 --- a/packages/core/src/internal/checks/index.ts +++ b/packages/core/src/internal/checks/index.ts @@ -1,4 +1,5 @@ import cjsOnlyExportsDefault from "./cjsOnlyExportsDefault.js"; +import namedExports from "./namedExports.js"; import entrypointResolutions from "./entrypointResolutions.js"; import exportDefaultDisagreement from "./exportDefaultDisagreement.js"; import internalResolutionError from "./internalResolutionError.js"; @@ -9,6 +10,7 @@ export default [ entrypointResolutions, moduleKindDisagreement, exportDefaultDisagreement, + namedExports, cjsOnlyExportsDefault, unexpectedModuleSyntax, internalResolutionError, diff --git a/packages/core/src/internal/checks/namedExports.ts b/packages/core/src/internal/checks/namedExports.ts new file mode 100644 index 0000000..05bb1a8 --- /dev/null +++ b/packages/core/src/internal/checks/namedExports.ts @@ -0,0 +1,66 @@ +import ts from "typescript"; +import { defineCheck } from "../defineCheck.js"; +import { getEsmModuleNamespace } from "../esm/esmNamespace.js"; + +export default defineCheck({ + name: "NamedExports", + dependencies: ({ entrypoints, subpath, resolutionKind }) => { + const entrypoint = entrypoints[subpath].resolutions[resolutionKind]; + const typesFileName = entrypoint.resolution?.fileName; + const implementationFileName = entrypoint.implementationResolution?.fileName; + return [implementationFileName, typesFileName, resolutionKind]; + }, + execute: ([implementationFileName, typesFileName, resolutionKind], context) => { + if (!implementationFileName || !typesFileName || resolutionKind !== "node16-esm") { + return; + } + + // Get declared exported names from TypeScript + const host = context.hosts.findHostForFiles([typesFileName])!; + const typesSourceFile = host.getSourceFile(typesFileName)!; + const expectedNames = (() => { + if (typesSourceFile.scriptKind === ts.ScriptKind.JSON) { + // TypeScript reports top-level JSON keys as exports which is WRONG WRONG WRONG. A JSON file + // never export anything other than `default`. + return ["default"]; + } else { + // nb: This is incomplete and reports type-only exports. This should be fixed to only return + // expected runtime exports. + const typeChecker = host.createAuxiliaryProgram([typesFileName]).getTypeChecker(); + const typesExports = typeChecker.getExportsAndPropertiesOfModule(typesSourceFile.symbol); + return Array.from( + new Set( + typesExports + .flatMap((node) => [...(node.declarations?.values() ?? [])]) + .filter((node) => !ts.isTypeAlias(node) && !ts.isTypeDeclaration(node) && !ts.isNamespaceBody(node)) + .map((declaration) => declaration.symbol.escapedName) + .map(String), + ), + ); + } + })(); + + // Get actual exported names as seen by nodejs + const exports = (() => { + try { + return getEsmModuleNamespace(context.pkg, implementationFileName); + } catch { + // nb: If this fails then the result is indeterminate. This could happen in many cases, but + // a common one would be for packages which re-export from another another package. + } + })(); + if (exports) { + const missing = expectedNames.filter((name) => !exports.includes(name)); + if (missing.length > 0) { + const lengthWithoutDefault = (names: readonly string[]) => names.length - (names.includes("default") ? 1 : 0); + return { + kind: "NamedExports", + implementationFileName, + typesFileName, + isMissingAllNamed: lengthWithoutDefault(missing) === lengthWithoutDefault(expectedNames), + missing, + }; + } + } + }, +}); diff --git a/packages/core/src/internal/esm/cjsBindings.ts b/packages/core/src/internal/esm/cjsBindings.ts new file mode 100644 index 0000000..118bd0b --- /dev/null +++ b/packages/core/src/internal/esm/cjsBindings.ts @@ -0,0 +1,6 @@ +import type { Exports } from "cjs-module-lexer"; +import { parse as cjsParse } from "cjs-module-lexer"; + +export function getCjsModuleBindings(sourceText: string): Exports { + return cjsParse(sourceText); +} diff --git a/packages/core/src/internal/esm/cjsNamespace.ts b/packages/core/src/internal/esm/cjsNamespace.ts new file mode 100644 index 0000000..99470ad --- /dev/null +++ b/packages/core/src/internal/esm/cjsNamespace.ts @@ -0,0 +1,32 @@ +import { Package } from "../../createPackage.js"; +import { getCjsModuleBindings } from "./cjsBindings.js"; +import { cjsResolve } from "./cjsResolve.js"; + +export function getCjsModuleNamespace(fs: Package, file: URL, seen = new Set()) { + seen.add(file.pathname); + const { exports, reexports } = getCjsModuleBindings(fs.readFile(file.pathname)); + + // CJS always exports `default` + if (!exports.includes("default")) { + exports.push("default"); + } + + // Additionally, resolve facade reexports + const lastResolvableReexport = (() => { + for (const source of reexports.reverse()) { + try { + return cjsResolve(fs, source, file); + } catch {} + } + })(); + if ( + lastResolvableReexport && + lastResolvableReexport.format === "commonjs" && + !seen.has(lastResolvableReexport.resolved.pathname) + ) { + const extra = getCjsModuleNamespace(fs, lastResolvableReexport.resolved, seen); + exports.push(...extra.filter((name) => !exports.includes(name))); + } + + return exports; +} diff --git a/packages/core/src/internal/esm/cjsResolve.ts b/packages/core/src/internal/esm/cjsResolve.ts new file mode 100644 index 0000000..7689300 --- /dev/null +++ b/packages/core/src/internal/esm/cjsResolve.ts @@ -0,0 +1,299 @@ +import { Package } from "../../createPackage.js"; +import { + esmFileFormat, + lookupPackageScope, + packageExportsResolve, + packageImportsResolve, + readPackageJson, +} from "./esmResolve.js"; +import { nodeCoreModules } from "./nodeModules.js"; + +// require(X) from module at path Y +export function cjsResolve(fs: Package, fragment: string, parentURL: URL) { + // 1. If X is a core module, + // a. return the core module + // b. STOP + if (fragment.startsWith("node:")) { + return { format: "node", resolved: new URL(fragment) }; + } else if (nodeCoreModules.includes(fragment)) { + return { format: "node", resolved: new URL(`node:${fragment}`) }; + } + + // 2. If X begins with '/' + if (fragment.startsWith("/")) { + // a. set Y to be the file system root + // nb: omitted + throw new Error("not implemented"); + } + + // 3. If X begins with './' or '/' or '../' + if (fragment.startsWith("./") || fragment.startsWith("../")) { + // a. LOAD_AS_FILE(Y + X) + const asFile = loadAsFile(fs, fragment, parentURL); + if (asFile) { + return asFile; + } + + // b. LOAD_AS_DIRECTORY(Y + X) + const asDirectory = loadAsDirectory(fs, new URL(`${fragment}/`, parentURL)); + if (asDirectory) { + return asDirectory; + } + + // c. THROW "not found" + throw new Error("not found"); + } + + // 4. If X begins with '#' + if (fragment.startsWith("#")) { + // a. LOAD_PACKAGE_IMPORTS(X, dirname(Y)) + const asPackageImports = loadPackageImports(fs, fragment, new URL("./", parentURL)); + if (asPackageImports) { + return asPackageImports; + } + } + + // 5. LOAD_PACKAGE_SELF(X, dirname(Y)) + const asSelf = loadPackageSelf(fs, fragment, new URL("./", parentURL)); + if (asSelf) { + return asSelf; + } + + // 6. LOAD_NODE_MODULES(X, dirname(Y)) + const asNodeModules = loadNodeModules(fs, fragment, new URL("./", parentURL)); + if (asNodeModules) { + return asNodeModules; + } + + // 7. THROW "not found" + throw new Error("not found"); +} + +// LOAD_AS_FILE(X) +function loadAsFile(fs: Package, fragment: string, parentURL: URL) { + // 1. If X is a file, load X as its file extension format. STOP + const asFile = new URL(fragment, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asFile))) { + return loadWithFormat(fs, asFile); + } + + // 2. If X.js is a file, load X.js as JavaScript text. STOP + const asJsFile = new URL(`${fragment}.js`, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asJsFile))) { + return loadWithFormat(fs, asJsFile); + } + + // 3. If X.json is a file, parse X.json to a JavaScript Object. STOP + const asJsonFile = new URL(`${fragment}.json`, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asJsonFile))) { + return loadWithFormat(fs, asJsonFile); + } + + // 4. If X.node is a file, load X.node as binary addon. STOP + const asNodeFile = new URL(`${fragment}.node`, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asNodeFile))) { + return { format: "node", resolved: asNodeFile }; + } +} + +// LOAD_INDEX(X) +function loadIndex(fs: Package, fragment: string, parentURL: URL) { + // 1. If X/index.js is a file, load X/index.js as JavaScript text. STOP + const asJsIndex = new URL(`${fragment}/index.js`, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asJsIndex))) { + return loadWithFormat(fs, asJsIndex); + } + + // 2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP + const asJsonIndex = new URL(`${fragment}/index.json`, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asJsonIndex))) { + return loadWithFormat(fs, asJsonIndex); + } + + // 3. If X/index.node is a file, load X/index.node as binary addon. STOP + const asNodeIndex = new URL(`${fragment}/index.node`, parentURL); + if (fs.fileExists(verbatimFileURLToPath(asNodeIndex))) { + return { format: "native", resolved: asNodeIndex }; + } +} + +// LOAD_AS_DIRECTORY(X) +function loadAsDirectory(fs: Package, path: URL) { + // 1. If X/package.json is a file, + // a. Parse X/package.json, and look for "main" field. + const pjson = readPackageJson(fs, path); + // b. If "main" is a falsy value, GOTO 2. + if (pjson === null || !pjson.name) { + // c. let M = X + (json main field) + // d. LOAD_AS_FILE(M) + // e. LOAD_INDEX(M) + // f. LOAD_INDEX(X) DEPRECATED + // g. THROW "not found" + } + // 2. LOAD_INDEX(X) + return loadIndex(fs, ".", path); +} + +function loadWithFormat(fs: Package, resolved: URL) { + // nb: The algorithm doesn't specify this but the implementation seems to do something similar. + // You cannot require a bare `.js` file from a `.cjs` parent with a `{"type":"module"}` + // `package.json`. + const format = esmFileFormat(fs, resolved); + return { format, resolved }; +} + +// LOAD_NODE_MODULES(X, START) +function loadNodeModules(fs: Package, fragment: string, parentURL: URL) { + // 1. let DIRS = NODE_MODULES_PATHS(START) + // 2. for each DIR in DIRS: + for (const dir of nodeModulesPaths(parentURL)) { + // a. LOAD_PACKAGE_EXPORTS(X, DIR) + const asPackageExports = loadPackageExports(fs, fragment, dir); + if (asPackageExports) { + return asPackageExports; + } + + // b. LOAD_AS_FILE(DIR/X) + const asFile = loadAsFile(fs, fragment, dir); + if (asFile) { + return asFile; + } + + // c. LOAD_AS_DIRECTORY(DIR/X) + const asDirectory = loadAsDirectory(fs, new URL(`${fragment}/`, dir)); + if (asDirectory) { + return asDirectory; + } + } +} + +// NODE_MODULES_PATHS(START) +function* nodeModulesPaths(path: URL) { + // 1. let PARTS = path split(START) + // 2. let I = count of PARTS - 1 + // 3. let DIRS = [] + // 4. while I >= 0, + if (path.protocol !== "file:") { + return; + } + do { + // a. if PARTS[I] = "node_modules" CONTINUE + if (path.pathname.endsWith("/node_modules/")) { + continue; + } + // b. DIR = path join(PARTS[0 .. I] + "node_modules") + yield new URL("./node_modules/", path); + // c. DIRS = DIR + DIRS + // d. let I = I - 1 + path = new URL("../", path); + } while (path.pathname !== "/"); + // 5. return DIRS + GLOBAL_FOLDERS +} + +// LOAD_PACKAGE_IMPORTS(X, DIR) +function loadPackageImports(fs: Package, fragment: string, parentURL: URL) { + // 1. Find the closest package scope SCOPE to DIR. + const packageURL = lookupPackageScope(fs, parentURL); + + // 2. If no scope was found, return. + if (packageURL === null) { + return; + } + + // 3. If the SCOPE/package.json "imports" is null or undefined, return. + const pjson = readPackageJson(fs, packageURL); + if (pjson.imports == null) { + return; + } + + // 4. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE), ["node", "require"]) defined in + // the ESM resolver. + const match = packageImportsResolve(fs, fragment, packageURL, ["node", "require"]); + + // 5. RESOLVE_ESM_MATCH(MATCH). + return resolveEsmMatch(fs, match); +} + +// LOAD_PACKAGE_EXPORTS(X, DIR) +function loadPackageExports(fs: Package, fragment: string, parentURL: URL) { + // 1. Try to interpret X as a combination of NAME and SUBPATH where the name + // may have a @scope/ prefix and the subpath begins with a slash (`/`). + const matches = /^((?:@[^/]+\/)?[^/]+)(.*)$/.exec(fragment); + // 2. If X does not match this pattern or DIR/NAME/package.json is not a file, + // return. + if (matches === null) { + return; + } + const dir = new URL(`${matches[1]}/`, parentURL); + const subpath = matches[2]; + + // 3. Parse DIR/NAME/package.json, and look for "exports" field. + const pjson = readPackageJson(fs, dir); + if (pjson === null) { + return; + } + + // 4. If "exports" is null or undefined, return. + if (pjson.exports == null) { + return; + } + + // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH, `package.json` + // "exports", ["node", "require"]) defined in the ESM resolver. + const match = packageExportsResolve(fs, dir, `.${subpath}`, pjson.exports, ["node", "require"]); + + // 6. RESOLVE_ESM_MATCH(MATCH) + return resolveEsmMatch(fs, match); +} + +// LOAD_PACKAGE_SELF(X, DIR) +function loadPackageSelf(fs: Package, fragment: string, parentURL: URL) { + // 1. Find the closest package scope SCOPE to DIR. + const packageURL = lookupPackageScope(fs, parentURL); + + // 2. If no scope was found, return. + if (packageURL === null) { + return; + } + + // 3. If the SCOPE/package.json "exports" is null or undefined, return. + const pjson = readPackageJson(fs, packageURL); + if (pjson.exports == null) { + return; + } + + // 4. If the SCOPE/package.json "name" is not the first segment of X, return. + if (fragment !== pjson.name && !fragment.startsWith(`${pjson.name}/`)) { + return; + } + + // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE), "." + X.slice("name".length), + // `package.json` "exports", ["node", "require"]) defined in the ESM resolver. + const match = packageExportsResolve(fs, packageURL, `./${fragment.slice(pjson.name.length)}`, pjson.exports, [ + "node", + "require", + ]); + + // 6. RESOLVE_ESM_MATCH(MATCH) + return resolveEsmMatch(fs, match); +} + +// RESOLVE_ESM_MATCH(MATCH) +function resolveEsmMatch(fs: Package, match: URL) { + // 1. let RESOLVED_PATH = fileURLToPath(MATCH) + const resolvedPath = verbatimFileURLToPath(match); + + // 2. If the file at RESOLVED_PATH exists, load RESOLVED_PATH as its extension format. STOP + if (fs.fileExists(resolvedPath)) { + return loadWithFormat(fs, match); + } + + // 3. THROW "not found" + throw new Error("not found"); +} + +// nb: We use URLs for the path traversal convenience, but `require("./file.cjs?")` should be read +// as-is. +function verbatimFileURLToPath(url: URL) { + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/packages/core/src/internal/esm/esmBindings.ts b/packages/core/src/internal/esm/esmBindings.ts new file mode 100644 index 0000000..088adff --- /dev/null +++ b/packages/core/src/internal/esm/esmBindings.ts @@ -0,0 +1,110 @@ +import type { Exports } from "cjs-module-lexer"; +import ts from "typescript"; + +// Note: There is a pretty solid module `es-module-lexer` which performs a similar lexing operation +// as `cjs-module-lexer`, but has some limitations in what it can express. This implementation +// should be more complete. + +function* extractDestructedNames(node: ts.BindingName): Iterable { + switch (node.kind) { + case ts.SyntaxKind.ArrayBindingPattern: + for (const element of node.elements) { + if (element.kind === ts.SyntaxKind.BindingElement) { + yield* extractDestructedNames(element.name); + } + } + break; + + case ts.SyntaxKind.Identifier: + yield node.text; + break; + + case ts.SyntaxKind.ObjectBindingPattern: + for (const element of node.elements) { + yield* extractDestructedNames(element.name); + } + break; + + default: + node satisfies never; + } +} + +export function getEsmModuleBindings(sourceText: string): Exports { + const options: ts.CreateSourceFileOptions = { + languageVersion: ts.ScriptTarget.ESNext, + impliedNodeFormat: ts.ModuleKind.ESNext, + }; + const sourceFile = ts.createSourceFile("module.cjs", sourceText, options, false, ts.ScriptKind.JS); + + const exports: string[] = []; + const reexports: string[] = []; + for (const statement of sourceFile.statements) { + switch (statement.kind) { + case ts.SyntaxKind.ExportDeclaration: { + const declaration = statement as ts.ExportDeclaration; + const { exportClause } = declaration; + if (!declaration.isTypeOnly) { + if (exportClause) { + if (exportClause.kind === ts.SyntaxKind.NamedExports) { + // `export { foo }`; + // `export { foo } from 'specifier'`; + for (const element of exportClause.elements) { + if (!element.isTypeOnly) { + exports.push(element.name.text); + } + } + } else { + // `export * as namespace from 'specifier'` + exports.push(exportClause.name.text); + } + } else if (declaration.moduleSpecifier && ts.isStringLiteral(declaration.moduleSpecifier)) { + // `export * from 'specifier'` + reexports.push(declaration.moduleSpecifier.text); + } + } + break; + } + + case ts.SyntaxKind.ExportAssignment: { + const assignment = statement as ts.ExportAssignment; + if (!assignment.isExportEquals) { + // `export default ...` + exports.push("default"); + } + break; + } + + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.FunctionDeclaration: { + const declaration = statement as ts.ClassDeclaration | ts.FunctionDeclaration; + if (ts.hasSyntacticModifier(declaration, ts.ModifierFlags.Export)) { + if (ts.hasSyntacticModifier(declaration, ts.ModifierFlags.Default)) { + // `export default class {}` + // `export default function () {}` + exports.push("default"); + } else if (declaration.name) { + // `export class Foo {}` + // `export function foo() {}` + exports.push(declaration.name.text); + } + } + break; + } + + case ts.SyntaxKind.VariableStatement: { + const declaration = statement as ts.VariableStatement; + if (ts.hasSyntacticModifier(declaration, ts.ModifierFlags.Export)) { + // `export const foo = null;` + // `export const { foo, bar } = null;` + for (const declarator of declaration.declarationList.declarations) { + exports.push(...extractDestructedNames(declarator.name)); + } + } + break; + } + } + } + + return { exports, reexports }; +} diff --git a/packages/core/src/internal/esm/esmNamespace.ts b/packages/core/src/internal/esm/esmNamespace.ts new file mode 100644 index 0000000..390b923 --- /dev/null +++ b/packages/core/src/internal/esm/esmNamespace.ts @@ -0,0 +1,40 @@ +import { Package } from "../../createPackage.js"; +import { getEsmModuleBindings } from "./esmBindings.js"; +import { esmResolve } from "./esmResolve.js"; +import { getCjsModuleNamespace } from "./cjsNamespace.js"; + +// Note: this doesn't handle ambiguous indirect exports which probably isn't worth the +// implementation complexity. + +export function getEsmModuleNamespace( + fs: Package, + specifier: string, + parentURL = new URL("file:///"), + seen = new Set(), +): string[] { + // Resolve specifier + const { format, resolved } = esmResolve(fs, specifier, parentURL); + + // Don't recurse for circular indirect exports + if (seen.has(resolved.pathname)) { + return []; + } + seen.add(resolved.pathname); + + if (format === "commonjs") { + return getCjsModuleNamespace(fs, resolved); + } + + // Parse module bindings + const bindings = + (format ?? "module") === "module" + ? getEsmModuleBindings(fs.readFile(resolved.pathname)) + : // Maybe JSON, WASM, etc + { exports: ["default"], reexports: [] }; + + // Concat indirect exports + const indirect = bindings.reexports + .flatMap((specifier) => getEsmModuleNamespace(fs, specifier, resolved, seen)) + .filter((name) => name !== "default"); + return [...new Set([...bindings.exports, ...indirect])]; +} diff --git a/packages/core/src/internal/esm/esmResolve.ts b/packages/core/src/internal/esm/esmResolve.ts new file mode 100644 index 0000000..86ca56a --- /dev/null +++ b/packages/core/src/internal/esm/esmResolve.ts @@ -0,0 +1,689 @@ +import { Package } from "../../createPackage.js"; +import { nodeCoreModules } from "./nodeModules.js"; + +// defaultConditions is the conditional environment name array, [ "node", "import" ]. +const defaultConditions = ["node", "import"]; + +// ESM_RESOLVE(specifier, parentURL) +export function esmResolve(fs: Package, specifier: string, parentURL: URL) { + // 1. Let resolved be undefined. + const resolved = (() => { + try { + // 2. If specifier is a valid URL, then + // 1. Set resolved to the result of parsing and reserializing specifier as a URL. + return new URL(specifier); + } catch {} + + // 3. Otherwise, if specifier starts with "/", "./", or "../", then + // 1. Set resolved to the URL resolution of specifier relative to parentURL. + if (/^(\/|\.\.?\/)/.test(specifier)) { + return new URL(specifier, parentURL); + } + + // 4. Otherwise, if specifier starts with "#", then + // 1. Set resolved to the result of PACKAGE_IMPORTS_RESOLVE(specifier, parentURL, defaultConditions). + if (specifier.startsWith("#")) { + return packageImportsResolve(fs, specifier, parentURL, defaultConditions); + } + + // 5. Otherwise, + // 1. Note: specifier is now a bare specifier. + // 2. Set resolved the result of PACKAGE_RESOLVE(specifier, parentURL). + return packageResolve(fs, specifier, parentURL); + })(); + + // 6. Let format be undefined. + const format = (() => { + // 7. If resolved is a "file:" URL, then + if (resolved.protocol === "file:") { + // 1. If resolved contains any percent encodings of "/" or "\" ("%2F" and "%5C" respectively), then + if (/%2F|%5C/.test(resolved.href)) { + // 1. Throw an Invalid Module Specifier error. + throw new Error("Invalid Module Specifier"); + } + + // 2. If the file at resolved is a directory, then + if (fs.directoryExists(resolved.pathname)) { + // 1. Throw an Unsupported Directory Import error. + throw new Error("Unsupported Directory Import"); + } + + // 3. If the file at resolved does not exist, then + if (!fs.fileExists(resolved.pathname)) { + // 1. Throw a Module Not Found error. + throw new Error("Module Not Found"); + } + + // 4. Set resolved to the real path of resolved, maintaining the same URL querystring and fragment components. + // 5. Set format to the result of ESM_FILE_FORMAT(resolved). + return esmFileFormat(fs, resolved); + } + + // 8. Otherwise, + if (resolved.protocol === "node:") { + // 1. Set format the module format of the content type associated with the URL resolved. + return "node"; + } + + // nb: otherwise omitted + return; + })(); + + // 9. Return format and resolved to the loading phase + return { format, resolved }; +} + +// PACKAGE_RESOLVE(packageSpecifier, parentURL) +function packageResolve(fs: Package, packageSpecifier: string, parentURL: URL) { + // 1. Let packageName be undefined. + const packageName = (() => { + // 2. If packageSpecifier is an empty string, then + if (packageSpecifier === "") { + // 1. Throw an Invalid Module Specifier error. + throw new Error("Invalid Module Specifier"); + } + + // 3. If packageSpecifier is a Node.js builtin module name, then + if (nodeCoreModules.includes(packageSpecifier)) { + // 1. Return the string "node:" concatenated with packageSpecifier. + return `node:${packageSpecifier}`; + } + + // 4. If packageSpecifier does not start with "@", then + if (!packageSpecifier.startsWith("@")) { + // 1. Set packageName to the substring of packageSpecifier until the first "/" separator or + // the end of the string. + return packageSpecifier.split("/")[0]; + } + + // 5. Otherwise, + const matches = /^([^/]*\/[^/]*)/.exec(packageSpecifier); + // 1. If packageSpecifier does not contain a "/" separator, then + if (matches === null) { + // 1. Throw an Invalid Module Specifier error. + throw new Error("Invalid Module Specifier"); + } + // 2. Set packageName to the substring of packageSpecifier until the second "/" separator or the + // end of the string. + return matches[1]; + })(); + + // 6. If packageName starts with "." or contains "\" or "%", then + if (packageName.startsWith(".") || packageName.includes("\\") || packageName.includes("%")) { + // 1. Throw an Invalid Module Specifier error. + throw new Error("Invalid Module Specifier"); + } + + // 7. Let packageSubpath be "." concatenated with the substring of packageSpecifier from the + // position at the length of packageName. + const packageSubpath = `.${packageSpecifier.substring(packageName.length)}`; + + // 8. If packageSubpath ends in "/", then + if (packageSubpath.endsWith("/")) { + // 1. Throw an Invalid Module Specifier error. + throw new Error("Invalid Module Specifier"); + } + + // 9. Let selfUrl be the result of PACKAGE_SELF_RESOLVE(packageName, packageSubpath, parentURL). + const selfUrl = packageSelfResolve(fs, packageName, packageSubpath, parentURL); + + // 10. If selfUrl is not undefined, return selfUrl. + if (selfUrl !== undefined) { + return selfUrl; + } + + // 11. While parentURL is not the file system root, + // nb: Modified to search up to "root" + do { + // 1. Let packageURL be the URL resolution of "node_modules/" concatenated with + // packageSpecifier, relative to parentURL. + const packageURL = new URL(`node_modules/${packageSpecifier}/`, parentURL); + + // 2. Set parentURL to the parent folder URL of parentURL. + parentURL = new URL("../", parentURL); + + // 3. If the folder at packageURL does not exist, then + if (!fs.directoryExists(packageURL.pathname)) { + // 1. Continue the next loop iteration. + continue; + } + + // 4. Let pjson be the result of READ_PACKAGE_JSON(packageURL). + const pjson = readPackageJson(fs, packageURL); + + // 5. If pjson is not null and pjson.exports is not null or undefined, then + if (pjson !== null && pjson.exports != null) { + // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, + // defaultConditions). + return packageExportsResolve(fs, packageURL, packageSubpath, pjson.exports, defaultConditions); + } + + // 6. Otherwise, if packageSubpath is equal to ".", then + if (packageSubpath === ".") { + // 1. If pjson.main is a string, then + if (typeof pjson.main === "string") { + // 1. Return the URL resolution of main in packageURL. + return new URL(pjson.main, packageURL); + } + + // 2. Otherwise, + // 1. Return the URL resolution of packageSubpath in packageURL. + return new URL(packageSubpath, packageURL); + } + } while (parentURL.pathname !== "/"); + + // 12. Throw a Module Not Found error + throw new Error("Module Not Found"); +} + +// PACKAGE_SELF_RESOLVE(packageName, packageSubpath, parentURL) +function packageSelfResolve(fs: Package, packageName: string, packageSubpath: string, parentURL: URL) { + // 1. Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL). + const packageURL = lookupPackageScope(fs, parentURL); + + // 2. If packageURL is null, then + if (packageURL === null) { + // 1. Return undefined. + return; + } + + // 3. Let pjson be the result of READ_PACKAGE_JSON(packageURL). + const pjson = readPackageJson(fs, packageURL); + + // 4. If pjson is null or if pjson.exports is null or undefined, then + if (pjson === null || pjson.exports == null) { + // 1. Return undefined. + return; + } + + // 5. If pjson.name is equal to packageName, then + if (pjson.name === packageName) { + // 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions). + return packageExportsResolve(fs, packageURL, packageSubpath, pjson.exports, defaultConditions); + } + + // 6. Otherwise, return undefined. + return; +} + +// PACKAGE_EXPORTS_RESOLVE(packageURL, subpath, exports, conditions) +export function packageExportsResolve( + fs: Package, + packageURL: URL, + subpath: string, + exports: unknown, + conditions: readonly string[], +) { + // 1. If exports is an Object with both a key starting with "." and a key not starting with ".", + // throw an Invalid Package Configuration error. + const exportsIsObject = typeof exports === "object" && exports !== null; + const exportsKeys = exportsIsObject ? Object.keys(exports) : undefined; + const hasDotKeys = exportsKeys?.some((key) => key.startsWith(".")); + const hasNonDotKeys = exportsKeys?.some((key) => !key.startsWith(".")); + if (hasDotKeys && hasNonDotKeys) { + throw new Error("Invalid Package Configuration"); + } + + // 2. If subpath is equal to ".", then + if (subpath === ".") { + // 1. Let mainExport be undefined. + const mainExport = (() => { + // 2. If exports is a String or Array, or an Object containing no keys starting with ".", then + if (typeof exports === "string" || Array.isArray(exports) || !hasDotKeys) { + // 1. Set mainExport to exports. + return exports; + } + + // 3. Otherwise if exports is an Object containing a "." property, then + if (exportsIsObject && "." in exports) { + // 1. Set mainExport to the value of the "." property in exports. + return exports["."]; + } + })(); + + // 4. If mainExport is not undefined, then + if (mainExport !== undefined) { + // 1. Let resolved be the result of PACKAGE_TARGET_RESOLVE(packageURL, mainExport, null, + // false, conditions). + const resolved = packageTargetResolve(fs, packageURL, mainExport, null, false, conditions); + + // 2. If resolved is not null or undefined, return resolved. + if (resolved != null) { + return resolved; + } + } + } + + // 3. Otherwise, if exports is an Object and all keys of exports start with ".", then + if (exportsIsObject && hasDotKeys && !hasNonDotKeys) { + // 1. Assert: subpath begins with "./". + // 2. Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE(subpath, exports, + // packageURL, false, conditions). + const resolved = packageImportsExportsResolve( + fs, + subpath, + exports satisfies object as Record, + packageURL, + false, + conditions, + ); + + // 3. If resolved is not null or undefined, return resolved. + if (resolved != null) { + return resolved; + } + } + + // 4. Throw a Package Path Not Exported error. + throw new Error("Package Path Not Exported"); +} + +// PACKAGE_IMPORTS_RESOLVE(specifier, parentURL, conditions) +export function packageImportsResolve(fs: Package, specifier: string, parentURL: URL, conditions: readonly string[]) { + // 1. Assert: specifier begins with "#". + // 2. If specifier is exactly equal to "#" or starts with "#/", then + if (specifier === "#" || specifier.startsWith("#/")) { + // 1. Throw an Invalid Module Specifier error. + throw new Error("Invalid Module Specifier"); + } + + // 3. Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL). + const packageURL = lookupPackageScope(fs, parentURL); + + // 4. If packageURL is not null, then + if (packageURL !== null) { + // 1. Let pjson be the result of READ_PACKAGE_JSON(packageURL). + const pjson = readPackageJson(fs, packageURL); + + // 2. If pjson.imports is a non-null Object, then + if (typeof pjson.imports === "object" && pjson?.imports !== null) { + // 1. Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE(specifier, pjson.imports, + // packageURL, true, conditions). + const resolved = packageImportsExportsResolve(fs, specifier, pjson.imports, packageURL, true, conditions); + + // 2. If resolved is not null or undefined, return resolved. + if (resolved != null) { + return resolved; + } + } + } + + // 5. Throw a Package Import Not Defined error. + throw new Error("Package Import Not Defined"); +} + +// PACKAGE_IMPORTS_EXPORTS_RESOLVE(matchKey, matchObj, packageURL, isImports, conditions) +function packageImportsExportsResolve( + fs: Package, + matchKey: string, + matchObj: Record, + packageURL: URL, + isImports: boolean, + conditions: readonly string[], +) { + // 1. If matchKey is a key of matchObj and does not contain "*", then + if (matchKey in matchObj && !matchKey.includes("*")) { + // 1. Let target be the value of matchObj[matchKey]. + const target = matchObj[matchKey]; + + // 2. Return the result of PACKAGE_TARGET_RESOLVE(packageURL, target, null, isImports, + // conditions). + return packageTargetResolve(fs, packageURL, target, null, isImports, conditions); + } + + // 2. Let expansionKeys be the list of keys of matchObj containing only a single "*", sorted by + // the sorting function PATTERN_KEY_COMPARE which orders in descending order of specificity. + const expansionKeys = Object.keys(matchObj) + .filter((key) => { + const ii = key.indexOf("*"); + return ii !== -1 && ii === key.lastIndexOf("*"); + }) + .sort(patternKeyCompare); + + // 3. For each key expansionKey in expansionKeys, do + for (const key of expansionKeys) { + // 1. Let patternBase be the substring of expansionKey up to but excluding the first "*" + // character. + const patternBase = key.substring(0, key.indexOf("*")); + + // 2. If matchKey starts with but is not equal to patternBase, then + if (matchKey.startsWith(patternBase) && matchKey !== patternBase) { + // 1. Let patternTrailer be the substring of expansionKey from the index after the first "*" + // character. + const patternTrailer = key.substring(key.indexOf("*") + 1); + + // 2. If patternTrailer has zero length, or if matchKey ends with patternTrailer and the + // length of matchKey is greater than or equal to the length of expansionKey, then + if (patternTrailer.length === 0 || (matchKey.endsWith(patternTrailer) && matchKey.length >= key.length)) { + // 1. Let target be the value of matchObj[expansionKey]. + const target = matchObj[key]; + + // 2. Let patternMatch be the substring of matchKey starting at the index of the length of + // patternBase up to the length of matchKey minus the length of patternTrailer. + const patternMatch = matchKey.substring(patternBase.length, matchKey.length - patternTrailer.length); + + // 3. Return the result of PACKAGE_TARGET_RESOLVE(packageURL, target, patternMatch, + // isImports, conditions). + return packageTargetResolve(fs, packageURL, target, patternMatch, isImports, conditions); + } + } + } + + // 4. Return null + return null; +} + +// PATTERN_KEY_COMPARE(keyA, keyB) +function patternKeyCompare(keyA: string, keyB: string) { + // 1. Assert: keyA ends with "/" or contains only a single "*". + // 2. Assert: keyB ends with "/" or contains only a single "*". + + // 3. Let baseLengthA be the index of "*" in keyA plus one, if keyA contains "*", or the length of keyA otherwise. + const baseLengthA = keyA.includes("*") ? keyA.indexOf("*") + 1 : keyA.length; + + // 4. Let baseLengthB be the index of "*" in keyB plus one, if keyB contains "*", or the length of keyB otherwise. + const baseLengthB = keyB.includes("*") ? keyB.indexOf("*") + 1 : keyB.length; + + // 5. If baseLengthA is greater than baseLengthB, return -1. + // 6. If baseLengthB is greater than baseLengthA, return 1. + const baseDifference = baseLengthB - baseLengthA; + if (baseDifference !== 0) { + return baseDifference; + } + + // 7. If keyA does not contain "*", return 1. + if (!keyA.includes("*")) { + return 1; + } + + // 8. If keyB does not contain "*", return -1. + if (!keyB.includes("*")) { + return -1; + } + + // 9. If the length of keyA is greater than the length of keyB, return -1. + // 10. If the length of keyB is greater than the length of keyA, return 1. + const difference = keyB.length - keyA.length; + if (difference !== 0) { + return difference; + } + + // 11. Return 0. + return 0; +} + +// PACKAGE_TARGET_RESOLVE(packageURL, target, patternMatch, isImports, conditions) +function packageTargetResolve( + fs: Package, + packageURL: URL, + target: unknown, + patternMatch: string | null, + isImports: boolean, + conditions: readonly string[], +): URL | null | undefined { + // 1. If target is a String, then + if (typeof target === "string") { + // 1. If target does not start with "./", then + if (!target.startsWith("./")) { + // 1. If isImports is false, or if target starts with "../" or "/", or if target is a valid + // URL, then + if (!isImports || target.startsWith("../") || target.startsWith("/") || URL.canParse(target)) { + // 1. Throw an Invalid Package Target error. + throw new Error("Invalid Package Target error"); + } + + // 2. If patternMatch is a String, then + if (patternMatch !== null) { + // 1. Return PACKAGE_RESOLVE(target with every instance of "*" replaced by patternMatch, + // packageURL + "/"). + return packageResolve(fs, target.replace(/\*/g, patternMatch), new URL(`${packageURL}/`)); + } else { + // 3. Return PACKAGE_RESOLVE(target, packageURL + "/"). + return packageResolve(fs, target, new URL(`${packageURL}/`)); + } + } + + // 2. If target split on "/" or "\" contains any "", ".", "..", or "node_modules" segments after + // the first "." segment, case insensitive and including percent encoded variants, throw an + // Invalid Package Target error. + if ( + target + .slice(2) + .split(/\/|\\/) + .some((segment) => segment === "" || segment === "." || segment === ".." || segment === "node_modules") + ) { + throw new Error("Invalid Package Target error"); + } + + // 3. Let resolvedTarget be the URL resolution of the concatenation of packageURL and target. + const resolvedTarget = new URL(target, packageURL); + + // 4. Assert: packageURL is contained in resolvedTarget. + // 5. If patternMatch is null, then + if (patternMatch === null) { + // 1. Return resolvedTarget. + return resolvedTarget; + } + + // 6. If patternMatch split on "/" or "\" contains any "", ".", "..", or "node_modules" + // segments, case insensitive and including percent encoded variants, throw an Invalid Module + // Specifier error. + if ( + patternMatch + .split(/\/|\\/) + .some((segment) => segment === "" || segment === "." || segment === ".." || segment === "node_modules") + ) { + throw new Error("Invalid Module Specifier"); + } + + // 7. Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch. + return new URL(resolvedTarget.href.replace(/\*/g, patternMatch)); + } + + // 2. Otherwise, if target is a non-null Object, then + if (typeof target === "object" && target !== null) { + // 1. If target contains any index property keys, as defined in ECMA-262 6.1.7 Array Index , + // throw an Invalid Package Configuration error. + if (Object.keys(target).some((key) => /^[0-9]+$/.test(key))) { + throw new Error("Invalid Package Configuration error"); + } + + // 2. For each property p of target, in object insertion order as, + for (const [property, targetValue] of Object.entries(target)) { + // 1. If p equals "default" or conditions contains an entry for p, then + if (property === "default" || conditions.includes(property)) { + // 1. Let targetValue be the value of the p property in target. + // 2. Let resolved be the result of PACKAGE_TARGET_RESOLVE(packageURL, targetValue, patternMatch, + // isImports, conditions). + const resolved = packageTargetResolve(fs, packageURL, targetValue, patternMatch, isImports, conditions); + + // 3. If resolved is undefined, continue the loop. + if (resolved === undefined) { + continue; + } + + // 4. Return resolved. + return resolved; + } + } + + // 3. Return undefined. + return; + } + + // 3. Otherwise, if target is an Array, then + if (Array.isArray(target)) { + // 1. If target.length is zero, return null. + if (target.length === 0) { + return null; + } + + // 2. For each item targetValue in target, do + for (const targetValue of target) { + // 1. Let resolved be the result of PACKAGE_TARGET_RESOLVE(packageURL, targetValue, patternMatch, + // isImports, conditions). + const resolved = packageTargetResolve(fs, packageURL, targetValue, patternMatch, isImports, conditions); + + // 2. If resolved is undefined, continue the loop. + if (resolved === undefined) { + continue; + } + + // 3. Return resolved. + return resolved; + } + + // 3. Return or throw the last fallback resolution null return or error. + // nb: ???? + return null; + } + + // 4. Otherwise, if target is null, return null. + if (target === null) { + return null; + } + + // 5. Otherwise throw an Invalid Package Target error. + throw new Error("Invalid Package Target error"); +} + +// ESM_FILE_FORMAT(url) +export function esmFileFormat(fs: Package, url: URL) { + // 1. Assert: url corresponds to an existing file. + // 2. If url ends in ".mjs", then + if (url.pathname.endsWith(".mjs")) { + // 1. Return "module". + return "module"; + } + + // 3. If url ends in ".cjs", then + if (url.pathname.endsWith(".cjs")) { + // 1. Return "commonjs". + return "commonjs"; + } + + // 4. If url ends in ".json", then + if (url.pathname.endsWith(".json")) { + // 1. Return "json". + return "json"; + } + + // 5. If --experimental-wasm-modules is enabled and url ends in ".wasm", then + if (url.pathname.endsWith(".wasm")) { + // 1. Return "wasm". + return "wasm"; + } + + // 6. Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(url). + const packageURL = lookupPackageScope(fs, url); + if (packageURL === null) { + // nb: The algorithm seems to be poorly specified here because `READ_PACKAGE_JSON` does not + // handle the null case, but `LOOKUP_PACKAGE_SCOPE` is allowed to return `null`. + throw new Error("Invalid Module Specifier"); + } + + // 7. Let pjson be the result of READ_PACKAGE_JSON(packageURL). + const pjson = readPackageJson(fs, packageURL); + + // 8. Let packageType be null. + // 9. If pjson?.type is "module" or "commonjs", then + // 1. Set packageType to pjson.type. + const packageType = pjson.type === "module" || pjson.type === "commonjs" ? (pjson.type as string) : null; + + // 10. If url ends in ".js", then + if (url.pathname.endsWith(".js")) { + // 1. If packageType is not null, then + if (packageType !== null) { + // 1. Return packageType. + if (typeof packageType !== "string") { + throw new Error("Invalid Package Configuration"); + } + return packageType; + } + + // 2. If --experimental-detect-module is enabled and the source of module contains static + // import or export syntax, then + // 1. Return "module". + // nb: omitted + + // 3. Return "commonjs". + return "commonjs"; + } + + // 11. If url does not have any extension, then + const segments = url.pathname.split("/"); + if (!segments[segments.length - 1].includes(".")) { + // 1. If packageType is "module" and --experimental-wasm-modules is enabled and the file at url + // contains the header for a WebAssembly module, then + // 1. Return "wasm". + // nb: omitted + + // 2. If packageType is not null, then + if (packageType !== null) { + // 1. Return packageType. + return packageType; + } + + // 3. If --experimental-detect-module is enabled and the source of module contains static import + // or export syntax, then + // 1. Return "module". + // nb: omitted + + // 4. Return "commonjs". + return "commonjs"; + } + + // 12. Return undefined (will throw during load phase). + return; +} + +// LOOKUP_PACKAGE_SCOPE(url) +export function lookupPackageScope(fs: Package, url: URL) { + if (url.protocol !== "file:") { + return null; + } + + // 1. Let scopeURL be url. + let scopeURL = url; + + // 2. While scopeURL is not the file system root, + // nb: Modified to search to include "root", also for "parent URL" operation. + do { + // 2. If scopeURL ends in a "node_modules" path segment, return null. + if (scopeURL.pathname.endsWith("/node_modules/")) { + return null; + } + + // 3. Let pjsonURL be the resolution of "package.json" within scopeURL. + const pjsonURL = new URL("package.json", scopeURL); + + // 4. if the file at pjsonURL exists, then + if (fs.fileExists(pjsonURL.pathname)) { + // 1. Return scopeURL. + return scopeURL; + } + + // 1. Set scopeURL to the parent URL of scopeURL. + scopeURL = new URL("../", scopeURL); + } while (url.pathname !== "/"); + + // 3. Return null. + return null; +} + +// READ_PACKAGE_JSON(packageURL) +export function readPackageJson(fs: Package, packageURL: URL) { + // 1. Let pjsonURL be the resolution of "package.json" within packageURL. + const pjsonURL = new URL("package.json", packageURL); + + // 2. If the file at pjsonURL does not exist, then + if (!fs.fileExists(pjsonURL.pathname)) { + // 1. Return null. + return null; + } + + // 3. If the file at packageURL does not parse as valid JSON, then + // 1. Throw an Invalid Package Configuration error. + // 4. Return the parsed JSON source of the file at pjsonURL. + return JSON.parse(fs.readFile(pjsonURL.pathname)); +} diff --git a/packages/core/src/internal/esm/nodeModules.ts b/packages/core/src/internal/esm/nodeModules.ts new file mode 100644 index 0000000..582c393 --- /dev/null +++ b/packages/core/src/internal/esm/nodeModules.ts @@ -0,0 +1,60 @@ +// Object.keys(process.binding("natives")).filter(name => !/^(?:_|internal\/)/.test(name)) +export const nodeCoreModules = [ + "assert", + "assert/strict", + "async_hooks", + "buffer", + "child_process", + "cluster", + // "configs", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "dns/promises", + "domain", + "events", + "fs", + "fs/promises", + "http", + "http2", + "https", + "inspector", + "inspector/promises", + "module", + "net", + "os", + "path", + "path/posix", + "path/win32", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "readline/promises", + "repl", + "stream", + "stream/consumers", + "stream/promises", + "stream/web", + "string_decoder", + "sys", + "test", + "test/reporters", + "timers", + "timers/promises", + "tls", + "trace_events", + "tty", + "url", + "util", + "util/types", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]; diff --git a/packages/core/src/internal/getEntrypointInfo.ts b/packages/core/src/internal/getEntrypointInfo.ts index b91ba44..77cd89a 100644 --- a/packages/core/src/internal/getEntrypointInfo.ts +++ b/packages/core/src/internal/getEntrypointInfo.ts @@ -24,9 +24,10 @@ function getEntrypoints(fs: Package, exportsObject: unknown, options: CheckPacka const proxies = getProxyDirectories(rootDir, fs); if (proxies.length === 0) { if (options?.entrypointsLegacy) { - return fs.listFiles() - .filter(f => !ts.isDeclarationFileName(f) && extensions.has(f.slice(f.lastIndexOf(".")))) - .map(f => "." + f.slice(rootDir.length)); + return fs + .listFiles() + .filter((f) => !ts.isDeclarationFileName(f) && extensions.has(f.slice(f.lastIndexOf(".")))) + .map((f) => "." + f.slice(rootDir.length)); } return ["."]; } diff --git a/packages/core/src/problems.ts b/packages/core/src/problems.ts index c2a5a3c..1a8b02e 100644 --- a/packages/core/src/problems.ts +++ b/packages/core/src/problems.ts @@ -39,6 +39,13 @@ export const problemKindInfo: Record = { description: "Import resolved to an ESM type declaration file, but a CommonJS JavaScript file.", docsUrl: "https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseESM.md", }, + NamedExports: { + emoji: "🕵️", + title: "Named runtime ESM types are inaccurate", + shortDescription: "Named ESM exports", + description: "Module advertises named ESM exports which will not exist at runtime.", + docsUrl: "https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/NamedExports.md", + }, CJSResolvesToESM: { emoji: "⚠️", title: "Entrypoint is ESM-only", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9c428fe..333f345 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -126,6 +126,12 @@ export interface CJSResolvesToESMProblem extends EntrypointResolutionProblem { kind: "CJSResolvesToESM"; } +export interface NamedExportsProblem extends FilePairProblem { + kind: "NamedExports"; + isMissingAllNamed: boolean; + missing: string[]; +} + export interface FallbackConditionProblem extends EntrypointResolutionProblem { kind: "FallbackCondition"; } @@ -162,6 +168,7 @@ export type Problem = | FalseESMProblem | FalseCJSProblem | CJSResolvesToESMProblem + | NamedExportsProblem | FallbackConditionProblem | FalseExportDefaultProblem | MissingExportEqualsProblem diff --git a/packages/core/test/getEsmLocalExports.ts b/packages/core/test/getEsmLocalExports.ts new file mode 100644 index 0000000..9fcef6f --- /dev/null +++ b/packages/core/test/getEsmLocalExports.ts @@ -0,0 +1,46 @@ +import { getEsmModuleBindings } from "#internal/esm/esmBindings.js"; +import assert from "node:assert"; +import { describe, test } from "node:test"; + +describe("getEsmModuleBindings", () => { + test("correctness", () => { + const body = `export default null; + export const declaration = null; + export const [ + arrayBinding = null, + ...arrayRest + ] = null; + export const { + objectBinding = null, + objectBinding: objectRebinding, + ...objectSpread + } = null; + const named = null; + export { named, named as renamed, named as "renamed string" }; + export class classDeclaration {} + export function functionDeclaration() {} + + export * as namespace from 'specifier'; + export * from 'specifier'; + `; + const expected = { + exports: [ + "default", + "declaration", + "arrayBinding", + "arrayRest", + "objectBinding", + "objectRebinding", + "objectSpread", + "named", + "renamed", + "renamed string", + "classDeclaration", + "functionDeclaration", + "namespace", + ], + reexports: ["specifier"], + }; + assert.deepStrictEqual(getEsmModuleBindings(body), expected); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c7653..6cd879e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@andrewbranch/untar.js': specifier: ^1.0.3 version: 1.0.3 + cjs-module-lexer: + specifier: ^1.2.3 + version: 1.2.3 fflate: specifier: ^0.8.2 version: 0.8.2 @@ -3035,7 +3038,12 @@ snapshots: ci-info@3.9.0: {} - clean-stack@2.2.0: {} + /cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + + /clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} cli-highlight@2.1.11: dependencies: