Skip to content
Draft
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# Unreleased

* Stop emitting `[DEP0205] module.register() is deprecated` on Node.js 26+
* On Node 26 and later, quibble now registers its ES module hooks with
`module.registerHooks()` (synchronous, in-thread) instead of
`module.register()`
* Earlier versions are unchanged and keep using `module.register()`. Node
versions from 22.15 through 22.22, 23.x, and 24.0 through 24.11 have
`registerHooks`, but it can't be combined with an off-thread loader (e.g.
`--loader`, tsx, ts-node) when ESM imports CommonJS, so quibble avoids it
there
* `canRegisterLoader()` now also returns true on a Node that only has
`registerHooks`

# 0.10.0

* Add initial TypeScript type definitions (`index.d.ts`)
Expand Down
3 changes: 2 additions & 1 deletion lib/canRegisterLoader.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const Module = require('module')

function canRegisterLoader () {
return !!Module.register
return typeof Module.registerHooks === 'function' ||
typeof Module.register === 'function'
}

exports.canRegisterLoader = canRegisterLoader
217 changes: 217 additions & 0 deletions lib/loader-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Shared logic for the resolve/load hooks: the async ones in quibble.mjs
// (--loader / register) and the sync ones in quibble-sync-hooks.js
// (registerHooks). Kept as CJS so both can load it without an extra
// module-format dance.
//
// Only planResolve/finishResolve/recoverResolve/stubbedLoadResult and
// stripQueryAndHash are used outside this file; everything else here is a
// private helper for them.

/**
* @typedef {{hasDefaultExport: boolean, namedExports: [string]}} ModuleLoaderMockInfo
* @typedef {{
* quibbledModules: Map<string, ModuleLoaderMockInfo>,
* stubModuleGeneration: number
* }} QuibbleLoaderState
*/

/**
* `state.quibbledModules` is read on every call (never cached by callers):
* reset() replaces the Map.
*
* @param {QuibbleLoaderState} state
* @param {string} moduleUrl
* @returns {[string, ModuleLoaderMockInfo] | undefined}
*/
function getStubsInfo (state, moduleUrl) {
if (!state.quibbledModules) return undefined
if (!moduleUrl.includes('__quibble=')) return undefined

const moduleKey = stripQueryAndHash(moduleUrl)
const moduleMockingInfo = state.quibbledModules.get(moduleKey)

return moduleMockingInfo ? [moduleKey, moduleMockingInfo] : undefined
}

/**
* @param {[string, ModuleLoaderMockInfo]} options
* @returns {string}
*/
function transformModuleSource ([moduleKey, mockingInfo]) {
return `
${mockingInfo.namedExports
.map(
(name) =>
`export let ${name} = globalThis[Symbol.for('__quibbleUserState')].quibbledModules.get(${JSON.stringify(
moduleKey
)}).namedExportStubs["${name}"]`
)
.join(';\n')};
${
mockingInfo.hasDefaultExport
? `export default globalThis[Symbol.for('__quibbleUserState')].quibbledModules.get(${JSON.stringify(
moduleKey
)}).defaultExportStub;`
: ''
}
`
}

