Rclnodejs web capability runtime over WebSocket#1509
Merged
minggangw merged 8 commits intoMay 11, 2026
Conversation
Introduces the rclnodejs/web/server backend layer:
- CapabilityRegistry: declarative `expose({ call, publish, subscribe })`
allow-list. Capabilities not registered are rejected with
`code: 'not_exposed'` before any `Node.create*()` call runs.
- Dispatcher: per-connection state, lazy creation/teardown of
publishers/subscriptions/clients, BigInt rehydration, reserved
`kind: 'action'` returning `code: 'not_implemented'` so future
Action support lands without a wire-protocol bump.
- TransportAdapter + Connection base classes: the only contract Layer 3
sees, so additional transport adapters can be added without touching
the runtime.
- WebSocketTransport: ws-backed adapter on `/capability` (default port
9000), with optional `verifyClient(req)` auth hook.
- createRuntime({ node, transports }) factory in lib/runtime/index.js.
- reviveBigInts helper added to lib/message_serialization.js (inverse
of the existing toJSONSafe; handles the BigInt 'Nn' wire convention).
Subpath export: `rclnodejs/web/server` -> ./lib/runtime/index.js.
Browser SDK + tests land in the next commit.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new “Web Runtime” server backend layer (rclnodejs/web/server) that exposes an allow-listed capability protocol over transport adapters (initially WebSocket), plus a BigInt “rehydration” helper to invert the existing toJSONSafe encoding.
Changes:
- Added a runtime composition API (
createRuntime) built aroundCapabilityRegistry,Dispatcher, and transport adapter contracts. - Implemented a WebSocket transport adapter (
WebSocketTransport) speaking capability frames on/capability(default port 9000). - Added
reviveBigIntsto rehydrate"123n"-style BigInt strings back intobigintvalues.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds subpath export for ./web/server and introduces a package exports map. |
| lib/runtime/transports/ws.js | Implements WebSocket-based TransportAdapter and Connection. |
| lib/runtime/transport.js | Defines Connection / TransportAdapter base contracts and CapabilityFrame typedef. |
| lib/runtime/registry.js | Adds a declarative allow-list registry for exposed capabilities. |
| lib/runtime/index.js | Adds Runtime class and createRuntime() factory; re-exports runtime components. |
| lib/runtime/dispatcher.js | Implements per-connection dispatch + lazy ROS entity creation/teardown. |
| lib/message_serialization.js | Adds reviveBigInts helper and exports it. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "default": "./index.js" | ||
| }, | ||
| "./web/server": { | ||
| "types": "./types/index.d.ts", |
Comment on lines
+14
to
+23
| * @typedef {Object} CapabilityFrame | ||
| * @property {string|number} [id] - Caller-assigned request id (echoed in reply). | ||
| * @property {'call'|'publish'|'subscribe'|'unsubscribe'} [kind] - Request kind (C→S only). | ||
| * @property {string} [capability] - Capability name, e.g. `/cmd_vel`. | ||
| * @property {*} [payload] - Message payload (call request, publish msg, sub event). | ||
| * @property {string|number} [subId] - For unsubscribe: id of the original subscribe. | ||
| * @property {boolean} [ok] - Reply success flag (S→C only). | ||
| * @property {string} [event] - `'message'` for streamed subscription deliveries. | ||
| * @property {string} [error] - Human-readable error message on failure. | ||
| * @property {string} [code] - Stable machine-readable error code. |
Comment on lines
+62
to
+63
| for (const t of this.transports) { | ||
| await t.start({ onConnection }); |
| return value; | ||
| } | ||
| if (Array.isArray(value)) return value.map(reviveBigInts); | ||
| const out = {}; |
Comment on lines
+177
to
+179
| * passes through unchanged. Used by the rosocket bridge and the Web | ||
| * Runtime dispatcher to rehydrate inbound JSON before handing it to | ||
| * rclnodejs. |
Comment on lines
+184
to
+195
| function reviveBigInts(value) { | ||
| if (value === null || typeof value !== 'object') { | ||
| if (typeof value === 'string' && /^-?\d+n$/.test(value)) { | ||
| return BigInt(value.slice(0, -1)); | ||
| } | ||
| return value; | ||
| } | ||
| if (Array.isArray(value)) return value.map(reviveBigInts); | ||
| const out = {}; | ||
| for (const k of Object.keys(value)) out[k] = reviveBigInts(value[k]); | ||
| return out; | ||
| } |
| "./web/server": { | ||
| "types": "./types/index.d.ts", | ||
| "default": "./lib/runtime/index.js" | ||
| }, |
Comment on lines
+23
to
+33
| class Dispatcher { | ||
| /** | ||
| * @param {object} options | ||
| * @param {import('../node.js')} options.node | ||
| * @param {import('./registry.js').CapabilityRegistry} options.registry | ||
| */ | ||
| constructor({ node, registry }) { | ||
| if (!node) throw new TypeError('Dispatcher: options.node is required'); | ||
| if (!registry) | ||
| throw new TypeError('Dispatcher: options.registry is required'); | ||
| this.node = node; |
The electron prebuilt binary download relies on extract-zip 2.0.1, a 2020-vintage unmaintained dependency that silently aborts after the first zip entry on Node >= 26.1. Verified locally: - Node 24.x + electron@34 -> install OK, test runs - Node 26.0.0 + electron@34 -> install OK, test runs - Node 26.1.0 + electron@34 -> install silently broken, no path.txt - Node 26.1.0 + electron@42 -> same (lazy install hits same extractor) Restrict the Electron usability test to Node < 26 with an explicit version gate at the top of run_test.js. The native addon coverage is already provided by the full mocha suite that runs before this script, so we do not lose meaningful coverage on Node 26 lanes. Drop this gate once Node fixes the extract-zip incompatibility or electron switches to a maintained extractor.
8527529 to
5598dd8
Compare
Comment on lines
+6
to
+24
| // The Electron prebuilt binary download is broken on Node >= 26.1: the | ||
| // extract-zip 2.0.1 dependency used by electron's install.js silently | ||
| // stops after the first zip entry, leaving node_modules/electron/path.txt | ||
| // missing. extract-zip is unmaintained (last release 2020-06) and this | ||
| // affects every electron version that depends on it (verified locally on | ||
| // electron@34 and electron@42 with Node 26.1.0; Node 26.0.0 still works). | ||
| // | ||
| // Skip the Electron usability test on Node >= 26 entirely. The native | ||
| // addon coverage is already provided by the full mocha suite that ran | ||
| // before this script. Drop this gate once either Node fixes the | ||
| // regression or electron switches to a maintained extractor. | ||
| const nodeMajor = parseInt(process.versions.node.split('.')[0], 10); | ||
| if (nodeMajor >= 26) { | ||
| console.warn( | ||
| `Skipping Electron usability test on Node.js ${process.versions.node}: ` + | ||
| 'electron postinstall (extract-zip 2.0.1) is broken on Node >= 26.1. ' + | ||
| 'The native addon coverage is already provided by the mocha suite above.' | ||
| ); | ||
| process.exit(0); |
| "default": "./lib/runtime/index.js" | ||
| }, | ||
| "./rosocket": "./rosocket/index.js", | ||
| "./lib/*": "./lib/*.js", |
Comment on lines
+103
to
+112
| function waitFrame(ws, predicate) { | ||
| return new Promise((resolve) => { | ||
| const onMsg = (data) => { | ||
| const frame = JSON.parse(data.toString('utf8')); | ||
| if (predicate(frame)) { | ||
| ws.off('message', onMsg); | ||
| resolve(frame); | ||
| } | ||
| }; | ||
| ws.on('message', onMsg); |
The exports map's './lib/*' pattern only covers bare deep imports
like require('rclnodejs/lib/distro'). Add a sibling './lib/*.js'
pattern so require('rclnodejs/lib/distro.js') resolves the same
file, instead of erroneously mapping to './lib/distro.js.js' and
throwing ERR_PACKAGE_PATH_NOT_EXPORTED.
Verified all forms now resolve: bare and .js-suffixed, top-level
and nested (lib/action/client[.js]).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opt-in Web Runtime that lets browser code talk to ROS 2 via an explicit, allow-listed set of capabilities exposed by a host Node.js process. JSON over WebSocket; transport-agnostic dispatcher so HTTP/etc. can plug in later. Reachable only through the new
rclnodejs/web/serversubpath export — no existing behavior changes.Files
lib/runtime/{registry,dispatcher,transport,index}.js,lib/runtime/transports/ws.js,lib/runtime/index.d.ts./web/server,./rosocket,./lib/*)package.jsonreviveBigInts(null-proto, prototype-pollution safe)lib/message_serialization.js,rosocket/index.jstest/test-runtime.js,test/test-serialization-modes.jsCI workaround
test/electron/run_test.jsskips the Electron smoke test on Node ≥ 26. Upstreamextract-zip@2.0.1(unmaintained, used byelectron's postinstall) silently aborts on Node ≥ 26.1. Native addon coverage is already provided by the mocha suite that runs first; drop the gate when upstream is fixed.Fix: #1510