From dcc78ea7a335e2b3bcdd59a67a24472b9f4b9575 Mon Sep 17 00:00:00 2001 From: dirkwa Date: Fri, 17 Jul 2026 10:39:02 +1200 Subject: [PATCH 1/5] feat: resolve core modules to the server's copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins installed in the data dir resolve dependencies from their own tree, so one plugin's tight pin on a core interface package holds the hoisted copy back for every other plugin whose range still admits it, and dual copies break object identity with server code. Generalize the baconjs-compat resolution hook into a host-provided modules list (the require('vscode') model): require() of baconjs and @signalk/server-api — including exported subpaths like @signalk/server-api/history — now always resolves to the server's own copy, regardless of what npm installed in the plugin tree. Subpaths the host copy does not export resolve normally, so deep requires into bundled copies keep working. baconjs-compat keeps only the .map('.property') shorthand patch. --- src/baconjs-compat.ts | 37 +++--------------- src/host-modules.ts | 81 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + test/host-modules.ts | 87 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 src/host-modules.ts create mode 100644 test/host-modules.ts diff --git a/src/baconjs-compat.ts b/src/baconjs-compat.ts index 881b69e82b..cf34751c1f 100644 --- a/src/baconjs-compat.ts +++ b/src/baconjs-compat.ts @@ -10,42 +10,17 @@ * plugin's 1.x code subscribes to a server 3.x Bus, it receives 3.x Events * and crashes with "TypeError: e.isEnd is not a function". * - * This module solves the problem by: - * 1. Hooking Node's module resolution so ALL require('baconjs') calls in the - * process return the server's 3.x version — eliminating version mismatches - * 2. Patching 3.x to restore the .map('.property') string shorthand that - * existed in 1.x (used by plugins like signalk-to-nmea2000) + * ./host-modules redirects every require('baconjs') in the process to the + * server's 3.x copy, eliminating version mismatches. This module patches + * that copy to restore the .map('.property') string shorthand that existed + * in 1.x (used by plugins like signalk-to-nmea2000). * - * This module MUST be imported before any other module that uses BaconJS. + * This module MUST be imported after ./host-modules and before any other + * module that uses BaconJS. */ -import Module from 'module' import * as Bacon from 'baconjs' -const serverBaconPath = require.resolve('baconjs') - -type ResolveFilename = ( - request: string, - parent: NodeModule | undefined, - isMain: boolean, - options: Record -) => string - -const ModuleInternal = Module as unknown as Record - -const origResolveFilename = ModuleInternal._resolveFilename as ResolveFilename -ModuleInternal._resolveFilename = function ( - request: string, - parent: NodeModule | undefined, - isMain: boolean, - options: Record -) { - if (request === 'baconjs') { - return serverBaconPath - } - return origResolveFilename.call(this, request, parent, isMain, options) -} - type Mappable = { map: (f: unknown) => unknown } function patchMapShorthand(proto: Mappable) { diff --git a/src/host-modules.ts b/src/host-modules.ts new file mode 100644 index 0000000000..985e0a8f61 --- /dev/null +++ b/src/host-modules.ts @@ -0,0 +1,81 @@ +/* + * Host-provided modules + * + * Plugins installed in the server's data directory resolve their + * dependencies from /node_modules, never from the server's own + * tree. For core interface packages this is harmful: dual copies break + * object identity between plugin and server code, and one plugin's tight + * version pin holds the hoisted copy back for every other plugin that would + * accept a newer version (npm optimizes for dedupe, not freshness). + * + * This module hooks Node's CJS module resolution so that require() of the + * packages listed below — including exported subpaths such as + * '@signalk/server-api/history' — always resolves to the server's own copy, + * no matter what npm installed in the plugin tree: the same model as + * require('vscode') in VS Code extensions. Plugin-bundled copies remain on + * disk but are never loaded. + * + * This module MUST be imported before any other module that may load one of + * the host-provided packages. + */ + +import Module from 'module' +import { createDebug } from './debug' + +const debug = createDebug('signalk-server:host-modules') + +const HOST_PROVIDED_MODULES = ['baconjs', '@signalk/server-api'] + +const hostModulePaths = new Map( + HOST_PROVIDED_MODULES.map((name) => [name, require.resolve(name)]) +) + +function isHostProvided(request: string): boolean { + return HOST_PROVIDED_MODULES.some( + (name) => request === name || request.startsWith(name + '/') + ) +} + +function resolveHostPath(request: string): string | null { + let hostPath = hostModulePaths.get(request) + if (hostPath === undefined) { + try { + // resolve from this module's context via the original resolver: + // require.resolve() would re-enter the hook and recurse + hostPath = origResolveFilename.call(Module, request, module, false) + } catch { + // subpath not exported by the host copy: leave resolution alone, so + // requires that only work against a bundled copy keep working + hostPath = null + } + hostModulePaths.set(request, hostPath) + } + return hostPath +} + +type ResolveFilename = ( + request: string, + parent: NodeModule | undefined, + isMain: boolean, + options?: Record +) => string + +const ModuleInternal = Module as unknown as Record + +const origResolveFilename = ModuleInternal._resolveFilename as ResolveFilename +ModuleInternal._resolveFilename = function ( + request: string, + parent: NodeModule | undefined, + isMain: boolean, + options: Record +) { + if (isHostProvided(request)) { + const hostPath = resolveHostPath(request) + if (hostPath !== null) { + debug.enabled && + debug(`resolving ${request} for ${parent?.filename} to the host copy`) + return hostPath + } + } + return origResolveFilename.call(this, request, parent, isMain, options) +} diff --git a/src/index.ts b/src/index.ts index 5010eaf484..81719fa238 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ */ import './networkConfig' +import './host-modules' import './baconjs-compat' import { Context, diff --git a/test/host-modules.ts b/test/host-modules.ts new file mode 100644 index 0000000000..360bba381e --- /dev/null +++ b/test/host-modules.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import '../dist/host-modules.js' +import { importOrRequire } from '../dist/modules.js' + +function writeModule(dir: string, pkg: object, files: Record) { + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)) + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), content) + } +} + +describe('host-provided modules', () => { + let testDir: string | undefined + let plugin: { + serverApi: Record + historyApi: Record + deep: { BUNDLED_STALE_COPY?: boolean } + bacon: Record + } + + before(async () => { + testDir = fs.mkdtempSync( + path.join(os.tmpdir(), '_skservertest_host_modules') + ) + const pluginDir = path.join(testDir, 'node_modules', 'testplugin') + writeModule( + pluginDir, + { name: 'testplugin', version: '1.0.0', main: 'index.js' }, + { + 'index.js': `module.exports = { + serverApi: require('@signalk/server-api'), + historyApi: require('@signalk/server-api/history'), + deep: require('@signalk/server-api/deep.js'), + bacon: require('baconjs') + }` + } + ) + // stale bundled copy without an exports map, like server-api 2.9.x + writeModule( + path.join(pluginDir, 'node_modules', '@signalk', 'server-api'), + { name: '@signalk/server-api', version: '0.0.1', main: 'index.js' }, + { + 'index.js': `module.exports = { BUNDLED_STALE_COPY: true }`, + 'history.js': `module.exports = { BUNDLED_STALE_COPY: true }`, + 'deep.js': `module.exports = { BUNDLED_STALE_COPY: true }` + } + ) + writeModule( + path.join(pluginDir, 'node_modules', 'baconjs'), + { name: 'baconjs', version: '0.0.1', main: 'index.js' }, + { 'index.js': `module.exports = { BUNDLED_STALE_COPY: true }` } + ) + plugin = await importOrRequire(pluginDir) + }) + + after(() => { + if (testDir !== undefined) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + }) + + it('plugin bundling its own @signalk/server-api gets the host copy', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(plugin.serverApi).to.equal(require('@signalk/server-api')) + expect(plugin.serverApi.BUNDLED_STALE_COPY).to.equal(undefined) + }) + + it('exported subpaths resolve to the host copy', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(plugin.historyApi).to.equal(require('@signalk/server-api/history')) + expect(plugin.historyApi.BUNDLED_STALE_COPY).to.equal(undefined) + }) + + it('subpaths the host copy does not export resolve normally', () => { + expect(plugin.deep.BUNDLED_STALE_COPY).to.equal(true) + }) + + it('plugin bundling its own baconjs gets the host copy', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(plugin.bacon).to.equal(require('baconjs')) + expect(plugin.bacon.BUNDLED_STALE_COPY).to.equal(undefined) + }) +}) From 48db5b8f490ac46f20a75f8c78d343cba5c5df08 Mon Sep 17 00:00:00 2001 From: dirkwa Date: Fri, 17 Jul 2026 14:42:48 +1200 Subject: [PATCH 2/5] chore: re-trigger CI From c8679daa48fd2dba7d21c80bece95c8c2639caf5 Mon Sep 17 00:00:00 2001 From: dirkwa Date: Fri, 17 Jul 2026 15:06:47 +1200 Subject: [PATCH 3/5] refactor: mark resolve hook options as optional --- src/host-modules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/host-modules.ts b/src/host-modules.ts index 985e0a8f61..4506ed113f 100644 --- a/src/host-modules.ts +++ b/src/host-modules.ts @@ -67,7 +67,7 @@ ModuleInternal._resolveFilename = function ( request: string, parent: NodeModule | undefined, isMain: boolean, - options: Record + options?: Record ) { if (isHostProvided(request)) { const hostPath = resolveHostPath(request) From 197a04213a5be12057abfd92d2fdf64cd041a4cc Mon Sep 17 00:00:00 2001 From: t Date: Sat, 15 Aug 2026 12:21:41 +1200 Subject: [PATCH 4/5] feat: cover ESM import of host-provided modules --- src/host-modules.ts | 31 +++++++++++++++++++- test/host-modules.ts | 70 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/host-modules.ts b/src/host-modules.ts index 4506ed113f..5fc9fcc851 100644 --- a/src/host-modules.ts +++ b/src/host-modules.ts @@ -8,18 +8,26 @@ * version pin holds the hoisted copy back for every other plugin that would * accept a newer version (npm optimizes for dedupe, not freshness). * - * This module hooks Node's CJS module resolution so that require() of the + * This module hooks Node's module resolution so that loading any of the * packages listed below — including exported subpaths such as * '@signalk/server-api/history' — always resolves to the server's own copy, * no matter what npm installed in the plugin tree: the same model as * require('vscode') in VS Code extensions. Plugin-bundled copies remain on * disk but are never loaded. * + * Coverage comes from two hooks: the CJS _resolveFilename hook answers + * require() on every supported Node version, and module.registerHooks + * (Node >= 22.15) extends the same redirect to `import` statements in ESM + * plugins, which never reach the CJS hook. On Node 22.0-22.14 only the + * CJS hook is available, so a pure-ESM plugin bundling its own copy can + * still load it there. + * * This module MUST be imported before any other module that may load one of * the host-provided packages. */ import Module from 'module' +import { pathToFileURL } from 'url' import { createDebug } from './debug' const debug = createDebug('signalk-server:host-modules') @@ -79,3 +87,24 @@ ModuleInternal._resolveFilename = function ( } return origResolveFilename.call(this, request, parent, isMain, options) } + +// registerHooks resolve hooks see both `import` and require() specifiers, +// but never the direct origResolveFilename calls made by resolveHostPath — +// verified empirically, so no re-entrancy guard is needed here +if (typeof Module.registerHooks === 'function') { + Module.registerHooks({ + resolve(specifier, context, nextResolve) { + if (isHostProvided(specifier)) { + const hostPath = resolveHostPath(specifier) + if (hostPath !== null) { + debug.enabled && + debug( + `resolving ${specifier} for ${context.parentURL} to the host copy` + ) + return { url: pathToFileURL(hostPath).href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } + }) +} diff --git a/test/host-modules.ts b/test/host-modules.ts index 360bba381e..e7fda5f221 100644 --- a/test/host-modules.ts +++ b/test/host-modules.ts @@ -1,10 +1,16 @@ import { expect } from 'chai' import fs from 'fs' +import Module from 'module' import os from 'os' import path from 'path' import '../dist/host-modules.js' import { importOrRequire } from '../dist/modules.js' +// ESM import coverage needs module.registerHooks (Node >= 22.15); on older +// Node 22.x the server intentionally falls back to CJS-only coverage +const itWithRegisterHooks = + typeof Module.registerHooks === 'function' ? it : it.skip + function writeModule(dir: string, pkg: object, files: Record) { fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)) @@ -21,6 +27,7 @@ describe('host-provided modules', () => { deep: { BUNDLED_STALE_COPY?: boolean } bacon: Record } + let esmPlugin: typeof plugin before(async () => { testDir = fs.mkdtempSync( @@ -39,7 +46,32 @@ describe('host-provided modules', () => { }` } ) - // stale bundled copy without an exports map, like server-api 2.9.x + writeStaleBundledCopies(pluginDir) + plugin = await importOrRequire(pluginDir) + + const esmPluginDir = path.join(testDir, 'node_modules', 'testplugin-esm') + writeModule( + esmPluginDir, + { + name: 'testplugin-esm', + version: '1.0.0', + type: 'module', + main: 'index.js' + }, + { + 'index.js': `import serverApi from '@signalk/server-api' + import historyApi from '@signalk/server-api/history' + import deep from '@signalk/server-api/deep.js' + import bacon from 'baconjs' + export { serverApi, historyApi, deep, bacon }` + } + ) + writeStaleBundledCopies(esmPluginDir) + esmPlugin = await importOrRequire(esmPluginDir) + }) + + // stale bundled copies without an exports map, like server-api 2.9.x + function writeStaleBundledCopies(pluginDir: string) { writeModule( path.join(pluginDir, 'node_modules', '@signalk', 'server-api'), { name: '@signalk/server-api', version: '0.0.1', main: 'index.js' }, @@ -54,8 +86,7 @@ describe('host-provided modules', () => { { name: 'baconjs', version: '0.0.1', main: 'index.js' }, { 'index.js': `module.exports = { BUNDLED_STALE_COPY: true }` } ) - plugin = await importOrRequire(pluginDir) - }) + } after(() => { if (testDir !== undefined) { @@ -84,4 +115,37 @@ describe('host-provided modules', () => { expect(plugin.bacon).to.equal(require('baconjs')) expect(plugin.bacon.BUNDLED_STALE_COPY).to.equal(undefined) }) + + itWithRegisterHooks( + 'ESM plugin importing @signalk/server-api gets the host copy', + () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(esmPlugin.serverApi).to.equal(require('@signalk/server-api')) + expect(esmPlugin.serverApi.BUNDLED_STALE_COPY).to.equal(undefined) + } + ) + + itWithRegisterHooks( + 'ESM-imported exported subpaths resolve to the host copy', + () => { + expect(esmPlugin.historyApi).to.equal( + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('@signalk/server-api/history') + ) + expect(esmPlugin.historyApi.BUNDLED_STALE_COPY).to.equal(undefined) + } + ) + + itWithRegisterHooks( + 'ESM-imported subpaths the host copy does not export resolve normally', + () => { + expect(esmPlugin.deep.BUNDLED_STALE_COPY).to.equal(true) + } + ) + + itWithRegisterHooks('ESM plugin importing baconjs gets the host copy', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(esmPlugin.bacon).to.equal(require('baconjs')) + expect(esmPlugin.bacon.BUNDLED_STALE_COPY).to.equal(undefined) + }) }) From 6f9d512c8ef8e8e20fcd2207e6f6d7835f7b5ab2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 15 Aug 2026 14:14:16 +1200 Subject: [PATCH 5/5] test: gate ESM fixture on registerHooks support --- test/host-modules.ts | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/test/host-modules.ts b/test/host-modules.ts index e7fda5f221..1c86ad0ce6 100644 --- a/test/host-modules.ts +++ b/test/host-modules.ts @@ -49,25 +49,29 @@ describe('host-provided modules', () => { writeStaleBundledCopies(pluginDir) plugin = await importOrRequire(pluginDir) - const esmPluginDir = path.join(testDir, 'node_modules', 'testplugin-esm') - writeModule( - esmPluginDir, - { - name: 'testplugin-esm', - version: '1.0.0', - type: 'module', - main: 'index.js' - }, - { - 'index.js': `import serverApi from '@signalk/server-api' + // without registerHooks coverage the ESM fixture cannot even load: + // importing the extensionless subpath fails against the stale copy + if (typeof Module.registerHooks === 'function') { + const esmPluginDir = path.join(testDir, 'node_modules', 'testplugin-esm') + writeModule( + esmPluginDir, + { + name: 'testplugin-esm', + version: '1.0.0', + type: 'module', + main: 'index.js' + }, + { + 'index.js': `import serverApi from '@signalk/server-api' import historyApi from '@signalk/server-api/history' import deep from '@signalk/server-api/deep.js' import bacon from 'baconjs' export { serverApi, historyApi, deep, bacon }` - } - ) - writeStaleBundledCopies(esmPluginDir) - esmPlugin = await importOrRequire(esmPluginDir) + } + ) + writeStaleBundledCopies(esmPluginDir) + esmPlugin = await importOrRequire(esmPluginDir) + } }) // stale bundled copies without an exports map, like server-api 2.9.x