From d2e7a7b7e3d289a12313586443f0cdabac2d3d2e Mon Sep 17 00:00:00 2001 From: Patrick Shaw Date: Fri, 14 Aug 2026 14:52:53 +1000 Subject: [PATCH] Added non-deprecated registerHooks entrypoints --- .changeset/olive-poems-repeat.md | 14 + .github/workflows/CI.yml | 1 + README.md | 1 + ava.config.mjs | 11 + package.json | 19 +- packages/integrate-module/src/index.ts | 16 +- .../__test__/register-runtime-tuning.spec.ts | 33 +- packages/register/esm-next.mts | 170 +++++++ packages/register/esm-register-next.mts | 5 + packages/register/esm-shared.mts | 416 ++++++++++++++++++ packages/register/esm.mts | 344 +++------------ packages/register/package.json | 6 + packages/register/tsconfig.esm.json | 10 +- 13 files changed, 724 insertions(+), 322 deletions(-) create mode 100644 .changeset/olive-poems-repeat.md create mode 100644 ava.config.mjs create mode 100644 packages/register/esm-next.mts create mode 100644 packages/register/esm-register-next.mts create mode 100644 packages/register/esm-shared.mts diff --git a/.changeset/olive-poems-repeat.md b/.changeset/olive-poems-repeat.md new file mode 100644 index 000000000..9339c1f97 --- /dev/null +++ b/.changeset/olive-poems-repeat.md @@ -0,0 +1,14 @@ +--- +'@swc-node/register': minor +--- + +Add `@swc-node/register/esm-register-next` and `@swc-node/register/esm-next`, which register the loader hooks with +`module.registerHooks()` instead of the runtime deprecated `module.register()` (DEP0205). + +The hooks run synchronously in the same thread as the modules they transform, so they also apply to `require()`, they +are easier to debug, and they are not subject to the deadlocks of the off-thread hooks. `esm-register` keeps working +unchanged for Node.js versions without `module.registerHooks()` (added in 22.15). + +```bash +node --import @swc-node/register/esm-register-next script.ts +``` diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e9ebc08be..4ed157353 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -46,6 +46,7 @@ jobs: pnpm test pnpm test:jest pnpm test:module + pnpm test:module:next publish: name: Publish diff --git a/README.md b/README.md index 3f4e5dd66..425dc0f3d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Run TypeScript with node, without compilation or typechecking: ```bash npm i -D @swc-node/register node -r @swc-node/register script.ts +node --import @swc-node/register/esm-register-next --enable-source-maps script.ts # for esm project with node>=22.15 node --import @swc-node/register/esm-register --enable-source-maps script.ts # for esm project with node>=20.6 node --loader @swc-node/register/esm script.ts # for esm project with node<=20.5, deprecated ``` diff --git a/ava.config.mjs b/ava.config.mjs new file mode 100644 index 000000000..272163d97 --- /dev/null +++ b/ava.config.mjs @@ -0,0 +1,11 @@ +const loader = process.env.SWC_NODE_ESM_LOADER ?? '@swc-node/register/esm-register' + +export default { + extensions: ['js', 'ts', 'tsx'], + nodeArguments: [`--import=${loader}`], + cache: false, + files: ['packages/**/*.spec.{js,ts,tsx}'], + environmentVariables: { + SWC_NODE_PROJECT: './tsconfig.test.json', + }, +} diff --git a/package.json b/package.json index f33655129..6acb7b999 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "test": "ava", "test:jest": "jest --config jest.config.js", "test:module": "cross-env SWC_NODE_PROJECT=packages/integrate-module/tsconfig.json node --enable-source-maps --conditions=dev --import=@swc-node/register/esm-register packages/integrate-module/src/index.ts", + "test:next": "cross-env SWC_NODE_ESM_LOADER=@swc-node/register/esm-register-next ava", + "test:module:next": "cross-env SWC_NODE_ESM_LOADER=@swc-node/register/esm-register-next SWC_NODE_PROJECT=packages/integrate-module/tsconfig.json node --enable-source-maps --conditions=dev --import=@swc-node/register/esm-register-next packages/integrate-module/src/index.ts", "postinstall": "husky" }, "devDependencies": { @@ -80,22 +82,5 @@ "singleQuote": true, "arrowParens": "always" }, - "ava": { - "extensions": [ - "js", - "ts", - "tsx" - ], - "nodeArguments": [ - "--import=@swc-node/register/esm-register" - ], - "cache": false, - "files": [ - "packages/**/*.spec.{js,ts,tsx}" - ], - "environmentVariables": { - "SWC_NODE_PROJECT": "./tsconfig.test.json" - } - }, "packageManager": "pnpm@11.17.0" } diff --git a/packages/integrate-module/src/index.ts b/packages/integrate-module/src/index.ts index 730821d3c..ad8e65a2c 100644 --- a/packages/integrate-module/src/index.ts +++ b/packages/integrate-module/src/index.ts @@ -109,7 +109,7 @@ await test('resolve conditions', () => { const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number) const supportsTextImports = nodeMajor > 26 || (nodeMajor === 26 && nodeMinor >= 5) -await test('text import attributes should pass through to the default loader', { skip: !supportsTextImports }, () => { +await test('text import attributes should pass through to the default loader for @swc-node/register/esm-register', { skip: !supportsTextImports }, () => { const { status, stderr } = spawnSync( process.execPath, [ @@ -122,3 +122,17 @@ await test('text import attributes should pass through to the default loader', { assert.equal(status, 0, stderr?.toString()) }) + +await test('text import attributes should pass through to the default loader @swc-node/register/esm-register-next', { skip: !supportsTextImports }, () => { + const { status, stderr } = spawnSync( + process.execPath, + [ + '--experimental-import-text', + '--import=@swc-node/register/esm-register-next', + fileURLToPath(new URL('./text-import/index.ts', import.meta.url)), + ], + { env: process.env }, + ) + + assert.equal(status, 0, stderr?.toString()) +}) diff --git a/packages/register/__test__/register-runtime-tuning.spec.ts b/packages/register/__test__/register-runtime-tuning.spec.ts index 556ab3def..3fd9541b7 100644 --- a/packages/register/__test__/register-runtime-tuning.spec.ts +++ b/packages/register/__test__/register-runtime-tuning.spec.ts @@ -16,6 +16,15 @@ import { clearTransformCache } from '../lib/transform-cache.js' const require = createRequire(import.meta.url) const swcCore = require('@swc-node/core') +// Under the synchronous hooks (`@swc-node/register/esm-register-next`) `require()` +// itself is customized, so `@swc-node/core` is served from its TypeScript source +// rather than its published CommonJS build. swc emits ESM exports as getters, which +// matches the read-only semantics of ES module bindings but leaves nothing for +// sinon to replace. These tests exercise `compile()` internals rather than loader +// behaviour, so they run against whichever build exposes a stubbable seam. +const isSwcCoreStubbable = Boolean(Object.getOwnPropertyDescriptor(swcCore, 'transformSync')?.writable) +const serial = isSwcCoreStubbable ? test.serial : test.serial.skip + const originalEnv = { ...process.env } const emptyMap = '{"version":3,"sources":[],"names":[],"mappings":""}' @@ -34,7 +43,7 @@ test.afterEach.always(() => { process.env = { ...originalEnv } }) -test.serial('reuses transform cache for sync compile', (t) => { +serial('reuses transform cache for sync compile', (t) => { const transformSyncStub = sinon.stub(swcCore, 'transformSync').returns({ code: 'console.log("cached")', map: emptyMap, @@ -56,7 +65,7 @@ test.serial('reuses transform cache for sync compile', (t) => { t.is(transformSyncStub.callCount, 1) }) -test.serial('reuses transform cache for async compile', async (t) => { +serial('reuses transform cache for async compile', async (t) => { const transformStub = sinon.stub(swcCore, 'transform').resolves({ code: 'console.log("cached-async")', map: emptyMap, @@ -88,7 +97,7 @@ test.serial('reuses transform cache for async compile', async (t) => { t.true(transformStub.callCount <= 1) }) -test.serial('supports sourcemap inline-only mode to reduce map-store memory', (t) => { +serial('supports sourcemap inline-only mode to reduce map-store memory', (t) => { process.env.SWC_NODE_SOURCE_MAP_MODE = 'inline' sinon.stub(swcCore, 'transformSync').returns({ @@ -106,7 +115,7 @@ test.serial('supports sourcemap inline-only mode to reduce map-store memory', (t t.false(SourcemapMap.has(filename)) }) -test.serial('supports sourcemap store-only mode to avoid inline map payload', (t) => { +serial('supports sourcemap store-only mode to avoid inline map payload', (t) => { process.env.SWC_NODE_SOURCE_MAP_MODE = 'store' sinon.stub(swcCore, 'transformSync').returns({ @@ -124,7 +133,7 @@ test.serial('supports sourcemap store-only mode to avoid inline map payload', (t t.true(SourcemapMap.has(filename)) }) -test.serial('auto source map mode inlines the map so debuggers can bind breakpoints', (t) => { +serial('auto source map mode inlines the map so debuggers can bind breakpoints', (t) => { // Regression guard for https://github.com/swc-project/swc-node/issues/1059. // In auto mode (no SWC_NODE_SOURCE_MAP_MODE) the emitted code must carry an // inline sourceMappingURL even when process.sourceMapsEnabled is false. The V8 @@ -155,7 +164,7 @@ test.serial('auto source map mode inlines the map so debuggers can bind breakpoi t.true(SourcemapMap.has(filename)) }) -test.serial('skips transform for plain js in commonjs mode', (t) => { +serial('skips transform for plain js in commonjs mode', (t) => { const transformSyncStub = sinon.stub(swcCore, 'transformSync') const output = compile('module.exports = 42', uniquePath('plain-cjs', 'js'), { @@ -167,7 +176,7 @@ test.serial('skips transform for plain js in commonjs mode', (t) => { t.false(transformSyncStub.called) }) -test.serial('still transforms js with esm syntax in commonjs mode', (t) => { +serial('still transforms js with esm syntax in commonjs mode', (t) => { const transformSyncStub = sinon.stub(swcCore, 'transformSync').returns({ code: 'exports.value = 42', map: emptyMap, @@ -182,7 +191,7 @@ test.serial('still transforms js with esm syntax in commonjs mode', (t) => { t.true(transformSyncStub.calledOnce) }) -test.serial('skips transform for runtime js in esm mode', async (t) => { +serial('skips transform for runtime js in esm mode', async (t) => { const transformStub = sinon.stub(swcCore, 'transform') const output = await compile( @@ -199,7 +208,7 @@ test.serial('skips transform for runtime js in esm mode', async (t) => { t.false(transformStub.called) }) -test.serial('transforms jsx in a .js file when jsx is configured (commonjs)', (t) => { +serial('transforms jsx in a .js file when jsx is configured (commonjs)', (t) => { const transformSyncStub = sinon.stub(swcCore, 'transformSync').returns({ code: 'h("div", null, "hi")', map: emptyMap, @@ -215,7 +224,7 @@ test.serial('transforms jsx in a .js file when jsx is configured (commonjs)', (t t.true(transformSyncStub.calledOnce) }) -test.serial('transforms jsx in a .js file in esm mode instead of skipping', async (t) => { +serial('transforms jsx in a .js file in esm mode instead of skipping', async (t) => { const transformStub = sinon.stub(swcCore, 'transform').resolves({ code: 'h("div", null, "hi")', map: emptyMap, @@ -236,7 +245,7 @@ test.serial('transforms jsx in a .js file in esm mode instead of skipping', asyn t.true(transformStub.calledOnce) }) -test.serial('does not skip a .js file whose content looks like jsx even without jsx config', (t) => { +serial('does not skip a .js file whose content looks like jsx even without jsx config', (t) => { const transformSyncStub = sinon.stub(swcCore, 'transformSync').returns({ code: 'compiled', map: emptyMap, @@ -251,7 +260,7 @@ test.serial('does not skip a .js file whose content looks like jsx even without t.true(transformSyncStub.calledOnce) }) -test.serial('async compile returns a Promise even on a warm cache hit', async (t) => { +serial('async compile returns a Promise even on a warm cache hit', async (t) => { sinon.stub(swcCore, 'transform').resolves({ code: 'console.log("async-contract")', map: emptyMap, diff --git a/packages/register/esm-next.mts b/packages/register/esm-next.mts new file mode 100644 index 000000000..a6e45b8f1 --- /dev/null +++ b/packages/register/esm-next.mts @@ -0,0 +1,170 @@ +import { readFileSync } from 'node:fs' +import { createRequire, type LoadHookSync, type ResolveHookSync } from 'node:module' +import { join } from 'node:path' +import { URL, pathToFileURL } from 'node:url' + +// @ts-expect-error +import { compile } from '../lib/register.js' +// @ts-expect-error +import { shouldSkipTransformForRuntimeJs } from '../lib/transform-cache.js' +import { + type PackageJson, + addShortCircuitSignal, + debug, + formatForResolvedPath, + formatFromExtension, + getCompilerOptions, + getResolver, + isPathNotInNodeModules, + packageJSONCache, + packageJSONPathsFor, + parsePackageJSON, + planResolve, + planTransform, + shouldDelegateLoad, +} from './esm-shared.mjs' + +const readFileIfExists = (path: string) => { + try { + const content = readFileSync(path, 'utf-8') + + return parsePackageJSON(content) + } catch (e) { + // eslint-disable-next-line no-undef + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined + } + + throw e + } +} + +const readPackageJSON = (path: string) => { + if (packageJSONCache.has(path)) { + return packageJSONCache.get(path) + } + + const res = readFileIfExists(path) as PackageJson + packageJSONCache.set(path, res) + return res +} + +function getModuleType(path: string): 'module' | 'commonjs' | undefined { + return readPackageJSON(path)?.type +} + +export const getPackageType = (url: string) => { + for (const path of packageJSONPathsFor(url)) { + const packageJson = readPackageJSON(path) + + if (packageJson) { + return packageJson.type ?? undefined + } + } + + return undefined +} + +export const resolve: ResolveHookSync = (specifier, context, nextResolve) => { + debug('resolve', specifier, JSON.stringify(context)) + + const resolver = getResolver(context.conditions) + // Builtins are handed down the chain: these hooks run ahead of the ones + // registered with `module.register()`, and claiming `node:*` here would hide + // builtins from loaders that mock them. + const plan = planResolve(specifier, context, { shortCircuitBuiltins: false }) + + if (plan.kind === 'result') { + return plan.output + } + + if (plan.kind === 'next') { + return addShortCircuitSignal(nextResolve(specifier)) + } + + if (plan.kind === 'entrypoint') { + const format = + plan.ext === '.js' + ? getPackageType(plan.url) === 'module' + ? 'module' + : 'commonjs' + : formatFromExtension(plan.ext) + + return addShortCircuitSignal({ + url: plan.url, + format, + }) + } + + const { error, path, moduleType, packageJsonPath } = resolver.sync(plan.parentDir, plan.request) + + if (error) { + debug('oxc-resolver error, falling back to node resolver', specifier, error) + try { + return addShortCircuitSignal(nextResolve(specifier)) + } catch (resolveError) { + throw new Error(`${error}: ${specifier} cannot be resolved in ${context.parentURL}`) + } + } + + // local project file + if (path && isPathNotInNodeModules(path)) { + debug('resolved: typescript', specifier, moduleType, path) + const url = new URL('file://' + join(path)) + const mt = moduleType ?? (packageJsonPath ? getModuleType(packageJsonPath) : null) + + return addShortCircuitSignal({ + ...context, + url: url.href, + format: formatForResolvedPath(path, mt), + }) + } + + try { + // files could not resolved by typescript or resolved as dts, fallback to use node resolver + const res = 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 + } + } +} + +export const load: LoadHookSync = (url, context, nextLoad) => { + debug('load', url, JSON.stringify(context)) + + if (shouldDelegateLoad(url, context)) { + return nextLoad(url, context) + } + + const loaded = nextLoad(url, context) + // Unlike the asynchronous hooks, the source returned here is what the CommonJS + // loader evaluates, so a file resolved as CommonJS must be emitted as CommonJS. + const plan = planTransform(url, loaded, getCompilerOptions(loaded.format), shouldSkipTransformForRuntimeJs) + + if (plan.kind === 'passthrough') { + return plan.output + } + + const compiled = compile(plan.code, plan.filename, plan.options, false) + + debug('compiled', url, plan.format) + + return addShortCircuitSignal({ + format: plan.format, + source: compiled, + }) +} diff --git a/packages/register/esm-register-next.mts b/packages/register/esm-register-next.mts new file mode 100644 index 000000000..6d1a15906 --- /dev/null +++ b/packages/register/esm-register-next.mts @@ -0,0 +1,5 @@ +import { registerHooks } from 'node:module' + +import { load, resolve } from './esm-next.mjs' + +registerHooks({ resolve, load }) diff --git a/packages/register/esm-shared.mts b/packages/register/esm-shared.mts new file mode 100644 index 000000000..d3e9d2807 --- /dev/null +++ b/packages/register/esm-shared.mts @@ -0,0 +1,416 @@ +import { + type LoadFnOutput, + type LoadHookContext, + type ResolveFnOutput, + type ResolveHookContext, + builtinModules, +} from 'node:module' +import { extname, isAbsolute, join } from 'node:path' +import { fileURLToPath, URL } from 'node:url' + +import debugFactory from 'debug' +import { EnforceExtension, ResolverFactory, type NapiResolveOptions } from 'oxc-resolver' +import ts from 'typescript' + +// @ts-expect-error +import { readDefaultTsConfig } from '../lib/read-default-tsconfig.js' + +export const debug = debugFactory('@swc-node') + +const builtin = new Set(builtinModules) + +const tsconfig: ts.CompilerOptions = readDefaultTsConfig() +tsconfig.module = ts.ModuleKind.ESNext + +export const TSCONFIG_PATH = (function () { + const pathFromEnv = + process.env.SWC_NODE_PROJECT ?? process.env.TS_NODE_PROJECT ?? join(process.cwd(), 'tsconfig.json') + if (!isAbsolute(pathFromEnv)) { + return join(process.cwd(), pathFromEnv) + } + return pathFromEnv +})() + +// `paths`/`baseUrl` are resolved by oxc-resolver in the resolve hook, so they must +// not be applied a second time by swc when the source is transformed. +export const tsconfigForSWCNode = { + ...tsconfig, + paths: undefined, + baseUrl: undefined, +} + +// The synchronous hooks feed their output straight into the CommonJS loader, so a +// file resolved as CommonJS has to be emitted as CommonJS. Cache both variants +// because `compile` derives its cache key from these objects. +const tsconfigForCommonJS = { + ...tsconfigForSWCNode, + module: ts.ModuleKind.CommonJS, +} + +export const getCompilerOptions = (format: string | null | undefined) => + format === 'commonjs' ? tsconfigForCommonJS : tsconfigForSWCNode + +export const addShortCircuitSignal = (input: T): T => { + return { + ...input, + shortCircuit: true, + } +} + +export interface PackageJson { + name: string + version: string + type?: 'module' | 'commonjs' + main?: string +} + +export const packageJSONCache = new Map() + +export const parsePackageJSON = (content: string): PackageJson => { + const packageJson = JSON.parse(content) as PackageJson + + if (packageJson?.type && packageJson.type !== 'module' && packageJson.type !== 'commonjs') { + packageJson.type = undefined + } + + return packageJson +} + +/** + * Yields the `package.json` paths to probe for `url`, nearest first, applying the + * same stop conditions for both the asynchronous and the synchronous loader: give + * up at a package manager's `node_modules/package.json` and at the filesystem root. + */ +export function* packageJSONPathsFor(url: string): Generator { + // 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')) { + return + } + + yield path + + const lastPath = packageJsonURL.pathname + packageJsonURL = new URL('../package.json', packageJsonURL) + + // root level /package.json + if (packageJsonURL.pathname === lastPath) { + return + } + } +} + +const EXTENSION_MODULE_MAP = { + '.mjs': 'module', + '.cjs': 'commonjs', + '.ts': 'module', + '.tsx': 'module', + '.mts': 'module', + '.cts': 'commonjs', + '.json': 'json', + '.wasm': 'wasm', + '.node': 'commonjs', +} as const + +// Source extensions swc-node is responsible for transforming. A file: URL import +// that lands on one of these must flow through the resolver/transform below +// instead of the runtime dynamic-import fast path, otherwise the file is loaded +// untransformed (this is how test runners such as AVA import `.ts` test files). +// Already-runnable files (.mjs/.cjs/…) keep the native fast path that #883 needs. +const TRANSFORMABLE_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx']) + +let conditions: string[] | undefined = undefined + +const resolverOptions: NapiResolveOptions = { + tsconfig: { + configFile: TSCONFIG_PATH, + references: 'auto', + }, + conditionNames: ['node', 'import'], + enforceExtension: EnforceExtension.Auto, + extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.json', '.wasm', '.node'], + extensionAlias: { + '.js': ['.ts', '.tsx', '.js'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], + }, + moduleType: true, +} + +let resolver = new ResolverFactory(resolverOptions) + +/** + * The conditions Node.js runs with are only known once the first resolution comes + * in, so the resolver is rebuilt on the first call and reused afterwards. + */ +export const getResolver = (hookConditions: string[]) => { + if (!conditions) { + conditions = hookConditions + resolver = resolver.cloneWithOptions({ + ...resolverOptions, + conditionNames: conditions, + }) + } + + return resolver +} + +export const formatFromExtension = (ext: string): ResolveFnOutput['format'] => + EXTENSION_MODULE_MAP[ext as keyof typeof EXTENSION_MODULE_MAP] ?? null + +export const formatForResolvedPath = ( + path: string, + moduleType: string | null | undefined, +): ResolveFnOutput['format'] => + path.endsWith('cjs') || path.endsWith('cts') || moduleType === 'commonjs' || !moduleType + ? 'commonjs' + : moduleType === 'module' + ? 'module' + : 'commonjs' + +export const isPathNotInNodeModules = (path: string) => { + return ( + (process.platform !== 'win32' && !path.includes('/node_modules/')) || + (process.platform === 'win32' && !path.includes('\\node_modules\\')) + ) +} + +export const parseUrl = + typeof URL.parse === 'function' + ? URL.parse + : (url: string) => { + try { + return new URL(url) + } catch { + return null + } + } + +/** + * What the resolve hook has to do after the specifier has been classified. + * + * The classification itself never touches the filesystem, which lets the + * asynchronous and the synchronous loader share every routing decision and differ + * only in how they perform the IO each branch asks for. + */ +export type ResolvePlan = + /** Fully resolved without IO. */ + | { kind: 'result'; output: ResolveFnOutput } + /** Defer to the next hook in the chain. */ + | { kind: 'next' } + /** An entrypoint or absolute file URL: the format follows from the extension. */ + | { kind: 'entrypoint'; url: string; ext: string } + /** A bare or relative specifier that has to go through oxc-resolver. */ + | { kind: 'resolve'; parentDir: string; request: string } + +export interface PlanResolveOptions { + /** + * Whether builtin specifiers may be answered without consulting the rest of the + * chain. Synchronous hooks run ahead of the hooks registered with + * `module.register()`, so short-circuiting there would hide builtins from + * loaders that mock them (esmock, quibble, …). + */ + shortCircuitBuiltins?: boolean +} + +export const planResolve = ( + specifier: string, + context: ResolveHookContext, + { shortCircuitBuiltins = true }: PlanResolveOptions = {}, +): ResolvePlan => { + if (specifier.startsWith('node:') || specifier.startsWith('nodejs:')) { + debug('skip resolve: internal format', specifier) + + return shortCircuitBuiltins + ? { + kind: 'result', + output: addShortCircuitSignal({ + url: specifier, + format: 'builtin', + }), + } + : { kind: 'next' } + } + + if (builtin.has(specifier)) { + debug('skip resolve: internal format', specifier) + + return shortCircuitBuiltins + ? { + kind: 'result', + output: addShortCircuitSignal({ + url: `node:${specifier}`, + format: 'builtin', + }), + } + : { kind: 'next' } + } + + if (specifier.startsWith('data:')) { + debug('skip resolve: data url', specifier) + + return { + kind: 'result', + output: addShortCircuitSignal({ + url: specifier, + }), + } + } + + const parsedUrl = parseUrl(specifier) + + // A file: URL specifier that arrives with a parentURL is either a runtime + // dynamic import (`await import('file://…')`, see #883) or a test runner such + // as AVA importing a source file. When it points at a source file swc-node is + // responsible for transforming, let it fall through to the resolver/transform + // below; skipping it would load the raw, untransformed source. Files Node can + // already execute (.mjs/.cjs/…) keep the native fast path that #883 needs. + const isParentedFileUrl = Boolean(context.parentURL) && parsedUrl?.protocol === 'file:' + const shouldTransformParentedFileUrl = + isParentedFileUrl && TRANSFORMABLE_SOURCE_EXTENSIONS.has(extname(parsedUrl!.pathname).toLowerCase()) + + if (isParentedFileUrl && !shouldTransformParentedFileUrl) { + debug('skip resolve: dynamic import', specifier) + + return { + kind: 'result', + output: addShortCircuitSignal({ + ...context, + url: specifier, + importAttributes: { + ...context.importAttributes, + dynamic: 'true', + }, + }), + } + } + + // as entrypoint, just return specifier + if (!context.parentURL || parsedUrl?.protocol === 'file:') { + debug('skip resolve: absolute path or entrypoint', specifier) + + return { + kind: 'entrypoint', + url: specifier, + ext: extname(fileURLToPath(specifier)), + } + } + + // import attributes, support json currently + if (context.importAttributes?.type) { + debug('skip resolve: import attributes', specifier) + + return { kind: 'next' } + } + + return { + kind: 'resolve', + parentDir: join(fileURLToPath(context.parentURL), '..'), + request: specifier.startsWith('file:') ? fileURLToPath(specifier) : specifier, + } +} + +/** + * Decides whether a URL is swc-node's business at all, before any source is read. + */ +export const shouldDelegateLoad = (url: string, context: LoadHookContext): boolean => { + // `require()` reaches the synchronous hooks without any import attributes. + if (context.importAttributes?.dynamic === 'true') { + debug('skip load: dynamic file url', url) + delete context.importAttributes.dynamic + return true + } + + if (url.startsWith('data:')) { + debug('skip load: data url', url) + return true + } + + if (url.includes('/node_modules/')) { + debug('skip load: node_modules', url) + return true + } + + if (context.format && ['builtin', 'json', 'wasm'].includes(context.format)) { + debug('loaded: internal format', url) + return true + } + + // import attributes are handled by the default loader, + // e.g. `with { type: 'text' }` since Node.js 26.5 (behind --experimental-import-text) + if (context.importAttributes?.type) { + debug('skip load: import attributes', url) + return true + } + + return false +} + +/** + * What to do with the source the next hook in the chain produced. + */ +export type TransformPlan = + /** Nothing to compile: hand the loaded output back untouched. */ + | { kind: 'passthrough'; output: LoadFnOutput } + /** Compile `code` with `options` and emit it as `format`. */ + | { + kind: 'compile' + filename: string + code: string + format: LoadFnOutput['format'] + options: typeof tsconfigForSWCNode + } + +export const planTransform = ( + url: string, + { source, format }: LoadFnOutput, + options: typeof tsconfigForSWCNode, + shouldSkipTransformForRuntimeJs: ( + filename: string, + code: string, + module: ts.ModuleKind | undefined, + jsx: boolean, + ) => boolean, +): TransformPlan => { + if (!source) { + debug('No source', url, format) + + return { + kind: 'passthrough', + output: { + source, + format, + }, + } + } + + debug('loaded', url, format) + + const code = typeof source === 'string' ? source : Buffer.from(source as ArrayBuffer).toString() + + // url may be essentially an arbitrary string, but fixing the binding module, which currently + // expects a real file path, to correctly interpret this doesn't have an obvious solution, + // and would likely be a breaking change anyway. Do a best effort to give a real path + // like it expects, which at least fixes relative input sourcemap paths. + const filename = url.startsWith('file:') ? fileURLToPath(url) : url + + if (shouldSkipTransformForRuntimeJs(filename, code, options.module, Boolean(options.jsx))) { + debug('skip compile: runtime js module', url) + + return { + kind: 'passthrough', + output: addShortCircuitSignal({ + format, + source, + }), + } + } + + return { kind: 'compile', filename, code, format, options } +} diff --git a/packages/register/esm.mts b/packages/register/esm.mts index 7a2dc9b0a..6c433c016 100644 --- a/packages/register/esm.mts +++ b/packages/register/esm.mts @@ -1,68 +1,34 @@ import { readFile } from 'node:fs/promises' -import { - createRequire, - type LoadFnOutput, - type LoadHook, - type ResolveFnOutput, - type ResolveHook, - builtinModules, -} from 'node:module' -import { extname, isAbsolute, join } from 'node:path' -import { fileURLToPath, URL, pathToFileURL } from 'node:url' - -import debugFactory from 'debug' -import { EnforceExtension, ResolverFactory, type NapiResolveOptions } from 'oxc-resolver' -import ts from 'typescript' +import { createRequire, type LoadHook, type ResolveHook } from 'node:module' +import { join } from 'node:path' +import { URL, pathToFileURL } from 'node:url' -// @ts-expect-error -import { readDefaultTsConfig } from '../lib/read-default-tsconfig.js' // @ts-expect-error import { compile } from '../lib/register.js' // @ts-expect-error import { shouldSkipTransformForRuntimeJs } from '../lib/transform-cache.js' - -const debug = debugFactory('@swc-node') - -const builtin = new Set(builtinModules) - -const tsconfig: ts.CompilerOptions = readDefaultTsConfig() -tsconfig.module = ts.ModuleKind.ESNext - -const TSCONFIG_PATH = (function () { - const pathFromEnv = - process.env.SWC_NODE_PROJECT ?? process.env.TS_NODE_PROJECT ?? join(process.cwd(), 'tsconfig.json') - if (!isAbsolute(pathFromEnv)) { - return join(process.cwd(), pathFromEnv) - } - return pathFromEnv -})() - -async function getModuleType(path: string): Promise<'module' | 'commonjs' | undefined> { - const pkgJsonReadContent = await readPackageJSON(path) - return pkgJsonReadContent?.type -} - -const addShortCircuitSignal = (input: T): T => { - return { - ...input, - shortCircuit: true, - } -} - -interface PackageJson { - name: string - version: string - type?: 'module' | 'commonjs' - main?: string -} - -const packageJSONCache = new Map() +import { + type PackageJson, + addShortCircuitSignal, + debug, + formatForResolvedPath, + formatFromExtension, + getResolver, + isPathNotInNodeModules, + packageJSONCache, + packageJSONPathsFor, + parsePackageJSON, + planResolve, + planTransform, + shouldDelegateLoad, + tsconfigForSWCNode, +} from './esm-shared.mjs' const readFileIfExists = async (path: string) => { try { const content = await readFile(path, 'utf-8') - return JSON.parse(content) + return parsePackageJSON(content) } catch (e) { // eslint-disable-next-line no-undef if ((e as NodeJS.ErrnoException).code === 'ENOENT') { @@ -83,181 +49,52 @@ const readPackageJSON = async (path: string) => { 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 - } +async function getModuleType(path: string): Promise<'module' | 'commonjs' | undefined> { + const pkgJsonReadContent = await readPackageJSON(path) + return pkgJsonReadContent?.type +} +export const getPackageType = async (url: string) => { + for (const path of packageJSONPathsFor(url)) { 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 + if (packageJson) { + return packageJson.type ?? undefined } - - return packageJson } return undefined } -export const getPackageType = async (url: string) => { - const packageJson = await getPackageForFile(url) - - return packageJson?.type ?? undefined -} - -const EXTENSION_MODULE_MAP = { - '.mjs': 'module', - '.cjs': 'commonjs', - '.ts': 'module', - '.tsx': 'module', - '.mts': 'module', - '.cts': 'commonjs', - '.json': 'json', - '.wasm': 'wasm', - '.node': 'commonjs', -} as const - -// Source extensions swc-node is responsible for transforming. A file: URL import -// that lands on one of these must flow through the resolver/transform below -// instead of the runtime dynamic-import fast path, otherwise the file is loaded -// untransformed (this is how test runners such as AVA import `.ts` test files). -// Already-runnable files (.mjs/.cjs/…) keep the native fast path that #883 needs. -const TRANSFORMABLE_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx']) - -let conditions: string[] | undefined = undefined - -const resolverOptions: NapiResolveOptions = { - tsconfig: { - configFile: TSCONFIG_PATH, - references: 'auto', - }, - conditionNames: ['node', 'import'], - enforceExtension: EnforceExtension.Auto, - extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.json', '.wasm', '.node'], - extensionAlias: { - '.js': ['.ts', '.tsx', '.js'], - '.mjs': ['.mts', '.mjs'], - '.cjs': ['.cts', '.cjs'], - }, - moduleType: true, -} - -let resolver = new ResolverFactory(resolverOptions) - export const resolve: ResolveHook = async (specifier, context, nextResolve) => { debug('resolve', specifier, JSON.stringify(context)) - if (!conditions) { - conditions = context.conditions - resolver = resolver.cloneWithOptions({ - ...resolverOptions, - conditionNames: conditions, - }) - } - - if (specifier.startsWith('node:') || specifier.startsWith('nodejs:')) { - debug('skip resolve: internal format', specifier) - - return addShortCircuitSignal({ - url: specifier, - format: 'builtin', - }) - } - - if (builtin.has(specifier)) { - debug('skip resolve: internal format', specifier) + const resolver = getResolver(context.conditions) + const plan = planResolve(specifier, context) - return addShortCircuitSignal({ - url: `node:${specifier}`, - format: 'builtin', - }) + if (plan.kind === 'result') { + return plan.output } - if (specifier.startsWith('data:')) { - debug('skip resolve: data url', specifier) - - return addShortCircuitSignal({ - url: specifier, - }) - } - - const parsedUrl = parseUrl(specifier) - - // A file: URL specifier that arrives with a parentURL is either a runtime - // dynamic import (`await import('file://…')`, see #883) or a test runner such - // as AVA importing a source file. When it points at a source file swc-node is - // responsible for transforming, let it fall through to the resolver/transform - // below; skipping it would load the raw, untransformed source. Files Node can - // already execute (.mjs/.cjs/…) keep the native fast path that #883 needs. - const isParentedFileUrl = Boolean(context.parentURL) && parsedUrl?.protocol === 'file:' - const shouldTransformParentedFileUrl = - isParentedFileUrl && TRANSFORMABLE_SOURCE_EXTENSIONS.has(extname(parsedUrl!.pathname).toLowerCase()) - - if (isParentedFileUrl && !shouldTransformParentedFileUrl) { - debug('skip resolve: dynamic import', specifier) - return addShortCircuitSignal({ - ...context, - url: specifier, - importAttributes: { - ...context.importAttributes, - dynamic: 'true', - }, - }) + if (plan.kind === 'next') { + return addShortCircuitSignal(await nextResolve(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] - } + if (plan.kind === 'entrypoint') { + const format = + plan.ext === '.js' + ? (await getPackageType(plan.url)) === 'module' + ? 'module' + : 'commonjs' + : formatFromExtension(plan.ext) return addShortCircuitSignal({ - url: specifier, + url: plan.url, format, }) } - // import attributes, support json currently - if (context.importAttributes?.type) { - debug('skip resolve: import attributes', specifier) - - return addShortCircuitSignal(await nextResolve(specifier)) - } - - const { error, path, moduleType, packageJsonPath } = await resolver.async( - join(fileURLToPath(context.parentURL), '..'), - specifier.startsWith('file:') ? fileURLToPath(specifier) : specifier, - ) + const { error, path, moduleType, packageJsonPath } = await resolver.async(plan.parentDir, plan.request) if (error) { debug('oxc-resolver error, falling back to node resolver', specifier, error) @@ -274,15 +111,11 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { debug('resolved: typescript', specifier, moduleType, path) const url = new URL('file://' + join(path)) const mt = moduleType ?? (packageJsonPath ? await getModuleType(packageJsonPath) : null) + return addShortCircuitSignal({ ...context, url: url.href, - format: - path.endsWith('cjs') || path.endsWith('cts') || mt === 'commonjs' || !mt - ? 'commonjs' - : mt === 'module' - ? 'module' - : 'commonjs', + format: formatForResolvedPath(path, mt), }) } @@ -309,98 +142,27 @@ export const resolve: ResolveHook = async (specifier, context, nextResolve) => { } } -const tsconfigForSWCNode = { - ...tsconfig, - paths: undefined, - baseUrl: undefined, -} - export const load: LoadHook = async (url, context, nextLoad) => { debug('load', url, JSON.stringify(context)) - if (context.importAttributes.dynamic === 'true') { - debug('skip load: dynamic file url', url) - delete context.importAttributes.dynamic + if (shouldDelegateLoad(url, context)) { return nextLoad(url, context) } - if (url.startsWith('data:')) { - debug('skip load: data url', url) + const loaded = await nextLoad(url, context) + const plan = planTransform(url, loaded, tsconfigForSWCNode, shouldSkipTransformForRuntimeJs) - return nextLoad(url, context) + if (plan.kind === 'passthrough') { + return plan.output } - if (url.includes('/node_modules/')) { - debug('skip load: node_modules', url) - - return nextLoad(url, context) - } - - if (context.format && ['builtin', 'json', 'wasm'].includes(context.format)) { - debug('loaded: internal format', url) - return nextLoad(url, context) - } + const compiled = await compile(plan.code, plan.filename, plan.options, true) - // import attributes are handled by the default loader, - // e.g. `with { type: 'text' }` since Node.js 26.5 (behind --experimental-import-text) - if (context.importAttributes?.type) { - debug('skip load: import attributes', url) - return nextLoad(url, context) - } - - const { source, format: resolvedFormat } = await nextLoad(url, context) - - if (!source) { - debug('No source', url, resolvedFormat) - return { - source, - format: resolvedFormat, - } - } - - debug('loaded', url, resolvedFormat) - - const code = !source || typeof source === 'string' ? source : Buffer.from(source as ArrayBuffer).toString() - - // url may be essentially an arbitrary string, but fixing the binding module, which currently - // expects a real file path, to correctly interpret this doesn't have an obvious solution, - // and would likely be a breaking change anyway. Do a best effort to give a real path - // like it expects, which at least fixes relative input sourcemap paths. - const filename = url.startsWith('file:') ? fileURLToPath(url) : url - - if (shouldSkipTransformForRuntimeJs(filename, code, tsconfigForSWCNode.module, Boolean(tsconfigForSWCNode.jsx))) { - debug('skip compile: runtime js module', url) - return addShortCircuitSignal({ - format: resolvedFormat, - source, - }) - } - - const compiled = await compile(code, filename, tsconfigForSWCNode, true) - - debug('compiled', url, resolvedFormat) + debug('compiled', url, plan.format) return addShortCircuitSignal({ // for lazy: ts-node think format would undefined, actually it should not, keep it as original temporarily - format: resolvedFormat, + format: plan.format, source: compiled, }) } - -function isPathNotInNodeModules(path: string) { - return ( - (process.platform !== 'win32' && !path.includes('/node_modules/')) || - (process.platform === 'win32' && !path.includes('\\node_modules\\')) - ) -} - -const parseUrl = - typeof URL.parse === 'function' - ? URL.parse - : (url: string) => { - try { - return new URL(url) - } catch { - return null - } - } diff --git a/packages/register/package.json b/packages/register/package.json index e33864311..89649f71d 100644 --- a/packages/register/package.json +++ b/packages/register/package.json @@ -82,6 +82,12 @@ }, "./esm-register": { "import": "./esm/esm-register.mjs" + }, + "./esm-next": { + "import": "./esm/esm-next.mjs" + }, + "./esm-register-next": { + "import": "./esm/esm-register-next.mjs" } } } diff --git a/packages/register/tsconfig.esm.json b/packages/register/tsconfig.esm.json index 74cbabdb2..4000957ea 100644 --- a/packages/register/tsconfig.esm.json +++ b/packages/register/tsconfig.esm.json @@ -5,5 +5,13 @@ "outDir": "esm" }, "include": [], - "files": ["./esm.mts", "./esm-register.mts", "register.d.ts", "./read-default-tsconfig.d.ts"] + "files": [ + "./esm.mts", + "./esm-register.mts", + "./esm-shared.mts", + "./esm-next.mts", + "./esm-register-next.mts", + "register.d.ts", + "./read-default-tsconfig.d.ts" + ] }