diff --git a/README.md b/README.md index b78f1a2b..09cf572f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **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. -**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. +**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. ```javascript const rclnodejs = require('rclnodejs'); @@ -30,11 +30,11 @@ This example assumes your ROS 2 environment is already sourced. ## Documentation - Get started: - [Installation](#installation), [Quick Start](#quick-start), [Tutorials](./tutorials/) + [Installation](#installation), [Quick Start](#quick-start), [Web SDK guide](./web/README.md), [Tutorials](./tutorials/) - Reference: - [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) -- Features and examples: - [rosocket](#rosocket--browser--ros-2-bridge), [Observable Subscriptions](#observable-subscriptions), [Electron-based Visualization](#electron-based-visualization), [Performance Benchmarks](#performance-benchmarks), [rclnodejs-cli](#rclnodejs-cli) + [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) +- Features: + [ROS 2 in the browser](#ros-2-in-the-browser), [Observable Subscriptions](#observable-subscriptions), [Electron-based Visualization](#electron-based-visualization) - Project docs: [Efficient Usage Tips](./docs/EFFICIENCY.md), [FAQ and Known Issues](./docs/FAQ.md), [Building from Scratch](./docs/BUILDING.md), [Contributing](./docs/CONTRIBUTING.md) @@ -73,8 +73,6 @@ npm install RobotWebTools/rclnodejs# > **Docker:** For containerized development, see the included [Dockerfile](./Dockerfile) for building and testing with different ROS distributions and Node.js versions. -See the [features](./docs/FEATURES.md) and try the [examples](https://github.com/RobotWebTools/rclnodejs/tree/develop/example) to get started. - ### Prebuilt Binaries 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 More runnable examples in [example/](https://github.com/RobotWebTools/rclnodejs/tree/develop/example) and step-by-step guides in [tutorials/](./tutorials/). +## ROS 2 in the browser + +`rclnodejs` ships **two** ways to reach ROS 2 from the browser — pick one based on +how much glue you want to write. + +- **[`rclnodejs/web`](./web/README.md)** — **typed, allow-listed, + curl-able** ROS 2 in the browser. A `web.json` file is your public API; + the browser SDK types `call` / `publish` / `subscribe` end-to-end + from your ROS 2 message types; and every capability + is also a plain HTTP endpoint — + `curl -X POST http:///capability/call/` — so shell + scripts, Postman, and AI-agent tool-use just work. + _New in `2.0.0-beta.0`._ + + ```ts + import { connect } from 'rclnodejs/web'; + const ros = await connect('ws://host:9000/capability'); + const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + '/add_two_ints', { a: '2n', b: '40n' } + ); // reply.sum is typed as `${number}n` + ``` + +- **[`rosocket`](./rosocket/README.md)** — thin WebSocket gateway, + zero browser dependencies (just built-in `WebSocket` + `JSON`). + Best for quick prototypes and `roslibjs`-style apps. + _New in `2.0.0-beta.0`._ + + ```bash + npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String + ``` + +## Observable Subscriptions + +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. + +```javascript +const { throttleTime, map } = require('rxjs'); + +const obsSub = node.createObservableSubscription( + 'sensor_msgs/msg/LaserScan', + '/scan' +); +obsSub.observable + .pipe( + throttleTime(200), + map((msg) => msg.ranges) + ) + .subscribe((ranges) => console.log('Ranges:', ranges.length)); +``` + +See the [Observable Subscriptions Tutorial](./tutorials/observable-subscriptions.md) for more details. + +## Electron-based Visualization + +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/). + ## ROS 2 Interface Message Generation 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 `/node_modules/rclnodejs/generated/ ### IDL Message Generation -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. - -To generate messages from IDL files: - -```bash -npm run generate-messages-idl -``` +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. ## Using rclnodejs with TypeScript @@ -160,73 +208,10 @@ TypeScript declaration files are included in the package and exposed through the 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. -## rosocket — Browser ↔ ROS 2 bridge - -> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`. _New in `2.0.0-beta.0`._ - -**rosocket** exposes ROS 2 topics/services as plain WebSocket URLs — a -**lightweight** alternative to the rosbridge + roslibjs stack. Zero browser -code, one Node.js process; browsers use only built-in `WebSocket` + `JSON`, -no JavaScript library required. - -```bash -npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String -``` - -```js -const ws = new WebSocket('ws://host:9000/topic/chatter'); -ws.onmessage = (e) => console.log(JSON.parse(e.data).data); -ws.onopen = () => ws.send(JSON.stringify({ data: 'hi' })); -``` - -See [rosocket/README.md](./rosocket/README.md) for the URL scheme, service calls, and the programmatic `startRosocket()` API. - -## Observable Subscriptions - -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. - -```javascript -const { throttleTime, map } = require('rxjs'); - -const obsSub = node.createObservableSubscription( - 'sensor_msgs/msg/LaserScan', - '/scan' -); -obsSub.observable - .pipe( - throttleTime(200), - map((msg) => msg.ranges) - ) - .subscribe((ranges) => console.log('Ranges:', ranges.length)); -``` - -See the [Observable Subscriptions Tutorial](./tutorials/observable-subscriptions.md) for more details. - -## Electron-based Visualization - -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. - -

- manipulator demo -

