Skip to content

Commit

Permalink
feat: update esm module resolver (#781)
Browse files Browse the repository at this point in the history
  • Loading branch information
yeliex authored Jun 7, 2024
1 parent fa5ff8d commit 91ad893
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 46 deletions.
6 changes: 2 additions & 4 deletions packages/integrate-module/src/index.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/integrate-module/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"outDir": "dist",
"baseUrl": "./",
"paths": {
"@subdirectory/*": ["./src/subdirectory/*"],
},
"@subdirectory/*": ["./src/subdirectory/*"]
}
},
"include": ["src"],
"include": ["src", "package.json"]
}
238 changes: 199 additions & 39 deletions packages/register/esm.mts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,21 +22,151 @@ const host: ts.ModuleResolutionHost = {
readFile: ts.sys.readFile,
}

const addShortCircuitSignal = <T extends ResolveFnOutput | LoadFnOutput>(input: T): T => {
return {
...input,
shortCircuit: true,
}
}

interface PackageJson {
name: string
version: string
type?: 'module' | 'commonjs'
main?: string
}

const packageJSONCache = new Map<string, undefined | PackageJson>()

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(
Expand All @@ -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 = {
Expand All @@ -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,
})
}

0 comments on commit 91ad893

Please sign in to comment.