diff --git a/src/baconjs-compat.ts b/src/baconjs-compat.ts index 881b69e82..cf34751c1 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 000000000..5fc9fcc85 --- /dev/null +++ b/src/host-modules.ts @@ -0,0 +1,110 @@ +/* + * 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 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') + +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) +} + +// 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/src/index.ts b/src/index.ts index 5010eaf48..81719fa23 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 000000000..1c86ad0ce --- /dev/null +++ b/test/host-modules.ts @@ -0,0 +1,155 @@ +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)) + 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 + } + let esmPlugin: typeof plugin + + 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') + }` + } + ) + writeStaleBundledCopies(pluginDir) + plugin = await importOrRequire(pluginDir) + + // 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) + } + }) + + // 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' }, + { + '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 }` } + ) + } + + 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) + }) + + 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) + }) +})