- -More in [demo/electron](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/electron). - -## Performance Benchmarks - -Benchmark results for 1000 iterations with 1024 KB messages (Ubuntu 24.04 WSL2, i7-1185G7): - -| Library | Topic (ms) | Service (ms) | -| ----------------------- | ---------: | -----------: | -| **rclcpp** (C++) | 168 | 627 | -| **rclnodejs** (Node.js) | 744 | 927 | -| **rclpy** (Python) | 1,618 | 15,380 | - -See [benchmark/README.md](./benchmark/README.md) for the full setup and methodology. - -## rclnodejs-cli +## More -[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. +- **Performance** — faster than `rclpy` and competitive with `rclcpp` for both topic and service round-trips. Full benchmarks in [benchmark/README.md](./benchmark/README.md). +- **Companion CLI** — [`rclnodejs-cli`](https://github.com/RobotWebTools/rclnodejs-cli/) scaffolds rclnodejs application skeletons and orchestrates launch files for multi-node setups. ## Contributing diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 17ff6561..b56da189 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -64,68 +64,96 @@ const argv = process.argv.slice(2); HttpTransport, } = require('../lib/runtime'); - await rclnodejs.init(); - const node = rclnodejs.createNode(cfg.node); - rclnodejs.spin(node); - - // Always start the WebSocket transport. Add HTTP only when the user - // configured an http.port (via --http-port or in the config file). - const transports = [ - new WebSocketTransport({ - port: cfg.port, - host: cfg.host, - path: cfg.path, - }), - ]; - const httpEnabled = - cfg.http && cfg.http.port !== null && cfg.http.port !== undefined; - if (httpEnabled) { - transports.push( - new HttpTransport({ - port: cfg.http.port, - host: cfg.http.host || cfg.host, - basePath: cfg.http.basePath || cfg.path, - }) - ); - } - const runtime = createRuntime({ node, transports }); - runtime.expose(cfg.expose); - await runtime.start(); - - // After start(), each transport reports the actual bound port - // (matters for `--port 0` / `--http-port 0` ephemeral modes). - const wsTransport = runtime.transports[0]; - const httpTransport = httpEnabled ? runtime.transports[1] : null; - - if (!parsed.quiet) { - const displayHost = (h) => - ['0.0.0.0', '::'].includes(h) ? 'localhost' : h; - const list = runtime.registry.list(); - const totals = - Object.keys(list.call).length + - Object.keys(list.publish).length + - Object.keys(list.subscribe).length; - process.stdout.write( - `rclnodejs/web listening on ws://${displayHost(cfg.host)}:${wsTransport.port}${cfg.path} (${totals} capabilities)\n` - ); - if (httpTransport) { - const httpHost = displayHost(cfg.http.host || cfg.host); - const httpBase = cfg.http.basePath || cfg.path; + // Track partial init so the catch block can clean up native handles before + // process.exit() — without this, a startup failure (e.g. EADDRINUSE on the + // WS port) leaves rclnodejs's native spin loop running and segfaults on exit. + let rclInitialized = false; + let runtime = null; + + try { + await rclnodejs.init(); + rclInitialized = true; + + const node = rclnodejs.createNode(cfg.node); + rclnodejs.spin(node); + + // Always start the WebSocket transport. Add HTTP only when the user + // configured an http.port (via --http-port or in the config file). + const transports = [ + new WebSocketTransport({ + port: cfg.port, + host: cfg.host, + path: cfg.path, + }), + ]; + const httpEnabled = + cfg.http && cfg.http.port !== null && cfg.http.port !== undefined; + if (httpEnabled) { + transports.push( + new HttpTransport({ + port: cfg.http.port, + host: cfg.http.host || cfg.host, + basePath: cfg.http.basePath || cfg.path, + }) + ); + } + runtime = createRuntime({ node, transports }); + runtime.expose(cfg.expose); + await runtime.start(); + + // After start(), each transport reports the actual bound port + // (matters for `--port 0` / `--http-port 0` ephemeral modes). + const wsTransport = runtime.transports[0]; + const httpTransport = httpEnabled ? runtime.transports[1] : null; + + if (!parsed.quiet) { + const displayHost = (h) => + ['0.0.0.0', '::'].includes(h) ? 'localhost' : h; + const list = runtime.registry.list(); + const totals = + Object.keys(list.call).length + + Object.keys(list.publish).length + + Object.keys(list.subscribe).length; + const noun = totals === 1 ? 'capability' : 'capabilities'; process.stdout.write( - ` also http://${httpHost}:${httpTransport.port}${httpBase} (call/publish only)\n` + `rclnodejs/web listening on ws://${displayHost(cfg.host)}:${wsTransport.port}${cfg.path} (${totals} ${noun})\n` ); + if (httpTransport) { + const httpHost = displayHost(cfg.http.host || cfg.host); + const httpBase = cfg.http.basePath || cfg.path; + process.stdout.write( + ` also http://${httpHost}:${httpTransport.port}${httpBase} (call/publish only)\n` + ); + } } - } - const stop = async () => { - if (!parsed.quiet) process.stdout.write('\nstopping…\n'); - await runtime.stop(); - rclnodejs.shutdown(); - process.exit(0); - }; - process.once('SIGINT', stop); - process.once('SIGTERM', stop); -})().catch((err) => fail(err)); + const stop = async () => { + if (!parsed.quiet) process.stdout.write('\nstopping…\n'); + await runtime.stop(); + rclnodejs.shutdown(); + process.exit(0); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + } catch (err) { + // Best-effort native cleanup so rclnodejs doesn't segfault on dirty exit. + if (runtime) { + try { + await runtime.stop(); + } catch (_) { + /* ignore — we're already failing */ + } + } + if (rclInitialized) { + try { + rclnodejs.shutdown(); + } catch (_) { + /* ignore — we're already failing */ + } + } + fail(err); + } +})(); function fail(err) { if (err && err.cli) { diff --git a/demo/rosocket/README.md b/demo/rosocket/README.md index 6e35b96a..4dffa0cd 100644 --- a/demo/rosocket/README.md +++ b/demo/rosocket/README.md @@ -1,7 +1,7 @@ -# rosocket demo (browser ↔ ROS 2) +# rosocket demo — ROS 2 in the browser A minimal end-to-end example of the -[`rosocket`](../../rosocket/README.md) WebSocket bridge. The Node +[`rosocket`](../../rosocket/README.md) WebSocket gateway. The Node server runs anywhere ROS 2 is sourced; the HTML page runs in any modern browser and talks to it over plain `WebSocket` — no client library required. @@ -26,7 +26,7 @@ library required. # 1. Source your ROS 2 distro (humble / jazzy / kilted / lyrical / rolling) source /opt/ros/$ROS_DISTRO/setup.bash -# 2. Terminal A — start the WebSocket bridge +# 2. Terminal A — start the WebSocket gateway node demo/rosocket/server.js # [rosocket-demo] listening on ws://localhost:9000 (bind=0.0.0.0) diff --git a/demo/web/javascript/README.md b/demo/web/javascript/README.md new file mode 100644 index 00000000..082ed171 --- /dev/null +++ b/demo/web/javascript/README.md @@ -0,0 +1,88 @@ +# Zero-build ROS 2 in a single HTML page + +A single static HTML page that talks to a real ROS 2 graph — just +` +``` + +The page also has a **transport toggle** (WebSocket vs. HTTP) so you +can flip the SDK between the two without restarting. + +## Same capability, no SDK + +Every `call` / `publish` is also reachable as plain HTTP — drive the +runtime from `curl`, Postman, or an AI agent without any JavaScript: + +```bash +curl -sS -X POST http://localhost:9001/capability/call/add_two_ints \ + -H 'content-type: application/json' \ + -d '{"a":"7n","b":"35n"}' +# => {"sum":"42n"} +``` + +Subscribe stays on WebSocket. + +## Without the bundled `runtime.js` + +`runtime.js` bundles the rclnodejs/web runtime and the demo's sample +ROS 2 nodes (the `/add_two_ints` service + the `/web_demo_tick` +publisher) into one process so the demo runs out of the box. In a +real project you already have those ROS 2 nodes running elsewhere, +so you only need the runtime. **Replace shell 1's `node runtime.js` +with the CLI** — shell 2 (`node static.js`) and the browser code are +unchanged: + +```bash +# shell 1 (instead of `node runtime.js`); the `-p rclnodejs` tells npx +# the `rclnodejs-web` binary lives inside the `rclnodejs` package: +npx -p rclnodejs rclnodejs-web web.json + +# the publisher / service the demo expects: +ros2 run demo_nodes_cpp add_two_ints_server +# (and a publisher of std_msgs/String on /web_demo_tick from any source) +``` diff --git a/demo/web/javascript/index.html b/demo/web/javascript/index.html new file mode 100644 index 00000000..f3865598 --- /dev/null +++ b/demo/web/javascript/index.html @@ -0,0 +1,459 @@ + + + + + + rclnodejs/web — zero-build ROS 2 in a single HTML page + + + +

+ Zero-build ROS 2 in a single HTML page + JavaScript +

+

+ A single static HTML page that talks to a real ROS 2 graph — one Node + process exposes a typed, capability-allow-listed gateway, the browser + drives it with three verbs. +

+

+ Endpoint: + ws://localhost:9000/capability +  connecting… +

+
+ Transport: + + +
+ Same registry, same allow-list — the SDK picks the transport from the + URL scheme. +
+
+ +

