Skip to content

Commit f5a4eb6

Browse files
authored
Rclnodejs web capability runtime over WebSocket (#1509)
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. ```js 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
1 parent 09b1f2d commit f5a4eb6

13 files changed

Lines changed: 1395 additions & 19 deletions

lib/message_serialization.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,40 @@ function isValidSerializationMode(mode) {
168168
return ['default', 'plain', 'json'].includes(mode);
169169
}
170170

171+
/**
172+
* Inverse of {@link toJSONSafe} for 64-bit integer fields.
173+
*
174+
* `toJSONSafe` encodes `bigint` as the string `"<n>n"` so values survive
175+
* `JSON.stringify`. `reviveBigInts` walks an arbitrary JSON value and
176+
* converts any such string back into a real `bigint`. Everything else
177+
* passes through unchanged. Used by the rosocket bridge and the Web
178+
* Runtime dispatcher to rehydrate inbound JSON before handing it to
179+
* rclnodejs.
180+
*
181+
* Returned objects use a null prototype and skip the well-known
182+
* prototype-pollution keys (`__proto__`, `constructor`, `prototype`)
183+
* because the input is attacker-controllable JSON arriving from a
184+
* remote peer.
185+
*
186+
* @param {*} value
187+
* @returns {*}
188+
*/
189+
function reviveBigInts(value) {
190+
if (value === null || typeof value !== 'object') {
191+
if (typeof value === 'string' && /^-?\d+n$/.test(value)) {
192+
return BigInt(value.slice(0, -1));
193+
}
194+
return value;
195+
}
196+
if (Array.isArray(value)) return value.map(reviveBigInts);
197+
const out = Object.create(null);
198+
for (const k of Object.keys(value)) {
199+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
200+
out[k] = reviveBigInts(value[k]);
201+
}
202+
return out;
203+
}
204+
171205
module.exports = {
172206
isTypedArray,
173207
needsJSONConversion,
@@ -176,4 +210,5 @@ module.exports = {
176210
toJSONString,
177211
applySerializationMode,
178212
isValidSerializationMode,
213+
reviveBigInts,
179214
};

lib/runtime/capability_registry.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
'use strict';
10+
11+
/**
12+
* Declarative allow-list of ROS 2 capabilities exposed to the Web Runtime.
13+
*
14+
* The registry is the single source of truth for "what may a connected client
15+
* do?" — every dispatched frame is checked against it. Capabilities not listed
16+
* here are rejected at the runtime layer before any rclnodejs Node API is
17+
* touched.
18+
*
19+
* Each capability is a tuple `(kind, name, type)` where:
20+
* - `kind` is one of `'call' | 'publish' | 'subscribe'`
21+
* - `name` is the ROS 2 service or topic name (e.g. `'/cmd_vel'`)
22+
* - `type` is the ROS 2 interface name (e.g. `'std_msgs/msg/String'`)
23+
*/
24+
class CapabilityRegistry {
25+
constructor() {
26+
// Per-kind allow-lists. Each map is keyed by ROS name and stores the
27+
// resolved interface type, e.g. `_call.get('/add_two_ints')` returns
28+
// `'example_interfaces/srv/AddTwoInts'`. Looked up at dispatch time
29+
// by Dispatcher.resolve(kind, name).
30+
this._call = new Map(); // ROS service name -> srv type
31+
this._publish = new Map(); // ROS topic name -> msg type
32+
this._subscribe = new Map(); // ROS topic name -> msg type
33+
}
34+
35+
/**
36+
* Register one or more capabilities.
37+
*
38+
* @example Shorthand (string value = type name)
39+
* registry.expose({
40+
* call: { '/add': 'example_interfaces/srv/AddTwoInts' },
41+
* publish: { '/chatter':'std_msgs/msg/String' },
42+
* subscribe: { '/scan': 'sensor_msgs/msg/LaserScan' },
43+
* });
44+
*
45+
* @example Rich form (object value with metadata)
46+
* registry.expose({
47+
* subscribe: {
48+
* '/scan': { type: 'sensor_msgs/msg/LaserScan' /* future: qos, keep_last, ... *\/ },
49+
* },
50+
* });
51+
*
52+
* The rich form is accepted today but the runtime only consumes
53+
* `type`; additional fields are reserved for forward-compatibility
54+
* (e.g. future QoS metadata). Snapshots via {@link list} always
55+
* return the canonical `{ name: typeName }` form regardless of
56+
* which form was used here.
57+
*
58+
* @param {{
59+
* call?: Object<string, string | {type:string}>,
60+
* publish?: Object<string, string | {type:string}>,
61+
* subscribe?: Object<string, string | {type:string}>,
62+
* }} spec
63+
* @returns {CapabilityRegistry} this (chainable)
64+
*/
65+
expose(spec = {}) {
66+
for (const [name, value] of Object.entries(spec.call || {})) {
67+
this._call.set(name, _typeOf(value, 'call', name));
68+
}
69+
for (const [name, value] of Object.entries(spec.publish || {})) {
70+
this._publish.set(name, _typeOf(value, 'publish', name));
71+
}
72+
for (const [name, value] of Object.entries(spec.subscribe || {})) {
73+
this._subscribe.set(name, _typeOf(value, 'subscribe', name));
74+
}
75+
return this;
76+
}
77+
78+
/**
79+
* Resolve a capability lookup.
80+
* @param {'call'|'publish'|'subscribe'} kind
81+
* @param {string} name
82+
* @returns {{kind:string, name:string, type:string}|null}
83+
*/
84+
resolve(kind, name) {
85+
const map = this._mapFor(kind);
86+
if (!map) return null;
87+
const type = map.get(name);
88+
return type ? { kind, name, type } : null;
89+
}
90+
91+
/**
92+
* Snapshot the registered capabilities as a plain object.
93+
*
94+
* Always returns the canonical shorthand form
95+
* (`{ [name]: typeName }`) regardless of how `expose()` was called.
96+
* Useful for introspection and future OpenAPI export.
97+
*/
98+
list() {
99+
return {
100+
call: Object.fromEntries(this._call),
101+
publish: Object.fromEntries(this._publish),
102+
subscribe: Object.fromEntries(this._subscribe),
103+
};
104+
}
105+
106+
_mapFor(kind) {
107+
if (kind === 'call') return this._call;
108+
if (kind === 'publish') return this._publish;
109+
if (kind === 'subscribe') return this._subscribe;
110+
return null;
111+
}
112+
}
113+
114+
function _typeOf(value, kind, name) {
115+
if (typeof value === 'string') {
116+
if (!value) {
117+
throw new TypeError(
118+
`expose.${kind}["${name}"] is an empty string; expected a ROS type name`
119+
);
120+
}
121+
return value;
122+
}
123+
if (value && typeof value === 'object' && typeof value.type === 'string') {
124+
if (!value.type) {
125+
throw new TypeError(
126+
`expose.${kind}["${name}"].type is empty; expected a ROS type name`
127+
);
128+
}
129+
return value.type;
130+
}
131+
throw new TypeError(
132+
`expose.${kind}["${name}"]: expected a string or { type: string }`
133+
);
134+
}
135+
136+
module.exports = { CapabilityRegistry };

lib/runtime/connection.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
'use strict';
10+
11+
const EventEmitter = require('events');
12+
13+
/**
14+
* @typedef {Object} CapabilityFrame
15+
* @property {string|number} [id] - Caller-assigned request id (echoed in reply).
16+
* @property {'call'|'publish'|'subscribe'|'unsubscribe'|'action'} [kind] - Request kind (C→S only). `'action'` is reserved and currently returns `code: 'not_implemented'`.
17+
* @property {string} [capability] - Capability name, e.g. `/cmd_vel`.
18+
* @property {*} [payload] - Message payload (call request, publish msg, sub event).
19+
* @property {string|number} [subId] - For unsubscribe: id of the original subscribe.
20+
* @property {boolean} [ok] - Reply success flag (S→C only).
21+
* @property {string} [event] - `'message'` for streamed subscription deliveries.
22+
* @property {string} [error] - Human-readable error message on failure.
23+
* @property {string} [code] - Stable machine-readable error code.
24+
*/
25+
26+
/**
27+
* Per-connection abstraction handed to the runtime by a transport adapter.
28+
*
29+
* The transport owns the wire (WebSocket today; other adapters may follow)
30+
* and exposes each connection as a `Connection` so the runtime can stay
31+
* transport-agnostic. Concrete adapters subclass `Connection` and call
32+
* `this.emit('message', frame)` / `this.emit('close')` as frames arrive on
33+
* the wire, and implement `send(frame)` / `close(code, reason)` to push
34+
* frames back out.
35+
*
36+
* @experimental — the base class shape may grow (e.g. backpressure hooks)
37+
* and is not part of the SemVer-stable surface for third-party adapter
38+
* authors. The first-party `WebSocketTransport` is stable and recommended
39+
* for production use.
40+
*
41+
* @event Connection#message
42+
* @type {CapabilityFrame}
43+
*
44+
* @event Connection#close
45+
*/
46+
class Connection extends EventEmitter {
47+
/**
48+
* Push a frame to the remote peer. Implementations should silently drop
49+
* sends on a closed connection rather than throwing.
50+
* @param {CapabilityFrame} frame
51+
*/
52+
// eslint-disable-next-line no-unused-vars
53+
send(frame) {
54+
throw new Error('Connection.send() must be implemented by the transport');
55+
}
56+
57+
/**
58+
* Close the connection.
59+
* @param {number} [code]
60+
* @param {string} [reason]
61+
*/
62+
// eslint-disable-next-line no-unused-vars
63+
close(code, reason) {
64+
throw new Error('Connection.close() must be implemented by the transport');
65+
}
66+
}
67+
68+
module.exports = { Connection };

0 commit comments

Comments
 (0)