diff --git a/packages/integrate-module/src/index.ts b/packages/integrate-module/src/index.ts index 506cf4fbb..57f3f9c00 100644 --- a/packages/integrate-module/src/index.ts +++ b/packages/integrate-module/src/index.ts @@ -1,14 +1,12 @@ /* eslint import/order: off */ +import { bar as subBar } from '@subdirectory/bar.mjs' +import { supportedExtensions } from 'file-type' import assert from 'node:assert' import test from 'node:test' - -import { supportedExtensions } from 'file-type' - import { CompiledClass } from './compiled.js' import { foo } from './foo.mjs' import { bar } from './subdirectory/bar.mjs' import { baz } from './subdirectory/index.mjs' -import { bar as subBar } from '@subdirectory/bar.mjs' import './js-module.mjs' await test('file-type should work', () => { diff --git a/packages/integrate-module/tsconfig.json b/packages/integrate-module/tsconfig.json index 06ade40e4..60eab5c3b 100644 --- a/packages/integrate-module/tsconfig.json +++ b/packages/integrate-module/tsconfig.json @@ -7,8 +7,8 @@ "outDir": "dist", "baseUrl": "./", "paths": { - "@subdirectory/*": ["./src/subdirectory/*"], - }, + "@subdirectory/*": ["./src/subdirectory/*"] + } }, - "include": ["src"], + "include": ["src", "package.json"] } diff --git a/packages/register/esm.mts b/packages/register/esm.mts index d9cbc417c..97ca38586 100644 --- a/packages/register/esm.mts +++ b/packages/register/esm.mts @@ -1,12 +1,17 @@ -import type { LoadHook, ResolveHook } from 'node:module' -import { fileURLToPath, pathToFileURL } from 'url' +import { readFile } from 'fs/promises' +import { createRequire, type LoadFnOutput, type LoadHook, type ResolveFnOutput, type ResolveHook } from 'node:module' +import { extname } from 'path' +import { fileURLToPath, parse as parseUrl, pathToFileURL } from 'url' +import debugFactory from 'debug' import ts from 'typescript' // @ts-expect-error import { readDefaultTsConfig } from '../lib/read-default-tsconfig.js' // @ts-expect-error -import { AVAILABLE_EXTENSION_PATTERN, AVAILABLE_TS_EXTENSION_PATTERN, compile } from '../lib/register.js' +import { AVAILABLE_TS_EXTENSION_PATTERN, compile } from '../lib/register.js' + +const debug = debugFactory('@swc-node') const tsconfig: ts.CompilerOptions = readDefaultTsConfig() tsconfig.module = ts.ModuleKind.ESNext @@ -17,21 +22,151 @@ const host: ts.ModuleResolutionHost = { readFile: ts.sys.readFile, } +const addShortCircuitSignal = (input: T): T => { + return { + ...input, + shortCircuit: true, + } +} + +interface PackageJson { + name: string + version: string + type?: 'module' | 'commonjs' + main?: string +} + +const packageJSONCache = new Map() + +const readFileIfExists = async (path: string) => { + try { + const content = await readFile(path, 'utf-8') + + return JSON.parse(content) + } catch (e) { + // eslint-disable-next-line no-undef + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined + } + + throw e + } +} + +const readPackageJSON = async (path: string) => { + if (packageJSONCache.has(path)) { + return packageJSONCache.get(path) + } + + const res = (await readFileIfExists(path)) as PackageJson + packageJSONCache.set(path, res) + return res +} + +const getPackageForFile = async (url: string) => { + // use URL instead path.resolve to handle relative path + let packageJsonURL = new URL('./package.json', url) + + // eslint-disable-next-line no-constant-condition + while (true) { + const path = fileURLToPath(packageJsonURL) + + // for special case by some package manager + if (path.endsWith('node_modules/package.json')) { + break + } + + const packageJson = await readPackageJSON(path) + + if (!packageJson) { + const lastPath = packageJsonURL.pathname + packageJsonURL = new URL('../package.json', packageJsonURL) + + // root level /package.json + if (packageJsonURL.pathname === lastPath) { + break + } + + continue + } + + if (packageJson.type && packageJson.type !== 'module' && packageJson.type !== 'commonjs') { + packageJson.type = undefined + } + + return packageJson + } + + return undefined +} + +export const getPackageType = async (url: string) => { + const packageJson = await getPackageForFile(url) + + return packageJson?.type ?? undefined +} + +const INTERNAL_MODULE_PATTERN = /^(node|nodejs):/ + +const EXTENSION_MODULE_MAP = { + '.mjs': 'module', + '.cjs': 'commonjs', + '.ts': 'module', + '.mts': 'module', + '.cts': 'commonjs', + '.json': 'json', + '.wasm': 'wasm', + '.node': 'commonjs', +} as const + export const resolve: ResolveHook = async (specifier, context, nextResolve) => { - if (!AVAILABLE_EXTENSION_PATTERN.test(specifier)) { - return nextResolve(specifier) + debug('resolve', specifier, JSON.stringify(context)) + + if (INTERNAL_MODULE_PATTERN.test(specifier)) { + debug('skip resolve: internal format', specifier) + + return addShortCircuitSignal({ + url: specifier, + format: 'builtin', + }) } - // entrypoint - if (!context.parentURL) { - return { - importAttributes: { - ...context.importAttributes, - swc: 'entrypoint', - }, + if (specifier.startsWith('data:')) { + debug('skip resolve: data url', specifier) + + return addShortCircuitSignal({ url: specifier, - shortCircuit: true, + }) + } + + const parsedUrl = parseUrl(specifier) + + // as entrypoint, just return specifier + if (!context.parentURL || parsedUrl.protocol === 'file:') { + debug('skip resolve: absolute path or entrypoint', specifier) + + let format: ResolveFnOutput['format'] = null + + const specifierPath = fileURLToPath(specifier) + const ext = extname(specifierPath) + + if (ext === '.js') { + format = (await getPackageType(specifier)) === 'module' ? 'module' : 'commonjs' + } else { + format = EXTENSION_MODULE_MAP[ext as keyof typeof EXTENSION_MODULE_MAP] } + + return addShortCircuitSignal({ + url: specifier, + format, + }) + } + + // import attributes, support json currently + if (context.importAttributes?.type) { + debug('skip resolve: import attributes', specifier) + + return addShortCircuitSignal(await nextResolve(specifier)) } const { resolvedModule } = ts.resolveModuleName( @@ -45,21 +180,39 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { // local project file if ( resolvedModule && - (!resolvedModule.resolvedFileName.includes('/node_modules/') || - AVAILABLE_TS_EXTENSION_PATTERN.test(resolvedModule.resolvedFileName)) + !resolvedModule.resolvedFileName.includes('/node_modules/') && + AVAILABLE_TS_EXTENSION_PATTERN.test(resolvedModule.resolvedFileName) ) { - return { + debug('resolved: typescript', specifier, resolvedModule.resolvedFileName) + + return addShortCircuitSignal({ + ...context, url: pathToFileURL(resolvedModule.resolvedFileName).href, - shortCircuit: true, - importAttributes: { - ...context.importAttributes, - swc: resolvedModule.resolvedFileName, - }, - } + format: 'module', + }) } - // files could not resolved by typescript - return nextResolve(specifier) + try { + // files could not resolved by typescript or resolved as dts, fallback to use node resolver + const res = await nextResolve(specifier) + debug('resolved: fallback node', specifier, res.url, res.format) + return addShortCircuitSignal(res) + } catch (resolveError) { + // fallback to cjs resolve as may import non-esm files + try { + const resolution = pathToFileURL(createRequire(process.cwd()).resolve(specifier)).toString() + + debug('resolved: fallback commonjs', specifier, resolution) + + return addShortCircuitSignal({ + format: 'commonjs', + url: resolution, + }) + } catch (error) { + debug('resolved by cjs error', specifier, error) + throw resolveError + } + } } const tsconfigForSWCNode = { @@ -69,24 +222,31 @@ const tsconfigForSWCNode = { } export const load: LoadHook = async (url, context, nextLoad) => { - const swcAttribute = context.importAttributes.swc + debug('load', url, JSON.stringify(context)) - if (swcAttribute) { - delete context.importAttributes.swc + if (url.startsWith('data:')) { + debug('skip load: data url', url) - const { source } = await nextLoad(url, { - ...context, - format: 'ts' as any, - }) + return nextLoad(url, context) + } - const code = !source || typeof source === 'string' ? source : Buffer.from(source).toString() - const compiled = await compile(code, fileURLToPath(url), tsconfigForSWCNode, true) - return { - format: 'module', - source: compiled, - shortCircuit: true, - } - } else { + if (['builtin', 'json', 'wasm'].includes(context.format)) { + debug('loaded: internal format', url) return nextLoad(url, context) } + + const { source, format: resolvedFormat } = await nextLoad(url, context) + + debug('loaded', url, resolvedFormat) + + const code = !source || typeof source === 'string' ? source : Buffer.from(source).toString() + const compiled = await compile(code, url, tsconfigForSWCNode, true) + + debug('compiled', url, resolvedFormat) + + return addShortCircuitSignal({ + // for lazy: ts-node think format would undefined, actually it should not, keep it as original temporarily + format: resolvedFormat, + source: compiled, + }) }