1. Service call — /add_two_ints

+
+
+
+ + + + + +
+
+
+
import { connect } from '/sdk/index.js';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+const reply = await ros.call('/add_two_ints', {
+  a: '2n', b: '40n', // int64 → "Nn" string on the wire
+});
+console.log(reply.sum); // '42n'
+
+ +

2. Topic subscription — /web_demo_tick

+
+
+
+ + +
+
+
+
import { connect } from '/sdk/index.js';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// The server publishes /web_demo_tick once a second.
+const sub = await ros.subscribe('/web_demo_tick', (msg) => {
+  console.log('recv:', msg.data);
+});
+
+// later — stop receiving:
+await sub.close();
+
+ +

3. Topic publish — /web_demo_chatter

+
+
+
+ + +
+
+
+
import { connect } from '/sdk/index.js';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// Subscribe so we can see the round-trip:
+await ros.subscribe('/web_demo_chatter', (m) =>
+  console.log('recv:', m.data),
+);
+
+await ros.publish('/web_demo_chatter', { data: 'hello' });
+
+ +

4. Capability allow-list in action

+
+
+
+ +
+
+
+
import { connect } from '/sdk/index.js';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// /dangerous is not in the server's allow-list — the runtime
+// rejects the call before it reaches ROS 2.
+try {
+  await ros.call('/dangerous', {});
+} catch (e) {
+  console.log(e.code); // 'not_exposed'
+}
+
+ +

5. Same capability, no SDK — just curl

+

+ The HTTP transport is what makes rclnodejs/web + actually web-native: every call and + publish in your allow-list is reachable from any HTTP client + — curl, Postman, an AI agent… no JavaScript required. Subscribe stays on + WebSocket. +

