Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

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 Pick this if you…
demo/web/javascript/ want a single static page — no build tools, no npm install for the page
demo/web/typescript/ already have a Vite / Next / React / Vue / Svelte project, want full typing

1. Server side: stand up the runtime

-p rclnodejs tells npx the rclnodejs-web binary lives inside the rclnodejs package; drop it once rclnodejs is already installed in the current project.

source /opt/ros/<distro>/setup.bash
npx -p rclnodejs 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:

{
  "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" }
  }
}
npx -p rclnodejs 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

import { connect } from 'rclnodejs/web'; // or via esm.sh in a <script type="module">

connect() accepts three URL shapes — the SDK picks transport(s) from the scheme:

You want… Pass
WebSocket only 'ws://host:9000/capability'
HTTP + WS behind one reverse proxy 'http://host:9001'
HTTP + WS on different ports { http: 'http://host:9001', ws: 'ws://host:9000/capability' }
HTTP only (no subscribe()) { http: 'http://host:9001' }

A bare http:// URL auto-derives the WS sibling at the same origin (/capability path); the { http }-only form disables WS entirely and subscribe() rejects with transport_unavailable.

const ros = await connect({
  http: 'http://localhost:9001',
  ws: 'ws://localhost:9000/capability',
});

The verb API

The snippet below is TypeScript — the <'pkg/.../Type'> generic in angle brackets is what drives end-to-end typing of the payload and reply from your ROS 2 message types (no codegen, no shared types module). From plain JavaScript, drop the generic and the calls behave identically.

// Service call — '7n' / '35n' are the string forms of BigInt 7n / 35n;
// ROS 2 64-bit integers round-trip as strings to survive JSON.
const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>(
  '/add_two_ints',
  { a: '7n', b: '35n' }
);
reply.sum; // typed as `${number}n`, runtime value '42n'

// Publish — resolves to undefined on success
await ros.publish<'std_msgs/msg/String'>('/chatter', { data: 'hello' });

// Subscribe — always uses WebSocket
const sub = await ros.subscribe<'std_msgs/msg/String'>('/chatter', (msg) =>
  console.log(msg.data)
);
await sub.close();

Lifecycle and cleanup

Each subscribe() returns a handle with its own close(); the top-level ros.close() cancels every active subscription and shuts down both transports.

const sub = await ros.subscribe('/chatter', handler);
// …
sub.close(); // drop just this subscription
await ros.close(); // tear down the whole connection

// Typical browser cleanup:
window.addEventListener('beforeunload', () => ros.close());

3. curl recipes (no JavaScript at all)

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.

# 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"}'

4. rclnodejs/web vs. rosbridge + roslibjs

rosbridge + roslibjs is the standard browser-side ROS 2 stack of the past decade. Both stacks target the same job (talk to ROS 2 from a web app over WebSocket + JSON) and both keep the browser facing topics/services rather than inventing a higher-level abstraction. What differs is what's exposed to the browser, how strongly it's typed, and whether plain HTTP works:

rclnodejs/web rosbridge + roslibjs
Public API surface web.json allow-list — reviewable artifact The whole live ROS graph
TypeScript types One ROS 2 type name → fully typed request/response/message any; bolt-on community packages
HTTP call / publish ✅ — curl, Postman, AI-agent tool-use just work ❌ (WebSocket only)