Skip to content

Commit 3176aca

Browse files
authored
Add lightweight WebSocket bridge for browser access to ROS 2 (#1495)
Adds a lightweight WebSocket bridge built into rclnodejs so a plain browser (or any WebSocket-capable client) can talk to ROS 2 with **no JavaScript library** on the client side — built-in `WebSocket` + `JSON` are enough. ## Highlights - **Resource-style URLs**: `ws://host:port/topic/<name>?type=<pkg>/msg/<Type>` and `ws://host:port/service/<name>?type=<pkg>/srv/<Type>`; the URL *is* the topic/service, the WS frame *is* the ROS message as JSON. - **Same Node.js process** as your `rclnodejs` app — no extra Python service to deploy or version-match against ROS distros. - **Lightweight alternative to `rosbridge_suite` + `roslibjs`** for the common pub/sub + service-client cases (full comparison table in `rosocket/README.md`). - **CLI shipped as a `bin` entry** — `npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String`; supports repeatable `--topic`/`--service` defaults, `--host`, `--node-name`, integer-validated `--port`, and clean SIGINT/SIGTERM shutdown with a hard-exit fallback. - **Programmatic API**: `const { startRosocket } = require('rclnodejs/rosocket'); await startRosocket({ node, port, topicTypes, serviceTypes, verifyClient })`. - **Optional pre-declared types** (`topicTypes` / `serviceTypes`) let browsers omit `?type=`; without them the bridge stays generic. - **Service envelope**: bare `{a,b}` request shape *or* wrapped `{id, request:{...}}` for concurrent in-flight call correlation; responses echo the same shape. - **64-bit ints**: accepts JSON numbers or BigInt-encoded strings (`"12n"`); responses use the rclnodejs `toJSONSafe` encoding. - **Error/close conventions**: `{error: "<msg>"}` frames; protocol violations close with `1008`, server errors with `1011`. - **`verifyClient` hook** wraps `ws`'s native `(info, cb)` callback into the documented `(req: IncomingMessage) => boolean` signature. - **Tests**: 7 mocha cases covering unknown-path/missing-type rejection, configured `topicTypes` subscribe, `?type=` publish, and bare + `{id, request}` service round-trips, exercised against `example_interfaces/srv/AddTwoInts` (BigInt-encoded `int64` request fields). ## Files - `rosocket/index.js` — implementation (`startRosocket`, debug namespace `rclnodejs:rosocket`). - `rosocket/cli.js` — `rosocket` CLI (also exposed as `npm run rosocket`). - `rosocket/README.md` — full feature doc, comparison table, URL scheme, server/CLI/browser snippets. - `test/test-rosocket.js` — mocha suite (7 tests). - `README.md` — new top-level "rosocket — ROS 2 in the browser, no library required" section + TOC entry. - `package.json` — adds `ws ^8.18.0`, `bin.rosocket`, `scripts.rosocket`. ## Availability Experimental; ships only on `develop`. Try it with `npm install RobotWebTools/rclnodejs#develop`. Fix: 1494
1 parent 8a1ce19 commit 3176aca

6 files changed

Lines changed: 769 additions & 4 deletions

File tree

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ This example assumes your ROS 2 environment is already sourced.
3434
- Reference:
3535
[API Documentation](#api-documentation), [Using TypeScript](#using-rclnodejs-with-typescript), [ROS 2 Interface Message Generation](#ros-2-interface-message-generation)
3636
- Features and examples:
37-
[rclnodejs-cli](#rclnodejs-cli), [Electron-based Visualization](#electron-based-visualization), [Observable Subscriptions](#observable-subscriptions), [Performance Benchmarks](#performance-benchmarks)
37+
[rclnodejs-cli](#rclnodejs-cli), [Electron-based Visualization](#electron-based-visualization), [Observable Subscriptions](#observable-subscriptions), [rosocket](#rosocket--ros-2-in-the-browser-no-library-required), [Performance Benchmarks](#performance-benchmarks)
3838
- Project docs:
3939
[Efficient Usage Tips](./docs/EFFICIENCY.md), [FAQ and Known Issues](./docs/FAQ.md), [Building from Scratch](./docs/BUILDING.md), [Contributing](./docs/CONTRIBUTING.md)
4040

@@ -198,6 +198,29 @@ obsSub.observable
198198

199199
See the [Observable Subscriptions Tutorial](./tutorials/observable-subscriptions.md) for more details.
200200

201+
## rosocket — ROS 2 in the browser, no library required
202+
203+
> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`.
204+
205+
> **Availability:** experimental; currently only on the `develop` branch and not yet part of any published `rclnodejs` release.
206+
207+
**rosocket** exposes ROS 2 topics/services as plain WebSocket URLs — a
208+
**lightweight** alternative to the rosbridge + roslibjs stack. Zero browser
209+
code, one Node.js process; browsers use only built-in `WebSocket` + `JSON`,
210+
no JavaScript library required.
211+
212+
```bash
213+
npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String
214+
```
215+
216+
```js
217+
const ws = new WebSocket('ws://host:9000/topic/chatter');
218+
ws.onmessage = (e) => console.log(JSON.parse(e.data).data);
219+
ws.onopen = () => ws.send(JSON.stringify({ data: 'hi' }));
220+
```
221+
222+
See [rosocket/README.md](./rosocket/README.md) for the URL scheme, service calls, and the programmatic `startRosocket()` API.
223+
201224
## ROS 2 Interface Message Generation
202225

203226
ROS client libraries convert IDL message descriptions into target language source code. rclnodejs provides the `generate-ros-messages` script to generate JavaScript message interface files and TypeScript declarations.

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@
3636
"coverage": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js",
3737
"prebuild:node": "prebuildify --napi --strip --name node --target 20.20.2",
3838
"prebuild:electron": "prebuildify --napi --strip --name electron --target electron@34.0.0",
39-
"prebuild": "npm run prebuild:node && npm run prebuild:electron && node scripts/tag_prebuilds.js"
39+
"prebuild": "npm run prebuild:node && npm run prebuild:electron && node scripts/tag_prebuilds.js",
40+
"rosocket": "node ./rosocket/cli.js"
4041
},
4142
"bin": {
42-
"generate-ros-messages": "./scripts/generate_messages.js"
43+
"generate-ros-messages": "./scripts/generate_messages.js",
44+
"rosocket": "./rosocket/cli.js"
4345
},
4446
"authors": [
4547
"Minggang Wang <minggangw@gmail.com>",
@@ -86,7 +88,8 @@
8688
"json-bigint": "^1.0.0",
8789
"node-addon-api": "^8.3.1",
8890
"rxjs": "^7.8.1",
89-
"walk": "^2.3.15"
91+
"walk": "^2.3.15",
92+
"ws": "^8.18.0"
9093
},
9194
"husky": {
9295
"hooks": {

rosocket/README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# rosocket — ROS 2 in the browser, no library required
2+
3+
> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`.
4+
5+
> **Availability:** experimental; currently only on the `develop` branch of
6+
> `rclnodejs` and not yet part of any published release. Install from GitHub
7+
> to try it (see the project's [Install from GitHub](../README.md#install-from-github) section):
8+
>
9+
> ```bash
10+
> npm install RobotWebTools/rclnodejs#develop
11+
> ```
12+
13+
**rosocket** is a **lightweight** WebSocket bridge that lets a **plain web
14+
browser** (or any WebSocket-capable client) talk to ROS 2 through `rclnodejs`,
15+
with **no extra JavaScript library** required on the client side. Browsers
16+
only need the built-in `WebSocket` and `JSON` APIs.
17+
18+
How it compares with the classic
19+
[rosbridge_suite](https://github.com/RobotWebTools/rosbridge_suite) +
20+
[roslibjs](https://github.com/RobotWebTools/roslibjs) stack:
21+
22+
| | **rosocket (rclnodejs)** | **rosbridge_suite + roslibjs** |
23+
| --- | --- | --- |
24+
| Server process | same Node.js process as your `rclnodejs` app | separate Python ROS 2 node |
25+
| Client-side library | none — built-in `WebSocket` + `JSON` | `roslibjs` (must be bundled/loaded) |
26+
| Wire protocol | resource-style URLs (`/topic/<name>`, `/service/<name>`); frame = bare ROS message as JSON | custom JSON envelope (`op: "publish" / "subscribe" / "call_service"`, …) |
27+
| Type discovery | URL `?type=` query, or server-side default map | advertised at runtime via envelope ops |
28+
| Features | publish / subscribe, service client | pub/sub, services, **actions, tf, parameters, compression, PNG/CBOR, auth, …** |
29+
| Deployment | one `npm` dep, runs anywhere Node runs | extra ROS package; version must match ROS distro |
30+
31+
## URL scheme
32+
33+
The bridge is **resource-style** — the URL *is* the topic or service name and
34+
the WebSocket frame *is* the ROS message as JSON.
35+
36+
| URL | Direction | Payload |
37+
| --- | --- | --- |
38+
| `ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>` | server → client (subscribe) | one frame per received ROS message, JSON-serialized |
39+
| `ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>` | client → server (publish) | one frame per ROS message to publish, JSON-encoded |
40+
| `ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>` | client → server (request) | one frame per request, JSON-encoded |
41+
| `ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>` | server → client (response) | one frame per response, JSON-serialized |
42+
43+
Notes:
44+
45+
- Each connection is dedicated to one topic or service. A single socket is
46+
full-duplex, so the same `/topic/<name>` socket can both publish and
47+
subscribe at the same time.
48+
- The `type=` query parameter can be omitted if the server was started with
49+
`topicTypes` / `serviceTypes` defaults for that name.
50+
- Service calls may be sent as a bare request (`{"a":1,"b":2}`) or wrapped
51+
with a correlation id (`{"id":"c1","request":{"a":1,"b":2}}`); responses
52+
echo the same shape (`{"id":"c1","response":{...}}`).
53+
- Errors are reported as `{"error":"<message>"}` frames; fatal protocol errors
54+
cause the socket to close with a `1008`/`1011` code.
55+
- 64-bit integer fields may be sent as JSON numbers or BigInt-encoded
56+
strings (`"12n"`); responses use the rclnodejs `toJSONSafe` encoding
57+
(BigInts become `"<n>n"` strings).
58+
59+
## Server side
60+
61+
```js
62+
const rclnodejs = require('rclnodejs');
63+
const { startRosocket } = require('rclnodejs/rosocket');
64+
65+
await rclnodejs.init();
66+
const node = new rclnodejs.Node('rosocket_node');
67+
rclnodejs.spin(node);
68+
69+
await startRosocket({
70+
node,
71+
port: 9000,
72+
// optional: pre-declare types so clients can omit ?type=
73+
topicTypes: { '/chatter': 'std_msgs/msg/String' },
74+
serviceTypes: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' },
75+
});
76+
```
77+
78+
### Without `topicTypes` / `serviceTypes`
79+
80+
The `topicTypes` / `serviceTypes` maps are entirely optional. If you omit
81+
them, the server stays generic and clients must specify the message type
82+
themselves via the `?type=` query parameter on each connection:
83+
84+
```js
85+
// server – open to any topic/service the node is allowed to access
86+
await startRosocket({ node, port: 9000 });
87+
```
88+
89+
```js
90+
// browser – type comes from the URL
91+
const sub = new WebSocket(
92+
'ws://localhost:9000/topic/chatter?type=std_msgs/msg/String'
93+
);
94+
const cli = new WebSocket(
95+
'ws://localhost:9000/service/add_two_ints?type=example_interfaces/srv/AddTwoInts'
96+
);
97+
```
98+
99+
The same applies to the CLI — drop `--topic` / `--service` to run a generic
100+
bridge: `npx rosocket --port 9000`.
101+
102+
## CLI (`rosocket`)
103+
104+
A ready-to-run command is shipped as a `bin` entry, so users do not need to
105+
write any server code:
106+
107+
```bash
108+
# from inside this repo
109+
npm run rosocket -- --port 9000 \
110+
--topic /chatter:std_msgs/msg/String \
111+
--service /add_two_ints:example_interfaces/srv/AddTwoInts
112+
113+
# anywhere after `npm i rclnodejs` (or via npx)
114+
npx rosocket --port 9000 \
115+
--topic /chatter:std_msgs/msg/String \
116+
--service /add_two_ints:example_interfaces/srv/AddTwoInts
117+
```
118+
119+
Options: `--port/-p`, `--host/-H`, `--node-name/-n`, repeatable
120+
`--topic/-t <name>:<type>` and `--service/-s <name>:<type>`, `--help/-h`.
121+
Pre-declared types let browsers omit the `?type=` query.
122+
123+
## Browser side (no library)
124+
125+
```html
126+
<script type="module">
127+
// Subscribe
128+
const sub = new WebSocket('ws://localhost:9000/topic/chatter');
129+
sub.onmessage = (e) => console.log('chatter:', JSON.parse(e.data).data);
130+
131+
// Publish on the same socket (or a different one)
132+
sub.onopen = () => sub.send(JSON.stringify({ data: 'hello from browser' }));
133+
134+
// Service call
135+
const cli = new WebSocket('ws://localhost:9000/service/add_two_ints');
136+
cli.onopen = () => cli.send(JSON.stringify({ a: 1, b: 2 }));
137+
cli.onmessage = (e) => console.log('sum =', JSON.parse(e.data).sum);
138+
</script>
139+
```
140+
141+
## Why not rosbridge?
142+
143+
Use this bridge when you want:
144+
145+
- **Zero browser dependency** — no JavaScript library to bundle or load.
146+
- **Zero extra process** — already in the same Node.js where your
147+
`rclnodejs` app runs.
148+
- **Greppable URLs** for reverse-proxy ACLs (`location /topic/...`).
149+
150+
Use a full-featured stack like rosbridge_suite when you need actions, tf,
151+
parameter helpers, compression, throttling, or compatibility with existing
152+
ROS web tooling.

rosocket/cli.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
10+
'use strict';
11+
12+
const rclnodejs = require('../index.js');
13+
const { startRosocket } = require('./index.js');
14+
15+
const USAGE = `Usage: rosocket [options]
16+
17+
rosocket — expose ROS 2 topics and services as resource-style WebSocket URLs.
18+
19+
Options:
20+
-p, --port <port> Port to listen on (default: 9000)
21+
-H, --host <host> Host/interface to bind (default: 0.0.0.0)
22+
-n, --node-name <name> ROS 2 node name (default: rosocket)
23+
-t, --topic <name>:<type> Pre-declare a topic type (repeatable)
24+
e.g. --topic /chatter:std_msgs/msg/String
25+
-s, --service <name>:<type> Pre-declare a service type (repeatable)
26+
e.g. --service /add:example_interfaces/srv/AddTwoInts
27+
-h, --help Show this help
28+
29+
URL scheme:
30+
ws://host:port/topic/<name>?type=<pkg>/msg/<Type>
31+
ws://host:port/service/<name>?type=<pkg>/srv/<Type>
32+
33+
Pre-declared types via --topic/--service let clients omit the ?type= query.
34+
`;
35+
36+
function parseArgs(argv) {
37+
const opts = {
38+
port: 9000,
39+
host: '0.0.0.0',
40+
nodeName: 'rosocket',
41+
topicTypes: {},
42+
serviceTypes: {},
43+
};
44+
const need = (i, flag) => {
45+
if (i + 1 >= argv.length) {
46+
console.error(`error: ${flag} requires a value`);
47+
process.exit(2);
48+
}
49+
return argv[i + 1];
50+
};
51+
const addPair = (target, raw, flag) => {
52+
const idx = raw.indexOf(':');
53+
if (idx <= 0) {
54+
console.error(`error: ${flag} expects <name>:<type>, got "${raw}"`);
55+
process.exit(2);
56+
}
57+
let name = raw.slice(0, idx);
58+
const type = raw.slice(idx + 1);
59+
if (!name.startsWith('/')) name = '/' + name;
60+
target[name] = type;
61+
};
62+
63+
for (let i = 0; i < argv.length; i++) {
64+
const a = argv[i];
65+
switch (a) {
66+
case '-h':
67+
case '--help':
68+
console.log(USAGE);
69+
process.exit(0);
70+
break;
71+
case '-p':
72+
case '--port': {
73+
const raw = need(i, a);
74+
const p = Number(raw);
75+
if (!Number.isInteger(p) || p < 0 || p > 65535) {
76+
console.error(
77+
`error: ${a} expects an integer in 0–65535, got "${raw}"`
78+
);
79+
process.exit(2);
80+
}
81+
opts.port = p;
82+
i++;
83+
break;
84+
}
85+
case '-H':
86+
case '--host':
87+
opts.host = need(i, a);
88+
i++;
89+
break;
90+
case '-n':
91+
case '--node-name':
92+
opts.nodeName = need(i, a);
93+
i++;
94+
break;
95+
case '-t':
96+
case '--topic':
97+
addPair(opts.topicTypes, need(i, a), a);
98+
i++;
99+
break;
100+
case '-s':
101+
case '--service':
102+
addPair(opts.serviceTypes, need(i, a), a);
103+
i++;
104+
break;
105+
default:
106+
console.error(`error: unknown argument: ${a}`);
107+
console.error(USAGE);
108+
process.exit(2);
109+
}
110+
}
111+
return opts;
112+
}
113+
114+
async function main() {
115+
const opts = parseArgs(process.argv.slice(2));
116+
117+
await rclnodejs.init();
118+
const node = rclnodejs.createNode(opts.nodeName);
119+
rclnodejs.spin(node);
120+
121+
const bridge = await startRosocket({
122+
node,
123+
port: opts.port,
124+
host: opts.host,
125+
topicTypes: opts.topicTypes,
126+
serviceTypes: opts.serviceTypes,
127+
});
128+
129+
// 0.0.0.0 / :: are bind wildcards, not reachable URLs. Show a usable
130+
// hostname in the log so users can paste the URL directly into a browser.
131+
const displayHost =
132+
opts.host === '0.0.0.0' || opts.host === '::' || opts.host === ''
133+
? '127.0.0.1'
134+
: opts.host;
135+
console.log(
136+
`[rosocket] node="${opts.nodeName}" listening on ws://${displayHost}:${bridge.port} (bind=${opts.host})`
137+
);
138+
for (const [name, type] of Object.entries(opts.topicTypes)) {
139+
console.log(` topic ${name}\t-> ${type}`);
140+
}
141+
for (const [name, type] of Object.entries(opts.serviceTypes)) {
142+
console.log(` service ${name}\t-> ${type}`);
143+
}
144+
145+
const shutdown = (sig) => {
146+
console.log(`[rosocket] received ${sig}, shutting down`);
147+
// Hard-exit fallback in case ws/rcl close callbacks don't fire
148+
// (e.g. due to in-flight rclnodejs.spin loop keeping the event loop busy).
149+
const hard = setTimeout(() => process.exit(0), 1500);
150+
hard.unref();
151+
Promise.resolve()
152+
.then(() => bridge.close())
153+
.catch(() => {})
154+
.then(() => {
155+
try {
156+
rclnodejs.shutdown();
157+
} catch (_) {}
158+
process.exit(0);
159+
});
160+
};
161+
process.on('SIGINT', () => shutdown('SIGINT'));
162+
process.on('SIGTERM', () => shutdown('SIGTERM'));
163+
}
164+
165+
main().catch((e) => {
166+
console.error(e.stack || e.message);
167+
process.exit(1);
168+
});

0 commit comments

Comments
 (0)