+
# service call — returns 200 + JSON
+curl -sS -X POST http://localhost:9001/capability/call/add_two_ints \
+  -H 'content-type: application/json' \
+  -d '{"a":"7n","b":"35n"}'
+# => {"sum":"42n"}
+
+# publish — returns 204 No Content on success
+curl -sS -X POST http://localhost:9001/capability/publish/web_demo_chatter \
+  -H 'content-type: application/json' \
+  -d '{"data":"hi from curl"}'
+
+# allow-list rejection — returns 404 + structured error body
+curl -sS -X POST http://localhost:9001/capability/call/dangerous \
+  -H 'content-type: application/json' -d '{}'
+# => {"ok":false,"error":"capability not exposed: call /dangerous","code":"not_exposed"}
+ + + + diff --git a/demo/web/javascript/runtime.js b/demo/web/javascript/runtime.js new file mode 100644 index 00000000..20608007 --- /dev/null +++ b/demo/web/javascript/runtime.js @@ -0,0 +1,155 @@ +// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// rclnodejs/web demo — runtime side (rclnodejs/web runtime + the demo's +// ROS 2 nodes; named `runtime.js` to avoid being confused with the +// page-side `static.js`). +// +// 1. Source ROS 2 (`source /opt/ros//setup.bash`) +// 2. From this folder run `node runtime.js` +// 3. In another shell run `node static.js` to host +// `index.html` on http://localhost:8080/ — same split as the +// TypeScript demo's `tsx server.ts` + `vite`. + +'use strict'; + +const rclnodejs = require('../../../index.js'); +// In a downstream project this is the public, supported import: +// const { createRuntime, WebSocketTransport, HttpTransport } = +// require('rclnodejs/web/server'); +// Inside this in-repo demo we use the relative path so the file runs +// straight out of a fresh git clone, no `npm install` required. +const { + createRuntime, + WebSocketTransport, + HttpTransport, +} = require('../../../lib/runtime'); + +const RUNTIME_PORT = Number(process.env.RUNTIME_PORT || 9000); +const HTTP_PORT = Number(process.env.HTTP_PORT || 9001); + +function displayHost(host) { + return host === '0.0.0.0' || host === '::' ? 'localhost' : host; +} + +// Render the registry as a small human-readable table: +// call /add_two_ints example_interfaces/srv/AddTwoInts +// publish /web_demo_chatter std_msgs/msg/String +// subscribe /web_demo_tick std_msgs/msg/String +function formatCapabilities(caps) { + const rows = []; + for (const verb of ['call', 'publish', 'subscribe']) { + for (const [topic, type] of Object.entries(caps[verb] || {})) { + rows.push([verb, topic, type]); + } + } + if (rows.length === 0) return ' (none)'; + const w0 = Math.max(...rows.map((r) => r[0].length)); + const w1 = Math.max(...rows.map((r) => r[1].length)); + return rows + .map(([v, t, ty]) => ` ${v.padEnd(w0)} ${t.padEnd(w1)} ${ty}`) + .join('\n'); +} + +async function main() { + // ---- Layer 1: rclnodejs core ---------------------------------------- + await rclnodejs.init(); + const node = rclnodejs.createNode('rclnodejs_web_demo_node'); + + // A real ROS 2 service the browser can call. + node.createService( + 'example_interfaces/srv/AddTwoInts', + '/add_two_ints', + (request, response) => { + const reply = response.template; + reply.sum = request.a + request.b; + response.send(reply); + } + ); + + // A real ROS 2 publisher producing a tick once a second so the + // browser's subscribe() has something to receive without the user + // having to publish first. + const tickPub = node.createPublisher('std_msgs/msg/String', '/web_demo_tick'); + let counter = 0; + setInterval(() => { + tickPub.publish({ + data: `tick ${counter++} @ ${new Date().toISOString()}`, + }); + }, 1000); + + rclnodejs.spin(node); + + // ---- Layer 2 + 3: capability runtime over WebSocket *and* HTTP ------- + // The same dispatcher / registry serves both transports — the L2 seam + // is what proves the runtime is transport-agnostic. Browser SDK picks + // a transport from the URL scheme; curl / Postman / AI agents use the + // HTTP one directly. + const runtime = createRuntime({ + node, + transports: [ + new WebSocketTransport({ + port: RUNTIME_PORT, + // '::' = dual-stack: accepts both IPv6 and IPv4-mapped + // connections. Matches Node's http server default and avoids + // the WSL2 / glibc "localhost → ::1" mismatch where + // 0.0.0.0-only servers appear unreachable from the browser. + host: '::', + }), + new HttpTransport({ + port: HTTP_PORT, + host: '::', + }), + ], + }); + runtime.expose({ + call: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' }, + publish: { '/web_demo_chatter': 'std_msgs/msg/String' }, + subscribe: { + '/web_demo_tick': 'std_msgs/msg/String', + '/web_demo_chatter': 'std_msgs/msg/String', + }, + }); + await runtime.start(); + + const caps = runtime.registry.list(); + const total = + Object.keys(caps.call || {}).length + + Object.keys(caps.publish || {}).length + + Object.keys(caps.subscribe || {}).length; + + console.log('rclnodejs/web demo running (JavaScript)'); + console.log( + ` WebSocket : ws://${displayHost('::')}:${RUNTIME_PORT}/capability` + ); + console.log( + ` HTTP : http://${displayHost('::')}:${HTTP_PORT}/capability (call / publish, curl-able)` + ); + console.log(); + console.log(`Exposed capabilities (${total}):`); + console.log(formatCapabilities(caps)); + console.log(); + console.log( + 'Static page: run `node static.js` in another shell, then open http://localhost:8080/' + ); + + // ---- Graceful shutdown ---------------------------------------------- + const stop = async () => { + console.log('\nstopping…'); + await runtime.stop(); + rclnodejs.shutdown(); + process.exit(0); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/demo/web/javascript/static.js b/demo/web/javascript/static.js new file mode 100644 index 00000000..7bd92c0f --- /dev/null +++ b/demo/web/javascript/static.js @@ -0,0 +1,89 @@ +// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// rclnodejs/web demo — static-file server (page side; named `static.js` +// to avoid being confused with the runtime-side `runtime.js`). +// +// Serves index.html on port 8080 and maps `/sdk/*` to the in-repo +// `web/` folder so the page can `import { connect } from '/sdk/index.js'` +// without bundling. In a downstream project you'd `npm install rclnodejs` +// and `import { connect } from 'rclnodejs/web'` instead. +// +// Pair with `node runtime.js` (the rclnodejs/web runtime + the demo's +// ROS 2 nodes) in another shell — the same split as the TypeScript +// demo's `tsx server.ts` + `vite`. Production deployments use nginx, +// a CDN, or any other static host. + +'use strict'; + +const path = require('path'); +const http = require('http'); +const fs = require('fs'); + +const STATIC_PORT = Number(process.env.STATIC_PORT || 8080); + +const demoDir = __dirname; +const sdkDir = path.resolve(__dirname, '..', '..', '..', 'web'); + +const server = http.createServer((req, res) => { + let urlPath = (req.url || '/').split('?')[0]; + if (urlPath === '/') urlPath = '/index.html'; + + let baseDir; + let relPath; + if (urlPath.startsWith('/sdk/')) { + baseDir = sdkDir; + relPath = urlPath.slice('/sdk/'.length); + } else { + baseDir = demoDir; + relPath = urlPath.replace(/^\/+/, ''); + } + const filePath = path.resolve(baseDir, relPath); + // Confine reads to baseDir. `path.relative` collapses `..` segments, + // so any escape attempt either yields a result that starts with `..` + // or is absolute — reject both. (`startsWith(baseDir)` alone would + // false-positive on a sibling like `${baseDir}-other/...`.) + const rel = path.relative(baseDir, filePath); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + res.writeHead(403).end('forbidden'); + return; + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404).end('not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const ctype = + { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + }[ext] || 'application/octet-stream'; + res.writeHead(200, { 'content-type': ctype }).end(data); + }); +}); + +server.on('error', (err) => { + console.error(err); + process.exit(1); +}); + +server.listen(STATIC_PORT, () => { + console.log(`Static files : http://localhost:${STATIC_PORT}/`); +}); + +const stop = () => { + console.log('\nstopping…'); + server.close(); + process.exit(0); +}; +process.once('SIGINT', stop); +process.once('SIGTERM', stop); diff --git a/demo/web/javascript/web.json b/demo/web/javascript/web.json new file mode 100644 index 00000000..13bccf5c --- /dev/null +++ b/demo/web/javascript/web.json @@ -0,0 +1,22 @@ +{ + "$comment": "Sample rclnodejs/web config consumed by `npx rclnodejs-web web.json`. Every key is optional; the defaults shown match the CLI defaults.", + "port": 9000, + "path": "/capability", + "node": "rclnodejs_web_demo", + "http": { + "$comment": "HTTP transport for `call` and `publish`. curl-able, AI-agent friendly. Subscribe still uses WebSocket.", + "port": 9001 + }, + "expose": { + "call": { + "/add_two_ints": "example_interfaces/srv/AddTwoInts" + }, + "publish": { + "/web_demo_chatter": "std_msgs/msg/String" + }, + "subscribe": { + "/web_demo_tick": "std_msgs/msg/String", + "/web_demo_chatter": "std_msgs/msg/String" + } + } +} diff --git a/demo/web/typescript/README.md b/demo/web/typescript/README.md new file mode 100644 index 00000000..a10ae538 --- /dev/null +++ b/demo/web/typescript/README.md @@ -0,0 +1,68 @@ +# End-to-end typed ROS 2 in a TypeScript app + +A **TypeScript** browser entry built with **Vite**, driven by the typed +SDK so request, reply, and message shapes are visible in your IDE. + +```ts +const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + '/add_two_ints', { a: '2n', b: '40n' } +); +// reply.sum is typed as `${number}n` — no hand-written types, no codegen. +``` + +## Run it (two shells) + +```bash +cd demo/web/typescript +npm install +``` + +**Shell 1 — runtime:** + +```bash +source /opt/ros//setup.bash +npm run server +# rclnodejs/web : ws://localhost:9000/capability +# also http://localhost:9001/capability +``` + +`server.ts` runs the runtime *and* a tiny `/add_two_ints` service + +1 Hz `/web_demo_tick` publisher so every panel has live data. + +**Shell 2 — Vite dev server:** + +```bash +npm run dev +# ➜ Local: http://localhost:8080/ +``` + +## Without the bundled `server.ts` + +`npm run server` is a convenience for this demo — it bundles the +runtime **and** a tiny `/add_two_ints` service + `/web_demo_tick` +publisher into one process so the demo works out of the box. + +In a real project you already have those ROS 2 nodes running +elsewhere, so you only need the runtime. **Replace shell 1's +`npm run server` with the CLI** — shell 2 (`npm run dev`) and +`src/main.ts` are unchanged: + +```bash +# shell 1 (instead of `npm run server`) +npx rclnodejs-web web.json + +# the publisher / service the demo expects: +ros2 run demo_nodes_cpp add_two_ints_server +# (and a publisher of std_msgs/String on /web_demo_tick from any source) +``` + +The browser doesn't know or care which option is running — it only +sees `ws://localhost:9000/capability` either way. + +## Other npm scripts + +| Command | What it does | +| ------------------- | ---------------------------------------------------- | +| `npm run typecheck` | `tsc --noEmit` — silent on success | +| `npm run build` | static bundle in `dist/` (~6 kB JS, ~1.5 kB CSS gz) | +| `npm run preview` | serve the built `dist/` | diff --git a/demo/web/typescript/index.html b/demo/web/typescript/index.html new file mode 100644 index 00000000..0f4947dc --- /dev/null +++ b/demo/web/typescript/index.html @@ -0,0 +1,167 @@ + + + + + + rclnodejs/web — end-to-end typed ROS 2 in a TypeScript app + + + +

+ End-to-end typed ROS 2 in a TypeScript app + TypeScript +

+

+ Same five panels as the JavaScript demo, but the browser entry is written + in TypeScript with Vite — pass one ROS type-name string + per call and the SDK derives the request, response, and message shapes + from rclnodejs's generated types. +

+

+ Endpoint: + ws://localhost:9000/capability +  connecting… +

+
+ Transport: + + +
+ Same registry, same allow-list, same typed SDK — only the URL scheme + changes. +
+
+ +

1. Service call — /add_two_ints

+
+
+
+ + + + + +
+
+
+
import { connect } from 'rclnodejs/web';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// One string generic — the ROS service type name. Request
+// and response shapes are derived from the generated types.
+const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>(
+  '/add_two_ints',
+  { a: '2n', b: '40n' }, // int64 → "Nn" string on the wire
+);
+console.log(reply.sum); // '42n'  (typed as `${number}n`)
+
+ +

