diff --git a/.chronus/changes/resolve-module-exports-2024-9-4-18-32-9.md b/.chronus/changes/resolve-module-exports-2024-9-4-18-32-9.md new file mode 100644 index 0000000000..b4bf9236c0 --- /dev/null +++ b/.chronus/changes/resolve-module-exports-2024-9-4-18-32-9.md @@ -0,0 +1,19 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add support for node `exports` field. Specific typespec exports can be provided with the `typespec` field + +```json +"exports": { + ".": { + "typespec": "./lib/main.tsp", + }, + "./named": { + "typespec": "./lib/named.tsp", + } +} +``` diff --git a/.chronus/changes/resolve-module-exports-2024-9-4-22-21-14.md b/.chronus/changes/resolve-module-exports-2024-9-4-22-21-14.md new file mode 100644 index 0000000000..6836d03702 --- /dev/null +++ b/.chronus/changes/resolve-module-exports-2024-9-4-22-21-14.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/playground" +--- + +Do not treat path as relative internally diff --git a/docs/extending-typespec/basics.md b/docs/extending-typespec/basics.md index 05d3cb92a8..b6eb09ea71 100644 --- a/docs/extending-typespec/basics.md +++ b/docs/extending-typespec/basics.md @@ -75,7 +75,19 @@ Your package.json needs to refer to two main files: your Node module main file, ```jsonc "main": "dist/src/index.js", - "tspMain": "lib/main.tsp" + "exports": { + ".": { + "typespec": "./lib/main.tsp" + }, + // Additional named export are possible + "./experimental": { + "typespec": "./lib/experimental.tsp" + }, + // Wildcard export as well + "./lib/*": { + "typespec": "./lib/*.tsp" + } + } ``` ### d. Install and initialize TypeScript diff --git a/docs/language-basics/imports.md b/docs/language-basics/imports.md index c8dfee102d..7ec9267e19 100644 --- a/docs/language-basics/imports.md +++ b/docs/language-basics/imports.md @@ -23,7 +23,7 @@ import "./decorators.js"; ## Importing a library -The import value can be the name of one of the package dependencies. In this case, TypeSpec will look for the `package.json` file and check the `tspMain` entry (defaulting to `main` if `tspMain` is absent) to determine the library entrypoint to load. +The import value can be the name of one of the package dependencies. ```typespec import "/rest"; @@ -32,12 +32,25 @@ import "/rest"; ```json // ./node_modules/@typespec/rest/package.json { - "tspMain": "./lib/main.tsp" + "exports": { + ".": { "typespec": "./lib/main.tsp" } + } } ``` This results in `./node_modules/@typespec/rest/lib/main.tsp` being imported. +### Package resolution algorithm + +When trying to import a package TypeSpec follows the following logic + +1. Parse the package name from the import specificier into `pkgName` and `subPath` (e.g. `@scope/lib/named` => pkgName: `@scope/lib` subpath: `named` ) +1. Look to see if `pkgName` is itself(Containing package) +1. Otherwise lookup for a parent folder with a `node_modules/${pkgName}` sub folder +1. Reading the `package.json` of the package + a. If `exports` is defined respect the [ESM logic](https://github.com/nodejs/node/blob/main/doc/api/esm.md) to resolve the `typespec` condition(TypeSpec will not respect the `default` condition) + b. If `exports` is not found or for back compat the `.` export is missing the `typespec` condition fallback to checking `tspMain` or `main` + ## Importing a directory If the import value is a directory, TypeSpec will check if that directory is a Node package and follow the npm package [lookup logic](#importing-a-library), or if the directory contains a `main.tsp` file. diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index bd4e298ce0..579765559e 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -662,8 +662,7 @@ const diagnostics = { "library-invalid": { severity: "error", messages: { - tspMain: paramMessage`Library "${"path"}" has an invalid tspMain file.`, - default: paramMessage`Library "${"path"}" has an invalid main file.`, + default: paramMessage`Library "${"path"}" is invalid: ${"message"}`, }, }, "incompatible-library": { diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index de18732822..19ff273eb8 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -5,6 +5,7 @@ import { validateEncodedNamesConflicts } from "../lib/encoded-names.js"; import { MANIFEST } from "../manifest.js"; import { ModuleResolutionResult, + ResolveModuleError, ResolveModuleHost, ResolvedModule, resolveModule, @@ -26,7 +27,13 @@ import { CompilerOptions } from "./options.js"; import { parse, parseStandaloneTypeReference } from "./parser.js"; import { getDirectoryPath, joinPaths, resolvePath } from "./path-utils.js"; import { createProjector } from "./projector.js"; -import { SourceLoader, SourceResolution, createSourceLoader, loadJsFile } from "./source-loader.js"; +import { + SourceLoader, + SourceResolution, + createSourceLoader, + loadJsFile, + moduleResolutionErrorToDiagnostic, +} from "./source-loader.js"; import { StateMap, StateSet, createStateAccessors } from "./state-accessors.js"; import { CompilerHost, @@ -148,7 +155,6 @@ export async function compile( const logger = createLogger({ sink: host.logSink }); const tracer = createTracer(logger, { filter: options.trace }); const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic); - const program: Program = { checker: undefined!, compilerOptions: resolveOptions(options), @@ -190,12 +196,11 @@ export async function compile( if (resolvedMain === undefined) { return program; } - await checkForCompilerVersionMismatch(resolvedMain); + const basedir = getDirectoryPath(resolvedMain) || "/"; + await checkForCompilerVersionMismatch(basedir); await loadSources(resolvedMain); - const basedir = getDirectoryPath(resolvedMain); - let emit = options.emit; let emitterOptions = options.options; /* eslint-disable @typescript-eslint/no-deprecated */ @@ -632,28 +637,8 @@ export async function compile( try { return [await resolveModule(getResolveModuleHost(), specifier, { baseDir }), []]; } catch (e: any) { - if (e.code === "MODULE_NOT_FOUND") { - return [ - undefined, - [ - createDiagnostic({ - code: "import-not-found", - format: { path: specifier }, - target: NoTarget, - }), - ], - ]; - } else if (e.code === "INVALID_MAIN") { - return [ - undefined, - [ - createDiagnostic({ - code: "library-invalid", - format: { path: specifier }, - target: NoTarget, - }), - ], - ]; + if (e instanceof ResolveModuleError) { + return [undefined, [moduleResolutionErrorToDiagnostic(e, specifier, NoTarget)]]; } else { throw e; } @@ -677,8 +662,7 @@ export async function compile( // different version of TypeSpec than the current one. Abort the compilation // with an error if the TypeSpec entry point resolves to a different local // compiler. - async function checkForCompilerVersionMismatch(mainPath: string): Promise { - const baseDir = getDirectoryPath(mainPath); + async function checkForCompilerVersionMismatch(baseDir: string): Promise { let actual: ResolvedModule; try { const resolved = await resolveModule( diff --git a/packages/compiler/src/core/source-loader.ts b/packages/compiler/src/core/source-loader.ts index bf019a0ba9..8eadaeae34 100644 --- a/packages/compiler/src/core/source-loader.ts +++ b/packages/compiler/src/core/source-loader.ts @@ -2,6 +2,7 @@ import { ModuleResolutionResult, ResolvedModule, resolveModule, + ResolveModuleError, ResolveModuleHost, } from "../module-resolver/module-resolver.js"; import { PackageJson } from "../types/package-json.js"; @@ -251,22 +252,12 @@ export async function createSourceLoader( // but using tspMain instead of main. return resolveTspMain(pkg) ?? pkg.main; }, + conditions: ["typespec"], + fallbackOnMissingCondition: true, }); } catch (e: any) { - if (e.code === "MODULE_NOT_FOUND") { - diagnostics.add( - createDiagnostic({ code: "import-not-found", format: { path: specifier }, target }), - ); - return undefined; - } else if (e.code === "INVALID_MAIN") { - diagnostics.add( - createDiagnostic({ - code: "library-invalid", - format: { path: specifier }, - messageId: "tspMain", - target, - }), - ); + if (e instanceof ResolveModuleError) { + diagnostics.add(moduleResolutionErrorToDiagnostic(e, specifier, target)); return undefined; } else { throw e; @@ -372,3 +363,29 @@ export async function loadJsFile( }; return [node, diagnostics]; } + +export function moduleResolutionErrorToDiagnostic( + e: ResolveModuleError, + specifier: string, + target: DiagnosticTarget | typeof NoTarget, +): Diagnostic { + switch (e.code) { + case "MODULE_NOT_FOUND": + return createDiagnostic({ code: "import-not-found", format: { path: specifier }, target }); + case "INVALID_MODULE": + case "INVALID_MODULE_EXPORT_TARGET": + return createDiagnostic({ + code: "library-invalid", + format: { path: specifier, message: e.message }, + target, + }); + case "INVALID_MAIN": + return createDiagnostic({ + code: "library-invalid", + format: { path: specifier, message: e.message }, + target, + }); + default: + return createDiagnostic({ code: "import-not-found", format: { path: specifier }, target }); + } +} diff --git a/packages/compiler/src/module-resolver/esm/resolve-package-exports.ts b/packages/compiler/src/module-resolver/esm/resolve-package-exports.ts new file mode 100644 index 0000000000..b8895b38b1 --- /dev/null +++ b/packages/compiler/src/module-resolver/esm/resolve-package-exports.ts @@ -0,0 +1,69 @@ +import { Exports } from "../../types/package-json.js"; +import { resolvePackageImportsExports } from "../esm/resolve-package-imports-exports.js"; +import { resolvePackageTarget } from "../esm/resolve-package-target.js"; +import { + EsmResolutionContext, + InvalidModuleSpecifierError, + NoMatchingConditionsError, +} from "./utils.js"; + +/** Implementation of PACKAGE_EXPORTS_RESOLVE https://github.com/nodejs/node/blob/main/doc/api/esm.md */ +export async function resolvePackageExports( + context: EsmResolutionContext, + subpath: string, + exports: Exports, +): Promise { + if (exports === null) return undefined; + + if (subpath === ".") { + let mainExport: Exports | undefined; + if (typeof exports === "string" || Array.isArray(exports) || isConditions(exports)) { + mainExport = exports; + } else if (exports["."]) { + mainExport = exports["."]; + } + + if (mainExport) { + if (context.ignoreDefaultCondition && typeof mainExport === "string") { + return undefined; + } + const resolved = await resolvePackageTarget(context, { + target: mainExport, + isImports: false, + }); + + // If resolved is not null or undefined, return resolved. + if (resolved) { + return resolved; + } else { + throw new NoMatchingConditionsError(context); + } + } + } else if (isMappings(exports)) { + // Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE + const resolvedMatch = await resolvePackageImportsExports(context, { + matchKey: subpath, + matchObj: exports, + isImports: false, + }); + + // If resolved is not null or undefined, return resolved. + if (resolvedMatch) { + return resolvedMatch; + } + } + + // 4. Throw a Package Path Not Exported error. + throw new InvalidModuleSpecifierError(context); +} + +/** Conditions is an export object where all keys are conditions(not a path starting with .). E.g. import, default, types, etc. */ +function isConditions(item: Exports) { + return typeof item === "object" && Object.keys(item).every((k) => !k.startsWith(".")); +} +/** + * Mappings is an export object where all keys start with '. + */ +export function isMappings(exports: Exports): exports is Record { + return typeof exports === "object" && !isConditions(exports); +} diff --git a/packages/compiler/src/module-resolver/esm/resolve-package-imports-exports.ts b/packages/compiler/src/module-resolver/esm/resolve-package-imports-exports.ts new file mode 100644 index 0000000000..6cad4ddd7e --- /dev/null +++ b/packages/compiler/src/module-resolver/esm/resolve-package-imports-exports.ts @@ -0,0 +1,96 @@ +import { Exports } from "../../types/package-json.js"; +import { resolvePackageTarget } from "./resolve-package-target.js"; + +import { EsmResolutionContext, InvalidModuleSpecifierError } from "./utils.js"; + +interface ResolvePackageImportsExportsOptions { + readonly matchKey: string; + readonly matchObj: Record; + readonly isImports?: boolean; +} + +/** Implementation of PACKAGE_IMPORTS_EXPORTS_RESOLVE https://github.com/nodejs/node/blob/main/doc/api/esm.md */ +export async function resolvePackageImportsExports( + context: EsmResolutionContext, + { matchKey, matchObj, isImports }: ResolvePackageImportsExportsOptions, +) { + // If matchKey is a key of matchObj and does not contain "*", then + if (!matchKey.includes("*") && matchKey in matchObj) { + // Let target be the value of matchObj[matchKey]. + const target = matchObj[matchKey]; + // Return the result of PACKAGE_TARGET_RESOLVE(packageURL, target, null, isImports, conditions). + const resolved = await resolvePackageTarget(context, { target, patternMatch: "", isImports }); + return resolved; + } + + // Let expansionKeys be the list of keys of matchObj containing only a single "*" + const expansionKeys = Object.keys(matchObj) + // Assert: ends with "/" or contains only a single "*". + .filter((k) => k.endsWith("/") || k.includes("*")) + // sorted by the sorting function PATTERN_KEY_COMPARE which orders in descending order of specificity. + .sort(nodePatternKeyCompare); + + // For each key expansionKey in expansionKeys, do + for (const expansionKey of expansionKeys) { + const indexOfAsterisk = expansionKey.indexOf("*"); + // Let patternBase be the substring of expansionKey up to but excluding the first "*" character. + const patternBase = + indexOfAsterisk === -1 ? expansionKey : expansionKey.substring(0, indexOfAsterisk); + + // If matchKey starts with but is not equal to patternBase, then + if (matchKey.startsWith(patternBase) && matchKey !== patternBase) { + // Let patternTrailer be the substring of expansionKey from the index after the first "*" character. + const patternTrailer = + indexOfAsterisk !== -1 ? expansionKey.substring(indexOfAsterisk + 1) : ""; + + // If patternTrailer has zero length, + if ( + patternTrailer.length === 0 || + // or if matchKey ends with patternTrailer and the length of matchKey is greater than or equal to the length of expansionKey, then + (matchKey.endsWith(patternTrailer) && matchKey.length >= expansionKey.length) + ) { + // Let target be the value of matchObj[expansionKey]. + const target = matchObj[expansionKey]; + // 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, + ); + // Return the result of PACKAGE_TARGET_RESOLVE + const resolved = await resolvePackageTarget(context, { + target, + patternMatch, + isImports, + }); + return resolved; + } + } + } + + throw new InvalidModuleSpecifierError(context, isImports); +} + +/** + * Implementation of Node's `PATTERN_KEY_COMPARE` function + */ +function nodePatternKeyCompare(keyA: string, keyB: string) { + // 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; + // 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; + + // if baseLengthA is greater, return -1, if lower 1 + const rval = baseLengthB - baseLengthA; + if (rval !== 0) return rval; + + // If keyA does not contain "*", return 1. + if (!keyA.includes("*")) return 1; + // If keyB does not contain "*", return -1. + if (!keyB.includes("*")) return -1; + + // If the length of keyA is greater than the length of keyB, return -1. + // If the length of keyB is greater than the length of keyA, return 1. + // Else Return 0. + return keyB.length - keyA.length; +} diff --git a/packages/compiler/src/module-resolver/esm/resolve-package-target.ts b/packages/compiler/src/module-resolver/esm/resolve-package-target.ts new file mode 100644 index 0000000000..933db5aca3 --- /dev/null +++ b/packages/compiler/src/module-resolver/esm/resolve-package-target.ts @@ -0,0 +1,174 @@ +import { resolvePath } from "../../core/path-utils.js"; +import { Exports } from "../../types/package-json.js"; +import { + EsmResolutionContext, + InvalidModuleSpecifierError, + InvalidPackageTargetError, + isUrl, +} from "./utils.js"; + +export interface ResolvePackageTargetOptions { + readonly target: Exports; + readonly patternMatch?: string; + readonly isImports?: boolean; +} + +/** Implementation of PACKAGE_TARGET_RESOLVE https://github.com/nodejs/node/blob/main/doc/api/esm.md */ +export async function resolvePackageTarget( + context: EsmResolutionContext, + { target, patternMatch, isImports }: ResolvePackageTargetOptions, +): Promise { + const { packageUrl } = context; + const packageUrlWithTrailingSlash = packageUrl.endsWith("/") ? packageUrl : `${packageUrl}/`; + // 1. If target is a String, then + if (typeof target === "string") { + // 1.i If target does not start with "./", then + if (!target.startsWith("./")) { + // 1.i.a If isImports is false, or if target starts with "../" or "/", or if target is a valid URL, then + if (!isImports || target.startsWith("../") || target.startsWith("/") || isUrl(target)) { + // 1.i.a.a Throw an Invalid Package Target error. + throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`); + } + + // 1.i.b If patternMatch is a String, then + if (typeof patternMatch === "string") { + // 1.i.b.a Return PACKAGE_RESOLVE(target with every instance of "*" replaced by patternMatch, packageURL + "/") + return await context.resolveId( + target.replace(/\*/g, patternMatch), + packageUrlWithTrailingSlash, + ); + } + + // 1.i.c Return PACKAGE_RESOLVE(target, packageURL + "/"). + return await context.resolveId(target, packageUrlWithTrailingSlash); + } + + // 1.ii If target split on "/" or "\" + checkInvalidSegment(context, target); + + // 1.iii Let resolvedTarget be the URL resolution of the concatenation of packageURL and target. + + const resolvedTarget = resolvePath(packageUrlWithTrailingSlash, target); + // 1.iv Assert: resolvedTarget is contained in packageURL. + if (!resolvedTarget.startsWith(packageUrl)) { + throw new InvalidPackageTargetError( + context, + `Resolved to ${resolvedTarget} which is outside package ${packageUrl}`, + ); + } + + // 1.v If patternMatch is null, then + if (!patternMatch) { + // Return resolvedTarget. + return resolvedTarget; + } + + // 1.vi If patternMatch split on "/" or "\" contains invalid segments + if (includesInvalidSegments(patternMatch.split(/\/|\\/), context.moduleDirs)) { + // throw an Invalid Module Specifier error. + throw new InvalidModuleSpecifierError(context); + } + + // 1.vii Return the URL resolution of resolvedTarget with every instance of "*" replaced with patternMatch. + return resolvedTarget.replace(/\*/g, patternMatch); + } + + // 3. Otherwise, if target is an Array, then + if (Array.isArray(target)) { + // 3.i If _target.length is zero, return null. + if (target.length === 0) { + return null; + } + + let lastError = null; + // 3.ii For each item in target, do + for (const item of target) { + // Let resolved be the result of PACKAGE_TARGET_RESOLVE of the item + // continuing the loop on any Invalid Package Target error. + try { + const resolved = await resolvePackageTarget(context, { + target: item, + patternMatch, + isImports, + }); + // If resolved is undefined, continue the loop. + // Else Return resolved. + if (resolved !== undefined) { + return resolved; + } + } catch (error) { + if (!(error instanceof InvalidPackageTargetError)) { + throw error; + } else { + lastError = error; + } + } + } + // Return or throw the last fallback resolution null return or error + if (lastError) { + throw lastError; + } + return null; + } + + // 2. Otherwise, if target is a non-null Object, then + if (target && typeof target === "object") { + // 2.ii For each property of target + for (const [key, value] of Object.entries(target)) { + // 2.ii.a If key equals "default" or conditions contains an entry for the key, then + if ( + (key === "default" && !context.ignoreDefaultCondition) || + context.conditions.includes(key) + ) { + // Let targetValue be the value of the property in target. + // Let resolved be the result of PACKAGE_TARGET_RESOLVE of the targetValue + const resolved = await resolvePackageTarget(context, { + target: value, + patternMatch, + isImports, + }); + // If resolved is equal to undefined, continue the loop. + // Return resolved. + if (resolved !== undefined) { + return resolved; + } + } + } + // Return undefined. + return undefined; + } + + // Otherwise, if target is null, return null. + if (target === null) { + return null; + } + + // Otherwise throw an Invalid Package Target error. + throw new InvalidPackageTargetError(context, `Invalid exports field.`); +} + +/** + * Check for invalid path segments + */ +function includesInvalidSegments(pathSegments: readonly string[], moduleDirs: readonly string[]) { + const invalidSegments = ["", ".", "..", ...moduleDirs]; + + // contains any "", ".", "..", or "node_modules" segments, including percent encoded variants + return pathSegments.some( + (v) => invalidSegments.includes(v) || invalidSegments.includes(decodeURI(v)), + ); +} + +function checkInvalidSegment(context: EsmResolutionContext, target: string) { + const pathSegments = target.split(/\/|\\/); + // after the first "." segment + const firstDot = pathSegments.indexOf("."); + firstDot !== -1 && pathSegments.slice(firstDot); + if ( + firstDot !== -1 && + firstDot < pathSegments.length - 1 && + includesInvalidSegments(pathSegments.slice(firstDot + 1), context.moduleDirs) + ) { + throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`); + } +} diff --git a/packages/compiler/src/module-resolver/esm/utils.ts b/packages/compiler/src/module-resolver/esm/utils.ts new file mode 100644 index 0000000000..d4223553a9 --- /dev/null +++ b/packages/compiler/src/module-resolver/esm/utils.ts @@ -0,0 +1,69 @@ +export interface EsmResolutionContext { + /** Original import specifier */ + readonly specifier: string; + + /** URL of the current package */ + readonly packageUrl: string; + + /** + * List of condition to match + * @example `["import", "require"]` + */ + readonly conditions: readonly string[]; + + /** + * Folders where modules exist that are banned from being used in exports. + * @example `["node_modules"]` + */ + readonly moduleDirs: readonly string[]; + + resolveId(id: string, baseDir: string | URL): any; + + /** Non standard option. Do not respect the default condition. */ + readonly ignoreDefaultCondition?: boolean; +} + +function createBaseErrorMsg(importSpecifier: string) { + return `Could not resolve import "${importSpecifier}" `; +} + +function createErrorMsg(context: EsmResolutionContext, reason?: string, isImports?: boolean) { + const { specifier, packageUrl } = context; + const base = createBaseErrorMsg(specifier); + const field = isImports ? "imports" : "exports"; + return `${base} using ${field} defined in ${packageUrl}.${reason ? ` ${reason}` : ""}`; +} + +export class EsmResolveError extends Error {} + +export class InvalidConfigurationError extends EsmResolveError { + constructor(context: EsmResolutionContext, reason?: string) { + super(createErrorMsg(context, `Invalid "exports" field. ${reason}`)); + } +} + +export class InvalidModuleSpecifierError extends EsmResolveError { + constructor(context: EsmResolutionContext, isImports?: boolean, reason?: string) { + super(createErrorMsg(context, reason, isImports)); + } +} + +export class InvalidPackageTargetError extends EsmResolveError { + constructor(context: EsmResolutionContext, reason?: string) { + super(createErrorMsg(context, reason)); + } +} + +export class NoMatchingConditionsError extends InvalidPackageTargetError { + constructor(context: EsmResolutionContext) { + super(context, `No conditions matched`); + } +} + +export function isUrl(str: string) { + try { + return !!new URL(str); + } catch (_) { + return false; + } +} diff --git a/packages/compiler/src/module-resolver/module-resolver.ts b/packages/compiler/src/module-resolver/module-resolver.ts index cf27419277..f747302e3d 100644 --- a/packages/compiler/src/module-resolver/module-resolver.ts +++ b/packages/compiler/src/module-resolver/module-resolver.ts @@ -1,5 +1,15 @@ -import { getDirectoryPath, joinPaths, resolvePath } from "../core/path-utils.js"; -import { PackageJson } from "../types/package-json.js"; +import { getDirectoryPath, joinPaths, normalizePath, resolvePath } from "../core/path-utils.js"; +import type { PackageJson } from "../types/package-json.js"; +import { resolvePackageExports } from "./esm/resolve-package-exports.js"; +import { + EsmResolveError, + InvalidPackageTargetError, + NoMatchingConditionsError, +} from "./esm/utils.js"; +import { parseNodeModuleSpecifier } from "./utils.js"; + +// Resolve algorithm of node https://nodejs.org/api/modules.html#modules_all_together + export interface ResolveModuleOptions { baseDir: string; @@ -11,9 +21,18 @@ export interface ResolveModuleOptions { /** * When resolution reach a directory without package.json look for those files to load in order. - * @default ["index.mjs", "index.js"] + * @default `["index.mjs", "index.js"]` */ directoryIndexFiles?: string[]; + + /** List of conditions to match in package exports */ + readonly conditions?: string[]; + + /** + * If exports is defined ignore if the none of the given condition is found and fallback to using main field resolution. + * By default it will throw an error. + */ + readonly fallbackOnMissingCondition?: boolean; } export interface ResolveModuleHost { @@ -33,7 +52,12 @@ export interface ResolveModuleHost { readFile(path: string): Promise; } -type ResolveModuleErrorCode = "MODULE_NOT_FOUND" | "INVALID_MAIN"; +type ResolveModuleErrorCode = + | "MODULE_NOT_FOUND" + | "INVALID_MAIN" + | "INVALID_MODULE" + /** When an exports points to an invalid file. */ + | "INVALID_MODULE_EXPORT_TARGET"; export class ResolveModuleError extends Error { public constructor( public code: ResolveModuleErrorCode, @@ -74,45 +98,51 @@ export interface ResolvedModule { /** * Resolve a module * @param host - * @param name + * @param specifier * @param options * @returns + * @throws {ResolveModuleError} When the module cannot be resolved. */ export async function resolveModule( host: ResolveModuleHost, - name: string, + specifier: string, options: ResolveModuleOptions, ): Promise { - const realpath = async (x: string) => resolvePath(await host.realpath(x)); + const realpath = async (x: string) => normalizePath(await host.realpath(x)); const { baseDir } = options; - const absoluteStart = baseDir === "" ? "." : await realpath(resolvePath(baseDir)); + const absoluteStart = await realpath(resolvePath(baseDir)); if (!(await isDirectory(host, absoluteStart))) { throw new TypeError(`Provided basedir '${baseDir}'is not a directory.`); } // Check if the module name is referencing a path(./foo, /foo, file:/foo) - if (/^(?:\.\.?(?:\/|$)|\/|([A-Za-z]:)?[/\\])/.test(name)) { - const res = resolvePath(absoluteStart, name); + if (/^(?:\.\.?(?:\/|$)|\/|([A-Za-z]:)?[/\\])/.test(specifier)) { + const res = resolvePath(absoluteStart, specifier); const m = (await loadAsFile(res)) || (await loadAsDirectory(res)); if (m) { return m; } } - const module = await findAsNodeModule(name, absoluteStart); + // Try to resolve package itself. + const self = await resolveSelf(specifier, absoluteStart); + if (self) return self; + + // Try to resolve as a node_module package. + const module = await resolveAsNodeModule(specifier, absoluteStart); if (module) return module; throw new ResolveModuleError( "MODULE_NOT_FOUND", - `Cannot find module '${name}' from '${baseDir}'`, + `Cannot find module '${specifier}' from '${baseDir}'`, ); /** * Returns a list of all the parent directory and the given one. */ - function listAllParentDirs(baseDir: string): string[] { + function listDirHierarchy(baseDir: string): string[] { const paths = [baseDir]; let current = getDirectoryPath(baseDir); while (current !== paths[paths.length - 1]) { @@ -123,59 +153,128 @@ export async function resolveModule( return paths; } - function getPackageCandidates( - name: string, - baseDir: string, - ): Array<{ path: string; type: "node_modules" | "self" }> { - const dirs = listAllParentDirs(baseDir); - return dirs.flatMap((x) => [ - { path: x, type: "self" }, - { path: joinPaths(x, "node_modules", name), type: "node_modules" }, - ]); + /** + * Equivalent implementation to node LOAD_PACKAGE_SELF + * Resolve if the import is importing the current package. + */ + async function resolveSelf(name: string, baseDir: string): Promise { + for (const dir of listDirHierarchy(baseDir)) { + const pkgFile = resolvePath(dir, "package.json"); + if (!(await isFile(host, pkgFile))) continue; + const pkg = await readPackage(host, pkgFile); + if (pkg.name === name) { + return loadPackage(dir, pkg); + } else { + return undefined; + } + } + return undefined; } - async function findAsNodeModule( - name: string, + /** + * Equivalent implementation to node LOAD_NODE_MODULES with a few non supported features. + * Cannot load any random file under the load path(only packages). + */ + async function resolveAsNodeModule( + importSpecifier: string, baseDir: string, ): Promise { - const dirs = getPackageCandidates(name, baseDir); - for (const { type, path } of dirs) { - if (type === "node_modules") { - if (await isDirectory(host, path)) { - const n = await loadAsDirectory(path, true); - if (n) return n; - } - } else if (type === "self") { - const pkgFile = resolvePath(path, "package.json"); - - if (await isFile(host, pkgFile)) { - const pkg = await readPackage(host, pkgFile); - if (pkg.name === name) { - const n = await loadPackage(path, pkg); - if (n) return n; - } + const module = parseNodeModuleSpecifier(importSpecifier); + if (module === null) return undefined; + const dirs = listDirHierarchy(baseDir); + + for (const dir of dirs) { + const n = await loadPackageAtPath( + joinPaths(dir, "node_modules", module.packageName), + module.subPath, + ); + if (n) return n; + } + return undefined; + } + + async function loadPackageAtPath( + path: string, + subPath?: string, + ): Promise { + const pkgFile = resolvePath(path, "package.json"); + if (!(await isFile(host, pkgFile))) return undefined; + + const pkg = await readPackage(host, pkgFile); + const n = await loadPackage(path, pkg, subPath); + if (n) return n; + return undefined; + } + + /** + * Try to load using package.json exports. + * @param importSpecifier A combination of the package name and exports entry. + * @param directory `node_modules` directory. + */ + async function resolveNodePackageExports( + subPath: string, + pkg: PackageJson, + pkgDir: string, + ): Promise { + if (!pkg.exports) return undefined; + + let match: string | undefined | null; + try { + match = await resolvePackageExports( + { + packageUrl: pathToFileURL(pkgDir), + specifier: specifier, + moduleDirs: ["node_modules"], + conditions: options.conditions ?? [], + ignoreDefaultCondition: options.fallbackOnMissingCondition, + resolveId: (id: string, baseDir: string) => { + throw new ResolveModuleError("INVALID_MODULE", "Not supported"); + }, + }, + subPath === "" ? "." : `./${subPath}`, + pkg.exports, + ); + } catch (error) { + if (error instanceof NoMatchingConditionsError) { + // For back compat we allow to fallback to main field for the `.` entry. + if (subPath === "") { + return; + } else { + throw new ResolveModuleError("INVALID_MODULE", error.message); } + } else if (error instanceof InvalidPackageTargetError) { + throw new ResolveModuleError("INVALID_MODULE_EXPORT_TARGET", error.message); + } else if (error instanceof EsmResolveError) { + throw new ResolveModuleError("INVALID_MODULE", error.message); + } else { + throw error; } } - return undefined; + if (!match) return undefined; + const resolved = await resolveEsmMatch(match); + return { + type: "module", + mainFile: resolved, + manifest: pkg, + path: pkgDir, + }; } - async function loadAsDirectory(directory: string): Promise; - async function loadAsDirectory( - directory: string, - mustBePackage: true, - ): Promise; - async function loadAsDirectory( - directory: string, - mustBePackage?: boolean, - ): Promise { - const pkgFile = resolvePath(directory, "package.json"); - if (await isFile(host, pkgFile)) { - const pkg = await readPackage(host, pkgFile); - return loadPackage(directory, pkg); + async function resolveEsmMatch(match: string) { + const resolved = await realpath(fileURLToPath(match)); + if (await isFile(host, resolved)) { + return resolved; } - if (mustBePackage) { - return undefined; + throw new ResolveModuleError( + "INVALID_MODULE_EXPORT_TARGET", + `Import "${specifier}" resolving to "${resolved}" is not a file.`, + ); + } + + async function loadAsDirectory(directory: string): Promise { + const pkg = await loadPackageAtPath(directory); + if (pkg) { + return pkg; } for (const file of options.directoryIndexFiles ?? defaultDirectoryIndexFiles) { @@ -187,7 +286,17 @@ export async function resolveModule( return undefined; } - async function loadPackage( + async function loadPackage(directory: string, pkg: PackageJson, subPath?: string) { + const e = await resolveNodePackageExports(subPath ?? "", pkg, directory); + if (e) return e; + + if (subPath !== undefined && subPath !== "") { + return undefined; + } + return loadPackageLegacy(directory, pkg); + } + + async function loadPackageLegacy( directory: string, pkg: PackageJson, ): Promise { @@ -219,7 +328,7 @@ export async function resolveModule( } else { throw new ResolveModuleError( "INVALID_MAIN", - `Package ${pkg.name} main file "${mainFile}" is invalid.`, + `Package ${pkg.name} main file "${mainFile}" is not pointing to a valid file or directory.`, ); } } @@ -272,3 +381,24 @@ async function isFile(host: ResolveModuleHost, path: string) { throw e; } } +function pathToFileURL(path: string): string { + return `file://${path}`; +} + +function fileURLToPath(url: string) { + if (!url.startsWith("file://")) throw new Error("Cannot convert non file: URL to path"); + + const pathname = url.slice("file://".length); + + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === "%") { + const third = pathname.codePointAt(n + 2)! | 0x20; + + if (pathname[n + 1] === "2" && third === 102) { + throw new Error("Invalid url to path: must not include encoded / characters"); + } + } + } + + return decodeURIComponent(pathname); +} diff --git a/packages/compiler/src/module-resolver/utils.ts b/packages/compiler/src/module-resolver/utils.ts new file mode 100644 index 0000000000..ec205a97a8 --- /dev/null +++ b/packages/compiler/src/module-resolver/utils.ts @@ -0,0 +1,22 @@ +export interface NodeModuleSpecifier { + readonly packageName: string; + readonly subPath: string; +} +// returns the imported package name for bare module imports +export function parseNodeModuleSpecifier(id: string): NodeModuleSpecifier | null { + if (id.startsWith(".") || id.startsWith("/")) { + return null; + } + + const split = id.split("/"); + + // @my-scope/my-package/foo.js -> @my-scope/my-package + // @my-scope/my-package -> @my-scope/my-package + if (split[0][0] === "@") { + return { packageName: `${split[0]}/${split[1]}`, subPath: split.slice(2).join("/") }; + } + + // my-package/foo.js -> my-package + // my-package -> my-package + return { packageName: split[0], subPath: split.slice(1).join("/") }; +} diff --git a/packages/compiler/src/testing/test-host.ts b/packages/compiler/src/testing/test-host.ts index afccfc577f..3369c1f64b 100644 --- a/packages/compiler/src/testing/test-host.ts +++ b/packages/compiler/src/testing/test-host.ts @@ -311,7 +311,7 @@ async function createTestHostInternal(): Promise { // default for tests is noEmit options = { ...options, noEmit: true }; } - const p = await compileProgram(fileSystem.compilerHost, mainFile, options); + const p = await compileProgram(fileSystem.compilerHost, resolveVirtualPath(mainFile), options); program = p; logVerboseTestOutput((log) => logDiagnostics(p.diagnostics, createLogger({ sink: fileSystem.compilerHost.logSink })), diff --git a/packages/compiler/src/types/package-json.ts b/packages/compiler/src/types/package-json.ts index aa2805be22..aad7e15dcd 100644 --- a/packages/compiler/src/types/package-json.ts +++ b/packages/compiler/src/types/package-json.ts @@ -20,7 +20,7 @@ export interface PackageJson { * Subpath exports to define entry points of the package. * [Read more.](https://nodejs.org/api/packages.html#subpath-exports) */ - exports?: Exports; + exports?: Exports | null; private?: boolean; dependencies?: Record; peerDependencies?: Record; @@ -30,7 +30,7 @@ export interface PackageJson { /** * Entry points of a module, optionally with conditions and subpath exports. */ -type Exports = null | string | Array | ExportConditions; +export type Exports = string | Array | ExportConditions; /** A mapping of conditions and the paths to which they resolve. diff --git a/packages/compiler/test/checker/imports.test.ts b/packages/compiler/test/checker/imports.test.ts index bce13964ac..12f52d53de 100644 --- a/packages/compiler/test/checker/imports.test.ts +++ b/packages/compiler/test/checker/imports.test.ts @@ -113,7 +113,36 @@ describe("compiler: imports", () => { expectFileLoaded({ typespec: ["main.tsp", "test/main.tsp"] }); }); - it("import library", async () => { + it("import library with typespec exports", async () => { + host.addTypeSpecFile( + "main.tsp", + ` + import "my-lib"; + + model A { x: C } + `, + ); + host.addTypeSpecFile( + "node_modules/my-lib/package.json", + JSON.stringify({ + name: "my-test-lib", + exports: { ".": { typespec: "./main.tsp" } }, + }), + ); + host.addTypeSpecFile( + "node_modules/my-lib/main.tsp", + ` + model C { } + `, + ); + + await host.compile("main.tsp"); + expectFileLoaded({ typespec: ["main.tsp", "node_modules/my-lib/main.tsp"] }); + const file = host.program.sourceFiles.get(resolveVirtualPath("node_modules/my-lib/main.tsp")); + ok(file, "File exists"); + }); + + it("import library(with tspmain)", async () => { host.addTypeSpecFile( "main.tsp", ` diff --git a/packages/compiler/test/e2e/scenarios/scenarios.e2e.ts b/packages/compiler/test/e2e/scenarios/scenarios.e2e.ts index e5bff363fe..d7e41d901a 100644 --- a/packages/compiler/test/e2e/scenarios/scenarios.e2e.ts +++ b/packages/compiler/test/e2e/scenarios/scenarios.e2e.ts @@ -47,7 +47,7 @@ describe("compiler: entrypoints", () => { }); expectDiagnostics(program.diagnostics, { code: "library-invalid", - message: `Library "my-lib" has an invalid tspMain file.`, + message: `Library "my-lib" is invalid: Package @typespec/my-lib main file "not-a-file.tsp" is not pointing to a valid file or directory.`, }); }); @@ -57,7 +57,7 @@ describe("compiler: entrypoints", () => { }); expectDiagnostics(program.diagnostics, { code: "library-invalid", - message: `Library "my-lib" has an invalid main file.`, + message: `Library "my-lib" is invalid: Package @typespec/my-lib main file "not-a-file.js" is not pointing to a valid file or directory.`, }); }); diff --git a/packages/compiler/test/module-resolver/esm/resolve-package-exports.test.ts b/packages/compiler/test/module-resolver/esm/resolve-package-exports.test.ts new file mode 100644 index 0000000000..0a6a1a1131 --- /dev/null +++ b/packages/compiler/test/module-resolver/esm/resolve-package-exports.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { resolvePackageExports } from "../../../src/module-resolver/esm/resolve-package-exports.js"; +import { EsmResolutionContext } from "../../../src/module-resolver/esm/utils.js"; + +const context: EsmResolutionContext = { + specifier: "test-lib", + packageUrl: "file:///test/node_modules/test-lib/", + moduleDirs: ["node_modules"], + conditions: ["import"], + resolveId: () => {}, +}; + +describe("exports is a string", () => { + it("returns value if subpath is .", async () => { + const result = await resolvePackageExports(context, ".", "./foo.js"); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); + }); + it("returns value if subpath is anything else than .", async () => { + const result = await resolvePackageExports(context, ".", "./foo.js"); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); + }); +}); + +describe("exports is an object", () => { + it("resolve matching subpath", async () => { + const result = await resolvePackageExports(context, ".", { + ".": "./root.js", + "./foo": "./foo.js", + }); + expect(result).toBe("file:///test/node_modules/test-lib/root.js"); + }); + + it("resolve sub path", async () => { + const result = await resolvePackageExports(context, "./foo", { + ".": "./root.js", + "./foo": "./foo.js", + }); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); + }); + + describe("wildcard", () => { + it("resolve file at the root", async () => { + const result = await resolvePackageExports(context, "./lib/foo.js", { + "./lib/*": "./dist/*", + }); + expect(result).toBe("file:///test/node_modules/test-lib/dist/foo.js"); + }); + it("resolve file nested", async () => { + const result = await resolvePackageExports(context, "./lib/sub/folder/foo.js", { + "./lib/*": "./dist/*", + }); + expect(result).toBe("file:///test/node_modules/test-lib/dist/sub/folder/foo.js"); + }); + }); + + it("throws error when export is missing mapping", async () => { + await expect( + resolvePackageExports(context, "./bar", { + ".": "./root.js", + "./foo": "./foo.js", + }), + ).rejects.toThrowError( + `Could not resolve import "test-lib" using exports defined in file:///test/node_modules/test-lib/.`, + ); + }); +}); diff --git a/packages/compiler/test/module-resolver/esm/resolve-package-target.test.ts b/packages/compiler/test/module-resolver/esm/resolve-package-target.test.ts new file mode 100644 index 0000000000..ca6cc4b357 --- /dev/null +++ b/packages/compiler/test/module-resolver/esm/resolve-package-target.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { resolvePackageTarget } from "../../../src/module-resolver/esm/resolve-package-target.js"; +import { EsmResolutionContext } from "../../../src/module-resolver/esm/utils.js"; + +const context: EsmResolutionContext = { + specifier: "test-lib", + packageUrl: "file:///test/node_modules/test-lib/", + moduleDirs: ["node_modules"], + conditions: ["import"], + resolveId: () => {}, +}; + +it("returns target if it is a string", async () => { + const result = await resolvePackageTarget(context, { + target: "./foo.js", + }); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); +}); + +describe("object value", () => { + it("resolve first matching condition", async () => { + const result = await resolvePackageTarget(context, { + target: { + require: "./bar.js", + import: "./foo.js", + }, + }); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); + }); + + it("resolve default if no condition match", async () => { + const result = await resolvePackageTarget(context, { + target: { + require: "./bar.js", + default: "./foo.js", + }, + }); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); + }); + + it("resolve multiple conditions", async () => { + const result = await resolvePackageTarget( + { ...context, conditions: ["import", "development"] }, + { + target: { + require: { + production: "./prod.js", + development: "./dev.js", + }, + import: { + production: "./prod.js", + development: "./dev.js", + }, + }, + }, + ); + expect(result).toBe("file:///test/node_modules/test-lib/dev.js"); + }); +}); + +it("package url doesn't need trailing /", async () => { + const result = await resolvePackageTarget( + { ...context, packageUrl: "file:///test/node_modules/test-lib" }, + { + target: "./foo.js", + }, + ); + expect(result).toBe("file:///test/node_modules/test-lib/foo.js"); +}); diff --git a/packages/compiler/test/module-resolver/module-resolver.test.ts b/packages/compiler/test/module-resolver/module-resolver.test.ts new file mode 100644 index 0000000000..ee1d9c284b --- /dev/null +++ b/packages/compiler/test/module-resolver/module-resolver.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from "vitest"; +import { + resolveModule, + ResolveModuleError, + type ResolveModuleHost, +} from "../../src/module-resolver/module-resolver.js"; +import { TestHostError } from "../../src/testing/types.js"; + +function mkFs(files: Record): { + host: ResolveModuleHost; + fs: Map; +} { + const fs = new Map(); + for (const [file, content] of Object.entries(files)) { + fs.set(file, content); + } + + const host: ResolveModuleHost = { + realpath: (path) => Promise.resolve(path), + stat: async (path: string) => { + if (fs.has(path)) { + return { + isDirectory: () => false, + isFile: () => true, + }; + } + + for (const fsPath of fs.keys()) { + if (fsPath.startsWith(path) && fsPath !== path) { + return { + isDirectory: () => true, + isFile: () => false, + }; + } + } + + throw new TestHostError(`File ${path} not found`, "ENOENT"); + }, + readFile: async (path: string) => { + const contents = fs.get(path); + if (contents === undefined) { + throw new TestHostError(`File ${path} not found.`, "ENOENT"); + } + return contents; + }, + }; + return { fs, host }; +} + +describe("resolve any extension", () => { + it.each([".js", ".mjs", ".ts", ".json", ".tsp"])("%s", async (ext) => { + const { host } = mkFs({ + [`/ws/proj/a${ext}`]: "", + }); + + const resolved = await resolveModule(host, `./a${ext}`, { baseDir: "/ws/proj" }); + expect(resolved).toEqual({ type: "file", path: `/ws/proj/a${ext}` }); + }); +}); + +describe("relative file", () => { + const { host } = mkFs({ + "/ws/proj1/a.js": "", + "/ws/proj1/b.js": "", + "/ws/proj2/a.js": "", + "/ws/proj2/b.js": "", + }); + + it("find ./ file", async () => { + const resolved = await resolveModule(host, "./a.js", { baseDir: "/ws/proj1" }); + expect(resolved).toEqual({ type: "file", path: "/ws/proj1/a.js" }); + }); + + it("find ../ file", async () => { + const resolved = await resolveModule(host, "../proj1/a.js", { baseDir: "/ws/proj2" }); + expect(resolved).toEqual({ type: "file", path: "/ws/proj1/a.js" }); + }); +}); + +describe("loads directory", () => { + const { host } = mkFs({ + "/ws/proj/a.js": "", + "/ws/proj/area1/index.js": "", + "/ws/proj/area2/index.mjs": "", + "/ws/proj/area3/index.js": "", + "/ws/proj/area3/index.mjs": "", + "/ws/proj/area3/index.tsp": "", + }); + + describe("default behavior", () => { + it("find index.js", async () => { + const resolved = await resolveModule(host, "./area1", { baseDir: "/ws/proj" }); + expect(resolved).toEqual({ type: "file", path: "/ws/proj/area1/index.js" }); + }); + it("find index.mjs", async () => { + const resolved = await resolveModule(host, "./area2", { baseDir: "/ws/proj" }); + expect(resolved).toEqual({ type: "file", path: "/ws/proj/area2/index.mjs" }); + }); + it("find index.mjs over index.js", async () => { + const resolved = await resolveModule(host, "./area3", { baseDir: "/ws/proj" }); + expect(resolved).toEqual({ type: "file", path: "/ws/proj/area3/index.mjs" }); + }); + }); + + it("resolve custom index file list in order", async () => { + const resolved = await resolveModule(host, "./area3", { + baseDir: "/ws/proj", + directoryIndexFiles: ["index.tsp", "index.mjs", "index.js"], + }); + expect(resolved).toEqual({ type: "file", path: "/ws/proj/area3/index.tsp" }); + }); +}); + +describe("packages", () => { + it("resolve using `main` file", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ main: "entry.js" }), + "/ws/proj/node_modules/test-lib/entry.js": "", + }); + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + }); + expect(resolved).toMatchObject({ + type: "module", + path: "/ws/proj/node_modules/test-lib", + mainFile: "/ws/proj/node_modules/test-lib/entry.js", + }); + }); + + it("resolve using custom resolveMain resolution", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + main: "entry.js", + tspMain: "entry.tsp", + }), + "/ws/proj/node_modules/test-lib/entry.js": "", + "/ws/proj/node_modules/test-lib/entry.tsp": "", + }); + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + resolveMain: (pkg) => pkg.tspMain, + }); + expect(resolved).toMatchObject({ + type: "module", + path: "/ws/proj/node_modules/test-lib", + mainFile: "/ws/proj/node_modules/test-lib/entry.tsp", + }); + }); + + describe("when exports is defined", () => { + describe("no condition", () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + exports: { ".": "./entry.js", "./named": "./named.js" }, + }), + "/ws/proj/node_modules/test-lib/entry.js": "", + "/ws/proj/node_modules/test-lib/named.js": "", + }); + it("resolve . export without condition", async () => { + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + }); + expect(resolved).toMatchObject({ + type: "module", + path: "/ws/proj/node_modules/test-lib", + mainFile: "/ws/proj/node_modules/test-lib/entry.js", + }); + }); + + it("resolve named export without condition", async () => { + const resolved = await resolveModule(host, "test-lib/named", { + baseDir: "/ws/proj", + }); + expect(resolved).toMatchObject({ + type: "module", + path: "/ws/proj/node_modules/test-lib", + mainFile: "/ws/proj/node_modules/test-lib/named.js", + }); + }); + }); + + describe("condition", () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + exports: { + ".": { + import: "./entry.js", + require: "./entry.cjs", + default: "./entry.default.js", + }, + }, + }), + "/ws/proj/node_modules/test-lib/entry.js": "", + "/ws/proj/node_modules/test-lib/entry.cjs": "", + "/ws/proj/node_modules/test-lib/entry.default.js": "", + }); + + it("resolve default condition if no condition are specified", async () => { + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + }); + expect(resolved).toMatchObject({ + type: "module", + path: "/ws/proj/node_modules/test-lib", + mainFile: "/ws/proj/node_modules/test-lib/entry.default.js", + }); + }); + + it("respect condition order", async () => { + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + conditions: ["require", "import"], + }); + expect(resolved).toMatchObject({ + type: "module", + path: "/ws/proj/node_modules/test-lib", + mainFile: "/ws/proj/node_modules/test-lib/entry.js", + }); + }); + }); + + describe("invalid exports", () => { + it("throws error if export path point to invalid file", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + exports: { ".": "./missing.js" }, + }), + }); + await expect(resolveModule(host, "test-lib", { baseDir: "/ws/proj" })).rejects.toThrowError( + new ResolveModuleError( + "INVALID_MODULE_EXPORT_TARGET", + `Import "test-lib" resolving to "/ws/proj/node_modules/test-lib/missing.js" is not a file.`, + ), + ); + }); + it("throws error if export path is not starting with ./", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + exports: { ".": "index.js" }, + }), + "/ws/proj/node_modules/test-lib/index.js": "", + }); + await expect(resolveModule(host, "test-lib", { baseDir: "/ws/proj" })).rejects.toThrowError( + new ResolveModuleError( + "INVALID_MODULE_EXPORT_TARGET", + `Could not resolve import "test-lib" using exports defined in file:///ws/proj/node_modules/test-lib. Invalid mapping: "index.js".`, + ), + ); + }); + + it("throws error if export is missing", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + exports: { ".": "index.js" }, + }), + "/ws/proj/node_modules/test-lib/index.js": "", + }); + await expect( + resolveModule(host, "test-lib/named", { baseDir: "/ws/proj" }), + ).rejects.toThrowError( + new ResolveModuleError( + "MODULE_NOT_FOUND", + `Could not resolve import "test-lib/named" using exports defined in file:///ws/proj/node_modules/test-lib.`, + ), + ); + }); + + describe("missing condition with fallbackOnMissingCondition", () => { + describe("for . export", () => { + it("fallback to main if default is not set", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + main: "main.js", + exports: { + ".": { + import: "./index.js", + }, + }, + }), + "/ws/proj/node_modules/test-lib/main.js": "", + "/ws/proj/node_modules/test-lib/index.js": "", + }); + + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + conditions: ["typespec"], + fallbackOnMissingCondition: true, + }); + + expect(resolved).toMatchObject({ + mainFile: "/ws/proj/node_modules/test-lib/main.js", + }); + }); + + it("fallback to main if default is set", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + main: "main.js", + exports: { + ".": { + default: "./index.js", + }, + }, + }), + "/ws/proj/node_modules/test-lib/main.js": "", + "/ws/proj/node_modules/test-lib/index.js": "", + }); + + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + conditions: ["typespec"], + fallbackOnMissingCondition: true, + }); + + expect(resolved).toMatchObject({ + mainFile: "/ws/proj/node_modules/test-lib/main.js", + }); + }); + + it("fallback to main if using no condition", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + main: "main.js", + exports: { + ".": "./index.js", + }, + }), + "/ws/proj/node_modules/test-lib/main.js": "", + "/ws/proj/node_modules/test-lib/index.js": "", + }); + + const resolved = await resolveModule(host, "test-lib", { + baseDir: "/ws/proj", + conditions: ["typespec"], + fallbackOnMissingCondition: true, + }); + + expect(resolved).toMatchObject({ + mainFile: "/ws/proj/node_modules/test-lib/main.js", + }); + }); + }); + + describe("for named export", () => { + it("throws an error for named path", async () => { + const { host } = mkFs({ + "/ws/proj/node_modules/test-lib/package.json": JSON.stringify({ + main: "main.js", + exports: { + "./named": { + import: "./index.js", + }, + }, + }), + "/ws/proj/node_modules/test-lib/main.js": "", + "/ws/proj/node_modules/test-lib/index.js": "", + }); + + await expect( + resolveModule(host, "test-lib/named", { + baseDir: "/ws/proj", + conditions: ["typespec"], + fallbackOnMissingCondition: true, + }), + ).rejects.toThrowError( + new ResolveModuleError( + "MODULE_NOT_FOUND", + `Could not resolve import "test-lib/named" using exports defined in file:///ws/proj/node_modules/test-lib.`, + ), + ); + }); + }); + }); + }); + }); +}); diff --git a/packages/compiler/test/module-resolver/utils.test.ts b/packages/compiler/test/module-resolver/utils.test.ts new file mode 100644 index 0000000000..d52071be8d --- /dev/null +++ b/packages/compiler/test/module-resolver/utils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseNodeModuleSpecifier } from "../../src/module-resolver/utils.js"; + +describe("parseNodeModuleImport()", () => { + it("returns null for relative imports ./", () => { + expect(parseNodeModuleSpecifier("./foo")).toBeNull(); + expect(parseNodeModuleSpecifier("./foo.js")).toBeNull(); + }); + it("returns null for relative imports ../", () => { + expect(parseNodeModuleSpecifier("../foo")).toBeNull(); + expect(parseNodeModuleSpecifier("../foo.js")).toBeNull(); + }); + + it.each([ + ["foo", "foo", ""], + ["foo-bar", "foo-bar", ""], + ["foo/export", "foo", "export"], + ["foo/nested/export", "foo", "nested/export"], + ["@scope/pkg", "@scope/pkg", ""], + ["@scope/pkg/export", "@scope/pkg", "export"], + ["@scope/pkg/nested/export", "@scope/pkg", "nested/export"], + ])("%s => pkg: %s, subPath: %s", (input, expectedPkg, expectedSubPath) => { + const result = parseNodeModuleSpecifier(input); + expect(result).toEqual({ packageName: expectedPkg, subPath: expectedSubPath }); + }); +}); diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index 7dae90b9d0..798b6fbf29 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -13,6 +13,7 @@ import { type ReactNode, } from "react"; import { CompletionItemTag } from "vscode-languageserver"; +import { resolveVirtualPath } from "../browser-host.js"; import { EditorCommandBar } from "../editor-command-bar/editor-command-bar.js"; import { getMonacoRange } from "../services.js"; import type { BrowserHost, PlaygroundSample } from "../types.js"; @@ -343,7 +344,7 @@ async function compile( await emptyOutputDir(host); try { const typespecCompiler = host.compiler; - const program = await typespecCompiler.compile(host, "main.tsp", { + const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), { ...options, options: { ...options.options,