diff --git a/src/internal/builtins.ts b/src/internal/builtins.ts index 62ebad1..5e13a54 100644 --- a/src/internal/builtins.ts +++ b/src/internal/builtins.ts @@ -1,6 +1,13 @@ /* - * Extracted from Node.js v22.14.0 + * Extracted from Node.js v24.16.0 (`require('node:module').builtinModules`). * For some reason, Bun decided to extend "node:modules" with bun specific modules which makes it unreliable source. + * + * Note: This list intentionally contains only modules that are requirable WITHOUT + * the `node:` prefix (used to map a bare specifier like `fs` to `node:fs`). + * The `node:`-prefix-only modules added in recent Node releases + * (`node:sea`, `node:sqlite`, `node:test`, `node:test/reporters`) are deliberately + * excluded: their bare forms (e.g. `sqlite`) do not resolve in Node, and `node:` + * prefixed specifiers are already handled before this list is consulted. */ // prettier-ignore export const nodeBuiltins = [ diff --git a/src/internal/errors.ts b/src/internal/errors.ts index 2373466..33179ce 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -1,7 +1,6 @@ // Source: https://github.com/nodejs/node/blob/main/lib/internal/errors.js -// Changes: https://github.com/nodejs/node/commits/main/lib/internal/errors.js?since=2024-04-29 +// Changes: https://github.com/nodejs/node/commits/main/lib/internal/errors.js?since=2026-06-21 -import v8 from "node:v8"; import assert from "node:assert"; import { format, inspect } from "node:util"; @@ -16,8 +15,6 @@ export type ErrnoExceptionFields = { export type ErrnoException = Error & ErrnoExceptionFields; export type MessageFunction = (...parameters: Array) => string; -const own = {}.hasOwnProperty; - const classRegExp = /^([A-Z][a-z\d]*)+$/; // Sorted by a rough estimate on most frequently used entries. @@ -36,10 +33,6 @@ const kTypes = new Set([ const messages: Map = new Map(); -const nodeInternalPrefix = "__node_internal_"; - -let userStackTraceLimit: number; - /** * Create a list string in the form like 'A and B' or 'A, B, ..., and Z'. * We cannot use Intl.ListFormat because it's not available in @@ -52,9 +45,33 @@ let userStackTraceLimit: number; * @returns {string} */ function formatList(array: string[], type = "and"): string { - return array.length < 3 - ? array.join(` ${type} `) - : `${array.slice(0, -1).join(", ")}, ${type} ${array.at(-1)}`; + switch (array.length) { + case 0: { + return ""; + } + case 1: { + return `${array[0]}`; + } + case 2: { + return `${array[0]} ${type} ${array[1]}`; + } + case 3: { + return `${array[0]}, ${array[1]}, ${type} ${array[2]}`; + } + default: { + return `${array.slice(0, -1).join(", ")}, ${type} ${array.at(-1)}`; + } + } +} + +/** + * Count the number of `%`-style placeholders in a static message string. + */ +function getExpectedArgumentLength(message: string): number { + let expectedLength = 0; + const regex = /%[dfijoOs]/g; + while (regex.exec(message) !== null) expectedLength++; + return expectedLength; } /** @@ -68,8 +85,12 @@ function createError< value: T, constructor: C, ): T extends string - ? C - : { new (...args: Parameters>): InstanceType } { + ? { new (...args: unknown[]): InstanceType & ErrnoException } + : { + new ( + ...args: Parameters> + ): InstanceType & ErrnoException; + } { // Special case for SystemError that formats the error message differently // The SystemErrors only have SystemError as their base classes. messages.set(sym, value); @@ -77,94 +98,105 @@ function createError< return makeNodeErrorWithCode(constructor, sym) as any; } +// Used to identify Node.js core errors created via `makeNodeErrorWithCode`. +const kIsNodeError = Symbol("kIsNodeError"); + function makeNodeErrorWithCode( Base: ErrorConstructor, key: string, ): ErrorConstructor { - // @ts-expect-error It’s a Node error. - return function NodeError(...parameters: unknown[]) { - const limit = Error.stackTraceLimit; - if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = 0; - const error = new Base(); - // Reset the limit and setting the name property. - if (isErrorStackTraceLimitWritable()) Error.stackTraceLimit = limit; - const message = getMessage(key, parameters, error); - Object.defineProperties(error, { - // Note: no need to implement `kIsNodeError` symbol, would be hard, - // probably. - message: { - value: message, - enumerable: false, - writable: true, - configurable: true, - }, - toString: { - /** @this {Error} */ - value() { + const message = messages.get(key); + const expectedLength = + typeof message === "string" ? getExpectedArgumentLength(message) : -1; + + switch (expectedLength) { + case 0: { + class NodeError extends Base { + code = key; + + constructor(...args: unknown[]) { + assert.ok( + args.length === 0, + `Code: ${key}; The provided arguments length (${args.length}) does not ` + + `match the required ones (${expectedLength}).`, + ); + super(message as string); + } + + override get ["constructor"](): ErrorConstructor { + return Base; + } + + get [kIsNodeError](): boolean { + return true; + } + + override toString(): string { return `${this.name} [${key}]: ${this.message}`; - }, - enumerable: false, - writable: true, - configurable: true, - }, - }); - - captureLargerStackTrace(error); - // @ts-expect-error It’s a Node error. - error.code = key; - return error; - }; -} - -function isErrorStackTraceLimitWritable(): boolean { - // Do no touch Error.stackTraceLimit as V8 would attempt to install - // it again during deserialization. - try { - if (v8.startupSnapshot.isBuildingSnapshot()) { - return false; + } + } + return NodeError as unknown as ErrorConstructor; + } + case -1: { + class NodeError extends Base { + code = key; + + constructor(...args: unknown[]) { + super(); + Object.defineProperty(this, "message", { + value: getMessage(key, args, this), + enumerable: false, + writable: true, + configurable: true, + }); + } + + override get ["constructor"](): ErrorConstructor { + return Base; + } + + get [kIsNodeError](): boolean { + return true; + } + + override toString(): string { + return `${this.name} [${key}]: ${this.message}`; + } + } + return NodeError as unknown as ErrorConstructor; + } + default: { + class NodeError extends Base { + code = key; + + constructor(...args: unknown[]) { + assert.ok( + args.length === expectedLength, + `Code: ${key}; The provided arguments length (${args.length}) does not ` + + `match the required ones (${expectedLength}).`, + ); + + args.unshift(message as string); + super(Reflect.apply(format, null, args) as string); + } + + override get ["constructor"](): ErrorConstructor { + return Base; + } + + get [kIsNodeError](): boolean { + return true; + } + + override toString(): string { + return `${this.name} [${key}]: ${this.message}`; + } + } + return NodeError as unknown as ErrorConstructor; } - } catch { - // ignore - } - - const desc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit"); - if (desc === undefined) { - return Object.isExtensible(Error); } - - return own.call(desc, "writable") && desc.writable !== undefined - ? desc.writable - : desc.set !== undefined; } -/** - * This function removes unnecessary frames from Node.js core errors. - */ -function hideStackFrames unknown>( - wrappedFunction: T, -): T { - // We rename the functions that will be hidden to cut off the stacktrace - // at the outermost one - const hidden = nodeInternalPrefix + wrappedFunction.name; - Object.defineProperty(wrappedFunction, "name", { value: hidden }); - return wrappedFunction; -} - -const captureLargerStackTrace = hideStackFrames(function (error: unknown) { - const stackTraceLimitIsWritable = isErrorStackTraceLimitWritable(); - if (stackTraceLimitIsWritable) { - userStackTraceLimit = Error.stackTraceLimit; - Error.stackTraceLimit = Number.POSITIVE_INFINITY; - } - - Error.captureStackTrace(error as Error); - - // Reset the limit - if (stackTraceLimitIsWritable) Error.stackTraceLimit = userStackTraceLimit; - - return error; -}); - function getMessage(key: string, parameters: unknown[], self: Error): string { const message = messages.get(key); assert.ok(message !== undefined, "expected `message` to be found"); @@ -178,9 +210,7 @@ function getMessage(key: string, parameters: unknown[], self: Error): string { return Reflect.apply(message, self, parameters); } - const regex = /%[dfijoOs]/g; - let expectedLength = 0; - while (regex.exec(message) !== null) expectedLength++; + const expectedLength = getExpectedArgumentLength(message); assert.ok( expectedLength === parameters.length, `Code: ${key}; The provided arguments length (${parameters.length}) does not ` + @@ -197,29 +227,66 @@ function getMessage(key: string, parameters: unknown[], self: Error): string { * Determine the specific type of a value for type-mismatch errors. */ function determineSpecificType(value: unknown): string { - if (value === null || value === undefined) { - return String(value); + if (value === null) { + return "null"; + } else if (value === undefined) { + return "undefined"; } - if (typeof value === "function" && value.name) { - return `function ${value.name}`; - } + const type = typeof value; - if (typeof value === "object") { - if (value.constructor && value.constructor.name) { - return `an instance of ${value.constructor.name}`; + switch (type) { + case "bigint": { + return `type bigint (${value}n)`; } + case "number": { + if (value === 0) { + return 1 / (value as number) === Number.NEGATIVE_INFINITY + ? "type number (-0)" + : "type number (0)"; + } else if (Number.isNaN(value)) { + return "type number (NaN)"; + } else if (value === Number.POSITIVE_INFINITY) { + return "type number (Infinity)"; + } else if (value === Number.NEGATIVE_INFINITY) { + return "type number (-Infinity)"; + } + return `type number (${value})`; + } + case "boolean": { + return value ? "type boolean (true)" : "type boolean (false)"; + } + case "symbol": { + return `type symbol (${String(value)})`; + } + case "function": { + return `function ${(value as () => void).name}`; + } + case "object": { + if (value.constructor && value.constructor.name) { + return `an instance of ${value.constructor.name}`; + } + return `${inspect(value, { depth: -1 })}`; + } + case "string": { + let string = value as string; + if (string.length > 28) { + string = `${string.slice(0, 25)}...`; + } + if (!string.includes("'")) { + return `type string ('${string}')`; + } + return `type string (${JSON.stringify(string)})`; + } + default: { + let inspected = inspect(value, { colors: false }); + if (inspected.length > 28) { + inspected = `${inspected.slice(0, 25)}...`; + } - return `${inspect(value, { depth: -1 })}`; - } - - let inspected = inspect(value, { colors: false }); - - if (inspected.length > 28) { - inspected = `${inspected.slice(0, 25)}...`; + return `type ${type} (${inspected})`; + } } - - return `type ${typeof value} (${inspected})`; } // ---------------------------------------------------------------------------- @@ -273,7 +340,7 @@ export const ERR_INVALID_ARG_TYPE = createError( if (instances.length > 0) { const pos = types.indexOf("object"); if (pos !== -1) { - types.slice(pos, 1); + types.splice(pos, 1); instances.push("Object"); } } @@ -369,7 +436,15 @@ export const ERR_INVALID_PACKAGE_TARGET = createError( export const ERR_MODULE_NOT_FOUND = createError( "ERR_MODULE_NOT_FOUND", - (path: string, base: string, exactUrl: boolean = false) => { + function ( + this: ErrnoException, + path: string, + base: string, + exactUrl: boolean | string = false, + ) { + if (exactUrl && typeof exactUrl === "string") { + this.url = `${exactUrl}`; + } return `Cannot find ${ exactUrl ? "module" : "package" } '${path}' imported from ${base}`; @@ -377,12 +452,6 @@ export const ERR_MODULE_NOT_FOUND = createError( Error, ); -export const ERR_NETWORK_IMPORT_DISALLOWED = createError( - "ERR_NETWORK_IMPORT_DISALLOWED", - "import of '%s' by %s is not supported: %s", - Error, -); - export const ERR_PACKAGE_IMPORT_NOT_DEFINED = createError( "ERR_PACKAGE_IMPORT_NOT_DEFINED", (specifier: string, packagePath: string | undefined, base: string) => { @@ -414,8 +483,18 @@ export const ERR_PACKAGE_PATH_NOT_EXPORTED = createError( export const ERR_UNSUPPORTED_DIR_IMPORT = createError( "ERR_UNSUPPORTED_DIR_IMPORT", - "Directory import '%s' is not supported " + - "resolving ES modules imported from %s", + function ( + this: ErrnoException, + path: string, + base: string, + exactUrl: string | undefined = undefined, + ) { + this.url = exactUrl; + return ( + `Directory import '${path}' is not supported ` + + `resolving ES modules imported from ${base}` + ); + }, Error, ); @@ -427,6 +506,9 @@ export const ERR_UNSUPPORTED_RESOLVE_REQUEST = createError( export const ERR_UNKNOWN_FILE_EXTENSION = createError( "ERR_UNKNOWN_FILE_EXTENSION", + // Upstream: 'Unknown file extension "%s" for %s' (static string). + // Kept as a typed function to preserve the typed 2-arg call signature used + // by `get-format.ts`; the produced message is identical to upstream. (extension: string, path: string) => { return `Unknown file extension "${extension}" for ${path}`; }, diff --git a/src/internal/get-format.ts b/src/internal/get-format.ts index 6fb1c81..0156a0d 100644 --- a/src/internal/get-format.ts +++ b/src/internal/get-format.ts @@ -1,5 +1,5 @@ // Source: https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/get_format.js -// Changes: https://github.com/nodejs/node/commits/main/lib/internal/modules/esm/get_format.js?since=2025-02-24 +// Changes: https://github.com/nodejs/node/commits/main/lib/internal/modules/esm/get_format.js?since=2026-06-21 import { fileURLToPath } from "node:url"; import { getPackageScopeConfig } from "./package-json-reader.ts"; @@ -7,6 +7,13 @@ import { ERR_UNKNOWN_FILE_EXTENSION } from "./errors.ts"; const hasOwnProperty = {}.hasOwnProperty; +// Note: this intentionally diverges from upstream: +// - Upstream emits `module-typescript`/`commonjs-typescript` for `.ts`/`.mts`/`.cts` +// (behind `--strip-types`), but exsolve does not strip types and its only consumer +// (`resolve.ts`) checks `format === "module"`, so we keep `.ts`/`.mts` → `module` +// and `.cts` → `commonjs`. +// - Upstream has `.wasm` → `wasm` (and `.node` → `addon` behind a flag), but exsolve +// does not support WASM/addon resolution, so those are intentionally omitted. const extensionFormatMap: Record & { __proto__: null } = { __proto__: null, @@ -39,20 +46,25 @@ const protocolHandlers: Record & { function mimeToFormat(mime: string | null): string | null { if ( mime && - /\s*(text|application)\/javascript\s*(;\s*charset=utf-?8\s*)?/i.test(mime) + /^\s*(text|application)\/javascript\s*(;\s*charset=utf-?8\s*)?$/i.test(mime) ) return "module"; if (mime === "application/json") return "json"; + // Note: upstream also maps `application/wasm` → `wasm`, intentionally omitted + // here since exsolve does not support WASM. return null; } function getDataProtocolModuleFormat(parsed: URL): string | null { - const { 1: mime } = /^([^/]+\/[^;,]+)[^,]*?(;base64)?,/.exec( + const { 1: mime } = /^([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/.exec( parsed.pathname, ) || [null, null, null]; return mimeToFormat(mime); } +const DOT_CODE = 46; +const SLASH_CODE = 47; + /** * Returns the file extension from a URL. * @@ -63,22 +75,18 @@ function getDataProtocolModuleFormat(parsed: URL): string | null { */ function extname(url: URL): string { const pathname = url.pathname; - let index = pathname.length; - - while (index--) { - const code = pathname.codePointAt(index); - - if (code === 47 /* `/` */) { - return ""; - } - - if (code === 46 /* `.` */) { - return pathname.codePointAt(index - 1) === 47 /* `/` */ - ? "" - : pathname.slice(index); + for (let i = pathname.length - 1; i > 0; i--) { + switch (pathname.charCodeAt(i)) { + case SLASH_CODE: { + return ""; + } + case DOT_CODE: { + return pathname.charCodeAt(i - 1) === SLASH_CODE + ? "" + : pathname.slice(i); + } } } - return ""; } @@ -96,20 +104,31 @@ function getFileProtocolModuleFormat( return packageType; } + // The controlling `package.json` file has no `type` field. + // Note: upstream sniffs the source here (`detectModuleFormat`) to decide + // between `module` and `commonjs`. exsolve never has a `source`, so for + // ambiguous `.js` files we fall back to `commonjs` (legacy behavior). return "commonjs"; } if (ext === "") { const { type: packageType } = getPackageScopeConfig(url); - // Legacy behavior - if (packageType === "none" || packageType === "commonjs") { - return "commonjs"; + if (packageType === "module") { + // Note: upstream calls `getFormatOfExtensionlessFile` here to + // disambiguate `module` vs `wasm` by reading the file header. exsolve + // does not support WASM, so this is always `module`. + return "module"; } - // Note: we don’t implement WASM, so we don’t need - // `getFormatOfExtensionlessFile` from `formats`. - return "module"; + if (packageType !== "none") { + return packageType; // 'commonjs' or future package types + } + + // The controlling `package.json` file has no `type` field. + // Note: upstream sniffs the source here; exsolve never has a `source`, so + // we fall back to `commonjs` (legacy behavior). + return "commonjs"; } const format = extensionFormatMap[ext]; diff --git a/src/internal/resolve.ts b/src/internal/resolve.ts index c531417..1066cfc 100644 --- a/src/internal/resolve.ts +++ b/src/internal/resolve.ts @@ -228,9 +228,8 @@ function finalizeResolution( ); if (stats && stats.isDirectory()) { - // @ts-expect-error TODO: type issue const error = new ERR_UNSUPPORTED_DIR_IMPORT(filePath, fileURLToPath(base)); - // @ts-expect-error Add this for `import.meta.resolve`. + // Add this for `import.meta.resolve`. error.url = String(resolved); throw error; } @@ -241,7 +240,7 @@ function finalizeResolution( base && fileURLToPath(base), true, ); - // @ts-expect-error Add this for `import.meta.resolve`. + // Add this for `import.meta.resolve`. error.url = String(resolved); throw error; } @@ -454,7 +453,7 @@ function resolvePackageTarget( internal: boolean, isPathMap: boolean, conditions: Set | undefined, -): URL | null { +): URL | null | undefined { if (typeof target === "string") { return resolvePackageTargetString( target, @@ -478,7 +477,7 @@ function resolvePackageTarget( while (++i < targetList.length) { const targetItem = targetList[i]; - let resolveResult: URL | null; + let resolveResult: URL | null | undefined; try { resolveResult = resolvePackageTarget( packageJsonUrl, @@ -509,7 +508,7 @@ function resolvePackageTarget( } if (lastException === undefined || lastException === null) { - return null; + return lastException; } throw lastException; @@ -553,7 +552,7 @@ function resolvePackageTarget( } } - return null; + return undefined; } if (target === null) { @@ -748,7 +747,7 @@ function packageImportsResolve( base: URL, conditions?: Set, ): URL { - if (name === "#" || name.startsWith("#/") || name.endsWith("/")) { + if (name === "#" || name.endsWith("/")) { const reason = "is not a valid internal imports specifier name"; throw new ERR_INVALID_MODULE_SPECIFIER(name, reason, fileURLToPath(base)); } @@ -997,7 +996,6 @@ export function moduleResolve( try { resolved = new URL(specifier, base); } catch (error_) { - // @ts-expect-error TODO: type issue const error = new ERR_UNSUPPORTED_RESOLVE_REQUEST(specifier, base); error.cause = error_; throw error; @@ -1010,7 +1008,6 @@ export function moduleResolve( } catch (error_) { // Note: actual code uses `canBeRequiredWithoutScheme`. if (isData && !nodeBuiltins.includes(specifier)) { - // @ts-expect-error TODO: type issue const error = new ERR_UNSUPPORTED_RESOLVE_REQUEST(specifier, base); error.cause = error_; throw error; diff --git a/test/fixture/exports-pkg/default.js b/test/fixture/exports-pkg/default.js new file mode 100644 index 0000000..6577c75 --- /dev/null +++ b/test/fixture/exports-pkg/default.js @@ -0,0 +1 @@ +export const isDefault = true; diff --git a/test/fixture/exports-pkg/index.js b/test/fixture/exports-pkg/index.js new file mode 100644 index 0000000..21b4de7 --- /dev/null +++ b/test/fixture/exports-pkg/index.js @@ -0,0 +1 @@ +export const main = true; diff --git a/test/fixture/exports-pkg/package.json b/test/fixture/exports-pkg/package.json new file mode 100644 index 0000000..b59ad36 --- /dev/null +++ b/test/fixture/exports-pkg/package.json @@ -0,0 +1,11 @@ +{ + "name": "exports-pkg", + "exports": { + ".": { + "import": { + "worker": "./worker.js" + }, + "default": "./default.js" + } + } +} diff --git a/test/fixture/exports-pkg/worker.js b/test/fixture/exports-pkg/worker.js new file mode 100644 index 0000000..5d754a8 --- /dev/null +++ b/test/fixture/exports-pkg/worker.js @@ -0,0 +1 @@ +export const worker = true; diff --git a/test/fixture/imports-pkg/index.js b/test/fixture/imports-pkg/index.js new file mode 100644 index 0000000..21b4de7 --- /dev/null +++ b/test/fixture/imports-pkg/index.js @@ -0,0 +1 @@ +export const main = true; diff --git a/test/fixture/imports-pkg/internal.js b/test/fixture/imports-pkg/internal.js new file mode 100644 index 0000000..cc7ad55 --- /dev/null +++ b/test/fixture/imports-pkg/internal.js @@ -0,0 +1 @@ +export const internal = true; diff --git a/test/fixture/imports-pkg/package.json b/test/fixture/imports-pkg/package.json new file mode 100644 index 0000000..aeb189c --- /dev/null +++ b/test/fixture/imports-pkg/package.json @@ -0,0 +1,7 @@ +{ + "name": "imports-pkg", + "imports": { + "#/*": "./src/*", + "#internal": "./internal.js" + } +} diff --git a/test/fixture/imports-pkg/src/util.js b/test/fixture/imports-pkg/src/util.js new file mode 100644 index 0000000..ad1d380 --- /dev/null +++ b/test/fixture/imports-pkg/src/util.js @@ -0,0 +1 @@ +export const x = 1; diff --git a/test/internal-errors.test.ts b/test/internal-errors.test.ts new file mode 100644 index 0000000..61afa86 --- /dev/null +++ b/test/internal-errors.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { + ERR_INVALID_ARG_TYPE, + ERR_MODULE_NOT_FOUND, + ERR_UNSUPPORTED_DIR_IMPORT, + ERR_UNSUPPORTED_RESOLVE_REQUEST, +} from "../src/internal/errors.ts"; + +describe("ERR_INVALID_ARG_TYPE splice fix", () => { + it("folds lowercase object into instances when a class name is present", () => { + const message = new ERR_INVALID_ARG_TYPE("opts.x", ["Object", "Buffer"], 42) + .message; + expect(message).toBe( + `The "opts.x" property must be an instance of Buffer or Object. Received type number (42)`, + ); + expect(message).not.toContain("of type object"); + }); + + it("keeps object in the type list for a pure-types case", () => { + const message = new ERR_INVALID_ARG_TYPE("opts.x", ["Object", "string"], 42) + .message; + expect(message).toBe( + `The "opts.x" property must be one of type object or string. Received type number (42)`, + ); + }); +}); + +describe("class-based machinery", () => { + it("exposes the code key on instances", () => { + expect(new ERR_MODULE_NOT_FOUND("/x/y", "/base").code).toBe( + "ERR_MODULE_NOT_FOUND", + ); + expect(new ERR_INVALID_ARG_TYPE("x", "string", 1).code).toBe( + "ERR_INVALID_ARG_TYPE", + ); + }); + + it("uses the correct base classes for instanceof", () => { + const typeErr = new ERR_INVALID_ARG_TYPE("x", "string", 1); + expect(typeErr).toBeInstanceOf(TypeError); + expect(typeErr).toBeInstanceOf(Error); + + const modErr = new ERR_MODULE_NOT_FOUND("/x/y", "/base"); + expect(modErr).toBeInstanceOf(Error); + expect(modErr).not.toBeInstanceOf(TypeError); + }); + + it("formats toString as ' []: '", () => { + const typeErr = new ERR_INVALID_ARG_TYPE("x", "string", 1); + expect(typeErr.toString()).toBe( + `TypeError [ERR_INVALID_ARG_TYPE]: ${typeErr.message}`, + ); + expect(typeErr.toString()).toContain("TypeError [ERR_INVALID_ARG_TYPE]: "); + }); + + it("asserts the required argument count for static-format-string codes", () => { + expect(() => new ERR_UNSUPPORTED_RESOLVE_REQUEST("only-one")).toThrow( + /does not match the required ones/, + ); + + const err = new ERR_UNSUPPORTED_RESOLVE_REQUEST("spec", "base"); + expect(err.message).toBe( + `Failed to resolve module specifier "spec" from "base": Invalid relative URL or base scheme is not hierarchical.`, + ); + }); +}); + +describe("this.url side-effect", () => { + it("sets url and uses 'module' wording when an exact url is given", () => { + const err = new ERR_MODULE_NOT_FOUND("/x/y", "/base", "file:///x/y"); + expect(err.url).toBe("file:///x/y"); + expect(err.message).toBe("Cannot find module '/x/y' imported from /base"); + }); + + it("leaves url undefined and uses 'package' wording without an exact url", () => { + const err = new ERR_MODULE_NOT_FOUND("pkg", "/base"); + expect(err.url).toBeUndefined(); + expect(err.message).toBe("Cannot find package 'pkg' imported from /base"); + }); + + it("formats ERR_UNSUPPORTED_DIR_IMPORT message", () => { + const err = new ERR_UNSUPPORTED_DIR_IMPORT("/dir", "/base"); + expect(err.message).toBe( + "Directory import '/dir' is not supported resolving ES modules imported from /base", + ); + }); +}); + +describe("determineSpecificType via ERR_INVALID_ARG_TYPE 'Received' tail", () => { + const cases: Array<[string, unknown, string]> = [ + ["bigint", 10n, "Received type bigint (10n)"], + ["negative zero", -0, "Received type number (-0)"], + ["zero", 0, "Received type number (0)"], + ["NaN", Number.NaN, "Received type number (NaN)"], + [ + "positive infinity", + Number.POSITIVE_INFINITY, + "Received type number (Infinity)", + ], + [ + "negative infinity", + Number.NEGATIVE_INFINITY, + "Received type number (-Infinity)", + ], + ["boolean", true, "Received type boolean (true)"], + ["symbol", Symbol("s"), "Received type symbol (Symbol(s))"], + ["short string", "abc", "Received type string ('abc')"], + ["null", null, "Received null"], + ["undefined", undefined, "Received undefined"], + ]; + + for (const [label, value, expected] of cases) { + it(`describes ${label}`, () => { + const message = new ERR_INVALID_ARG_TYPE("x", "string", value).message; + expect(message.endsWith(expected)).toBe(true); + }); + } +}); diff --git a/test/internal-get-format.test.ts b/test/internal-get-format.test.ts new file mode 100644 index 0000000..811f901 --- /dev/null +++ b/test/internal-get-format.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { defaultGetFormatWithoutErrors } from "../src/internal/get-format.ts"; + +const context = { parentURL: import.meta.url }; + +const getFormat = (url: string) => + defaultGetFormatWithoutErrors(new URL(url), context); + +// A valid, absolute file: URL on every platform (Windows needs a drive letter, +// so a hard-coded `file:///tmp/...` would throw in `fileURLToPath`). The temp +// dir also has no `type` package.json in its ancestry, so extensionless files +// fall back to "commonjs". +const tmpFileURL = (name: string) => pathToFileURL(join(tmpdir(), name)).href; + +describe("defaultGetFormatWithoutErrors", () => { + describe("data: protocol (mime mapping + regex anchoring)", () => { + it("maps text/javascript to module", () => { + expect(getFormat("data:text/javascript,console.log(1)")).toBe("module"); + }); + + it("maps application/javascript to module", () => { + expect(getFormat("data:application/javascript,1")).toBe("module"); + }); + + it("maps text/javascript with charset=utf-8 to module", () => { + expect(getFormat("data:text/javascript;charset=utf-8,1")).toBe("module"); + }); + + // Regression: the mime regex is now anchored (`/^...$/`). Previously it was + // unanchored and would match `javascript` as a substring, wrongly returning + // "module" for these mime types. + it("does NOT match text/javascriptx (anchoring regression)", () => { + expect(getFormat("data:text/javascriptx,1")).toBe(null); + }); + + it("does NOT match application/javascript-foo (anchoring regression)", () => { + expect(getFormat("data:application/javascript-foo,1")).toBe(null); + }); + + it("maps application/json to json", () => { + expect(getFormat("data:application/json,{}")).toBe("json"); + }); + + it("returns null for text/plain", () => { + expect(getFormat("data:text/plain,hello")).toBe(null); + }); + }); + + describe("node: protocol", () => { + it("returns builtin", () => { + expect(getFormat("node:fs")).toBe("builtin"); + }); + }); + + describe("file: protocol with mapped extensions", () => { + it("maps .json to json", () => { + expect(getFormat("file:///tmp/x.json")).toBe("json"); + }); + + it("maps .cjs to commonjs", () => { + expect(getFormat("file:///tmp/x.cjs")).toBe("commonjs"); + }); + + it("maps .mjs to module", () => { + expect(getFormat("file:///tmp/x.mjs")).toBe("module"); + }); + + // exsolve intentionally diverges from upstream: it does not strip types, so + // TypeScript extensions map to plain module/commonjs (not the + // `-typescript` variants). Pin that behavior. + it("maps .mts to module", () => { + expect(getFormat("file:///tmp/x.mts")).toBe("module"); + }); + + it("maps .cts to commonjs", () => { + expect(getFormat("file:///tmp/x.cts")).toBe("commonjs"); + }); + + it("maps .ts to module", () => { + expect(getFormat("file:///tmp/x.ts")).toBe("module"); + }); + + it("returns null for an unknown extension", () => { + // ignoreErrors is true, so unknown extension yields undefined → null. + expect(getFormat("file:///tmp/x.foobar")).toBe(null); + }); + }); + + describe("extname edge cases", () => { + // A leading-dot file like `.foobar`: extname() sees the `.` is preceded by + // a SLASH, so it returns "" (no extension). That routes to the + // extensionless branch; with no `type` in the controlling package.json + // (none found walking up from the temp dir), it falls back to "commonjs". + it("treats a dotfile as extensionless (commonjs)", () => { + expect(getFormat(tmpFileURL(".foobar"))).toBe("commonjs"); + }); + + // A trailing dot: extname() returns ".", which is not in the map → null. + it("returns null for a trailing-dot path", () => { + expect(getFormat("file:///tmp/x.")).toBe(null); + }); + }); + + describe("unknown protocol", () => { + it("returns null", () => { + expect(getFormat("https://example.com/x.js")).toBe(null); + }); + }); +}); diff --git a/test/resolve-imports-exports.test.ts b/test/resolve-imports-exports.test.ts new file mode 100644 index 0000000..fdc6d88 --- /dev/null +++ b/test/resolve-imports-exports.test.ts @@ -0,0 +1,64 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; +import { resolveModuleURL } from "../src"; + +describe("package.json imports field", () => { + const from = new URL("fixture/imports-pkg/index.js", import.meta.url); + + // Regression: a `#/`-prefixed subpath import used to be rejected by + // `packageImportsResolve` with ERR_INVALID_MODULE_SPECIFIER. The guard was + // removed (matching Node upstream) so it now resolves via the imports field. + it("resolves `#/`-prefixed subpath imports", () => { + const resolved = resolveModuleURL("#/util.js", { from }); + expect(resolved).toMatch(/imports-pkg\/src\/util\.js$/); + expect(existsSync(fileURLToPath(resolved))).toBe(true); + }); + + it("does not throw for `#/`-prefixed subpath imports", () => { + expect(() => resolveModuleURL("#/util.js", { from })).not.toThrow(); + }); + + it("resolves a plain `#`-prefixed import", () => { + const resolved = resolveModuleURL("#internal", { from }); + expect(resolved).toMatch(/imports-pkg\/internal\.js$/); + expect(existsSync(fileURLToPath(resolved))).toBe(true); + }); +}); + +describe("package.json conditional exports fall-through", () => { + const from = new URL("fixture/exports-pkg/index.js", import.meta.url); + + // Regression: a nested conditions object that matches no condition used to + // return `null`, causing the parent to stop and throw + // ERR_PACKAGE_PATH_NOT_EXPORTED. It now returns `undefined`, letting the + // parent fall through to the next key (`default`). + it("falls through to `default` when nested conditions do not match", () => { + const resolved = resolveModuleURL("exports-pkg", { + from, + conditions: ["node", "import"], + }); + expect(resolved).toMatch(/exports-pkg\/default\.js$/); + expect(existsSync(fileURLToPath(resolved))).toBe(true); + }); + + it("does not throw when nested conditions do not match", () => { + expect(() => + resolveModuleURL("exports-pkg", { + from, + conditions: ["node", "import"], + }), + ).not.toThrow(); + }); + + // Positive control: when the `worker` condition is present, the nested + // object matches and resolves to `worker.js` instead of `default.js`. + it("resolves to the matched nested condition when present", () => { + const resolved = resolveModuleURL("exports-pkg", { + from, + conditions: ["node", "import", "worker"], + }); + expect(resolved).toMatch(/exports-pkg\/worker\.js$/); + expect(existsSync(fileURLToPath(resolved))).toBe(true); + }); +});