2. Topic subscription — /web_demo_tick

+
+
+
+ + +
+
+
+
import { connect } from 'rclnodejs/web';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// Generic = ROS message type name. msg is auto-typed.
+const sub = await ros.subscribe<'std_msgs/msg/String'>(
+  '/web_demo_tick',
+  (msg) => console.log(msg.data), // msg.data: string
+);
+
+// later — stop receiving:
+await sub.close();
+
+ +

3. Topic publish — /web_demo_chatter

+
+
+
+ + +
+
+
+
import { connect } from 'rclnodejs/web';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// Generic = ROS message type name. Payload is auto-typed.
+await ros.subscribe<'std_msgs/msg/String'>(
+  '/web_demo_chatter',
+  (msg) => console.log('recv:', msg.data),
+);
+await ros.publish<'std_msgs/msg/String'>(
+  '/web_demo_chatter',
+  { data: 'hello from TS' },
+);
+
+ +

4. Capability allow-list in action

+
+
+
+ +
+
+
+
import { connect } from 'rclnodejs/web';
+
+const ros = await connect('ws://localhost:9000/capability');
+
+// `/dangerous` is not in the server's expose() allow-list.
+try {
+  await ros.call('/dangerous', {});
+} catch (e) {
+  // Rejected by the runtime before any ROS 2 Node API runs.
+  console.log((e as { code?: string }).code); // 'not_exposed'
+}
+
+ +

5. Same capability, no SDK — just curl

+

+ Every call / publish in the allow-list is also + reachable as plain HTTP. AI agents and CLI scripts can hit the runtime + without a JavaScript stack. +