function stripQueryAndHash (url) {
return url.replace(/\?.*/, '').replace(/#.*/, '')
}

/** Removes the marker query params the `import()` helpers add to specifiers */
function stripMarkers (specifier) {
return specifier.includes('__quibble')
? specifier
.replace(/[?&]__quibbleresolveurl/, '')
.replace(/[?&]__quibbleoriginal/, '')
: specifier
}

function addQueryToUrl (url, query, value) {
const urlObject = new URL(url)
urlObject.searchParams.set(query, value)
return urlObject.href
}

/** The URL to fall back to when the default resolver can't find `specifier` */
function unresolvedUrl (specifier, parentURL, generation) {
return parentURL
? addQueryToUrl(new URL(specifier, parentURL).href, '__quibble', generation)
: new URL(specifier).href
}

/**
* Decides what a `resolve` hook should do with `specifier`, without calling
* `nextResolve` (that stays in the driver, since only it knows whether to
* `await` it). Shared by the async hooks (quibble.mjs) and the sync hooks
* (quibble-sync-hooks.js).
*
* @param {QuibbleLoaderState} state
* @param {string} specifier
* @param {object} context
* @returns {
* | { kind: 'reentrant' }
* | { kind: 'resolveUrl' | 'passthrough', nextSpecifier: string }
* | { kind: 'quibble', nextSpecifier: string, specifier: string, parentURL: string, stubModuleGeneration: number }
* }
*/
function planResolve (state, specifier, context) {
if (specifier.includes('__quibbleresolveurl')) {
return { kind: 'resolveUrl', nextSpecifier: stripMarkers(specifier) }
}

if (!state.quibbledModules || specifier === 'quibble' || specifier.includes('__quibbleoriginal')) {
return { kind: 'passthrough', nextSpecifier: stripMarkers(specifier) }
}

// Only here - resolving a plain, unmarked specifier - do we need to worry
// about Node 22+ re-entering this hook recursively while we're in the
// middle of our own `nextResolve` call below (e.g. while walking
// conditional/array-form `exports` fallbacks), handing back the same
// `context` object. Left unhandled, that re-entrant call would look just
// like a brand new import and get quibble-ified a second time.
//
// `context.__quibbleSuppressed` (set/cleared by the driver around
// `nextResolve`) flags that window. Node can also reuse/pool the same
// `context` object across *unrelated* resolutions, so this check is
// deliberately placed after every marker-based branch above: a stale
// leaked flag can at worst make us skip quibble-ifying one plain import
// that should have gotten one, never corrupt the stripping of an
// explicitly marked specifier (which is what caused a prior version of
// this fix to regress testdouble.js's test suite).
if (context.__quibbleSuppressed) {
return { kind: 'reentrant' }
}

return {
kind: 'quibble',
nextSpecifier: stripMarkers(specifier),
specifier,
parentURL: context.parentURL,
stubModuleGeneration: state.stubModuleGeneration
}
}

/**
* Interprets what `nextResolve` returned for a plan from `planResolve`.
* `normalizeUrl` (only needed by the sync hooks) strips a `__quibble` tag a
* coexisting off-thread `--loader=quibble` may have already added.
*
* @param {QuibbleLoaderState} state
* @param {ReturnType<typeof planResolve>} plan
* @param {object} result - whatever `nextResolve` returned
* @param {(url: string) => string} [normalizeUrl]
*/
function finishResolve (state, plan, result, normalizeUrl = url => url) {
if (plan.kind === 'resolveUrl') {
const error = new Error()
error.code = 'QUIBBLE_RESOLVED_URL'
error.resolvedUrl = normalizeUrl(result.url)
throw error
}

if (plan.kind === 'passthrough') {
return result
}

const { url: nextUrl, ...ctx } = result
const url = normalizeUrl(nextUrl)
const quibbledUrl = addQueryToUrl(url, '__quibble', plan.stubModuleGeneration)

if (url.startsWith('node:') && !getStubsInfo(state, quibbledUrl)) {
return { ...ctx, url } // It's allowed to change ctx for a builtin (but unlikely)
}

return { ...ctx, url: quibbledUrl }
}

/**
* Interprets an error `nextResolve` threw for a plan from `planResolve`.
* Either returns the fallback result for a `'quibble'` plan's
* `ERR_MODULE_NOT_FOUND`, or rethrows.
*
* @param {ReturnType<typeof planResolve>} plan
* @param {Error} error
*/
function recoverResolve (plan, error) {
if (plan.kind === 'quibble' && error.code === 'ERR_MODULE_NOT_FOUND') {
return {
url: unresolvedUrl(plan.specifier, plan.parentURL, plan.stubModuleGeneration),
shortCircuit: true
}
}

throw error
}

/**
* The `load` hook's stub/passthrough decision. Returns the stub result, or
* `undefined` when the driver should call `nextLoad` itself (its return
* needs an `await` in the async hooks and not in the sync ones, so that part
* stays in each driver).
*
* @param {QuibbleLoaderState} state
* @param {string} url
*/
function stubbedLoadResult (state, url) {
const mockingInfo = getStubsInfo(state, url)

return mockingInfo
? {
source: transformModuleSource(mockingInfo),
format: 'module',
shortCircuit: true
}
: undefined
}

module.exports = {
planResolve,
finishResolve,
recoverResolve,
stubbedLoadResult,
stripQueryAndHash
}
65 changes: 65 additions & 0 deletions lib/quibble-sync-hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Synchronous, same-thread driver for the hooks in `Module.registerHooks()`.
// The decision logic - what to do with a given specifier/URL - lives in
// planResolve()/finishResolve()/recoverResolve() (loader-helpers.js), shared
// with the async hooks in quibble.mjs. This file is only the sync driver for
// it, plus the two things that are genuinely sync-hooks-only (see below).

const {
planResolve,
finishResolve,
recoverResolve,
stubbedLoadResult,
stripQueryAndHash
} = require('./loader-helpers')

/**
* @param {import('./loader-helpers').QuibbleLoaderState} state
*/
exports.createSyncHooks = function createSyncHooks (state) {
function resolve (specifier, context, nextResolve) {
// registerHooks() hooks also see CommonJS require() calls, which
// register()-style hooks never did. Quibble's ESM cache-busting query
// string would break CJS resolution, so leave those alone. There's no
// equivalent in the async hooks (Module.register() never sees require()).
if (isRequire(context)) {
return nextResolve(specifier, context)
}

const plan = planResolve(state, specifier, context)

if (plan.kind === 'reentrant') {
return nextResolve(specifier, context)
}

context.__quibbleSuppressed = true
try {
const result = nextResolve(plan.nextSpecifier, context)
// An explicit `--loader=quibble` can be registered (off-thread)
// alongside these hooks and will already have tagged the resolved URL;
// strip it before quibble re-tags it with its own generation number.
// The async hooks never run alongside another quibble instance.
return finishResolve(state, plan, result, removeQuibbleQuery)
} catch (error) {
return recoverResolve(plan, error)
} finally {
context.__quibbleSuppressed = false
}
}

function load (url, context, nextLoad) {
return stubbedLoadResult(state, url) ?? nextLoad(stripQueryAndHash(url), context)
}

return { resolve, load }
}

function isRequire (context) {
return Array.isArray(context.conditions) && context.conditions.includes('require')
}

function removeQuibbleQuery (url) {
if (!url.includes('__quibble=')) return url
const urlObject = new URL(url)
urlObject.searchParams.delete('__quibble')
return urlObject.href
}
36 changes: 36 additions & 0 deletions lib/quibble.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ function isBareSpecifier (modulePath) {
}

function registerEsmLoader () {
if (shouldUseRegisterHooks()) {
return registerSyncEsmHooks()
}

const { port1, port2 } = new MessageChannel()

Module.register(
Expand All @@ -329,3 +333,35 @@ function registerEsmLoader () {
port1
)
}

// `module.register()` is runtime-deprecated (DEP0205) starting in Node 26, so
// that's where we switch over. Earlier Nodes that have `registerHooks` (22.15+,
// 23.5+) are deliberately left on `register()`: until 22.23 / 24.12 / 25.1 they
// throw ERR_INVALID_RETURN_PROPERTY_VALUE when a sync hook chains into any
// off-thread loader (--loader, tsx, ts-node, ...) and ESM imports a CJS file.
function shouldUseRegisterHooks () {
if (typeof Module.registerHooks !== 'function') {
return false
}
if (typeof Module.register !== 'function') {
return true
}
return parseInt(process.versions.node, 10) >= 26
}

// In-thread synchronous hooks, so no worker thread or
// MessagePort/Atomics.wait round trips are needed. Setting the loader-state
// global up front puts thisWillRunInUserThread on its same-thread branch.
function registerSyncEsmHooks () {
const state = { quibbledModules: new Map(), stubModuleGeneration: 0 }
globalThis[Symbol.for('__quibbleLoaderState')] = state

Module.registerHooks(
require('./quibble-sync-hooks.js').createSyncHooks(state)
)

require('./thisWillRunInUserThread.js').thisWillRunInUserThread(
globalThis,
undefined
)
}
Loading
Loading