Skip to content

Commit 91ad893

Browse files
authored
feat: update esm module resolver (#781)
1 parent fa5ff8d commit 91ad893

File tree

3 files changed

+204
-46
lines changed

3 files changed

+204
-46
lines changed

packages/integrate-module/src/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
/* eslint import/order: off */
2+
import { bar as subBar } from '@subdirectory/bar.mjs'
3+
import { supportedExtensions } from 'file-type'
24
import assert from 'node:assert'
35
import test from 'node:test'
4-
5-
import { supportedExtensions } from 'file-type'
6-
76
import { CompiledClass } from './compiled.js'
87
import { foo } from './foo.mjs'
98
import { bar } from './subdirectory/bar.mjs'
109
import { baz } from './subdirectory/index.mjs'
11-
import { bar as subBar } from '@subdirectory/bar.mjs'
1210
import './js-module.mjs'
1311

1412
await test('file-type should work', () => {

packages/integrate-module/tsconfig.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"outDir": "dist",
88
"baseUrl": "./",
99
"paths": {
10-
"@subdirectory/*": ["./src/subdirectory/*"],
11-
},
10+
"@subdirectory/*": ["./src/subdirectory/*"]
11+
}
1212
},
13-
"include": ["src"],
13+
"include": ["src", "package.json"]
1414
}

packages/register/esm.mts

Lines changed: 199 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import type { LoadHook, ResolveHook } from 'node:module'
2-
import { fileURLToPath, pathToFileURL } from 'url'
1+
import { readFile } from 'fs/promises'
2+
import { createRequire, type LoadFnOutput, type LoadHook, type ResolveFnOutput, type ResolveHook } from 'node:module'
3+
import { extname } from 'path'
4+
import { fileURLToPath, parse as parseUrl, pathToFileURL } from 'url'
35

6+
import debugFactory from 'debug'
47
import ts from 'typescript'
58

69
// @ts-expect-error
710
import { readDefaultTsConfig } from '../lib/read-default-tsconfig.js'
811
// @ts-expect-error
9-
import { AVAILABLE_EXTENSION_PATTERN, AVAILABLE_TS_EXTENSION_PATTERN, compile } from '../lib/register.js'
12+
import { AVAILABLE_TS_EXTENSION_PATTERN, compile } from '../lib/register.js'
13+
14+
const debug = debugFactory('@swc-node')
1015

1116
const tsconfig: ts.CompilerOptions = readDefaultTsConfig()
1217
tsconfig.module = ts.ModuleKind.ESNext
@@ -17,21 +22,151 @@ const host: ts.ModuleResolutionHost = {
1722
readFile: ts.sys.readFile,
1823
}
1924

