From eee813d35c826246e8687474b410ff7932243fad Mon Sep 17 00:00:00 2001 From: yeliex Date: Wed, 29 May 2024 10:58:19 +0800 Subject: [PATCH 1/2] feat: update esm module resolver --- packages/integrate-module/src/index.ts | 6 +- packages/integrate-module/tsconfig.json | 6 +- packages/register/esm.mts | 128 ++++++++++++++++-------- 3 files changed, 91 insertions(+), 49 deletions(-) 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..17ccec455 100644 --- a/packages/register/esm.mts +++ b/packages/register/esm.mts @@ -1,12 +1,16 @@ -import type { LoadHook, ResolveHook } from 'node:module' -import { fileURLToPath, pathToFileURL } from 'url' +import { createRequire, type LoadFnOutput, type LoadHook, type ResolveFnOutput, type ResolveHook } from 'node:module' +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 +21,40 @@ const host: ts.ModuleResolutionHost = { readFile: ts.sys.readFile, } +const addShortCircuitSignal = (input: T): T => { + return { + ...input, + shortCircuit: true, + } +} + +const INTERNAL_MODULE_PATTERN = /^(data|node|nodejs):/ + 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('resolved original caused by internal format', specifier) + + return addShortCircuitSignal({ + url: specifier, + }) } - // entrypoint - if (!context.parentURL) { - return { - importAttributes: { - ...context.importAttributes, - swc: 'entrypoint', - }, + const parsedUrl = parseUrl(specifier) + + // as entrypoint, just return specifier + if (!context.parentURL || parsedUrl.protocol === 'file:') { + debug('resolved original caused by protocol', specifier) + return addShortCircuitSignal({ url: specifier, - shortCircuit: true, - } + format: 'module', + }) + } + + // import attributes, support json currently + if (context.importAttributes?.type) { + return addShortCircuitSignal(await nextResolve(specifier)) } const { resolvedModule } = ts.resolveModuleName( @@ -45,21 +68,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 by 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('fallback resolved by 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 by node commonjs', specifier, resolution) + + return addShortCircuitSignal({ + format: 'commonjs', + url: resolution, + }) + } catch (error) { + debug('resolved by cjs error', specifier, error) + throw resolveError + } + } } const tsconfigForSWCNode = { @@ -69,24 +110,27 @@ const tsconfigForSWCNode = { } export const load: LoadHook = async (url, context, nextLoad) => { - const swcAttribute = context.importAttributes.swc - - if (swcAttribute) { - delete context.importAttributes.swc - - const { source } = await nextLoad(url, { - ...context, - format: 'ts' as any, - }) + debug('load', url, JSON.stringify(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('load original caused by internal format', url) return nextLoad(url, context) } + + const { source, format } = await nextLoad(url, { + ...context, + }) + + debug('loaded', url, format) + + const code = !source || typeof source === 'string' ? source : Buffer.from(source).toString() + const compiled = await compile(code, url, tsconfigForSWCNode, true) + + debug('compiled', url, format) + + return addShortCircuitSignal({ + // for lazy: ts-node think format would undefined, actually it should not, keep it as original temporarily + format, + source: compiled, + }) } From 1ee0ed84c3b45724f53956a6d637f4d5a7f7fce3 Mon Sep 17 00:00:00 2001 From: yeliex Date: Wed, 29 May 2024 15:08:35 +0800 Subject: [PATCH 2/2] feat: try to resolve format for absolute path import --- packages/register/esm.mts | 146 ++++++++++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 15 deletions(-) diff --git a/packages/register/esm.mts b/packages/register/esm.mts index 17ccec455..97ca38586 100644 --- a/packages/register/esm.mts +++ b/packages/register/esm.mts @@ -1,10 +1,11 @@ +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 @@ -28,13 +29,110 @@ const addShortCircuitSignal = (input: } } -const INTERNAL_MODULE_PATTERN = /^(data|node|nodejs):/ +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) => { debug('resolve', specifier, JSON.stringify(context)) if (INTERNAL_MODULE_PATTERN.test(specifier)) { - debug('resolved original caused by internal format', specifier) + debug('skip resolve: internal format', specifier) + + return addShortCircuitSignal({ + url: specifier, + format: 'builtin', + }) + } + + if (specifier.startsWith('data:')) { + debug('skip resolve: data url', specifier) return addShortCircuitSignal({ url: specifier, @@ -45,15 +143,29 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { // as entrypoint, just return specifier if (!context.parentURL || parsedUrl.protocol === 'file:') { - debug('resolved original caused by protocol', specifier) + 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: 'module', + format, }) } // import attributes, support json currently if (context.importAttributes?.type) { + debug('skip resolve: import attributes', specifier) + return addShortCircuitSignal(await nextResolve(specifier)) } @@ -71,7 +183,7 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { !resolvedModule.resolvedFileName.includes('/node_modules/') && AVAILABLE_TS_EXTENSION_PATTERN.test(resolvedModule.resolvedFileName) ) { - debug('resolved by typescript', specifier, resolvedModule.resolvedFileName) + debug('resolved: typescript', specifier, resolvedModule.resolvedFileName) return addShortCircuitSignal({ ...context, @@ -83,14 +195,14 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { try { // files could not resolved by typescript or resolved as dts, fallback to use node resolver const res = await nextResolve(specifier) - debug('fallback resolved by node', specifier, res.url, res.format) + 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 by node commonjs', specifier, resolution) + debug('resolved: fallback commonjs', specifier, resolution) return addShortCircuitSignal({ format: 'commonjs', @@ -112,25 +224,29 @@ const tsconfigForSWCNode = { export const load: LoadHook = async (url, context, nextLoad) => { debug('load', url, JSON.stringify(context)) + if (url.startsWith('data:')) { + debug('skip load: data url', url) + + return nextLoad(url, context) + } + if (['builtin', 'json', 'wasm'].includes(context.format)) { - debug('load original caused by internal format', url) + debug('loaded: internal format', url) return nextLoad(url, context) } - const { source, format } = await nextLoad(url, { - ...context, - }) + const { source, format: resolvedFormat } = await nextLoad(url, context) - debug('loaded', url, format) + 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, format) + debug('compiled', url, resolvedFormat) return addShortCircuitSignal({ // for lazy: ts-node think format would undefined, actually it should not, keep it as original temporarily - format, + format: resolvedFormat, source: compiled, }) }