Skip to content

Rclnodejs web capability runtime over WebSocket#1509

Merged
minggangw merged 8 commits into
RobotWebTools:developfrom
minggangw:feat/web-runtime-l3-ws
May 11, 2026
Merged

Rclnodejs web capability runtime over WebSocket#1509
minggangw merged 8 commits into
RobotWebTools:developfrom
minggangw:feat/web-runtime-l3-ws

Conversation

@minggangw
Copy link
Copy Markdown
Member

@minggangw minggangw commented May 11, 2026

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/server subpath export — no existing behavior changes.

const { createRuntime, WebSocketTransport } = require('rclnodejs/web/server');
const runtime = createRuntime({
  node, transports: [new WebSocketTransport({ port: 9000 })],
});
runtime.expose({ call: ['/add_two_ints'], publish: ['/chatter'], subscribe: ['/chatter'] });
await runtime.start();

Files

Area Path
Registry / Dispatcher / Transport / WS / Façade / Types lib/runtime/{registry,dispatcher,transport,index}.js, lib/runtime/transports/ws.js, lib/runtime/index.d.ts
Subpath exports (./web/server, ./rosocket, ./lib/*) package.json
Shared reviveBigInts (null-proto, prototype-pollution safe) lib/message_serialization.js, rosocket/index.js
Tests (+19 cases) test/test-runtime.js, test/test-serialization-modes.js

CI workaround

test/electron/run_test.js skips the Electron smoke test on Node ≥ 26. Upstream extract-zip@2.0.1 (unmaintained, used by electron'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

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.
Copilot AI review requested due to automatic review settings May 11, 2026 02:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 around CapabilityRegistry, Dispatcher, and transport adapter contracts.
  • Implemented a WebSocket transport adapter (WebSocketTransport) speaking capability frames on /capability (default port 9000).
  • Added reviveBigInts to rehydrate "123n"-style BigInt strings back into bigint values.

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.

Comment thread package.json Outdated
"default": "./index.js"
},
"./web/server": {
"types": "./types/index.d.ts",
Comment thread lib/runtime/transport.js
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 thread lib/runtime/index.js Outdated
Comment on lines +62 to +63
for (const t of this.transports) {
await t.start({ onConnection });
Comment thread lib/message_serialization.js Outdated
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;
}
Comment thread package.json
"./web/server": {
"types": "./types/index.d.ts",
"default": "./lib/runtime/index.js"
},
Comment thread lib/runtime/dispatcher.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;
@coveralls
Copy link
Copy Markdown

coveralls commented May 11, 2026

Coverage Status

coverage: 85.631% (-0.2%) from 85.864% — minggangw:feat/web-runtime-l3-ws into RobotWebTools:develop

minggangw added 2 commits May 11, 2026 14:18
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.
@minggangw minggangw force-pushed the feat/web-runtime-l3-ws branch from 8527529 to 5598dd8 Compare May 11, 2026 07:23
@minggangw minggangw changed the title feat(runtime): capability registry + dispatcher (L3) over WebSocket browser ↔ ROS 2 capability runtime over WebSocket May 11, 2026
@minggangw minggangw changed the title browser ↔ ROS 2 capability runtime over WebSocket Rclnodejs web capability runtime over WebSocket May 11, 2026
@minggangw minggangw requested a review from Copilot May 11, 2026 09:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.

Comment thread test/electron/run_test.js
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);
Comment thread package.json
"default": "./lib/runtime/index.js"
},
"./rosocket": "./rosocket/index.js",
"./lib/*": "./lib/*.js",
Comment thread test/test-runtime.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);
minggangw added 3 commits May 11, 2026 17:27
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]).
@minggangw minggangw merged commit f5a4eb6 into RobotWebTools:develop May 11, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Browser ↔ ROS 2 capability runtime (Web Runtime)

3 participants