+
# service call — returns 200 + JSON
+curl -sS -X POST http://localhost:9001/capability/call/add_two_ints \
+  -H 'content-type: application/json' \
+  -d '{"a":"7n","b":"35n"}'
+# => {"sum":"42n"}
+
+# publish — returns 204 No Content
+curl -sS -X POST http://localhost:9001/capability/publish/web_demo_chatter \
+  -H 'content-type: application/json' \
+  -d '{"data":"hi from curl"}'
+ + + + diff --git a/demo/web/typescript/package.json b/demo/web/typescript/package.json new file mode 100644 index 00000000..4b94d3b8 --- /dev/null +++ b/demo/web/typescript/package.json @@ -0,0 +1,23 @@ +{ + "name": "rclnodejs-web-ts-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "TypeScript-first demo of rclnodejs/web with Vite.", + "scripts": { + "server": "NODE_OPTIONS=--no-deprecation tsx server.ts", + "rclnodejs-web": "rclnodejs-web web.json", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "rclnodejs": "github:minggangw/rclnodejs-1#feat/web-tutorial-and-demos" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/demo/web/typescript/server.ts b/demo/web/typescript/server.ts new file mode 100644 index 00000000..462aa890 --- /dev/null +++ b/demo/web/typescript/server.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// TypeScript demo server. Run with `npm run server` (which uses tsx) or +// `npx tsx server.ts`. Behaviour matches demo/web/javascript/runtime.js +// — same runtime + same demo nodes — except this side is written in +// TypeScript so the typed SDK story is visible end to end. The static +// page server is Vite (`npm run dev`), parallel to the JS demo's +// separate `node static.js`. + +// rclnodejs is a CommonJS module without first-class ESM types; using +// require keeps the server independent of how a downstream project +// configures TypeScript module resolution. +// eslint-disable-next-line @typescript-eslint/no-var-requires +import { createRequire } from 'node:module'; +const require_ = createRequire(import.meta.url); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rclnodejs: any = require_('rclnodejs'); +const { createRuntime, WebSocketTransport, HttpTransport } = require_( + 'rclnodejs/web/server' +); + +const RUNTIME_PORT = Number(process.env.RUNTIME_PORT || 9000); +const HTTP_PORT = Number(process.env.HTTP_PORT || 9001); + +// Render the registry as a small human-readable table — see the matching +// helper in demo/web/javascript/runtime.js. +function formatCapabilities( + caps: Record<'call' | 'publish' | 'subscribe', Record> +): string { + const rows: Array<[string, string, string]> = []; + for (const verb of ['call', 'publish', 'subscribe'] as const) { + for (const [topic, type] of Object.entries(caps[verb] || {})) { + rows.push([verb, topic, type]); + } + } + if (rows.length === 0) return ' (none)'; + const w0 = Math.max(...rows.map((r) => r[0].length)); + const w1 = Math.max(...rows.map((r) => r[1].length)); + return rows + .map(([v, t, ty]) => ` ${v.padEnd(w0)} ${t.padEnd(w1)} ${ty}`) + .join('\n'); +} + +async function main(): Promise { + await rclnodejs.init(); + const node = rclnodejs.createNode('rclnodejs_web_ts_demo_node'); + + // Service the browser will call. + node.createService( + 'example_interfaces/srv/AddTwoInts', + '/add_two_ints', + ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: any + ): void => { + const reply = response.template; + reply.sum = request.a + request.b; + response.send(reply); + } + ); + + // 1 Hz tick publisher so the browser's subscribe() shows live data + // without the user having to publish first. + const tickPub = node.createPublisher('std_msgs/msg/String', '/web_demo_tick'); + let counter = 0; + setInterval(() => { + tickPub.publish({ + data: `tick ${counter++} @ ${new Date().toISOString()}`, + }); + }, 1000); + + rclnodejs.spin(node); + + const runtime = createRuntime({ + node, + transports: [ + new WebSocketTransport({ + port: RUNTIME_PORT, + // Dual-stack — see the matching note in the JS demo. + host: '::', + }), + // HTTP for `call` / `publish` (curl, Postman, AI agents). + // Same registry / dispatcher — the L2 seam in action. + new HttpTransport({ + port: HTTP_PORT, + host: '::', + }), + ], + }); + + runtime.expose({ + call: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' }, + publish: { '/web_demo_chatter': 'std_msgs/msg/String' }, + subscribe: { + '/web_demo_tick': 'std_msgs/msg/String', + '/web_demo_chatter': 'std_msgs/msg/String', + }, + }); + await runtime.start(); + + const caps = runtime.registry.list(); + const total = + Object.keys(caps.call || {}).length + + Object.keys(caps.publish || {}).length + + Object.keys(caps.subscribe || {}).length; + + console.log('rclnodejs/web demo running (TypeScript)'); + console.log(` WebSocket : ws://localhost:${RUNTIME_PORT}/capability`); + console.log( + ` HTTP : http://localhost:${HTTP_PORT}/capability (call / publish, curl-able)` + ); + console.log(); + console.log(`Exposed capabilities (${total}):`); + console.log(formatCapabilities(caps)); + console.log(); + console.log( + 'Static page: run `npm run dev` in another shell, then open http://localhost:8080/' + ); + + const stop = async (): Promise => { + console.log('\nstopping…'); + await runtime.stop(); + rclnodejs.shutdown(); + process.exit(0); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +} + +main().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/demo/web/typescript/src/main.ts b/demo/web/typescript/src/main.ts new file mode 100644 index 00000000..ccf8b8f2 --- /dev/null +++ b/demo/web/typescript/src/main.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Browser entry. Demonstrates the rclnodejs Web Runtime browser SDK +// with **zero glue** — no hand-written message shapes, no shared +// types module. Every type comes from rclnodejs's auto-generated +// MessagesMap / ServicesMap, looked up by the ROS interface name +// passed as a single string generic at the call site. + +import { connect, type RosClient, type Subscription } from 'rclnodejs/web'; +import './style.css'; + +type Mode = 'ws' | 'http'; + +const HOST = location.hostname || 'localhost'; +const ENDPOINTS: Record = { + ws: `ws://${HOST}:9000/capability`, + // HTTP base for call/publish. + http: `http://${HOST}:9001`, +}; +// Pass Form C ({http, ws}) when the user picks HTTP so subscribe still +// reaches the WS runtime on :9000. The SDK's auto-derived sibling would +// land on :9001 instead and fail — this dev layout splits the two +// transports across separate ports. +function connectArg(mode: Mode): string | { http: string; ws: string } { + return mode === 'http' + ? { http: ENDPOINTS.http, ws: ENDPOINTS.ws } + : ENDPOINTS.ws; +} + +function $(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`missing element #${id}`); + return el as T; +} + +function setStatus(text: string, cls: 'ok' | 'err' | '' = ''): void { + const el = $('status'); + el.textContent = text; + el.className = `status ${cls}`; +} + +function setEndpoint(mode: Mode): void { + $('endpoint').textContent = + mode === 'http' + ? `${ENDPOINTS.http} (subscribe routed to ${ENDPOINTS.ws})` + : ENDPOINTS.ws; +} + +function log(id: string, text: string, cls: 'ok' | 'err' | '' = ''): void { + const el = $(id); + const line = document.createElement('div'); + line.textContent = `${new Date().toLocaleTimeString()} ${text}`; + if (cls) line.className = cls; + el.appendChild(line); + el.scrollTop = el.scrollHeight; +} + +async function main(): Promise { + let ros: RosClient | undefined; + let tickSub: Subscription | undefined; + + async function teardown(): Promise { + if (tickSub) { + try { + await tickSub.close(); + } catch { + /* noop */ + } + tickSub = undefined; + $('subBtn').disabled = false; + $('unsubBtn').disabled = true; + } + if (ros) { + try { + await ros.close(); + } catch { + /* noop */ + } + ros = undefined; + } + } + + async function reconnect(mode: Mode): Promise { + await teardown(); + setEndpoint(mode); + setStatus(`connecting (${mode})…`); + try { + ros = await connect(connectArg(mode)); + setStatus(`connected (${mode})`, 'ok'); + } catch (e) { + setStatus(`failed: ${String(e)}`, 'err'); + return; + } + + // Always-on chatter subscription; subscribe always uses WS — the + // explicit { ws } in connectArg() makes this work in HTTP mode too. + try { + await ros.subscribe<'std_msgs/msg/String'>('/web_demo_chatter', (msg) => + log('chatLog', `<- ${msg.data}`) + ); + } catch (e) { + const err = e as { message?: string; code?: string }; + log('chatLog', `subscribe failed: ${err.message} (${err.code})`, 'err'); + } + } + + // Wire button handlers once. They use the live `ros` ref so they + // work across reconnect()s. + + // 1. Service call. + $('callBtn').onclick = async (): Promise => { + if (!ros) return; + const a = Number($('addA').value); + const b = Number($('addB').value); + try { + const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + '/add_two_ints', + { a: `${a}n`, b: `${b}n` } + ); + log('callLog', `${a} + ${b} = ${reply.sum}`, 'ok'); + } catch (e) { + const err = e as { message?: string; code?: string }; + log('callLog', `error: ${err.message} (${err.code})`, 'err'); + } + }; + + // 2. Subscription. + const subBtn = $('subBtn'); + const unsubBtn = $('unsubBtn'); + subBtn.onclick = async (): Promise => { + if (!ros) return; + try { + tickSub = await ros.subscribe<'std_msgs/msg/String'>( + '/web_demo_tick', + (msg) => log('tickLog', msg.data) + ); + subBtn.disabled = true; + unsubBtn.disabled = false; + log('tickLog', `subscribed (subId=${tickSub.subId})`, 'ok'); + } catch (e) { + const err = e as { message?: string; code?: string }; + log('tickLog', `error: ${err.message} (${err.code})`, 'err'); + } + }; + unsubBtn.onclick = async (): Promise => { + if (!tickSub) return; + await tickSub.close(); + tickSub = undefined; + unsubBtn.disabled = true; + subBtn.disabled = false; + log('tickLog', 'unsubscribed', 'ok'); + }; + + // 3. Topic publish (chatter is subscribed in reconnect()). + $('pubBtn').onclick = async (): Promise => { + if (!ros) return; + const data = $('chatMsg').value; + try { + await ros.publish<'std_msgs/msg/String'>('/web_demo_chatter', { data }); + log('chatLog', `-> ${data}`, 'ok'); + } catch (e) { + const err = e as { message?: string; code?: string }; + log('chatLog', `error: ${err.message} (${err.code})`, 'err'); + } + }; + + // 4. Allow-list rejection (untyped fallback overload). + $('badCallBtn').onclick = async (): Promise => { + if (!ros) return; + try { + await ros.call('/dangerous', {}); + log( + 'badLog', + 'unexpected success — registry should have rejected', + 'err' + ); + } catch (e) { + const err = e as { message?: string; code?: string }; + log('badLog', `rejected: ${err.message} (${err.code})`, 'ok'); + } + }; + + // Transport toggle. + for (const radio of document.querySelectorAll( + 'input[name="transport"]' + )) { + radio.addEventListener('change', (e) => + reconnect((e.target as HTMLInputElement).value as Mode) + ); + } + + await reconnect('ws'); +} + +main().catch((err: unknown) => { + console.error(err); + setStatus(`fatal: ${String(err)}`, 'err'); +}); diff --git a/demo/web/typescript/src/style.css b/demo/web/typescript/src/style.css new file mode 100644 index 00000000..9a1b07f7 --- /dev/null +++ b/demo/web/typescript/src/style.css @@ -0,0 +1,131 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} +body { + max-width: 880px; + margin: 2em auto; + padding: 0 1em; +} +h1 { + margin-bottom: 0; +} +h2 { + margin-top: 2em; +} +.endpoint { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; +} +input, +button { + font-size: 1em; + padding: 0.3em 0.5em; +} +input[type='text'], +input[type='number'] { + width: 8em; +} +.panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; + align-items: start; +} +.controls { + padding: 0.25em 0; +} +.controls .row { + margin-bottom: 0.5em; +} +.log { + background: #1e1e1e; + color: #d4d4d4; + padding: 0.6em; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + max-height: 12em; + overflow: auto; + white-space: pre-wrap; +} +/* Don't render the dark log box until something is logged. */ +.log:empty { + display: none; +} +.log .ok { + color: #6ec06e; +} +.log .err { + color: #f08080; +} +pre.code { + margin: 0; + background: #1e1e1e; + color: #e6e6e6; + padding: 0.75em; + border-radius: 4px; + font-size: 0.85em; + line-height: 1.4; + overflow: auto; +} +.code .kw { + color: #569cd6; +} +.code .ty { + color: #4ec9b0; +} +.code .str { + color: #ce9178; +} +.code .com { + color: #6a9955; + font-style: italic; +} +.status { + display: inline-block; + font-size: 0.85em; + padding: 0.15em 0.6em; + border-radius: 999px; + background: #fde68a; + color: #92400e; +} +.status.ok { + background: #bbf7d0; + color: #166534; +} +.status.err { + background: #fecaca; + color: #991b1b; +} +.badge { + display: inline-block; + font-size: 0.7em; + padding: 0.1em 0.4em; + border-radius: 4px; + background: #3178c6; + color: white; + vertical-align: middle; + margin-left: 0.5em; +} +.transport { + margin: 1em 0 0.5em; + padding: 0.6em 0.8em; + background: #f5f5f4; + border-radius: 4px; + font-size: 0.9em; +} +.transport label { + margin-right: 1em; + cursor: pointer; +} +@media (prefers-color-scheme: dark) { + .transport { + background: #2a2a2a; + } +} +@media (max-width: 720px) { + .panel { + grid-template-columns: 1fr; + } +} diff --git a/demo/web/typescript/tsconfig.json b/demo/web/typescript/tsconfig.json new file mode 100644 index 00000000..b5ee8951 --- /dev/null +++ b/demo/web/typescript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "server.ts", "vite.config.ts"] +} diff --git a/demo/web/typescript/vite.config.ts b/demo/web/typescript/vite.config.ts new file mode 100644 index 00000000..8f3a1bff --- /dev/null +++ b/demo/web/typescript/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite'; + +// Vite config for the rclnodejs Web Runtime TypeScript demo. +// +// `rclnodejs/web` is the public ESM entry exported from the +// `rclnodejs` npm package (see its package.json `exports` field), so +// Vite resolves it through `node_modules` with no alias required. +// +// The browser SDK uses a top-level `await import('ws')` to optionally +// pull in a Node WebSocket polyfill (no-op in real browsers). That +// requires an ES2022 build target both for the production bundle +// (`build.target`) and for esbuild's dev-mode dependency pre-bundling +// (`optimizeDeps.esbuildOptions.target`). +export default defineConfig({ + server: { + // 8080 to match the JavaScript demo's static-file server, instead of + // Vite's default 5173 — keeps the two demos' README instructions in sync. + port: 8080, + }, + esbuild: { + target: 'es2022', + }, + optimizeDeps: { + esbuildOptions: { + target: 'es2022', + }, + }, + build: { + target: 'es2022', + sourcemap: true, + }, +}); diff --git a/demo/web/typescript/web.json b/demo/web/typescript/web.json new file mode 100644 index 00000000..e418e06d --- /dev/null +++ b/demo/web/typescript/web.json @@ -0,0 +1,22 @@ +{ + "$comment": "Sample rclnodejs/web config consumed by `npx rclnodejs-web web.json`. Every key is optional; the defaults shown match the CLI defaults.", + "port": 9000, + "path": "/capability", + "node": "rclnodejs_web_ts_demo", + "http": { + "$comment": "HTTP transport for `call` and `publish`. curl-able, AI-agent friendly. Subscribe still uses WebSocket.", + "port": 9001 + }, + "expose": { + "call": { + "/add_two_ints": "example_interfaces/srv/AddTwoInts" + }, + "publish": { + "/web_demo_chatter": "std_msgs/msg/String" + }, + "subscribe": { + "/web_demo_tick": "std_msgs/msg/String", + "/web_demo_chatter": "std_msgs/msg/String" + } + } +} diff --git a/lib/message_serialization.js b/lib/message_serialization.js index c8e20ea8..56c4d1a2 100644 --- a/lib/message_serialization.js +++ b/lib/message_serialization.js @@ -174,7 +174,7 @@ function isValidSerializationMode(mode) { * `toJSONSafe` encodes `bigint` as the string `"n"` so values survive * `JSON.stringify`. `reviveBigInts` walks an arbitrary JSON value and * converts any such string back into a real `bigint`. Everything else - * passes through unchanged. Used by the rosocket bridge and the Web + * passes through unchanged. Used by the rosocket gateway and the Web * Runtime dispatcher to rehydrate inbound JSON before handing it to * rclnodejs. * diff --git a/lib/runtime/dispatcher.js b/lib/runtime/dispatcher.js index 86ccd5e2..f637777c 100644 --- a/lib/runtime/dispatcher.js +++ b/lib/runtime/dispatcher.js @@ -18,7 +18,7 @@ const { toJSONSafe, reviveBigInts } = require('../message_serialization.js'); * * Rejects any frame whose capability is not in the {@link CapabilityRegistry} * — this is the security contract that distinguishes the runtime from the - * raw rosocket bridge. + * raw rosocket gateway. */ class Dispatcher { /** diff --git a/lib/runtime/transports/ws.js b/lib/runtime/transports/ws.js index 3f6e81e0..4f7d613f 100644 --- a/lib/runtime/transports/ws.js +++ b/lib/runtime/transports/ws.js @@ -72,7 +72,7 @@ class WebSocketConnection extends Connection { * * Speaks the capability wire protocol on a single URL path (default * `/capability`). This is intentionally separate from the lower-level - * {@link module:rosocket} bridge: rosocket exposes raw ROS-shaped URLs + * {@link module:rosocket} gateway: rosocket exposes raw ROS-shaped URLs * (`/topic/*`, `/service/*`); this adapter exposes one duplex channel * for the runtime's capability frames. */ diff --git a/rosocket/README.md b/rosocket/README.md index a22411e5..3d86d63e 100644 --- a/rosocket/README.md +++ b/rosocket/README.md @@ -10,11 +10,22 @@ > npm install RobotWebTools/rclnodejs#develop > ``` -**rosocket** is a **lightweight** WebSocket bridge that lets a **plain web +**rosocket** is a **lightweight** WebSocket gateway that lets a **plain web browser** (or any WebSocket-capable client) talk to ROS 2 through `rclnodejs`, with **no extra JavaScript library** required on the client side. Browsers only need the built-in `WebSocket` and `JSON` APIs. +> 💡 **Building a new browser app? Start with [`rclnodejs/web`](../web/README.md).** +> It's the recommended SDK for ROS 2 in the browser — typed three-verb +> API (`call` / `publish` / `subscribe`), a reviewable per-app capability +> allow-list (`web.json`), and a `curl`-able HTTP transport for Postman / +> AI-agent tool-use. **`rosocket` (this page) is the lighter sibling**: +> one named topic or service per WebSocket, no SDK, no allow-list. Reach +> for `rosocket` when you genuinely want exactly that; reach for +> `rclnodejs/web` when you want a typed SDK, an allow-list, and HTTP +> fallback. See [`rclnodejs/web` vs. `rosbridge` + `roslibjs`](../web/README.md#4-rclnodejsweb-vs-rosbridge-roslibjs) +> in the SDK guide for the full picture. + How it compares with the classic [rosbridge_suite](https://github.com/RobotWebTools/rosbridge_suite) + [roslibjs](https://github.com/RobotWebTools/roslibjs) stack: @@ -97,7 +108,7 @@ const cli = new WebSocket( ``` The same applies to the CLI — drop `--topic` / `--service` to run a generic -bridge: `npx rosocket --port 9000`. +gateway: `npx rosocket --port 9000`. ## CLI (`rosocket`) diff --git a/rosocket/index.js b/rosocket/index.js index ef006e7f..181b3303 100644 --- a/rosocket/index.js +++ b/rosocket/index.js @@ -42,7 +42,7 @@ function parseResourcePath(pathname) { } /** - * Start a resource-style WebSocket bridge that exposes ROS 2 topics and + * Start a resource-style WebSocket gateway that exposes ROS 2 topics and * services as plain WebSocket URLs carrying ROS messages as JSON. * * URL scheme: diff --git a/scripts/npmjs-readme.md b/scripts/npmjs-readme.md index 5cacbd79..3cbc8b69 100644 --- a/scripts/npmjs-readme.md +++ b/scripts/npmjs-readme.md @@ -66,7 +66,7 @@ npm install rclnodejs - Tutorials: [tutorials/](https://github.com/RobotWebTools/rclnodejs/tree/develop/tutorials) - JavaScript examples: [example/](https://github.com/RobotWebTools/rclnodejs/tree/develop/example) - TypeScript demos: [demo/typescript/](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/typescript) -- WebSocket bridge demo (rosocket): [demo/rosocket/](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/rosocket) +- Browser demos: [demo/web/](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/web) (typed Web SDK) and [demo/rosocket/](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/rosocket) (WebSocket gateway) - Electron demos: [demo/electron/](https://github.com/RobotWebTools/rclnodejs/tree/develop/demo/electron) - Companion CLI: [rclnodejs-cli](https://github.com/RobotWebTools/rclnodejs-cli/) @@ -96,21 +96,29 @@ TypeScript declaration files are included in the package. In most projects, conf Then `import * as rclnodejs from 'rclnodejs'` works the same as the JavaScript example at the top of this README. -## rosocket — Browser ↔ ROS 2 bridge +## ROS 2 in the browser -A tiny WebSocket gateway to ROS 2, built into `rclnodejs`. Exposes ROS 2 topics and services as plain WebSocket URLs — a lightweight alternative to the rosbridge + roslibjs stack. Browsers use only built-in `WebSocket` + `JSON`; no JavaScript library required. +`rclnodejs` ships **two** ways to reach ROS 2 from the browser — pick one based on how much glue you want to write. -```bash -npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String -``` +- **`rclnodejs/web`** — typed, allow-listed, `curl`-able browser SDK. A `web.json` file is your public API; the browser SDK types `call` / `publish` / `subscribe` end-to-end from your ROS 2 message types; every capability is also a plain HTTP endpoint (`curl -X POST http:///capability/call/`), so shell scripts, Postman, and AI-agent tool-use just work. _New in `2.0.0-beta.0`._ -```js -const ws = new WebSocket('ws://host:9000/topic/chatter'); -ws.onmessage = (e) => console.log(JSON.parse(e.data).data); -ws.onopen = () => ws.send(JSON.stringify({ data: 'hi' })); -``` + ```ts + import { connect } from 'rclnodejs/web'; + const ros = await connect('ws://host:9000/capability'); + const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + '/add_two_ints', { a: '2n', b: '40n' } + ); // reply.sum is typed as `${number}n` + ``` + + See the [Web SDK guide](https://github.com/RobotWebTools/rclnodejs/tree/develop/web). + +- **`rosocket`** — thin WebSocket gateway, zero browser dependencies (just built-in `WebSocket` + `JSON`). Best for quick prototypes and `roslibjs`-style apps. + + ```bash + npx rosocket --port 9000 --topic /chatter:std_msgs/msg/String + ``` -See the [rosocket guide](https://github.com/RobotWebTools/rclnodejs/tree/develop/rosocket) for the URL scheme, service calls, and the programmatic `startRosocket()` API. + See the [rosocket guide](https://github.com/RobotWebTools/rclnodejs/tree/develop/rosocket). ## License diff --git a/test/test-rosocket.js b/test/test-rosocket.js index 15c9beec..27521ae0 100644 --- a/test/test-rosocket.js +++ b/test/test-rosocket.js @@ -14,7 +14,7 @@ const rclnodejs = require('../index.js'); const { startRosocket } = require('../rosocket'); const rosocket = require('../rosocket'); -describe('rosocket resource-style WebSocket bridge', function () { +describe('rosocket resource-style WebSocket gateway', function () { this.timeout(60 * 1000); it('exports startRosocket', function () { diff --git a/tutorials/README.md b/tutorials/README.md index e61d098a..27a6a1f7 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -42,6 +42,10 @@ Reduce network traffic and improve performance by filtering messages at the DDS Use RxJS Observables for reactive programming with ROS 2 subscriptions. Apply operators like `throttleTime()`, `combineLatest()`, and `filter()` to process message streams declaratively. +#### [rclnodejs/web — Browser SDK guide](../web/README.md) + +Front-end developer guide to talking to ROS 2 from a web app using `rclnodejs/web` and the bundled `rclnodejs-web` CLI. Covers the `connect()` URL forms (WebSocket vs. HTTP vs. split endpoints), the typed verb API (`call` / `publish` / `subscribe`) — one ROS 2 type name gives you a fully typed request, response, or message — the `"42n"` BigInt convention for 64-bit integers, structured error codes, and curl recipes. + ### 🔍 Introspection & Debugging #### [Type Description Service](type-description-service.md) diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..446024bb --- /dev/null +++ b/web/README.md @@ -0,0 +1,157 @@ +# rclnodejs/web — Browser SDK guide + +> Talk to ROS 2 from a web app — typed, allow-listed, `curl`-able. + +`rclnodejs/web` is the browser-side of `rclnodejs`: a compact ESM +module plus a server runtime that together expose a declarative +subset of your ROS 2 graph over WebSocket **and** plain HTTP. The +browser API is three verbs — `call`, `publish`, `subscribe` — typed +end-to-end from your ROS 2 message and service types. + +For runnable code see [`demo/web/`](../demo/web/): + +| Demo | Pick this if you… | +| ------------------------------------------------- | --------------------------------------------------------------------------- | +| [`demo/web/javascript/`](../demo/web/javascript/) | want a single static page — no build tools, no `npm install` for the page | +| [`demo/web/typescript/`](../demo/web/typescript/) | already have a Vite / Next / React / Vue / Svelte project, want full typing | + +## 1. Server side: stand up the runtime + +```bash +source /opt/ros//setup.bash +npx rclnodejs-web \ + --port 9000 --http-port 9001 \ + --call /add_two_ints=example_interfaces/srv/AddTwoInts \ + --publish /chatter=std_msgs/msg/String \ + --subscribe /scan=sensor_msgs/msg/LaserScan +# rclnodejs/web listening on ws://localhost:9000/capability (3 capabilities) +# also http://localhost:9001/capability (call/publish only) +``` + +Or feed the same allow-list from `web.json`: + +```json +{ + "port": 9000, + "http": { "port": 9001 }, + "expose": { + "call": { "/add_two_ints": "example_interfaces/srv/AddTwoInts" }, + "publish": { "/chatter": "std_msgs/msg/String" }, + "subscribe": { "/scan": "sensor_msgs/msg/LaserScan" } + } +} +``` + +```bash +npx rclnodejs-web web.json +``` + +> The `expose` block is the **public API** your browser depends on. +> Anything not listed is rejected with `code: 'not_exposed'` before +> any ROS 2 API runs. Keep it narrow. + +## 2. Client side: talk to it from the browser + +### Connect + +```ts +import { connect } from 'rclnodejs/web'; // or via esm.sh in a