Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(register): ts files extension resolve #793

Merged
merged 3 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/integrate-module/src/compiled.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export declare class CompiledClass {
name: string
}
5 changes: 5 additions & 0 deletions packages/integrate-module/src/compiled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class CompiledClass {
constructor() {
this.name = 'CompiledClass'
}
}
8 changes: 7 additions & 1 deletion packages/integrate-module/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import assert from 'node:assert'
import test from 'node:test'

import { bar as subBar } from '@subdirectory/bar.mjs'
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 All @@ -29,3 +30,8 @@ await test('resolve nested entry point', () => {
await test('resolve paths', () => {
assert.equal(subBar(), 'bar')
})

await test('compiled js file with .d.ts', () => {
const instance = new CompiledClass()
assert.equal(instance.name, 'CompiledClass')
})
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 (specifier.startsWith('file:') && !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,
})
}
Loading