217 lines
8.4 KiB
TypeScript
217 lines
8.4 KiB
TypeScript
/**
|
|
* Node module resolve hook for the `dsh` source launcher. It projects the root
|
|
* tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
|
|
* handling to Node's native transform-types runtime.
|
|
* @module @deepseek-ai/dsh/tsconfig-paths-loader
|
|
*/
|
|
|
|
import { readFile, stat } from 'node:fs/promises'
|
|
import { dirname, extname, join, resolve } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
|
|
import ts from 'typescript'
|
|
|
|
interface LoaderData {
|
|
tsconfigPath: string
|
|
}
|
|
|
|
interface PackageManifest {
|
|
name?: string
|
|
dependencies?: Record<string, string>
|
|
optionalDependencies?: Record<string, string>
|
|
peerDependencies?: Record<string, string>
|
|
}
|
|
|
|
interface PathRule {
|
|
pattern: string
|
|
prefix: string
|
|
suffix: string
|
|
targets: readonly string[]
|
|
}
|
|
|
|
interface PathsCompilerOptions {
|
|
readonly baseUrl?: string
|
|
readonly paths?: ts.MapLike<string[]>
|
|
readonly pathsBasePath?: string
|
|
}
|
|
|
|
// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded.
|
|
const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
|
|
|
|
/**
|
|
* Resolve package imports through one parsed tsconfig paths table.
|
|
*
|
|
* Manifest reads are process-scoped and memoized by path. Only matched source
|
|
* aliases enter the cache, bounding it to directories participating in source
|
|
* resolution.
|
|
*/
|
|
export class TsconfigPathsResolver {
|
|
private readonly rules: readonly PathRule[]
|
|
private readonly configDirectory: string
|
|
private readonly manifests = new Map<string, Promise<PackageManifest | undefined>>()
|
|
|
|
private constructor(configDirectory: string, paths: ts.MapLike<string[]>) {
|
|
this.configDirectory = configDirectory
|
|
this.rules = Object.entries(paths)
|
|
.map(([pattern, targets]) => {
|
|
const wildcard = pattern.indexOf('*')
|
|
return {
|
|
pattern,
|
|
prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
|
|
suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
|
|
targets,
|
|
}
|
|
})
|
|
.sort((left, right) => {
|
|
const leftExact = left.pattern.includes('*') ? 0 : 1
|
|
const rightExact = right.pattern.includes('*') ? 0 : 1
|
|
return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Parse a tsconfig including its `extends` chain.
|
|
* @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
|
|
* @returns A resolver backed by that path table.
|
|
*/
|
|
static create(tsconfigPath: string): TsconfigPathsResolver {
|
|
let unrecoverable: ts.Diagnostic | undefined
|
|
const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
|
|
...ts.sys,
|
|
onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
|
|
})
|
|
if (parsed === undefined) {
|
|
const detail = unrecoverable === undefined
|
|
? 'unknown configuration error'
|
|
: ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
|
|
throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
|
|
}
|
|
const options = parsed.options as PathsCompilerOptions
|
|
const paths = options.paths
|
|
if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
|
|
const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath)
|
|
return new TsconfigPathsResolver(configDirectory, paths)
|
|
}
|
|
|
|
/**
|
|
* Resolve one bare package specifier to a source file when the importing
|
|
* package (or config-directory owner) declares that package at runtime.
|
|
* @param specifier Module specifier passed to Node.
|
|
* @param parentURL Importing file or Loader config-directory URL.
|
|
* @returns Source file URL, or `undefined` when normal Node resolution owns the request.
|
|
*/
|
|
async resolve(specifier: string, parentURL: string | undefined): Promise<string | undefined> {
|
|
const packageName = packageNameFromSpecifier(specifier)
|
|
if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
|
|
const matched = this.match(specifier)
|
|
if (matched === undefined) return undefined
|
|
const configParent = parentURL.endsWith('/')
|
|
const parentPath = fileURLToPath(parentURL)
|
|
const startDirectory = configParent ? parentPath : dirname(parentPath)
|
|
if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
|
|
|
|
for (const target of matched.targets) {
|
|
const substituted = target.replace('*', matched.wildcard)
|
|
const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
|
|
if (candidate !== undefined) return pathToFileURL(candidate).href
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
|
|
for (const rule of this.rules) {
|
|
if (!rule.pattern.includes('*')) {
|
|
if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
|
|
continue
|
|
}
|
|
if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
|
|
const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
|
|
return { targets: rule.targets, wildcard }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
private async isDeclaredRuntimeDependency(
|
|
startDirectory: string,
|
|
packageName: string,
|
|
searchAncestors: boolean,
|
|
): Promise<boolean> {
|
|
for (let directory = startDirectory; ; directory = dirname(directory)) {
|
|
const manifest = await this.readManifest(join(directory, 'package.json'))
|
|
if (manifest !== undefined) {
|
|
if (declaresRuntimeDependency(manifest, packageName)) return true
|
|
if (!searchAncestors) return false
|
|
}
|
|
const parent = dirname(directory)
|
|
if (parent === directory) return false
|
|
}
|
|
}
|
|
|
|
private readManifest(path: string): Promise<PackageManifest | undefined> {
|
|
let pending = this.manifests.get(path)
|
|
if (pending !== undefined) return pending
|
|
pending = readFile(path, 'utf8').then(
|
|
content => JSON.parse(content) as PackageManifest,
|
|
(error: unknown) => {
|
|
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
throw error
|
|
},
|
|
)
|
|
this.manifests.set(path, pending)
|
|
return pending
|
|
}
|
|
}
|
|
|
|
let resolver: TsconfigPathsResolver | undefined
|
|
|
|
/** Initialize the hook worker from the source-launch preloader. */
|
|
export function initialize(data: LoaderData): void {
|
|
resolver = TsconfigPathsResolver.create(data.tsconfigPath)
|
|
}
|
|
|
|
/** Resolve declared workspace packages to source and delegate every other request to Node. */
|
|
export async function resolveHook(
|
|
specifier: string,
|
|
context: ResolveHookContext,
|
|
nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
|
|
): Promise<ResolveFnOutput> {
|
|
const url = await resolver?.resolve(specifier, context.parentURL)
|
|
return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
|
|
}
|
|
|
|
// Node customization hooks discover this exact export name.
|
|
export { resolveHook as resolve }
|
|
|
|
function packageNameFromSpecifier(specifier: string): string | undefined {
|
|
if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) {
|
|
return undefined
|
|
}
|
|
const segments = specifier.split('/')
|
|
return specifier.startsWith('@')
|
|
? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
|
: segments[0] || undefined
|
|
}
|
|
|
|
function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
|
|
return manifest.name === packageName
|
|
|| packageName in (manifest.dependencies ?? {})
|
|
|| packageName in (manifest.optionalDependencies ?? {})
|
|
|| packageName in (manifest.peerDependencies ?? {})
|
|
}
|
|
|
|
async function existingSourcePath(base: string): Promise<string | undefined> {
|
|
const extension = extname(base)
|
|
if (extension === '.tsx') return undefined
|
|
const candidates = extension === ''
|
|
? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
|
|
: [base]
|
|
for (const candidate of candidates) {
|
|
try {
|
|
if ((await stat(candidate)).isFile()) return candidate
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
}
|
|
return undefined
|
|
}
|