25+
const addShortCircuitSignal = <T extends ResolveFnOutput | LoadFnOutput>(input: T): T => {
26+
return {
27+
...input,
28+
shortCircuit: true,
29+
}
30+
}
31+
32+
interface PackageJson {
33+
name: string
34+
version: string
35+
type?: 'module' | 'commonjs'
36+
main?: string
37+
}
38+
39+
const packageJSONCache = new Map<string, undefined | PackageJson>()
40+
41+
const readFileIfExists = async (path: string) => {
42+
try {
43+
const content = await readFile(path, 'utf-8')
44+
45+
return JSON.parse(content)
46+
} catch (e) {
47+
// eslint-disable-next-line no-undef
48+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
49+
return undefined
50+
}
51+
52+
throw e
53+
}
54+
}
55+
56+
const readPackageJSON = async (path: string) => {
57+
if (packageJSONCache.has(path)) {
58+
return packageJSONCache.get(path)
59+
}
60+
61+
const res = (await readFileIfExists(path)) as PackageJson
62+
packageJSONCache.set(path, res)
63+
return res
64+
}
65+
66+
const getPackageForFile = async (url: string) => {
67+
// use URL instead path.resolve to handle relative path
68+
let packageJsonURL = new URL('./package.json', url)
69+
70+
// eslint-disable-next-line no-constant-condition
71+
while (true) {
72+
const path = fileURLToPath(packageJsonURL)
73+
74+
// for special case by some package manager
75+
if (path.endsWith('node_modules/package.json')) {
76+
break
77+
}
78+
79+
const packageJson = await readPackageJSON(path)
80+
81+
if (!packageJson) {
82+
const lastPath = packageJsonURL.pathname
83+
packageJsonURL = new URL('../package.json', packageJsonURL)
84+
85+
// root level /package.json
86+
if (packageJsonURL.pathname === lastPath) {
87+
break
88+
}
89+
90+
continue
91+
}
92+
93+
if (packageJson.type && packageJson.type !== 'module' && packageJson.type !== 'commonjs') {
94+
packageJson.type = undefined
95+
}
96+
97+
return packageJson
98+
}
99+
100+
return undefined
101+
}
102+
103+
export const getPackageType = async (url: string) => {
104+
const packageJson = await getPackageForFile(url)
105+
106+
return packageJson?.type ?? undefined
107+
}
108+
109+
const INTERNAL_MODULE_PATTERN = /^(node|nodejs):/
110+
111+
const EXTENSION_MODULE_MAP = {
112+
'.mjs': 'module',
113+
'.cjs': 'commonjs',
114+
'.ts': 'module',
115+
'.mts': 'module',
116+
'.cts': 'commonjs',
117+
'.json': 'json',
118+
'.wasm': 'wasm',
119+
'.node': 'commonjs',
120+
} as const
121+
20122
export const resolve: ResolveHook = async (specifier, context, nextResolve) => {
21-
if (!AVAILABLE_EXTENSION_PATTERN.test(specifier)) {
22-
return nextResolve(specifier)
123+
debug('resolve', specifier, JSON.stringify(context))
124+
125+
if (INTERNAL_MODULE_PATTERN.test(specifier)) {
126+
debug('skip resolve: internal format', specifier)
127+
128+
return addShortCircuitSignal({
129+
url: specifier,
130+
format: 'builtin',
131+
})
23132
}
24133

25-
// entrypoint
26-
if (!context.parentURL) {
27-
return {
28-
importAttributes: {
29-
...context.importAttributes,
30-
swc: 'entrypoint',
31-
},
134+
if (specifier.startsWith('data:')) {
135+
debug('skip resolve: data url', specifier)
136+
137+
return addShortCircuitSignal({
32138
url: specifier,
33-
shortCircuit: true,
139+
})
140+
}
141+
142+
const parsedUrl = parseUrl(specifier)
143+
144+
// as entrypoint, just return specifier
145+
if (!context.parentURL || parsedUrl.protocol === 'file:') {
146+
debug('skip resolve: absolute path or entrypoint', specifier)
147+
148+
let format: ResolveFnOutput['format'] = null
149+
150+
const specifierPath = fileURLToPath(specifier)
151+
const ext = extname(specifierPath)
152+
153+
if (ext === '.js') {
154+
format = (await getPackageType(specifier)) === 'module' ? 'module' : 'commonjs'
155+
} else {
156+
format = EXTENSION_MODULE_MAP[ext as keyof typeof EXTENSION_MODULE_MAP]
34157
}
158+
159+
return addShortCircuitSignal({
160+
url: specifier,
161+
format,
162+
})
163+
}
164+
165+
// import attributes, support json currently
166+
if (context.importAttributes?.type) {
167+
debug('skip resolve: import attributes', specifier)
168+
169+
return addShortCircuitSignal(await nextResolve(specifier))
35170
}
36171

37172
const { resolvedModule } = ts.resolveModuleName(
@@ -45,21 +180,39 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => {
45180
// local project file
46181
if (
47182
resolvedModule &&
48-
(!resolvedModule.resolvedFileName.includes('/node_modules/') ||
49-
AVAILABLE_TS_EXTENSION_PATTERN.test(resolvedModule.resolvedFileName))
183+
!resolvedModule.resolvedFileName.includes('/node_modules/') &&
184+
AVAILABLE_TS_EXTENSION_PATTERN.test(resolvedModule.resolvedFileName)
50185
) {
51-
return {
186+
debug('resolved: typescript', specifier, resolvedModule.resolvedFileName)
187+
188+
return addShortCircuitSignal({
189+
...context,
52190
url: pathToFileURL(resolvedModule.resolvedFileName).href,
53-
shortCircuit: true,
54-
importAttributes: {
55-
...context.importAttributes,
56-
swc: resolvedModule.resolvedFileName,
57-
},
58-
}
191+
format: 'module',
192+
})
59193
}
60194

61-
// files could not resolved by typescript
62-
return nextResolve(specifier)
195+
try {
196+
// files could not resolved by typescript or resolved as dts, fallback to use node resolver
197+
const res = await nextResolve(specifier)
198+
debug('resolved: fallback node', specifier, res.url, res.format)
199+
return addShortCircuitSignal(res)
200+
} catch (resolveError) {
201+
// fallback to cjs resolve as may import non-esm files
202+
try {
203+
const resolution = pathToFileURL(createRequire(process.cwd()).resolve(specifier)).toString()
204+
205+
debug('resolved: fallback commonjs', specifier, resolution)
206+
207+
return addShortCircuitSignal({
208+
format: 'commonjs',
209+
url: resolution,
210+
})
211+
} catch (error) {
212+
debug('resolved by cjs error', specifier, error)
213+
throw resolveError
214+
}
215+
}
63216
}
64217

65218
const tsconfigForSWCNode = {
@@ -69,24 +222,31 @@ const tsconfigForSWCNode = {
69222
}
70223

71224
export const load: LoadHook = async (url, context, nextLoad) => {
72-
const swcAttribute = context.importAttributes.swc
225+
debug('load', url, JSON.stringify(context))
73226

74-
if (swcAttribute) {
75-
delete context.importAttributes.swc
227+
if (url.startsWith('data:')) {
228+
debug('skip load: data url', url)
76229

77-
const { source } = await nextLoad(url, {
78-
...context,
79-
format: 'ts' as any,
80-
})
230+
return nextLoad(url, context)
231+
}
81232

82-
const code = !source || typeof source === 'string' ? source : Buffer.from(source).toString()
83-
const compiled = await compile(code, fileURLToPath(url), tsconfigForSWCNode, true)
84-
return {
85-
format: 'module',
86-
source: compiled,
87-
shortCircuit: true,
88-
}
89-
} else {
233+
if (['builtin', 'json', 'wasm'].includes(context.format)) {
234+
debug('loaded: internal format', url)
90235
return nextLoad(url, context)
91236
}
237+
238+
const { source, format: resolvedFormat } = await nextLoad(url, context)
239+
240+
debug('loaded', url, resolvedFormat)
241+
242+
const code = !source || typeof source === 'string' ? source : Buffer.from(source).toString()
243+
const compiled = await compile(code, url, tsconfigForSWCNode, true)
244+
245+
debug('compiled', url, resolvedFormat)
246+
247+
return addShortCircuitSignal({
248+
// for lazy: ts-node think format would undefined, actually it should not, keep it as original temporarily
249+
format: resolvedFormat,
250+
source: compiled,
251+
})
92252
}

0 commit comments

Comments
 (0)