From 386e49c3e3b786070fac6bd800eec724d5590819 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Sat, 9 May 2026 17:24:29 +0800 Subject: [PATCH 01/15] docs: tutorial + browser demos for rclnodejs/web User-facing learning material for the typed Web SDK + capability runtime introduced in the previous commits. Tutorial: - tutorials/web.md: front-end-developer guide. Covers connect() URL forms (WebSocket vs. HTTP vs. split endpoints), the typed verb API (call / publish / subscribe) with single-string-generic typing derived from generated MessagesMap / ServicesMap, the '42n' BigInt wire convention, structured error codes, and curl recipes. - tutorials/README.md: index entry pointing at it. JavaScript demo (no toolchain): - demo/web/javascript/{server.js, index.html, web.json, package.json, README.md}. Single static HTML page that talks to a real ROS 2 graph using only +``` + +That's the whole client. 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 `server.js` + +Real projects use the `rclnodejs-web` CLI against an existing ROS 2 +graph instead of running `server.js`: + +```bash +npx rclnodejs-web web.json +``` + +The browser code is unchanged; only the URL it points to changes. + +## Putting the SDK in your own project + +| Approach | When | How | +|---|---|---| +| Plain ` + + diff --git a/demo/web/javascript/package.json b/demo/web/javascript/package.json new file mode 100644 index 000000000..a310c303f --- /dev/null +++ b/demo/web/javascript/package.json @@ -0,0 +1,9 @@ +{ + "name": "rclnodejs-web-demo", + "version": "0.0.0", + "private": true, + "description": "rclnodejs/web browser demo — no toolchain, just + + diff --git a/demo/web/typescript/package.json b/demo/web/typescript/package.json new file mode 100644 index 000000000..a5a60fbc9 --- /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": "tsx server.ts", + "rclnodejs-web": "rclnodejs-web ../javascript/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 000000000..b248958ad --- /dev/null +++ b/demo/web/typescript/server.ts @@ -0,0 +1,113 @@ +// 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 is identical to demo/web/javascript/server.js +// except this side is written in TypeScript so the typed SDK story is +// visible end to end. + +// 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); + +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(); + + console.log( + `rclnodejs/web : ws://localhost:${RUNTIME_PORT}/capability (TS demo)` + ); + console.log( + ` also http://localhost:${HTTP_PORT}/capability (call/publish, curl-able)` + ); + console.log('Capabilities :', JSON.stringify(runtime.registry.list())); + console.log( + 'Vite dev : run `npm run dev` in another shell, then open http://localhost:5173/' + ); + + 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 000000000..de27b2fcd --- /dev/null +++ b/demo/web/typescript/src/main.ts @@ -0,0 +1,196 @@ +// 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 URL — the SDK derives the WS sibling automatically for + // subscribe (see web/client.js _resolveUrls). + http: `http://${HOST}:9001`, +}; + +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 lazily uses ws://${HOST}:9000/capability)` + : 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(ENDPOINTS[mode]); + setStatus(`connected (${mode})`, 'ok'); + } catch (e) { + setStatus(`failed: ${String(e)}`, 'err'); + return; + } + + // Always-on chatter subscription; over HTTP this lazily opens + // the WS sibling endpoint (subscribe always uses WS). + 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 000000000..9a1b07f75 --- /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 000000000..b5ee89512 --- /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 000000000..a85346386 --- /dev/null +++ b/demo/web/typescript/vite.config.ts @@ -0,0 +1,30 @@ +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: { + port: 5173, + }, + esbuild: { + target: 'es2022', + }, + optimizeDeps: { + esbuildOptions: { + target: 'es2022', + }, + }, + build: { + target: 'es2022', + sourcemap: true, + }, +}); diff --git a/tutorials/README.md b/tutorials/README.md index e61d098a6..bb68de456 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.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`) with single-string-generic typing derived from generated `MessagesMap` / `ServicesMap`, the `"42n"` BigInt convention, structured error codes, and curl recipes. + ### 🔍 Introspection & Debugging #### [Type Description Service](type-description-service.md) diff --git a/tutorials/web.md b/tutorials/web.md new file mode 100644 index 000000000..b90e08f27 --- /dev/null +++ b/tutorials/web.md @@ -0,0 +1,401 @@ +# rclnodejs/web — Browser SDK guide + +> Front-end-developer guide to talking to ROS 2 from a web app using +> `rclnodejs/web` — the typed Browser SDK and capability runtime +> shipped inside rclnodejs. +> +> This tutorial is _only_ about how to drive the runtime from the +> browser. For the SDK source see +> [`web/client.js`](../web/client.js); for runnable demos see +> [`demo/web/`](../demo/web/). + +## What you get + +- A 100-line ESM module (`rclnodejs/web`) with **zero native deps** — + safe to bundle for any browser, works in any modern bundler (Vite, + Next, esbuild, webpack), and importable from ` +``` + +> The SDK is `web/index.js` (ESM, `type: module`). It does **not** +> import the rclnodejs native binding, so it bundles cleanly. + +## 3. Connect — pick the right URL form + +`connect()` accepts three shapes. Choose based on which transport(s) +you actually want and where they live. + +### A — WebSocket only (most common) + +```ts +const ros = await connect('ws://localhost:9000/capability'); +``` + +All three verbs go over a single WebSocket. Use this if `subscribe` +is the dominant use case (UI dashboards, telemetry, robot state). + +### B — HTTP for `call`/`publish`, WebSocket lazily on first `subscribe` + +```ts +const ros = await connect('http://localhost:9001'); +``` + +- `call` and `publish` go over HTTP (stateless `fetch`). +- The first `subscribe()` lazily opens a WebSocket sibling at + `ws://:/capability`. +- If you never `subscribe()`, no WebSocket is ever opened. + +> ⚠️ Form B's auto-derived WS URL only works when HTTP and WS share +> a host:port (typical behind a reverse proxy). The default +> `rclnodejs-web` dev layout puts WS on `:9000` and HTTP on `:9001` +> — different ports — so `subscribe()` would fail with +> `code: 'transport_unavailable'`. Use Form C below for that layout. + +### C — Split endpoints (the form to use with the default layout) + +```ts +const ros = await connect({ + http: 'http://localhost:9001', + ws: 'ws://localhost:9000/capability', +}); +``` + +Spell out both URLs explicitly. This is the right form for the +default `--port` + `--http-port` deployment, or whenever HTTP and WS +sit behind different proxies / TLS terminations. + +You can pass just `{ http }` (call/publish only — `subscribe` will +throw `code: 'transport_unavailable'`) or just `{ ws }` (same as +Form A). + +### Decision table + +| You want… | Use | +| ----------------------------------------------------------- | ------------------------- | +| WebSocket-only (the simplest setup) | Form A (`ws://...`) | +| HTTP + WS behind one reverse proxy (shared host:port) | Form B (`http://...`) | +| `rclnodejs-web` default `--port` + `--http-port` dev layout | **Form C** (`{http, ws}`) | +| HTTP RPC only, no `subscribe()` ever | Form C with only `{http}` | + +## 4. Call a service + +```ts +const reply = await ros.call('/add_two_ints', { a: '7n', b: '35n' }); +console.log(reply.sum); // '42n' +``` + +Typed (recommended): + +```ts +const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + '/add_two_ints', + { a: '7n', b: '35n' } // ← request typed as { a: `${number}n`, b: `${number}n` } +); +reply.sum; // ← typed as `${number}n` +``` + +The single string generic is the **ROS service type name**. The +SDK derives request and response shapes from rclnodejs's generated +`ServicesMap`, so you don't write or import any types yourself. + +> 💡 **The `"42n"` convention.** ROS 2 64-bit integer fields don't +> survive JSON, so the runtime serialises them as `"n"` strings on +> the wire (e.g. BigInt `42n` → string `"42n"`). The TypeScript type +> for these fields is `\`${number}n\``. JavaScript's `BigInt`literal +syntax happens to round-trip cleanly through`String(42n)`. + +## 5. Publish to a topic + +```ts +await ros.publish('/chatter', { data: 'hello from the browser' }); +``` + +Resolves to `undefined` on success. On failure it throws an `Error` +with a `code` property (see §7), e.g. `code: 'not_exposed'` if the +topic isn't in the allow-list. + +Typed: + +```ts +await ros.publish<'std_msgs/msg/String'>('/chatter', { data: 'hello' }); +// 2nd arg typed as { data: string } +``` + +## 6. Subscribe to a topic + +```ts +const sub = await ros.subscribe('/chatter', (msg) => { + console.log('recv:', msg.data); +}); + +// later, when you no longer need it: +await sub.close(); +``` + +Typed: + +```ts +const sub = await ros.subscribe<'std_msgs/msg/String'>( + '/chatter', + (msg) => console.log(msg.data) // msg: { data: string } +); +``` + +`sub` is `{ subId: string, close(): Promise }`. The runtime +holds the underlying ROS 2 subscription open until `close()` (or +until the connection drops). + +> Subscribing always uses WebSocket. If you connected over HTTP +> only and the WS sibling can't be reached, you'll get +> `code: 'transport_unavailable'` — see §3. + +## 7. Errors + +Every error thrown by the SDK has a stable `code` property in +addition to `message`. The codes you'll see most often: + +| `code` | When | +| ----------------------- | ------------------------------------------------------------ | +| `not_exposed` | Capability isn't in the server's `expose({...})` allow-list. | +| `not_implemented` | Reserved kinds (e.g. `action`). | +| `unsupported_kind` | You sent `subscribe` over HTTP — use WS. | +| `network_error` | `fetch()` itself threw (DNS, refused, CORS). | +| `transport_unavailable` | `subscribe()` called with no WS sibling reachable. | +| `invalid_response` | Server replied non-JSON to a `call`. | +| `http_` | HTTP failure with no structured body (e.g. `http_502`). | +| `call_failed` | The ROS 2 service handler threw. | +| `publish_failed` | The publisher rejected the message. | + +```ts +try { + await ros.call('/dangerous', {}); +} catch (e) { + const err = e as { code?: string; message: string }; + if (err.code === 'not_exposed') { + // The capability isn't allow-listed — do not retry. + } else { + // Something else; surface to the user. + } +} +``` + +The codes are stable across minor versions; treat any unknown code as +a non-retryable error and surface `e.message` to logs. + +## 8. Connection lifecycle + +```ts +const ros = await connect('ws://localhost:9000/capability'); +// …use ros… +await ros.close(); // releases all subscriptions and closes the socket(s) +``` + +Today, **`reconnect: true` is accepted but ignored** (the SDK warns +to the console). If the server drops, you lose the client and have to +`connect()` again. Reconnect-on-close with automatic resubscribe is +on the roadmap. + +```ts +const ros = await connect(endpoint, { reconnect: true }); +// console: rclnodejs/web: reconnect is not yet implemented; ignoring option +``` + +## 9. End-to-end: a minimal app + +```ts +import { connect } from 'rclnodejs/web'; + +const ros = await connect({ + http: 'http://localhost:9001', // call/publish over HTTP (curl-able) + ws: 'ws://localhost:9000/capability', // subscribe over WS +}); + +const sumEl = document.querySelector('#sum')!; +const logEl = document.querySelector('#log')!; +const btnEl = document.querySelector('#btn')!; + +// Call a service. +const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + '/add_two_ints', + { a: '2n', b: '40n' } +); +sumEl.textContent = reply.sum; // '42n' + +// Subscribe to a topic. +const sub = await ros.subscribe<'std_msgs/msg/String'>('/chatter', (msg) => { + const line = document.createElement('div'); + line.textContent = msg.data; + logEl.appendChild(line); +}); + +// Publish on click. +btnEl.addEventListener('click', () => + ros.publish<'std_msgs/msg/String'>('/chatter', { data: 'hi' }) +); + +window.addEventListener('beforeunload', () => { + sub.close(); + ros.close(); +}); +``` + +That's the full SDK surface. + +## 10. When to use HTTP vs. WebSocket + +| Job | Transport | Why | +| ------------------------------------------- | ---------------- | ---------------------------------------------------------------- | +| Service calls from a UI button | either | HTTP is curl-able for debugging; WS is one fewer connection. | +| Publishing user input on click | either | Same. | +| Subscribing to telemetry / robot state | WebSocket | Server push; HTTP `subscribe` isn't supported by design. | +| AI-agent or external tool driving the robot | HTTP | No JS stack needed; just `curl -X POST /capability/call/`. | +| Mixed (curl-able RPC + UI subscribe) | both, via Form C | The SDK juggles them transparently. | + +There is intentionally **no SSE transport** for subscribe-over-HTTP. +WebSocket already multiplexes N subscriptions on one socket and +carries binary frames — SSE would force one HTTP connection per +topic and would still not help with duplex flows like Actions. + +## 11. curl recipes (no JavaScript at all) + +Any HTTP client can drive the runtime as long as `--http-port` is on: + +```bash +# Service call +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/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"} +``` + +## 12. Relationship to `rosocket` + +`rclnodejs` ships **two** browser ↔ ROS 2 bridges. They are +**siblings**, not layers — independent tools that share the same +rclnodejs core but solve different problems. Run them as separate +processes on separate ports; they never talk to each other. + +| | `rosocket` | `rclnodejs/web` | +| --------------- | --------------------------------------- | -------------------------------------------------------------------- | +| **What it is** | A thin WebSocket bridge | A typed SDK + capability runtime | +| **Browser API** | Hand-rolled `WebSocket + JSON` | `import { connect } from 'rclnodejs/web'` | +| **URL shape** | `/topic/`, `/service/` | `/capability` (WS) + `POST /capability/{call,publish}/` (HTTP) | +| **Allow-list** | Optional type hints | **Required** — `web.json` is the contract | +| **Best for** | Quick prototypes, `roslibjs`-style apps | New apps, AI agents, typed UIs | + +If you only need a couple of topics wired into a hand-rolled +`WebSocket` and don't want to pull in a TypeScript SDK, reach for +[`rosocket`](../rosocket/README.md). For new apps, AI-agent tool-use, +or anywhere you want a reviewable allow-list and end-to-end types, +use `rclnodejs/web`. + +## 13. Relationship to `rosbridge` + `roslibjs` + +`rclnodejs/web` and `rosbridge` + `roslibjs` ride the **same wire +transport** (WebSocket + JSON) but make a different contract with +the browser: + +| | `rosbridge` + `roslibjs` | `rclnodejs/web` | +| --------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------ | +| **Public API surface** | The whole ROS graph | `web.json` allow-list — a reviewable artifact | +| **TypeScript types** | `any`-shaped; bolt-on community packages | Single string generic derives request / response / message from rclnodejs's auto-generated types | +| **HTTP `call` / `publish`** | ❌ no HTTP surface | ✅ `curl`, Postman, AI-agent tool-use just work | + +**This is not a rosbridge replacement.** rosbridge is still the +right choice when you genuinely need graph-shaped semantics — +rqt-web, fleet dashboards that introspect topics, anything that has +to enumerate the live graph rather than work against a fixed +contract. + +## See also + +- [`web/client.js`](../web/client.js) — the SDK source (compact; + worth a skim). +- [`demo/web/javascript/`](../demo/web/javascript/) + — runnable demo, no build tools, with a transport toggle and curl + recipes. +- [`demo/web/typescript/`](../demo/web/typescript/) + — same demo with TypeScript + Vite, end-to-end typed. +- [`bin/rclnodejs-web.js`](../bin/rclnodejs-web.js) — the + `rclnodejs-web` CLI; `rclnodejs-web --help` lists every flag and + config-file key. From 9bcca10906afe2d0db77cae5ccf52f8bd0b1876a Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Tue, 12 May 2026 18:32:23 +0800 Subject: [PATCH 02/15] Address comments --- demo/web/README.md | 87 -------- demo/web/javascript/README.md | 17 +- demo/web/typescript/README.md | 25 +-- tutorials/web.md | 406 +++++++++++----------------------- 4 files changed, 145 insertions(+), 390 deletions(-) delete mode 100644 demo/web/README.md diff --git a/demo/web/README.md b/demo/web/README.md deleted file mode 100644 index cde2c5bc4..000000000 --- a/demo/web/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# rclnodejs/web — talk to ROS 2 from a browser - -> The web side of `rclnodejs`. One JSON file, one shell command, your -> browser talks to ROS 2 over a single typed WebSocket — and the same -> capabilities are reachable from plain HTTP for `curl`, Postman, and -> AI agents. - -Two demos, same browser API: - -| Path | Pick this if you… | What you write | -|---|---|---| -| **[`./javascript/`](./javascript/)** | want a single static page, no build tools, no `npm install` for the page | a ` ``` -That's the whole client. The page also has a **transport toggle** -(WebSocket vs. HTTP) so you can flip the SDK between the two without -restarting. +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 @@ -67,10 +66,10 @@ The browser code is unchanged; only the URL it points to changes. ## Putting the SDK in your own project -| Approach | When | How | -|---|---|---| -| Plain ` -``` - -> The SDK is `web/index.js` (ESM, `type: module`). It does **not** -> import the rclnodejs native binding, so it bundles cleanly. - -## 3. Connect — pick the right URL form - -`connect()` accepts three shapes. Choose based on which transport(s) -you actually want and where they live. - -### A — WebSocket only (most common) - -```ts -const ros = await connect('ws://localhost:9000/capability'); -``` - -All three verbs go over a single WebSocket. Use this if `subscribe` -is the dominant use case (UI dashboards, telemetry, robot state). - -### B — HTTP for `call`/`publish`, WebSocket lazily on first `subscribe` - -```ts -const ros = await connect('http://localhost:9001'); -``` - -- `call` and `publish` go over HTTP (stateless `fetch`). -- The first `subscribe()` lazily opens a WebSocket sibling at - `ws://:/capability`. -- If you never `subscribe()`, no WebSocket is ever opened. - -> ⚠️ Form B's auto-derived WS URL only works when HTTP and WS share -> a host:port (typical behind a reverse proxy). The default -> `rclnodejs-web` dev layout puts WS on `:9000` and HTTP on `:9001` -> — different ports — so `subscribe()` would fail with -> `code: 'transport_unavailable'`. Use Form C below for that layout. - -### C — Split endpoints (the form to use with the default layout) +| You want… | Pass | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| WebSocket only (the simplest setup) | `'ws://host:9000/capability'` | +| HTTP + WS behind one reverse proxy (shared host:port) | `'http://host:9001'` — WS sibling derived as `ws://host:9001//capability` | +| Default `--port` + `--http-port` dev layout (different ports) | `{ http: 'http://host:9001', ws: 'ws://host:9000/capability' }` | +| HTTP RPC only (no `subscribe()` ever) | `{ http: 'http://host:9001' }` — `subscribe()` rejects with `transport_unavailable` | ```ts const ros = await connect({ @@ -130,205 +74,114 @@ const ros = await connect({ }); ``` -Spell out both URLs explicitly. This is the right form for the -default `--port` + `--http-port` deployment, or whenever HTTP and WS -sit behind different proxies / TLS terminations. +## 3. The verb API -You can pass just `{ http }` (call/publish only — `subscribe` will -throw `code: 'transport_unavailable'`) or just `{ ws }` (same as -Form A). - -### Decision table - -| You want… | Use | -| ----------------------------------------------------------- | ------------------------- | -| WebSocket-only (the simplest setup) | Form A (`ws://...`) | -| HTTP + WS behind one reverse proxy (shared host:port) | Form B (`http://...`) | -| `rclnodejs-web` default `--port` + `--http-port` dev layout | **Form C** (`{http, ws}`) | -| HTTP RPC only, no `subscribe()` ever | Form C with only `{http}` | - -## 4. Call a service - -```ts -const reply = await ros.call('/add_two_ints', { a: '7n', b: '35n' }); -console.log(reply.sum); // '42n' -``` - -Typed (recommended): +All three verbs accept an optional ROS-type generic that drives +typing of the payload and reply. ```ts +// Service call const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( '/add_two_ints', - { a: '7n', b: '35n' } // ← request typed as { a: `${number}n`, b: `${number}n` } + { a: '7n', b: '35n' } ); -reply.sum; // ← typed as `${number}n` -``` +reply.sum; // typed as `${number}n`, runtime value '42n' -The single string generic is the **ROS service type name**. The -SDK derives request and response shapes from rclnodejs's generated -`ServicesMap`, so you don't write or import any types yourself. - -> 💡 **The `"42n"` convention.** ROS 2 64-bit integer fields don't -> survive JSON, so the runtime serialises them as `"n"` strings on -> the wire (e.g. BigInt `42n` → string `"42n"`). The TypeScript type -> for these fields is `\`${number}n\``. JavaScript's `BigInt`literal -syntax happens to round-trip cleanly through`String(42n)`. - -## 5. Publish to a topic - -```ts -await ros.publish('/chatter', { data: 'hello from the browser' }); -``` - -Resolves to `undefined` on success. On failure it throws an `Error` -with a `code` property (see §7), e.g. `code: 'not_exposed'` if the -topic isn't in the allow-list. - -Typed: - -```ts +// Publish — resolves to undefined on success await ros.publish<'std_msgs/msg/String'>('/chatter', { data: 'hello' }); -// 2nd arg typed as { data: string } -``` - -## 6. Subscribe to a topic - -```ts -const sub = await ros.subscribe('/chatter', (msg) => { - console.log('recv:', msg.data); -}); -// later, when you no longer need it: +// Subscribe — always uses WebSocket +const sub = await ros.subscribe<'std_msgs/msg/String'>('/chatter', (msg) => + console.log(msg.data) +); await sub.close(); ``` -Typed: +The generic is omittable; without it, payload and reply are typed as +`unknown`. With it, every shape comes from rclnodejs's generated +maps — no codegen, no shared types module. -```ts -const sub = await ros.subscribe<'std_msgs/msg/String'>( - '/chatter', - (msg) => console.log(msg.data) // msg: { data: string } -); -``` - -`sub` is `{ subId: string, close(): Promise }`. The runtime -holds the underlying ROS 2 subscription open until `close()` (or -until the connection drops). +> 💡 **The `"42n"` convention.** ROS 2 64-bit integer fields don't +> survive JSON, so the runtime serialises them as the string template +> `` `${big}n` `` on the wire (BigInt `42n` → string `"42n"`). The +> matching TypeScript field type is `` `${number}n` ``. -> Subscribing always uses WebSocket. If you connected over HTTP -> only and the WS sibling can't be reached, you'll get -> `code: 'transport_unavailable'` — see §3. +## 4. Errors -## 7. Errors +Every thrown error has a stable `code`. The most common: -Every error thrown by the SDK has a stable `code` property in -addition to `message`. The codes you'll see most often: +| `code` | When | +| ----------------------- | ------------------------------------------------------- | +| `not_exposed` | Capability isn't in the server's allow-list. | +| `transport_unavailable` | `subscribe()` called with no WS sibling reachable. | +| `network_error` | `fetch()` itself threw (DNS, refused, CORS). | +| `invalid_response` | Server replied non-JSON to a `call`. | +| `http_` | HTTP failure with no structured body (e.g. `http_502`). | +| `call_failed` | The ROS 2 service handler threw. | +| `publish_failed` | The publisher rejected the message. | -| `code` | When | -| ----------------------- | ------------------------------------------------------------ | -| `not_exposed` | Capability isn't in the server's `expose({...})` allow-list. | -| `not_implemented` | Reserved kinds (e.g. `action`). | -| `unsupported_kind` | You sent `subscribe` over HTTP — use WS. | -| `network_error` | `fetch()` itself threw (DNS, refused, CORS). | -| `transport_unavailable` | `subscribe()` called with no WS sibling reachable. | -| `invalid_response` | Server replied non-JSON to a `call`. | -| `http_` | HTTP failure with no structured body (e.g. `http_502`). | -| `call_failed` | The ROS 2 service handler threw. | -| `publish_failed` | The publisher rejected the message. | +The runtime defines a few more (`unsupported_kind`, `invalid_frame`, +`payload_too_large`, …); treat any unknown code as non-retryable and +surface `e.message` to logs. ```ts try { await ros.call('/dangerous', {}); } catch (e) { - const err = e as { code?: string; message: string }; - if (err.code === 'not_exposed') { - // The capability isn't allow-listed — do not retry. - } else { - // Something else; surface to the user. + if ((e as { code?: string }).code === 'not_exposed') { + // Allow-listed differently — don't retry. } } ``` -The codes are stable across minor versions; treat any unknown code as -a non-retryable error and surface `e.message` to logs. - -## 8. Connection lifecycle +## 5. Connection lifecycle ```ts -const ros = await connect('ws://localhost:9000/capability'); -// …use ros… -await ros.close(); // releases all subscriptions and closes the socket(s) +await ros.close(); // cancels subscriptions, closes both transports ``` -Today, **`reconnect: true` is accepted but ignored** (the SDK warns -to the console). If the server drops, you lose the client and have to -`connect()` again. Reconnect-on-close with automatic resubscribe is -on the roadmap. - -```ts -const ros = await connect(endpoint, { reconnect: true }); -// console: rclnodejs/web: reconnect is not yet implemented; ignoring option -``` +Today **`reconnect: true` is accepted but ignored** (the SDK warns +once). If the server drops, the client is dead — `connect()` again +and re-`subscribe()`. Auto-reconnect with resubscribe is on the +roadmap. -## 9. End-to-end: a minimal app +## 6. End-to-end ```ts import { connect } from 'rclnodejs/web'; const ros = await connect({ - http: 'http://localhost:9001', // call/publish over HTTP (curl-able) + http: 'http://localhost:9001', // call/publish over HTTP ws: 'ws://localhost:9000/capability', // subscribe over WS }); -const sumEl = document.querySelector('#sum')!; -const logEl = document.querySelector('#log')!; -const btnEl = document.querySelector('#btn')!; - -// Call a service. const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( '/add_two_ints', { a: '2n', b: '40n' } ); -sumEl.textContent = reply.sum; // '42n' - -// Subscribe to a topic. -const sub = await ros.subscribe<'std_msgs/msg/String'>('/chatter', (msg) => { - const line = document.createElement('div'); - line.textContent = msg.data; - logEl.appendChild(line); -}); +document.querySelector('#sum')!.textContent = reply.sum; // '42n' -// Publish on click. -btnEl.addEventListener('click', () => - ros.publish<'std_msgs/msg/String'>('/chatter', { data: 'hi' }) +const sub = await ros.subscribe<'std_msgs/msg/String'>('/chatter', (msg) => + log(msg.data) ); +document + .querySelector('#btn')! + .addEventListener('click', () => + ros.publish<'std_msgs/msg/String'>('/chatter', { data: 'hi' }) + ); + window.addEventListener('beforeunload', () => { sub.close(); ros.close(); }); ``` -That's the full SDK surface. - -## 10. When to use HTTP vs. WebSocket - -| Job | Transport | Why | -| ------------------------------------------- | ---------------- | ---------------------------------------------------------------- | -| Service calls from a UI button | either | HTTP is curl-able for debugging; WS is one fewer connection. | -| Publishing user input on click | either | Same. | -| Subscribing to telemetry / robot state | WebSocket | Server push; HTTP `subscribe` isn't supported by design. | -| AI-agent or external tool driving the robot | HTTP | No JS stack needed; just `curl -X POST /capability/call/`. | -| Mixed (curl-able RPC + UI subscribe) | both, via Form C | The SDK juggles them transparently. | - -There is intentionally **no SSE transport** for subscribe-over-HTTP. -WebSocket already multiplexes N subscriptions on one socket and -carries binary frames — SSE would force one HTTP connection per -topic and would still not help with duplex flows like Actions. - -## 11. curl recipes (no JavaScript at all) +## 7. curl recipes (no JavaScript at all) -Any HTTP client can drive the runtime as long as `--http-port` is on: +When `--http-port` is on, every `call` / `publish` is reachable from +any HTTP client — curl, Postman, AI-agent tool-use, no SDK required. +Subscribe stays on WebSocket. ```bash # Service call @@ -342,60 +195,53 @@ curl -sS -X POST http://localhost:9001/capability/publish/chatter \ -H 'content-type: application/json' \ -d '{"data":"hi from curl"}' -# Allow-list rejection (returns 404 + structured error body) +# Allow-list rejection (returns 404 + structured 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"} ``` -## 12. Relationship to `rosocket` - -`rclnodejs` ships **two** browser ↔ ROS 2 bridges. They are -**siblings**, not layers — independent tools that share the same -rclnodejs core but solve different problems. Run them as separate -processes on separate ports; they never talk to each other. - -| | `rosocket` | `rclnodejs/web` | -| --------------- | --------------------------------------- | -------------------------------------------------------------------- | -| **What it is** | A thin WebSocket bridge | A typed SDK + capability runtime | -| **Browser API** | Hand-rolled `WebSocket + JSON` | `import { connect } from 'rclnodejs/web'` | -| **URL shape** | `/topic/`, `/service/` | `/capability` (WS) + `POST /capability/{call,publish}/` (HTTP) | -| **Allow-list** | Optional type hints | **Required** — `web.json` is the contract | -| **Best for** | Quick prototypes, `roslibjs`-style apps | New apps, AI agents, typed UIs | - -If you only need a couple of topics wired into a hand-rolled -`WebSocket` and don't want to pull in a TypeScript SDK, reach for -[`rosocket`](../rosocket/README.md). For new apps, AI-agent tool-use, -or anywhere you want a reviewable allow-list and end-to-end types, -use `rclnodejs/web`. - -## 13. Relationship to `rosbridge` + `roslibjs` - -`rclnodejs/web` and `rosbridge` + `roslibjs` ride the **same wire -transport** (WebSocket + JSON) but make a different contract with -the browser: - -| | `rosbridge` + `roslibjs` | `rclnodejs/web` | -| --------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------ | -| **Public API surface** | The whole ROS graph | `web.json` allow-list — a reviewable artifact | -| **TypeScript types** | `any`-shaped; bolt-on community packages | Single string generic derives request / response / message from rclnodejs's auto-generated types | -| **HTTP `call` / `publish`** | ❌ no HTTP surface | ✅ `curl`, Postman, AI-agent tool-use just work | - -**This is not a rosbridge replacement.** rosbridge is still the -right choice when you genuinely need graph-shaped semantics — -rqt-web, fleet dashboards that introspect topics, anything that has -to enumerate the live graph rather than work against a fixed -contract. +## 8. How it compares + +`rclnodejs` ships **two** browser ↔ ROS 2 bridges, side-by-side +with the upstream `rosbridge` + `roslibjs` stack: + +| | `rosocket` | **`rclnodejs/web`** | `rosbridge` + `roslibjs` | +| --------------------------- | --------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------- | +| **Browser API** | Hand-rolled `WebSocket + JSON` | `import { connect } from 'rclnodejs/web'` | `roslibjs` | +| **URL shape** | `/topic/`, `/service/` | `/capability` (WS) + `POST /capability/{call,publish}/` (HTTP) | rosbridge protocol over WS | +| **Public surface** | All listed topics/services | **Required `web.json` allow-list** — a reviewable artifact | The whole ROS graph | +| **TypeScript types** | `any`-shaped | Single string generic → request/response/message | `any`-shaped; bolt-on community packages | +| **HTTP `call` / `publish`** | ❌ | ✅ — `curl`, Postman, AI-agent tool-use just work | ❌ | +| **Best for** | Quick prototypes, `roslibjs`-style apps | New apps, AI agents, typed UIs | Graph introspection, rqt-web, fleet UIs | + +`rosocket` and `rclnodejs/web` are **siblings**, not layers — run as +separate processes on separate ports. `rosbridge` remains the right +choice when you genuinely need graph-shaped semantics (anything that +has to enumerate the live graph rather than work against a fixed +contract). + +## 9. Auth, HTTPS, Actions + +- **HTTPS / `wss://`.** The runtime speaks plain `ws://` and + `http://`. Put nginx, Caddy, or any TLS proxy in front of + `rclnodejs-web`; clients then point at `wss://` / `https://`. +- **Auth.** Today, gate at the connection level via the + `verifyClient(req)` / `verifyRequest(req)` hooks on + `WebSocketTransport` / `HttpTransport` (return `false` to reject + with a 401). Per-capability scopes are on the roadmap. +- **Browser ROS install?** No — the browser only ever speaks to the + endpoint `rclnodejs-web` exposes. +- **Actions.** Not yet — reserved as the `action` kind in the wire + protocol; coming in a follow-up release. ## See also -- [`web/client.js`](../web/client.js) — the SDK source (compact; - worth a skim). -- [`demo/web/javascript/`](../demo/web/javascript/) - — runnable demo, no build tools, with a transport toggle and curl - recipes. -- [`demo/web/typescript/`](../demo/web/typescript/) - — same demo with TypeScript + Vite, end-to-end typed. +- [`web/client.js`](../web/client.js) — the SDK source, worth a skim. +- [`demo/web/javascript/`](../demo/web/javascript/) — runnable demo, + no build tools, transport toggle + curl recipes. +- [`demo/web/typescript/`](../demo/web/typescript/) — same demo with + TypeScript + Vite, end-to-end typed. - [`bin/rclnodejs-web.js`](../bin/rclnodejs-web.js) — the `rclnodejs-web` CLI; `rclnodejs-web --help` lists every flag and - config-file key. + every config-file key. From 60803187b501bf18621c742459a71056d62851f3 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Tue, 12 May 2026 18:38:21 +0800 Subject: [PATCH 03/15] Address comments --- README.md | 2 +- demo/web/javascript/index.html | 18 +++++++++++++----- demo/web/javascript/server.js | 13 ++++++++++++- demo/web/typescript/src/main.ts | 20 ++++++++++++++------ 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fdb65e444..ca6c6874d 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ how much glue you want to write. capability runtime. One string generic per call gives request, response, and message types; a `web.json` allow-list is the public API surface; the same capabilities are also reachable over plain - HTTP for `curl`, Postman, and AI-agent tool-use. _New in `2.0.0`._ + HTTP for `curl`, Postman, and AI-agent tool-use. _New in `2.0.0-beta.0`._ ```ts import { connect } from 'rclnodejs/web'; diff --git a/demo/web/javascript/index.html b/demo/web/javascript/index.html index 34717adff..545f44ab0 100644 --- a/demo/web/javascript/index.html +++ b/demo/web/javascript/index.html @@ -222,10 +222,17 @@

