Skip to content
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
14 changes: 14 additions & 0 deletions .changeset/olive-poems-repeat.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
pnpm test
pnpm test:jest
pnpm test:module
pnpm test:module:next

publish:
name: Publish
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
11 changes: 11 additions & 0 deletions ava.config.mjs
Original file line number Diff line number Diff line change
@@ -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',
},
}
19 changes: 2 additions & 17 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
16 changes: 15 additions & 1 deletion packages/integrate-module/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[
Expand All @@ -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())
})
33 changes: 21 additions & 12 deletions packages/register/__test__/register-runtime-tuning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":""}'

Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -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'), {
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
170 changes: 170 additions & 0 deletions packages/register/esm-next.mts
Original file line number Diff line number Diff line change
@@ -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,
})
}
Loading