Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ This example assumes your ROS 2 environment is already sourced.
- Reference:
[API Documentation](#api-documentation), [Using TypeScript](#using-rclnodejs-with-typescript), [ROS 2 Interface Message Generation](#ros-2-interface-message-generation)
- Features and examples:
[rclnodejs-cli](#rclnodejs-cli), [Electron-based Visualization](#electron-based-visualization), [Observable Subscriptions](#observable-subscriptions), [Performance Benchmarks](#performance-benchmarks)
[rclnodejs-cli](#rclnodejs-cli), [Electron-based Visualization](#electron-based-visualization), [Observable Subscriptions](#observable-subscriptions), [rosocket](#rosocket--ros-2-in-the-browser-no-library-required), [Performance Benchmarks](#performance-benchmarks)
- 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)

Expand Down Expand Up @@ -198,6 +198,29 @@ obsSub.observable

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

## rosocket — ROS 2 in the browser, no library required

> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`.

> **Availability:** experimental; currently only on the `develop` branch and not yet part of any published `rclnodejs` release.

**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.

## ROS 2 Interface Message Generation

ROS client libraries convert IDL message descriptions into target language source code. rclnodejs provides the `generate-ros-messages` script to generate JavaScript message interface files and TypeScript declarations.
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@
"coverage": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js",
"prebuild:node": "prebuildify --napi --strip --name node --target 20.20.2",
"prebuild:electron": "prebuildify --napi --strip --name electron --target electron@34.0.0",
"prebuild": "npm run prebuild:node && npm run prebuild:electron && node scripts/tag_prebuilds.js"
"prebuild": "npm run prebuild:node && npm run prebuild:electron && node scripts/tag_prebuilds.js",
"rosocket": "node ./rosocket/cli.js"
},
"bin": {
"generate-ros-messages": "./scripts/generate_messages.js"
"generate-ros-messages": "./scripts/generate_messages.js",
"rosocket": "./rosocket/cli.js"
},
"authors": [
"Minggang Wang <minggangw@gmail.com>",
Expand Down Expand Up @@ -86,7 +88,8 @@
"json-bigint": "^1.0.0",
"node-addon-api": "^8.3.1",
"rxjs": "^7.8.1",
"walk": "^2.3.15"
"walk": "^2.3.15",
"ws": "^8.18.0"
},
"husky": {
"hooks": {
Expand Down
152 changes: 152 additions & 0 deletions rosocket/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# rosocket — ROS 2 in the browser, no library required

> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`.

> **Availability:** experimental; currently only on the `develop` branch of
> `rclnodejs` and not yet part of any published release. Install from GitHub
> to try it (see the project's [Install from GitHub](../README.md#install-from-github) section):
>
> ```bash
> npm install RobotWebTools/rclnodejs#develop
> ```

**rosocket** is a **lightweight** WebSocket bridge 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.

How it compares with the classic
[rosbridge_suite](https://github.com/RobotWebTools/rosbridge_suite) +
[roslibjs](https://github.com/RobotWebTools/roslibjs) stack:

| | **rosocket (rclnodejs)** | **rosbridge_suite + roslibjs** |
| --- | --- | --- |
| Server process | same Node.js process as your `rclnodejs` app | separate Python ROS 2 node |
| Client-side library | none — built-in `WebSocket` + `JSON` | `roslibjs` (must be bundled/loaded) |
| Wire protocol | resource-style URLs (`/topic/<name>`, `/service/<name>`); frame = bare ROS message as JSON | custom JSON envelope (`op: "publish" / "subscribe" / "call_service"`, …) |
| Type discovery | URL `?type=` query, or server-side default map | advertised at runtime via envelope ops |
| Features | publish / subscribe, service client | pub/sub, services, **actions, tf, parameters, compression, PNG/CBOR, auth, …** |
| Deployment | one `npm` dep, runs anywhere Node runs | extra ROS package; version must match ROS distro |

## URL scheme

The bridge is **resource-style** — the URL *is* the topic or service name and
the WebSocket frame *is* the ROS message as JSON.

| URL | Direction | Payload |
| --- | --- | --- |
| `ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>` | server → client (subscribe) | one frame per received ROS message, JSON-serialized |
| `ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>` | client → server (publish) | one frame per ROS message to publish, JSON-encoded |
| `ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>` | client → server (request) | one frame per request, JSON-encoded |
| `ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>` | server → client (response) | one frame per response, JSON-serialized |

Notes:

- Each connection is dedicated to one topic or service. A single socket is
full-duplex, so the same `/topic/<name>` socket can both publish and
subscribe at the same time.
- The `type=` query parameter can be omitted if the server was started with
`topicTypes` / `serviceTypes` defaults for that name.
- Service calls may be sent as a bare request (`{"a":1,"b":2}`) or wrapped
with a correlation id (`{"id":"c1","request":{"a":1,"b":2}}`); responses
echo the same shape (`{"id":"c1","response":{...}}`).
- Errors are reported as `{"error":"<message>"}` frames; fatal protocol errors
cause the socket to close with a `1008`/`1011` code.
- 64-bit integer fields may be sent as JSON numbers or BigInt-encoded
strings (`"12n"`); responses use the rclnodejs `toJSONSafe` encoding
(BigInts become `"<n>n"` strings).

## Server side

```js
const rclnodejs = require('rclnodejs');
const { startRosocket } = require('rclnodejs/rosocket');

await rclnodejs.init();
const node = new rclnodejs.Node('rosocket_node');
rclnodejs.spin(node);

await startRosocket({
node,
port: 9000,
// optional: pre-declare types so clients can omit ?type=
topicTypes: { '/chatter': 'std_msgs/msg/String' },
serviceTypes: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' },
});
```

### Without `topicTypes` / `serviceTypes`

The `topicTypes` / `serviceTypes` maps are entirely optional. If you omit
them, the server stays generic and clients must specify the message type
themselves via the `?type=` query parameter on each connection:

```js
// server – open to any topic/service the node is allowed to access
await startRosocket({ node, port: 9000 });
```

```js
// browser – type comes from the URL
const sub = new WebSocket(
'ws://localhost:9000/topic/chatter?type=std_msgs/msg/String'
);
const cli = new WebSocket(
'ws://localhost:9000/service/add_two_ints?type=example_interfaces/srv/AddTwoInts'
);
```

The same applies to the CLI — drop `--topic` / `--service` to run a generic
bridge: `npx rosocket --port 9000`.

## CLI (`rosocket`)

A ready-to-run command is shipped as a `bin` entry, so users do not need to
write any server code:

```bash
# from inside this repo
npm run rosocket -- --port 9000 \
--topic /chatter:std_msgs/msg/String \
--service /add_two_ints:example_interfaces/srv/AddTwoInts

# anywhere after `npm i rclnodejs` (or via npx)
npx rosocket --port 9000 \
--topic /chatter:std_msgs/msg/String \
--service /add_two_ints:example_interfaces/srv/AddTwoInts
```

Options: `--port/-p`, `--host/-H`, `--node-name/-n`, repeatable
`--topic/-t <name>:<type>` and `--service/-s <name>:<type>`, `--help/-h`.
Pre-declared types let browsers omit the `?type=` query.

## Browser side (no library)

```html
<script type="module">
// Subscribe
const sub = new WebSocket('ws://localhost:9000/topic/chatter');
sub.onmessage = (e) => console.log('chatter:', JSON.parse(e.data).data);

// Publish on the same socket (or a different one)
sub.onopen = () => sub.send(JSON.stringify({ data: 'hello from browser' }));

// Service call
const cli = new WebSocket('ws://localhost:9000/service/add_two_ints');
cli.onopen = () => cli.send(JSON.stringify({ a: 1, b: 2 }));
cli.onmessage = (e) => console.log('sum =', JSON.parse(e.data).sum);
</script>
```

## Why not rosbridge?

Use this bridge when you want:

- **Zero browser dependency** — no JavaScript library to bundle or load.
- **Zero extra process** — already in the same Node.js where your
`rclnodejs` app runs.
- **Greppable URLs** for reverse-proxy ACLs (`location /topic/...`).

Use a full-featured stack like rosbridge_suite when you need actions, tf,
parameter helpers, compression, throttling, or compatibility with existing
ROS web tooling.
168 changes: 168 additions & 0 deletions rosocket/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env node
// 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

'use strict';

const rclnodejs = require('../index.js');
const { startRosocket } = require('./index.js');

const USAGE = `Usage: rosocket [options]

rosocket — expose ROS 2 topics and services as resource-style WebSocket URLs.

Options:
-p, --port <port> Port to listen on (default: 9000)
-H, --host <host> Host/interface to bind (default: 0.0.0.0)
-n, --node-name <name> ROS 2 node name (default: rosocket)
-t, --topic <name>:<type> Pre-declare a topic type (repeatable)
e.g. --topic /chatter:std_msgs/msg/String
-s, --service <name>:<type> Pre-declare a service type (repeatable)
e.g. --service /add:example_interfaces/srv/AddTwoInts
-h, --help Show this help

URL scheme:
ws://host:port/topic/<name>?type=<pkg>/msg/<Type>
ws://host:port/service/<name>?type=<pkg>/srv/<Type>

Pre-declared types via --topic/--service let clients omit the ?type= query.
`;

function parseArgs(argv) {
const opts = {
port: 9000,
host: '0.0.0.0',
nodeName: 'rosocket',
topicTypes: {},
serviceTypes: {},
};
const need = (i, flag) => {
if (i + 1 >= argv.length) {
console.error(`error: ${flag} requires a value`);
process.exit(2);
}
return argv[i + 1];
};
const addPair = (target, raw, flag) => {
const idx = raw.indexOf(':');
if (idx <= 0) {
console.error(`error: ${flag} expects <name>:<type>, got "${raw}"`);
process.exit(2);
}
let name = raw.slice(0, idx);
const type = raw.slice(idx + 1);
if (!name.startsWith('/')) name = '/' + name;
target[name] = type;
};

for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '-h':
case '--help':
console.log(USAGE);
process.exit(0);
break;
case '-p':
case '--port': {
const raw = need(i, a);
const p = Number(raw);
if (!Number.isInteger(p) || p < 0 || p > 65535) {
console.error(
`error: ${a} expects an integer in 0–65535, got "${raw}"`
);
process.exit(2);
}
opts.port = p;
i++;
break;
}
case '-H':
case '--host':
opts.host = need(i, a);
i++;
break;
case '-n':
case '--node-name':
opts.nodeName = need(i, a);
i++;
break;
case '-t':
case '--topic':
addPair(opts.topicTypes, need(i, a), a);
i++;
break;
case '-s':
case '--service':
addPair(opts.serviceTypes, need(i, a), a);
i++;
break;
default:
console.error(`error: unknown argument: ${a}`);
console.error(USAGE);
process.exit(2);
}
}
return opts;
}

async function main() {
const opts = parseArgs(process.argv.slice(2));

await rclnodejs.init();
const node = rclnodejs.createNode(opts.nodeName);
rclnodejs.spin(node);

const bridge = await startRosocket({
node,
port: opts.port,
host: opts.host,
topicTypes: opts.topicTypes,
serviceTypes: opts.serviceTypes,
});

// 0.0.0.0 / :: are bind wildcards, not reachable URLs. Show a usable
// hostname in the log so users can paste the URL directly into a browser.
const displayHost =
opts.host === '0.0.0.0' || opts.host === '::' || opts.host === ''
? '127.0.0.1'
: opts.host;
console.log(
`[rosocket] node="${opts.nodeName}" listening on ws://${displayHost}:${bridge.port} (bind=${opts.host})`
);
for (const [name, type] of Object.entries(opts.topicTypes)) {
console.log(` topic ${name}\t-> ${type}`);
}
for (const [name, type] of Object.entries(opts.serviceTypes)) {
console.log(` service ${name}\t-> ${type}`);
}

const shutdown = (sig) => {
console.log(`[rosocket] received ${sig}, shutting down`);
// Hard-exit fallback in case ws/rcl close callbacks don't fire
// (e.g. due to in-flight rclnodejs.spin loop keeping the event loop busy).
const hard = setTimeout(() => process.exit(0), 1500);
hard.unref();
Promise.resolve()
.then(() => bridge.close())
.catch(() => {})
.then(() => {
try {
rclnodejs.shutdown();
} catch (_) {}
process.exit(0);
});
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}

main().catch((e) => {
console.error(e.stack || e.message);
process.exit(1);
});
Loading
Loading