5. Same capability, no SDK — just curl

const host = location.hostname || 'localhost'; const ENDPOINTS = { ws: `ws://${host}:9000/capability`, - // HTTP base — the SDK derives the WS sibling automatically for - // subscribe (see web/client.js _resolveUrls). + // 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. + const connectArg = (mode) => + mode === 'http' + ? { http: ENDPOINTS.http, ws: ENDPOINTS.ws } + : ENDPOINTS.ws; const endpointEl = document.getElementById('endpoint'); const statusEl = document.getElementById('status'); @@ -236,7 +243,7 @@

5. Same capability, no SDK — just curl

const setEndpoint = (mode) => { endpointEl.textContent = mode === 'http' - ? `${ENDPOINTS.http} (subscribe lazily uses ws://${host}:9000/capability)` + ? `${ENDPOINTS.http} (subscribe routed to ${ENDPOINTS.ws})` : ENDPOINTS.ws; }; @@ -270,14 +277,15 @@

5. Same capability, no SDK — just curl

setEndpoint(mode); setStatus(`connecting (${mode})…`); try { - ros = await connect(ENDPOINTS[mode]); + ros = await connect(connectArg(mode)); setStatus(`connected (${mode})`, 'ok'); } catch (e) { setStatus(`failed: ${e.message || e}`, 'err'); return; } // Bind the always-on /web_demo_chatter subscription on every - // reconnect. Over HTTP this lazily opens the WS sibling. + // reconnect. Over HTTP the explicit { ws } in connectArg() makes + // this work — see comment above. try { await ros.subscribe('/web_demo_chatter', (msg) => log('chatLog', `<- ${msg.data}`), diff --git a/demo/web/javascript/server.js b/demo/web/javascript/server.js index d8426861b..d6019f4ea 100644 --- a/demo/web/javascript/server.js +++ b/demo/web/javascript/server.js @@ -19,6 +19,12 @@ const http = require('http'); const fs = require('fs'); 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 go through the relative path because the +// `demo/web/javascript/` folder has its own package.json (so Node's +// package self-reference doesn't see `rclnodejs` as resolvable here). const { createRuntime, WebSocketTransport, @@ -128,7 +134,12 @@ async function main() { relPath = urlPath.replace(/^\/+/, ''); } const filePath = path.resolve(baseDir, relPath); - if (!filePath.startsWith(baseDir)) { + // 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; } diff --git a/demo/web/typescript/src/main.ts b/demo/web/typescript/src/main.ts index de27b2fcd..ccf8b8f26 100644 --- a/demo/web/typescript/src/main.ts +++ b/demo/web/typescript/src/main.ts @@ -20,10 +20,18 @@ type Mode = 'ws' | 'http'; const HOST = location.hostname || 'localhost'; const ENDPOINTS: Record = { ws: `ws://${HOST}:9000/capability`, - // HTTP base URL — the SDK derives the WS sibling automatically for - // subscribe (see web/client.js _resolveUrls). + // 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); @@ -40,7 +48,7 @@ function setStatus(text: string, cls: 'ok' | 'err' | '' = ''): void { function setEndpoint(mode: Mode): void { $('endpoint').textContent = mode === 'http' - ? `${ENDPOINTS.http} (subscribe lazily uses ws://${HOST}:9000/capability)` + ? `${ENDPOINTS.http} (subscribe routed to ${ENDPOINTS.ws})` : ENDPOINTS.ws; } @@ -83,15 +91,15 @@ async function main(): Promise { setEndpoint(mode); setStatus(`connecting (${mode})…`); try { - ros = await connect(ENDPOINTS[mode]); + ros = await connect(connectArg(mode)); setStatus(`connected (${mode})`, 'ok'); } catch (e) { setStatus(`failed: ${String(e)}`, 'err'); return; } - // Always-on chatter subscription; over HTTP this lazily opens - // the WS sibling endpoint (subscribe always uses WS). + // 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}`) From 3a8a6c9cf516d437195ddde435368c8a6042c4ed Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 13 May 2026 13:12:02 +0800 Subject: [PATCH 04/15] docs(web): tighten differentiation, drop forward-looking promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 'Browser ↔ ROS 2' bullet leads with the three-word value prop (typed, allow-listed, curl-able) and substantiates each one inline. - tutorials/web.md §8 'How it compares' restructured around the decision: rclnodejs/web is the default for new code; rosbridge remains correct for graph introspection / Actions / fleet UIs. New keystone callout 'Why we don't try to hide the ROS graph' pre-empts the 'isn't this just modernised roslibjs?' pushback. Sibling-vs-competitor framing for rosocket made explicit. - Strip forward-looking promises ('on the roadmap', 'coming in a follow-up') from §5 reconnect note, §8 actions row, and §9 auth/actions bullets — replaced with present-tense facts. --- README.md | 11 ++++--- tutorials/web.md | 82 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ca6c6874d..1659b97d3 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,12 @@ More runnable examples in [example/](https://github.com/RobotWebTools/rclnodejs/ `rclnodejs` ships **two** browser ↔ ROS 2 bridges — pick one based on how much glue you want to write. -- **[`rclnodejs/web`](./tutorials/web.md)** — typed browser SDK + - capability runtime. One string generic per call gives request, - response, and message types; a `web.json` allow-list is the public - API surface; the same capabilities are also reachable over plain - HTTP for `curl`, Postman, and AI-agent tool-use. _New in `2.0.0-beta.0`._ +- **[`rclnodejs/web`](./tutorials/web.md)** — **typed, allow-listed, + curl-able** browser ↔ ROS 2. A `web.json` file is your public API; + the browser SDK types `call` / `publish` / `subscribe` end-to-end + from rclnodejs's auto-generated message maps; and + `curl -X POST .../capability/call/...` works for shell scripts, + Postman, and AI-agent tool-use. _New in `2.0.0-beta.0`._ ```ts import { connect } from 'rclnodejs/web'; diff --git a/tutorials/web.md b/tutorials/web.md index db5f51508..be0aea8ab 100644 --- a/tutorials/web.md +++ b/tutorials/web.md @@ -142,8 +142,7 @@ await ros.close(); // cancels subscriptions, closes both transports Today **`reconnect: true` is accepted but ignored** (the SDK warns once). If the server drops, the client is dead — `connect()` again -and re-`subscribe()`. Auto-reconnect with resubscribe is on the -roadmap. +and re-`subscribe()`. ## 6. End-to-end @@ -203,37 +202,74 @@ curl -sS -X POST http://localhost:9001/capability/call/dangerous \ ## 8. How it compares -`rclnodejs` ships **two** browser ↔ ROS 2 bridges, side-by-side -with the upstream `rosbridge` + `roslibjs` stack: - -| | `rosocket` | **`rclnodejs/web`** | `rosbridge` + `roslibjs` | -| --------------------------- | --------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------- | -| **Browser API** | Hand-rolled `WebSocket + JSON` | `import { connect } from 'rclnodejs/web'` | `roslibjs` | -| **URL shape** | `/topic/`, `/service/` | `/capability` (WS) + `POST /capability/{call,publish}/` (HTTP) | rosbridge protocol over WS | -| **Public surface** | All listed topics/services | **Required `web.json` allow-list** — a reviewable artifact | The whole ROS graph | -| **TypeScript types** | `any`-shaped | Single string generic → request/response/message | `any`-shaped; bolt-on community packages | -| **HTTP `call` / `publish`** | ❌ | ✅ — `curl`, Postman, AI-agent tool-use just work | ❌ | -| **Best for** | Quick prototypes, `roslibjs`-style apps | New apps, AI agents, typed UIs | Graph introspection, rqt-web, fleet UIs | - -`rosocket` and `rclnodejs/web` are **siblings**, not layers — run as -separate processes on separate ports. `rosbridge` remains the right -choice when you genuinely need graph-shaped semantics (anything that -has to enumerate the live graph rather than work against a fixed -contract). +The browser ↔ ROS 2 space already has `rosbridge` + `roslibjs`, and +`rclnodejs` itself ships a second, lighter bridge called `rosocket`. +All three speak to the same ROS graph — the differences live in **the +contract you sign with the browser**, not in transport tricks. + +> 💡 **Why we don't try to hide the ROS graph.** Robotics has no +> stable equivalent to the web's URL/DOM/REST/SQL primitives, so any +> attempt at a fully product-agnostic `robot.navigate(...)` style API +> ends in unbounded capability explosion. All three options below +> therefore keep the browser facing topics/services/actions +> deliberately. The differentiator is the operational surface +> _around_ the graph (allow-list, types, transports, governance), not +> a new frontend abstraction. `robot.navigate()`-style verbs belong +> in the integrator's product layer on top of these. + +### Pick by the contract you want + +| You want… | Use | +| ------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| **A reviewable allow-list, typed SDK, and curl-able HTTP** for new browser apps | **`rclnodejs/web`** _(this guide; default for new code)_ | +| Live graph introspection / debug consoles / `rqt`-web / fleet topic discovery | `rosbridge` + `roslibjs` | +| Hand-rolled `WebSocket + JSON` against a couple of named topics, no SDK at all | [`rosocket`](../rosocket/) | +| You already have a working `roslibjs` codebase with no migration pressure | Stay on `rosbridge` + `roslibjs` | +| You need ROS 2 Actions | `rosbridge` + `roslibjs` _(rclnodejs/web does not implement actions)_ | + +### Detailed matrix + +| | **`rclnodejs/web`** _(default)_ | [`rosocket`](../rosocket/) | `rosbridge` + `roslibjs` | +| --------------------------- | -------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------- | +| **Public API surface** | **`web.json` allow-list — reviewable artifact** | All listed topics/services | The whole live ROS graph | +| **Browser API** | `import { connect } from 'rclnodejs/web'` — typed, three verbs | Hand-rolled `WebSocket + JSON` | `roslibjs` | +| **TypeScript types** | Single string generic → request/response/message from generated maps | `any`-shaped | `any`; bolt-on community packages | +| **Wire shape** | `/capability` (WS) + `POST /capability/{call,publish}/` (HTTP) | `/topic/`, `/service/` (WS) | rosbridge protocol over WS | +| **HTTP `call` / `publish`** | ✅ — `curl`, Postman, AI-agent tool-use just work | ❌ | ❌ | +| **Setup** | `npx rclnodejs-web web.json` | `npx rosocket --topic …` | `apt install ros--rosbridge-server` + Python launch file | +| **Auth hooks** | `verifyClient` / `verifyRequest` per transport (today) | Connection-level | `securityglobs` plugin | +| **Mature / battle-tested** | New (2.0) | New (2.0) | 10+ years in production | + +### Sibling, not competitor: `rosocket` + +`rosocket` and `rclnodejs/web` ship in the **same** rclnodejs package +but are independent runtimes — different ports, different contracts, +no shared state. Reach for `rosocket` when you genuinely just want +"one named topic over a raw WebSocket"; reach for `rclnodejs/web` +when you want a typed SDK and a reviewable allow-list. Neither +replaces the other. + +### Not a `rosbridge` replacement + +`rosbridge` is still the right tool when the browser genuinely needs +**graph-shaped semantics** — enumerating live topics, building +`rqt`-style debug UIs, fleet dashboards that have to discover what's +running. `rclnodejs/web` deliberately gives that up in exchange for a +narrow, declarative contract. ## 9. Auth, HTTPS, Actions - **HTTPS / `wss://`.** The runtime speaks plain `ws://` and `http://`. Put nginx, Caddy, or any TLS proxy in front of `rclnodejs-web`; clients then point at `wss://` / `https://`. -- **Auth.** Today, gate at the connection level via the +- **Auth.** Gate at the connection level via the `verifyClient(req)` / `verifyRequest(req)` hooks on `WebSocketTransport` / `HttpTransport` (return `false` to reject - with a 401). Per-capability scopes are on the roadmap. + with a 401). - **Browser ROS install?** No — the browser only ever speaks to the endpoint `rclnodejs-web` exposes. -- **Actions.** Not yet — reserved as the `action` kind in the wire - protocol; coming in a follow-up release. +- **Actions.** Not implemented. Use `rosbridge` + `roslibjs` if you + need ROS 2 Actions in the browser today. ## See also From bd2c65b1a6de2f1bccdb4b6cb189e15a5df5821c Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 13 May 2026 13:30:03 +0800 Subject: [PATCH 05/15] docs(web): explain why rosocket and rclnodejs/web URL shapes differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reasonable reflex on first reading is 'shouldn't both bridges use the same URL shape?' Add a blockquote in §8 'Sibling, not competitor' that names the URL difference as deliberate and explains the multiplexing model behind it: rosocket exposes one socket per resource (path is the resource); rclnodejs/web exposes one socket per session (resource lives in the frame, supports call/publish/ subscribe multiplex). Same wire, different contract. --- tutorials/web.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tutorials/web.md b/tutorials/web.md index be0aea8ab..ce0b0c84e 100644 --- a/tutorials/web.md +++ b/tutorials/web.md @@ -249,6 +249,15 @@ no shared state. Reach for `rosocket` when you genuinely just want when you want a typed SDK and a reviewable allow-list. Neither replaces the other. +> **The URL shapes are deliberately different**, not a missed +> opportunity to unify. `rosocket`'s `ws://host:9000/topic/` +> means "one socket per resource, the path **is** the resource"; +> `rclnodejs/web`'s `ws://host:9000/capability` means "one socket per +> session, the resource lives in the message frame so call/publish/ +> subscribe can multiplex." Same wire (WebSocket + JSON), different +> multiplexing model. The URL is the user-visible signal of which +> contract you're talking to. + ### Not a `rosbridge` replacement `rosbridge` is still the right tool when the browser genuinely needs From 988ac9d175289f4d371868a00617d0c525281508 Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 13 May 2026 14:14:36 +0800 Subject: [PATCH 06/15] =?UTF-8?q?docs(web):=20align=20package=20layout=20?= =?UTF-8?q?=E2=80=94=20move=20tutorials/web.md=20to=20web/README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the rosocket/ layout: rosocket/README.md is the package-shaped reference for the rosocket subpackage, so rclnodejs/web's user-facing guide should live at web/README.md too. Same content, just renamed (git tracks it as a rename, history preserved). - tutorials/web.md → web/README.md (292 lines, no content change beyond intro link) - README.md cross-refs updated (Get-started row + 'Browser ↔ ROS 2' bullet) - tutorials/README.md entry now points at ../web/README.md - web/README.md intro path adjusted from 'web/client.js' to './client.js' since the doc is now next to the source - See-also section in web/README.md drops 'web/client.js' → './client.js' This also gives npmjs.com/package/rclnodejs/web a real rendered landing page (npm renders subpath READMEs), matching what's been true for rosocket since 2.0.0-beta.0. --- README.md | 4 ++-- tutorials/README.md | 2 +- tutorials/web.md => web/README.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename tutorials/web.md => web/README.md (98%) diff --git a/README.md b/README.md index 1659b97d3..6eb02f2db 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This example assumes your ROS 2 environment is already sourced. ## Documentation - Get started: - [Installation](#installation), [Quick Start](#quick-start), [Web SDK guide](./tutorials/web.md), [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: @@ -115,7 +115,7 @@ More runnable examples in [example/](https://github.com/RobotWebTools/rclnodejs/ `rclnodejs` ships **two** browser ↔ ROS 2 bridges — pick one based on how much glue you want to write. -- **[`rclnodejs/web`](./tutorials/web.md)** — **typed, allow-listed, +- **[`rclnodejs/web`](./web/README.md)** — **typed, allow-listed, curl-able** browser ↔ ROS 2. A `web.json` file is your public API; the browser SDK types `call` / `publish` / `subscribe` end-to-end from rclnodejs's auto-generated message maps; and diff --git a/tutorials/README.md b/tutorials/README.md index bb68de456..d0f09f450 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -42,7 +42,7 @@ 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.md) +#### [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`) with single-string-generic typing derived from generated `MessagesMap` / `ServicesMap`, the `"42n"` BigInt convention, structured error codes, and curl recipes. diff --git a/tutorials/web.md b/web/README.md similarity index 98% rename from tutorials/web.md rename to web/README.md index ce0b0c84e..6351b3cbb 100644 --- a/tutorials/web.md +++ b/web/README.md @@ -3,7 +3,7 @@ > Talk to ROS 2 from a web app — typed, allow-listed, no `roslibjs`. `rclnodejs/web` is the browser-side of `rclnodejs`: a compact ESM -module (`web/client.js`) plus a server runtime (`bin/rclnodejs-web.js`) +module (`./client.js`) plus a server runtime (`../bin/rclnodejs-web.js`) that exposes 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 rclnodejs's @@ -282,7 +282,7 @@ narrow, declarative contract. ## See also -- [`web/client.js`](../web/client.js) — the SDK source, worth a skim. +- [`client.js`](./client.js) — the SDK source, worth a skim. - [`demo/web/javascript/`](../demo/web/javascript/) — runnable demo, no build tools, transport toggle + curl recipes. - [`demo/web/typescript/`](../demo/web/typescript/) — same demo with From 05bba4c04fd56aeb25e5f3b5d2c46842801d883e Mon Sep 17 00:00:00 2001 From: Minggang Wang Date: Wed, 13 May 2026 17:38:25 +0800 Subject: [PATCH 07/15] Address comments --- bin/rclnodejs-web.js | 143 ++++++++++-------- demo/web/README.md | 67 +++++++++ demo/web/javascript/README.md | 36 +++-- demo/web/javascript/index.html | 158 ++++++++++++++------ demo/web/typescript/README.md | 25 +++- demo/web/typescript/index.html | 61 +++++--- demo/web/typescript/package.json | 2 +- demo/web/typescript/web.json | 22 +++ rosocket/README.md | 11 ++ web/README.md | 241 +++++++------------------------ 10 files changed, 437 insertions(+), 329 deletions(-) create mode 100644 demo/web/README.md create mode 100644 demo/web/typescript/web.json diff --git a/bin/rclnodejs-web.js b/bin/rclnodejs-web.js index 17ff65613..d28c2e709 100755 --- a/bin/rclnodejs-web.js +++ b/bin/rclnodejs-web.js @@ -64,68 +64,95 @@ 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; 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} capabilities)\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/web/README.md b/demo/web/README.md new file mode 100644 index 000000000..54d512873 --- /dev/null +++ b/demo/web/README.md @@ -0,0 +1,67 @@ +# rclnodejs/web — talk to ROS 2 from a browser + +> The web side of `rclnodejs`. One JSON file, one shell command, your +> browser talks to ROS 2 over a single typed WebSocket — and the same +> capabilities are reachable from plain HTTP for `curl`, Postman, and +> AI agents. + +Two demos, same browser API: + +| Path | Pick this if you… | What you write | +| ----------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | +| **[`./javascript/`](./javascript/)** | want a single static page, no build tools, no `npm install` for the page | a `