Skip to content

Commit 3b9eeaf

Browse files
authored
[Web Runtime] rclnodejs/web SDK guide + JavaScript and TypeScript browser demos (#1514)
A typed Web SDK guide for `rclnodejs/web`, two end-to-end browser demos (zero-build JavaScript + Vite TypeScript), and the surrounding doc / terminology / packaging cleanup. ## What's new - **`web/README.md`** *(new)* — single source of truth for the browser SDK: server runtime, three-verb client (`call` / `publish` / `subscribe`), `connect()` URL shapes, lifecycle, `curl`-able HTTP transport, and a `rclnodejs/web` vs. `rosbridge` + `roslibjs` table. - **`demo/web/javascript/`** *(new)* — zero-build demo: `node runtime.js` + `node static.js`, no `npm install`, no bundler. - **`demo/web/typescript/`** *(new)* — Vite + tsx demo with the typed three-verb client; `npm run server` wraps tsx with `NODE_OPTIONS=--no-deprecation` to silence Node's `DEP0205` noise. - **`bin/rclnodejs-web.js`** — CLI tightened: repeatable `--call/--publish/--subscribe` flags, `web.json` mode, friendlier startup banner. - **`README.md`**, **`scripts/npmjs-readme.md`**, **`tutorials/README.md`** — repositioned around three browser stories (`rclnodejs/web`, `rosocket`, none-of-the-above) and link the new SDK guide. - **`rosocket/README.md`** + **`demo/rosocket/README.md`** — recast as the **lighter sibling**; cross-link to the SDK guide for the rosbridge comparison. - **Runtime touch-ups** (`lib/runtime/dispatcher.js`, `lib/runtime/transports/ws.js`, `lib/message_serialization.js`, `rosocket/index.js`, `test/test-rosocket.js`) — comment / wording only, no behaviour change. Fix: #1510
1 parent 018aa1a commit 3b9eeaf

31 files changed

Lines changed: 1983 additions & 165 deletions

File tree

README.md

Lines changed: 65 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
**rclnodejs** is a Node.js client library for [ROS 2](https://www.ros.org/) that provides comprehensive JavaScript and TypeScript APIs for developing ROS 2 solutions.
1515

16-
**Key features:** Topics, Services, Actions, Parameters, Lifecycle Nodes, TypeScript support, RxJS Observables, Electron integration, browser ↔ ROS 2 WebSocket bridge (rosocket), and prebuilt binaries for Linux x64/arm64.
16+
**Key features:** Topics, Services, Actions, Parameters, Lifecycle Nodes, TypeScript support, RxJS Observables, Electron integration, ROS 2 in the browser (typed Web SDK + thin WebSocket gateway — `rclnodejs/web`, `rosocket`), and prebuilt binaries for Linux x64/arm64.
1717

1818
```javascript
1919
const rclnodejs = require('rclnodejs');
@@ -30,11 +30,11 @@ This example assumes your ROS 2 environment is already sourced.
3030
## Documentation
3131

3232
- Get started:
33-
[Installation](#installation), [Quick Start](#quick-start), [Tutorials](./tutorials/)
33+
[Installation](#installation), [Quick Start](#quick-start), [Web SDK guide](./web/README.md), [Tutorials](./tutorials/)
3434
- Reference:
35-
[API Documentation](https://robotwebtools.github.io/rclnodejs/docs/index.html), [Using TypeScript](#using-rclnodejs-with-typescript), [ROS 2 Interface Message Generation](#ros-2-interface-message-generation)
36-
- Features and examples:
37-
[rosocket](#rosocket--browser--ros-2-bridge), [Observable Subscriptions](#observable-subscriptions), [Electron-based Visualization](#electron-based-visualization), [Performance Benchmarks](#performance-benchmarks), [rclnodejs-cli](#rclnodejs-cli)
35+
[API Documentation](https://robotwebtools.github.io/rclnodejs/docs/index.html), [ROS 2 Interface Message Generation](#ros-2-interface-message-generation), [Using TypeScript](#using-rclnodejs-with-typescript)
36+
- Features:
37+
[ROS 2 in the browser](#ros-2-in-the-browser), [Observable Subscriptions](#observable-subscriptions), [Electron-based Visualization](#electron-based-visualization)
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

@@ -73,8 +73,6 @@ npm install RobotWebTools/rclnodejs#<branch>
7373

7474
> **Docker:** For containerized development, see the included [Dockerfile](./Dockerfile) for building and testing with different ROS distributions and Node.js versions.
7575
76-
See the [features](./docs/FEATURES.md) and try the [examples](https://github.com/RobotWebTools/rclnodejs/tree/develop/example) to get started.
77-
7876
### Prebuilt Binaries
7977

8078
rclnodejs ships with prebuilt native binaries for common Linux configurations, so most installs skip compilation.
@@ -112,6 +110,62 @@ node example/topics/publisher/publisher-example.js
112110

113111
More runnable examples in [example/](https://github.com/RobotWebTools/rclnodejs/tree/develop/example) and step-by-step guides in [tutorials/](./tutorials/).
114112

113+
## ROS 2 in the browser
114+
115+
`rclnodejs` ships **two** ways to reach ROS 2 from the browser — pick one based on
116+
how much glue you want to write.
117+
118+
- **[`rclnodejs/web`](./web/README.md)****typed, allow-listed,
119+
curl-able** ROS 2 in the browser. A `web.json` file is your public API;
120+
the browser SDK types `call` / `publish` / `subscribe` end-to-end
121+
from your ROS 2 message types; and every capability
122+
is also a plain HTTP endpoint —
123+
`curl -X POST http://<host>/capability/call/<name>` — so shell
124+
scripts, Postman, and AI-agent tool-use just work.
125+
_New in `2.0.0-beta.0`._
126+
127+
```ts
128+
import { connect } from 'rclnodejs/web';
129+
const ros = await connect('ws://host:9000/capability');
130+
const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>(
131+
'/add_two_ints', { a: '2n', b: '40n' }
132+
); // reply.sum is typed as `${number}n`
133+
```
134+
135+
- **[`rosocket`](./rosocket/README.md)** — thin WebSocket gateway,
136+
zero browser dependencies (just built-in `WebSocket` + `JSON`).
137+
Best for quick prototypes and `roslibjs`-style apps.
138+
_New in `2.0.0-beta.0`._
139+
140+
```bash
141+
npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String
142+
```
143+
144+
## Observable Subscriptions
145+
146+
rclnodejs supports [RxJS](https://rxjs.dev/) Observable subscriptions for reactive programming with ROS 2 messages. Use operators like `throttleTime()`, `debounceTime()`, `map()`, and `combineLatest()` to build declarative message processing pipelines.
147+
148+
```javascript
149+
const { throttleTime, map } = require('rxjs');
150+
151+
const obsSub = node.createObservableSubscription(
152+
'sensor_msgs/msg/LaserScan',
153+
'/scan'
154+
);
155+
obsSub.observable
156+
.pipe(
157+
throttleTime(200),
158+
map((msg) => msg.ranges)
159+
)
160+
.subscribe((ranges) => console.log('Ranges:', ranges.length));
161+
```
162+
163+
See the [Observable Subscriptions Tutorial](./tutorials/observable-subscriptions.md) for more details.
164+
165+
## Electron-based Visualization
166+
167+
Build desktop ROS 2 apps with Electron + Three.js, packaged for Windows/macOS/Linux via **Electron Forge**. Featured demo: 🦾 **[manipulator](./demo/electron/manipulator)** — a two-joint arm with manual/automatic control. More in [demo/electron](./demo/electron/).
168+
115169
## ROS 2 Interface Message Generation
116170

117171
rclnodejs auto-generates JavaScript bindings and TypeScript declarations for every ROS 2 `.msg`, `.srv`, and `.action` interface available in your sourced ROS 2 environment. This happens during `npm install`, so in most projects you do not need to run anything by hand.
@@ -136,13 +190,7 @@ Generated files are written to `<your-project>/node_modules/rclnodejs/generated/
136190

137191
### IDL Message Generation
138192

139-
In addition to the standard ROS 2 message generation (`.msg`, `.srv`, `.action`), rclnodejs can also generate JavaScript message files directly from IDL (Interface Definition Language) files. This is useful for custom IDL files or when you need finer control over the generation process.
140-
141-
To generate messages from IDL files:
142-
143-
```bash
144-
npm run generate-messages-idl
145-
```
193+
For custom `.idl` files (Interface Definition Language), this repo also exposes `npm run generate-messages-idl`. See [docs/BUILDING.md](./docs/BUILDING.md) for when you'd need it.
146194

147195
## Using rclnodejs with TypeScript
148196

@@ -160,73 +208,10 @@ TypeScript declaration files are included in the package and exposed through the
160208

161209
Then `import * as rclnodejs from 'rclnodejs'` works the same as the JavaScript example at the top of this README. See [TypeScript demos](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/typescript) for more.
162210

163-
## rosocket — Browser ↔ ROS 2 bridge
164-
165-
> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`. _New in `2.0.0-beta.0`._
166-
167-
**rosocket** exposes ROS 2 topics/services as plain WebSocket URLs — a
168-
**lightweight** alternative to the rosbridge + roslibjs stack. Zero browser
169-
code, one Node.js process; browsers use only built-in `WebSocket` + `JSON`,
170-
no JavaScript library required.
171-
172-
```bash
173-
npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String
174-
```
175-
176-
```js
177-
const ws = new WebSocket('ws://host:9000/topic/chatter');
178-
ws.onmessage = (e) => console.log(JSON.parse(e.data).data);
179-
ws.onopen = () => ws.send(JSON.stringify({ data: 'hi' }));
180-
```
181-
182-
See [rosocket/README.md](./rosocket/README.md) for the URL scheme, service calls, and the programmatic `startRosocket()` API.
183-
184-
## Observable Subscriptions
185-
186-
rclnodejs supports [RxJS](https://rxjs.dev/) Observable subscriptions for reactive programming with ROS 2 messages. Use operators like `throttleTime()`, `debounceTime()`, `map()`, and `combineLatest()` to build declarative message processing pipelines.
187-
188-
```javascript
189-
const { throttleTime, map } = require('rxjs');
190-
191-
const obsSub = node.createObservableSubscription(
192-
'sensor_msgs/msg/LaserScan',
193-
'/scan'
194-
);
195-
obsSub.observable
196-
.pipe(
197-
throttleTime(200),
198-
map((msg) => msg.ranges)
199-
)
200-
.subscribe((ranges) => console.log('Ranges:', ranges.length));
201-
```
202-
203-
See the [Observable Subscriptions Tutorial](./tutorials/observable-subscriptions.md) for more details.
204-
205-
## Electron-based Visualization
206-
207-
Build interactive desktop ROS 2 apps with Electron + Three.js, packaged for Windows/macOS/Linux via **Electron Forge**. Featured demo: 🦾 **[manipulator](./demo/electron/manipulator)** — a two-joint arm with manual/automatic control.
208-
209-
<p align="left">
210-
<a href="./demo/electron/manipulator"><img src="./demo/electron/manipulator/manipulator-demo.png" alt="manipulator demo" width="320"></a>
211-
</p>
212-
213-
More in [demo/electron](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/electron).
214-
215-
## Performance Benchmarks
216-
217-
Benchmark results for 1000 iterations with 1024 KB messages (Ubuntu 24.04 WSL2, i7-1185G7):
218-
219-
| Library | Topic (ms) | Service (ms) |
220-
| ----------------------- | ---------: | -----------: |
221-
| **rclcpp** (C++) | 168 | 627 |
222-
| **rclnodejs** (Node.js) | 744 | 927 |
223-
| **rclpy** (Python) | 1,618 | 15,380 |
224-
225-
See [benchmark/README.md](./benchmark/README.md) for the full setup and methodology.
226-
227-
## rclnodejs-cli
211+
## More
228212

229-
[rclnodejs-cli](https://github.com/RobotWebTools/rclnodejs-cli/) is a companion project providing command-line tooling for scaffolding rclnodejs application skeletons and working with launch files for multi-node orchestration.
213+
- **Performance** — faster than `rclpy` and competitive with `rclcpp` for both topic and service round-trips. Full benchmarks in [benchmark/README.md](./benchmark/README.md).
214+
- **Companion CLI**[`rclnodejs-cli`](https://github.com/RobotWebTools/rclnodejs-cli/) scaffolds rclnodejs application skeletons and orchestrates launch files for multi-node setups.
230215

231216
## Contributing
232217

bin/rclnodejs-web.js

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -64,68 +64,96 @@ const argv = process.argv.slice(2);
6464
HttpTransport,
6565
} = require('../lib/runtime');
6666

67-
await rclnodejs.init();
68-
const node = rclnodejs.createNode(cfg.node);
69-
rclnodejs.spin(node);
70-
71-
// Always start the WebSocket transport. Add HTTP only when the user
72-
// configured an http.port (via --http-port or in the config file).
73-
const transports = [
74-
new WebSocketTransport({
75-
port: cfg.port,
76-
host: cfg.host,
77-
path: cfg.path,
78-
}),
79-
];
80-
const httpEnabled =
81-
cfg.http && cfg.http.port !== null && cfg.http.port !== undefined;
82-
if (httpEnabled) {
83-
transports.push(
84-
new HttpTransport({
85-
port: cfg.http.port,
86-
host: cfg.http.host || cfg.host,
87-
basePath: cfg.http.basePath || cfg.path,
88-
})
89-
);
90-
}
91-
const runtime = createRuntime({ node, transports });
92-
runtime.expose(cfg.expose);
93-
await runtime.start();
94-
95-
// After start(), each transport reports the actual bound port
96-
// (matters for `--port 0` / `--http-port 0` ephemeral modes).
97-
const wsTransport = runtime.transports[0];
98-
const httpTransport = httpEnabled ? runtime.transports[1] : null;
99-
100-
if (!parsed.quiet) {
101-
const displayHost = (h) =>
102-
['0.0.0.0', '::'].includes(h) ? 'localhost' : h;
103-
const list = runtime.registry.list();
104-
const totals =
105-
Object.keys(list.call).length +
106-
Object.keys(list.publish).length +
107-
Object.keys(list.subscribe).length;
108-
process.stdout.write(
109-
`rclnodejs/web listening on ws://${displayHost(cfg.host)}:${wsTransport.port}${cfg.path} (${totals} capabilities)\n`
110-
);
111-
if (httpTransport) {
112-
const httpHost = displayHost(cfg.http.host || cfg.host);
113-
const httpBase = cfg.http.basePath || cfg.path;
67+
// Track partial init so the catch block can clean up native handles before
68+
// process.exit() — without this, a startup failure (e.g. EADDRINUSE on the
69+
// WS port) leaves rclnodejs's native spin loop running and segfaults on exit.
70+
let rclInitialized = false;
71+
let runtime = null;
72+
73+
try {
74+
await rclnodejs.init();
75+
rclInitialized = true;
76+
77+
const node = rclnodejs.createNode(cfg.node);
78+
rclnodejs.spin(node);
79+
80+
// Always start the WebSocket transport. Add HTTP only when the user
81+
// configured an http.port (via --http-port or in the config file).
82+
const transports = [
83+
new WebSocketTransport({
84+
port: cfg.port,
85+
host: cfg.host,
86+
path: cfg.path,
87+
}),
88+
];
89+
const httpEnabled =
90+
cfg.http && cfg.http.port !== null && cfg.http.port !== undefined;
91+
if (httpEnabled) {
92+
transports.push(
93+
new HttpTransport({
94+
port: cfg.http.port,
95+
host: cfg.http.host || cfg.host,
96+
basePath: cfg.http.basePath || cfg.path,
97+
})
98+
);
99+
}
100+
runtime = createRuntime({ node, transports });
101+
runtime.expose(cfg.expose);
102+
await runtime.start();
103+
104+
// After start(), each transport reports the actual bound port
105+
// (matters for `--port 0` / `--http-port 0` ephemeral modes).
106+
const wsTransport = runtime.transports[0];
107+
const httpTransport = httpEnabled ? runtime.transports[1] : null;
108+
109+
if (!parsed.quiet) {
110+
const displayHost = (h) =>
111+
['0.0.0.0', '::'].includes(h) ? 'localhost' : h;
112+
const list = runtime.registry.list();
113+
const totals =
114+
Object.keys(list.call).length +
115+
Object.keys(list.publish).length +
116+
Object.keys(list.subscribe).length;
117+
const noun = totals === 1 ? 'capability' : 'capabilities';
114118
process.stdout.write(
115-
` also http://${httpHost}:${httpTransport.port}${httpBase} (call/publish only)\n`
119+
`rclnodejs/web listening on ws://${displayHost(cfg.host)}:${wsTransport.port}${cfg.path} (${totals} ${noun})\n`
116120
);
121+
if (httpTransport) {
122+
const httpHost = displayHost(cfg.http.host || cfg.host);
123+
const httpBase = cfg.http.basePath || cfg.path;
124+
process.stdout.write(
125+
` also http://${httpHost}:${httpTransport.port}${httpBase} (call/publish only)\n`
126+
);
127+
}
117128
}
118-
}
119129

120-
const stop = async () => {
121-
if (!parsed.quiet) process.stdout.write('\nstopping…\n');
122-
await runtime.stop();
123-
rclnodejs.shutdown();
124-
process.exit(0);
125-
};
126-
process.once('SIGINT', stop);
127-
process.once('SIGTERM', stop);
128-
})().catch((err) => fail(err));
130+
const stop = async () => {
131+
if (!parsed.quiet) process.stdout.write('\nstopping…\n');
132+
await runtime.stop();
133+
rclnodejs.shutdown();
134+
process.exit(0);
135+
};
136+
process.once('SIGINT', stop);
137+
process.once('SIGTERM', stop);
138+
} catch (err) {
139+
// Best-effort native cleanup so rclnodejs doesn't segfault on dirty exit.
140+
if (runtime) {
141+
try {
142+
await runtime.stop();
143+
} catch (_) {
144+
/* ignore — we're already failing */
145+
}
146+
}
147+
if (rclInitialized) {
148+
try {
149+
rclnodejs.shutdown();
150+
} catch (_) {
151+
/* ignore — we're already failing */
152+
}
153+
}
154+
fail(err);
155+
}
156+
})();
129157

130158
function fail(err) {
131159
if (err && err.cli) {

demo/electron/car/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
],
2020
"license": "Apache-2.0",
2121
"dependencies": {
22-
"rclnodejs": "^1.8.1"
22+
"rclnodejs": "^2.0.0"
2323
},
2424
"devDependencies": {
2525
"@electron-forge/cli": "^7.11.1",

demo/electron/manipulator/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
],
2323
"license": "Apache-2.0",
2424
"dependencies": {
25-
"rclnodejs": "^1.8.1",
25+
"rclnodejs": "^2.0.0",
2626
"three": "^0.182.0"
2727
},
2828
"devDependencies": {

demo/electron/topics/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
],
1717
"license": "Apache",
1818
"dependencies": {
19-
"rclnodejs": "^1.8.1"
19+
"rclnodejs": "^2.0.0"
2020
},
2121
"devDependencies": {
2222
"@electron-forge/cli": "^7.11.1",

demo/electron/turtle_tf2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
],
2222
"license": "Apache-2.0",
2323
"dependencies": {
24-
"rclnodejs": "^1.8.1",
24+
"rclnodejs": "^2.0.0",
2525
"three": "^0.155.0"
2626
},
2727
"devDependencies": {

demo/rosocket/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# rosocket demo (browser ↔ ROS 2)
1+
# rosocket demo ROS 2 in the browser
22

33
A minimal end-to-end example of the
4-
[`rosocket`](../../rosocket/README.md) WebSocket bridge. The Node
4+
[`rosocket`](../../rosocket/README.md) WebSocket gateway. The Node
55
server runs anywhere ROS 2 is sourced; the HTML page runs in any
66
modern browser and talks to it over plain `WebSocket` — no client
77
library required.
@@ -26,7 +26,7 @@ library required.
2626
# 1. Source your ROS 2 distro (humble / jazzy / kilted / lyrical / rolling)
2727
source /opt/ros/$ROS_DISTRO/setup.bash
2828

29-
# 2. Terminal A — start the WebSocket bridge
29+
# 2. Terminal A — start the WebSocket gateway
3030
node demo/rosocket/server.js
3131
# [rosocket-demo] listening on ws://localhost:9000 (bind=0.0.0.0)
3232

0 commit comments

Comments
 (0)