rclnodejs 1.9.0-alpha.0 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +4 -0
- package/README.md +2 -2
- package/binding.gyp +6 -5
- package/index.js +10 -0
- package/lib/action/client.js +5 -4
- package/lib/action/server_goal_handle.js +26 -1
- package/lib/action/uuid.js +1 -1
- package/lib/client.js +0 -45
- package/lib/distro.js +11 -4
- package/lib/interface_loader.js +1 -1
- package/lib/message_introspector.js +1 -29
- package/lib/message_serialization.js +2 -2
- package/lib/native_loader.js +21 -9
- package/lib/node.js +209 -12
- package/lib/parameter_event_handler.js +98 -0
- package/lib/prebuilds.js +47 -0
- package/lib/qos_overriding_options.js +358 -0
- package/lib/rmw.js +6 -1
- package/lib/subscription.js +2 -2
- package/lib/timer.js +1 -1
- package/package.json +11 -6
- package/prebuilds/linux-arm64/humble-jammy-arm64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/humble-jammy-arm64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/jazzy-noble-arm64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/jazzy-noble-arm64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/kilted-noble-arm64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/kilted-noble-arm64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/lyrical-resolute-arm64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/lyrical-resolute-arm64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/humble-jammy-x64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/humble-jammy-x64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/jazzy-noble-x64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/jazzy-noble-x64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/kilted-noble-x64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/kilted-noble-x64-node-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/lyrical-resolute-x64-electron-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/lyrical-resolute-x64-node-rclnodejs.node +0 -0
- package/rosidl_gen/packages.js +4 -4
- package/rosidl_gen/templates/message-template.js +20 -6
- package/rosocket/README.md +152 -0
- package/rosocket/cli.js +168 -0
- package/rosocket/index.js +245 -0
- package/scripts/install.js +14 -3
- package/scripts/tag_prebuilds.js +26 -9
- package/src/rcl_action_client_bindings.cpp +4 -4
- package/src/rcl_graph_bindings.cpp +8 -8
- package/src/rcl_lifecycle_bindings.cpp +1 -1
- package/src/rcl_subscription_bindings.cpp +2 -2
- package/src/rcl_timer_bindings.cpp +21 -2
- package/src/rcl_utilities.cpp +4 -4
- package/src/rcl_utilities.h +2 -2
- package/types/distro.d.ts +15 -1
- package/types/node.d.ts +69 -5
- package/types/parameter_event_handler.d.ts +11 -0
- package/types/qos.d.ts +55 -0
- package/types/timer.d.ts +3 -2
- package/prebuilds/linux-arm64/humble-jammy-arm64-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/jazzy-noble-arm64-rclnodejs.node +0 -0
- package/prebuilds/linux-arm64/kilted-noble-arm64-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/humble-jammy-x64-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/jazzy-noble-x64-rclnodejs.node +0 -0
- package/prebuilds/linux-x64/kilted-noble-x64-rclnodejs.node +0 -0
- package/tools/jsdoc/Makefile +0 -5
- package/tools/jsdoc/README.md +0 -96
- package/tools/jsdoc/build-index.js +0 -610
- package/tools/jsdoc/publish.js +0 -854
- package/tools/jsdoc/regenerate-published-docs.js +0 -605
- package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.eot +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.svg +0 -1830
- package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.woff +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.svg +0 -1830
- package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.eot +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.svg +0 -1830
- package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.woff +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.eot +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.svg +0 -1831
- package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.woff +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.eot +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.svg +0 -1835
- package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.woff +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.eot +0 -0
- package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.svg +0 -1831
- package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.woff +0 -0
- package/tools/jsdoc/static/scripts/linenumber.js +0 -25
- package/tools/jsdoc/static/scripts/prettify/Apache-License-2.0.txt +0 -202
- package/tools/jsdoc/static/scripts/prettify/lang-css.js +0 -36
- package/tools/jsdoc/static/scripts/prettify/prettify.js +0 -738
- package/tools/jsdoc/static/styles/jsdoc-default.css +0 -1012
- package/tools/jsdoc/static/styles/prettify-jsdoc.css +0 -111
- package/tools/jsdoc/static/styles/prettify-tomorrow.css +0 -132
- package/tools/jsdoc/tmpl/augments.tmpl +0 -10
- package/tools/jsdoc/tmpl/container.tmpl +0 -193
- package/tools/jsdoc/tmpl/details.tmpl +0 -143
- package/tools/jsdoc/tmpl/example.tmpl +0 -2
- package/tools/jsdoc/tmpl/examples.tmpl +0 -13
- package/tools/jsdoc/tmpl/exceptions.tmpl +0 -17
- package/tools/jsdoc/tmpl/layout.tmpl +0 -83
- package/tools/jsdoc/tmpl/mainpage.tmpl +0 -163
- package/tools/jsdoc/tmpl/members.tmpl +0 -43
- package/tools/jsdoc/tmpl/method.tmpl +0 -124
- package/tools/jsdoc/tmpl/params.tmpl +0 -133
- package/tools/jsdoc/tmpl/properties.tmpl +0 -110
- package/tools/jsdoc/tmpl/returns.tmpl +0 -12
- package/tools/jsdoc/tmpl/source.tmpl +0 -8
- package/tools/jsdoc/tmpl/tutorial.tmpl +0 -19
- package/tools/jsdoc/tmpl/type.tmpl +0 -7
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# rosocket — ROS 2 in the browser, no library required
|
|
2
|
+
|
|
3
|
+
> A tiny WebSocket gateway to ROS 2 — built into `rclnodejs`.
|
|
4
|
+
|
|
5
|
+
> **Availability:** experimental; currently only on the `develop` branch of
|
|
6
|
+
> `rclnodejs` and not yet part of any published release. Install from GitHub
|
|
7
|
+
> to try it (see the project's [Install from GitHub](../README.md#install-from-github) section):
|
|
8
|
+
>
|
|
9
|
+
> ```bash
|
|
10
|
+
> npm install RobotWebTools/rclnodejs#develop
|
|
11
|
+
> ```
|
|
12
|
+
|
|
13
|
+
**rosocket** is a **lightweight** WebSocket bridge that lets a **plain web
|
|
14
|
+
browser** (or any WebSocket-capable client) talk to ROS 2 through `rclnodejs`,
|
|
15
|
+
with **no extra JavaScript library** required on the client side. Browsers
|
|
16
|
+
only need the built-in `WebSocket` and `JSON` APIs.
|
|
17
|
+
|
|
18
|
+
How it compares with the classic
|
|
19
|
+
[rosbridge_suite](https://github.com/RobotWebTools/rosbridge_suite) +
|
|
20
|
+
[roslibjs](https://github.com/RobotWebTools/roslibjs) stack:
|
|
21
|
+
|
|
22
|
+
| | **rosocket (rclnodejs)** | **rosbridge_suite + roslibjs** |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Server process | same Node.js process as your `rclnodejs` app | separate Python ROS 2 node |
|
|
25
|
+
| Client-side library | none — built-in `WebSocket` + `JSON` | `roslibjs` (must be bundled/loaded) |
|
|
26
|
+
| Wire protocol | resource-style URLs (`/topic/<name>`, `/service/<name>`); frame = bare ROS message as JSON | custom JSON envelope (`op: "publish" / "subscribe" / "call_service"`, …) |
|
|
27
|
+
| Type discovery | URL `?type=` query, or server-side default map | advertised at runtime via envelope ops |
|
|
28
|
+
| Features | publish / subscribe, service client | pub/sub, services, **actions, tf, parameters, compression, PNG/CBOR, auth, …** |
|
|
29
|
+
| Deployment | one `npm` dep, runs anywhere Node runs | extra ROS package; version must match ROS distro |
|
|
30
|
+
|
|
31
|
+
## URL scheme
|
|
32
|
+
|
|
33
|
+
The bridge is **resource-style** — the URL *is* the topic or service name and
|
|
34
|
+
the WebSocket frame *is* the ROS message as JSON.
|
|
35
|
+
|
|
36
|
+
| URL | Direction | Payload |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>` | server → client (subscribe) | one frame per received ROS message, JSON-serialized |
|
|
39
|
+
| `ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>` | client → server (publish) | one frame per ROS message to publish, JSON-encoded |
|
|
40
|
+
| `ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>` | client → server (request) | one frame per request, JSON-encoded |
|
|
41
|
+
| `ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>` | server → client (response) | one frame per response, JSON-serialized |
|
|
42
|
+
|
|
43
|
+
Notes:
|
|
44
|
+
|
|
45
|
+
- Each connection is dedicated to one topic or service. A single socket is
|
|
46
|
+
full-duplex, so the same `/topic/<name>` socket can both publish and
|
|
47
|
+
subscribe at the same time.
|
|
48
|
+
- The `type=` query parameter can be omitted if the server was started with
|
|
49
|
+
`topicTypes` / `serviceTypes` defaults for that name.
|
|
50
|
+
- Service calls may be sent as a bare request (`{"a":1,"b":2}`) or wrapped
|
|
51
|
+
with a correlation id (`{"id":"c1","request":{"a":1,"b":2}}`); responses
|
|
52
|
+
echo the same shape (`{"id":"c1","response":{...}}`).
|
|
53
|
+
- Errors are reported as `{"error":"<message>"}` frames; fatal protocol errors
|
|
54
|
+
cause the socket to close with a `1008`/`1011` code.
|
|
55
|
+
- 64-bit integer fields may be sent as JSON numbers or BigInt-encoded
|
|
56
|
+
strings (`"12n"`); responses use the rclnodejs `toJSONSafe` encoding
|
|
57
|
+
(BigInts become `"<n>n"` strings).
|
|
58
|
+
|
|
59
|
+
## Server side
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const rclnodejs = require('rclnodejs');
|
|
63
|
+
const { startRosocket } = require('rclnodejs/rosocket');
|
|
64
|
+
|
|
65
|
+
await rclnodejs.init();
|
|
66
|
+
const node = new rclnodejs.Node('rosocket_node');
|
|
67
|
+
rclnodejs.spin(node);
|
|
68
|
+
|
|
69
|
+
await startRosocket({
|
|
70
|
+
node,
|
|
71
|
+
port: 9000,
|
|
72
|
+
// optional: pre-declare types so clients can omit ?type=
|
|
73
|
+
topicTypes: { '/chatter': 'std_msgs/msg/String' },
|
|
74
|
+
serviceTypes: { '/add_two_ints': 'example_interfaces/srv/AddTwoInts' },
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Without `topicTypes` / `serviceTypes`
|
|
79
|
+
|
|
80
|
+
The `topicTypes` / `serviceTypes` maps are entirely optional. If you omit
|
|
81
|
+
them, the server stays generic and clients must specify the message type
|
|
82
|
+
themselves via the `?type=` query parameter on each connection:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
// server – open to any topic/service the node is allowed to access
|
|
86
|
+
await startRosocket({ node, port: 9000 });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// browser – type comes from the URL
|
|
91
|
+
const sub = new WebSocket(
|
|
92
|
+
'ws://localhost:9000/topic/chatter?type=std_msgs/msg/String'
|
|
93
|
+
);
|
|
94
|
+
const cli = new WebSocket(
|
|
95
|
+
'ws://localhost:9000/service/add_two_ints?type=example_interfaces/srv/AddTwoInts'
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The same applies to the CLI — drop `--topic` / `--service` to run a generic
|
|
100
|
+
bridge: `npx rosocket --port 9000`.
|
|
101
|
+
|
|
102
|
+
## CLI (`rosocket`)
|
|
103
|
+
|
|
104
|
+
A ready-to-run command is shipped as a `bin` entry, so users do not need to
|
|
105
|
+
write any server code:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# from inside this repo
|
|
109
|
+
npm run rosocket -- --port 9000 \
|
|
110
|
+
--topic /chatter:std_msgs/msg/String \
|
|
111
|
+
--service /add_two_ints:example_interfaces/srv/AddTwoInts
|
|
112
|
+
|
|
113
|
+
# anywhere after `npm i rclnodejs` (or via npx)
|
|
114
|
+
npx rosocket --port 9000 \
|
|
115
|
+
--topic /chatter:std_msgs/msg/String \
|
|
116
|
+
--service /add_two_ints:example_interfaces/srv/AddTwoInts
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Options: `--port/-p`, `--host/-H`, `--node-name/-n`, repeatable
|
|
120
|
+
`--topic/-t <name>:<type>` and `--service/-s <name>:<type>`, `--help/-h`.
|
|
121
|
+
Pre-declared types let browsers omit the `?type=` query.
|
|
122
|
+
|
|
123
|
+
## Browser side (no library)
|
|
124
|
+
|
|
125
|
+
```html
|
|
126
|
+
<script type="module">
|
|
127
|
+
// Subscribe
|
|
128
|
+
const sub = new WebSocket('ws://localhost:9000/topic/chatter');
|
|
129
|
+
sub.onmessage = (e) => console.log('chatter:', JSON.parse(e.data).data);
|
|
130
|
+
|
|
131
|
+
// Publish on the same socket (or a different one)
|
|
132
|
+
sub.onopen = () => sub.send(JSON.stringify({ data: 'hello from browser' }));
|
|
133
|
+
|
|
134
|
+
// Service call
|
|
135
|
+
const cli = new WebSocket('ws://localhost:9000/service/add_two_ints');
|
|
136
|
+
cli.onopen = () => cli.send(JSON.stringify({ a: 1, b: 2 }));
|
|
137
|
+
cli.onmessage = (e) => console.log('sum =', JSON.parse(e.data).sum);
|
|
138
|
+
</script>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Why not rosbridge?
|
|
142
|
+
|
|
143
|
+
Use this bridge when you want:
|
|
144
|
+
|
|
145
|
+
- **Zero browser dependency** — no JavaScript library to bundle or load.
|
|
146
|
+
- **Zero extra process** — already in the same Node.js where your
|
|
147
|
+
`rclnodejs` app runs.
|
|
148
|
+
- **Greppable URLs** for reverse-proxy ACLs (`location /topic/...`).
|
|
149
|
+
|
|
150
|
+
Use a full-featured stack like rosbridge_suite when you need actions, tf,
|
|
151
|
+
parameter helpers, compression, throttling, or compatibility with existing
|
|
152
|
+
ROS web tooling.
|
package/rosocket/cli.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const rclnodejs = require('../index.js');
|
|
13
|
+
const { startRosocket } = require('./index.js');
|
|
14
|
+
|
|
15
|
+
const USAGE = `Usage: rosocket [options]
|
|
16
|
+
|
|
17
|
+
rosocket — expose ROS 2 topics and services as resource-style WebSocket URLs.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-p, --port <port> Port to listen on (default: 9000)
|
|
21
|
+
-H, --host <host> Host/interface to bind (default: 0.0.0.0)
|
|
22
|
+
-n, --node-name <name> ROS 2 node name (default: rosocket)
|
|
23
|
+
-t, --topic <name>:<type> Pre-declare a topic type (repeatable)
|
|
24
|
+
e.g. --topic /chatter:std_msgs/msg/String
|
|
25
|
+
-s, --service <name>:<type> Pre-declare a service type (repeatable)
|
|
26
|
+
e.g. --service /add:example_interfaces/srv/AddTwoInts
|
|
27
|
+
-h, --help Show this help
|
|
28
|
+
|
|
29
|
+
URL scheme:
|
|
30
|
+
ws://host:port/topic/<name>?type=<pkg>/msg/<Type>
|
|
31
|
+
ws://host:port/service/<name>?type=<pkg>/srv/<Type>
|
|
32
|
+
|
|
33
|
+
Pre-declared types via --topic/--service let clients omit the ?type= query.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const opts = {
|
|
38
|
+
port: 9000,
|
|
39
|
+
host: '0.0.0.0',
|
|
40
|
+
nodeName: 'rosocket',
|
|
41
|
+
topicTypes: {},
|
|
42
|
+
serviceTypes: {},
|
|
43
|
+
};
|
|
44
|
+
const need = (i, flag) => {
|
|
45
|
+
if (i + 1 >= argv.length) {
|
|
46
|
+
console.error(`error: ${flag} requires a value`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
return argv[i + 1];
|
|
50
|
+
};
|
|
51
|
+
const addPair = (target, raw, flag) => {
|
|
52
|
+
const idx = raw.indexOf(':');
|
|
53
|
+
if (idx <= 0) {
|
|
54
|
+
console.error(`error: ${flag} expects <name>:<type>, got "${raw}"`);
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
let name = raw.slice(0, idx);
|
|
58
|
+
const type = raw.slice(idx + 1);
|
|
59
|
+
if (!name.startsWith('/')) name = '/' + name;
|
|
60
|
+
target[name] = type;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
switch (a) {
|
|
66
|
+
case '-h':
|
|
67
|
+
case '--help':
|
|
68
|
+
console.log(USAGE);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
break;
|
|
71
|
+
case '-p':
|
|
72
|
+
case '--port': {
|
|
73
|
+
const raw = need(i, a);
|
|
74
|
+
const p = Number(raw);
|
|
75
|
+
if (!Number.isInteger(p) || p < 0 || p > 65535) {
|
|
76
|
+
console.error(
|
|
77
|
+
`error: ${a} expects an integer in 0–65535, got "${raw}"`
|
|
78
|
+
);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
opts.port = p;
|
|
82
|
+
i++;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case '-H':
|
|
86
|
+
case '--host':
|
|
87
|
+
opts.host = need(i, a);
|
|
88
|
+
i++;
|
|
89
|
+
break;
|
|
90
|
+
case '-n':
|
|
91
|
+
case '--node-name':
|
|
92
|
+
opts.nodeName = need(i, a);
|
|
93
|
+
i++;
|
|
94
|
+
break;
|
|
95
|
+
case '-t':
|
|
96
|
+
case '--topic':
|
|
97
|
+
addPair(opts.topicTypes, need(i, a), a);
|
|
98
|
+
i++;
|
|
99
|
+
break;
|
|
100
|
+
case '-s':
|
|
101
|
+
case '--service':
|
|
102
|
+
addPair(opts.serviceTypes, need(i, a), a);
|
|
103
|
+
i++;
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
console.error(`error: unknown argument: ${a}`);
|
|
107
|
+
console.error(USAGE);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return opts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function main() {
|
|
115
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
116
|
+
|
|
117
|
+
await rclnodejs.init();
|
|
118
|
+
const node = rclnodejs.createNode(opts.nodeName);
|
|
119
|
+
rclnodejs.spin(node);
|
|
120
|
+
|
|
121
|
+
const bridge = await startRosocket({
|
|
122
|
+
node,
|
|
123
|
+
port: opts.port,
|
|
124
|
+
host: opts.host,
|
|
125
|
+
topicTypes: opts.topicTypes,
|
|
126
|
+
serviceTypes: opts.serviceTypes,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 0.0.0.0 / :: are bind wildcards, not reachable URLs. Show a usable
|
|
130
|
+
// hostname in the log so users can paste the URL directly into a browser.
|
|
131
|
+
const displayHost =
|
|
132
|
+
opts.host === '0.0.0.0' || opts.host === '::' || opts.host === ''
|
|
133
|
+
? '127.0.0.1'
|
|
134
|
+
: opts.host;
|
|
135
|
+
console.log(
|
|
136
|
+
`[rosocket] node="${opts.nodeName}" listening on ws://${displayHost}:${bridge.port} (bind=${opts.host})`
|
|
137
|
+
);
|
|
138
|
+
for (const [name, type] of Object.entries(opts.topicTypes)) {
|
|
139
|
+
console.log(` topic ${name}\t-> ${type}`);
|
|
140
|
+
}
|
|
141
|
+
for (const [name, type] of Object.entries(opts.serviceTypes)) {
|
|
142
|
+
console.log(` service ${name}\t-> ${type}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const shutdown = (sig) => {
|
|
146
|
+
console.log(`[rosocket] received ${sig}, shutting down`);
|
|
147
|
+
// Hard-exit fallback in case ws/rcl close callbacks don't fire
|
|
148
|
+
// (e.g. due to in-flight rclnodejs.spin loop keeping the event loop busy).
|
|
149
|
+
const hard = setTimeout(() => process.exit(0), 1500);
|
|
150
|
+
hard.unref();
|
|
151
|
+
Promise.resolve()
|
|
152
|
+
.then(() => bridge.close())
|
|
153
|
+
.catch(() => {})
|
|
154
|
+
.then(() => {
|
|
155
|
+
try {
|
|
156
|
+
rclnodejs.shutdown();
|
|
157
|
+
} catch (_) {}
|
|
158
|
+
process.exit(0);
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
162
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((e) => {
|
|
166
|
+
console.error(e.stack || e.message);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Copyright (c) 2026 RobotWebTools Contributors. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { WebSocketServer } = require('ws');
|
|
12
|
+
const debug = require('debug')('rclnodejs:rosocket');
|
|
13
|
+
const { toJSONSafe } = require('../lib/message_serialization.js');
|
|
14
|
+
|
|
15
|
+
// Convert a JSON value coming from the browser into shapes rclnodejs understands.
|
|
16
|
+
// Mainly: integer fields declared as 64-bit need to be BigInt. We don't have schema
|
|
17
|
+
// information here, so we accept either Number or BigInt for fields and let the
|
|
18
|
+
// underlying serializer coerce. For convenience we convert string-encoded ints
|
|
19
|
+
// like "12n" produced by toJSONSafe back to BigInt.
|
|
20
|
+
function reviveBigInts(value) {
|
|
21
|
+
if (value === null || typeof value !== 'object') {
|
|
22
|
+
if (typeof value === 'string' && /^-?\d+n$/.test(value)) {
|
|
23
|
+
return BigInt(value.slice(0, -1));
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) return value.map(reviveBigInts);
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const k of Object.keys(value)) out[k] = reviveBigInts(value[k]);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeSend(ws, payload) {
|
|
34
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
35
|
+
try {
|
|
36
|
+
ws.send(typeof payload === 'string' ? payload : JSON.stringify(payload));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
debug('send failed: %s', e.message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sendError(ws, message, extra) {
|
|
43
|
+
safeSend(ws, { error: message, ...(extra || {}) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Decode a URL path into { kind, name } where kind is 'topic' or 'service'.
|
|
47
|
+
// Examples:
|
|
48
|
+
// /topic/chatter -> { kind:'topic', name:'/chatter' }
|
|
49
|
+
// /topic/ns/sub/chatter -> { kind:'topic', name:'/ns/sub/chatter' }
|
|
50
|
+
// /service/add_two_ints -> { kind:'service', name:'/add_two_ints' }
|
|
51
|
+
function parseResourcePath(pathname) {
|
|
52
|
+
const m = /^\/(topic|service)\/(.+)$/.exec(pathname);
|
|
53
|
+
if (!m) return null;
|
|
54
|
+
const name = '/' + m[2].replace(/^\/+/, '');
|
|
55
|
+
if (name.length < 2) return null;
|
|
56
|
+
return { kind: m[1], name };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start a resource-style WebSocket bridge that exposes ROS 2 topics and
|
|
61
|
+
* services as plain WebSocket URLs carrying ROS messages as JSON.
|
|
62
|
+
*
|
|
63
|
+
* URL scheme:
|
|
64
|
+
* ws://host:port/topic/<topic_name>?type=<pkg>/msg/<Type>
|
|
65
|
+
* ws://host:port/service/<service_name>?type=<pkg>/srv/<Type>
|
|
66
|
+
*
|
|
67
|
+
* The browser only needs the built-in `WebSocket` and `JSON` APIs.
|
|
68
|
+
*
|
|
69
|
+
* @param {Object} options
|
|
70
|
+
* @param {import('../lib/node.js')} options.node - rclnodejs Node to host pubs/subs/clients on.
|
|
71
|
+
* @param {number} [options.port=9000] - Port to listen on.
|
|
72
|
+
* @param {string} [options.host='0.0.0.0'] - Host to bind to.
|
|
73
|
+
* @param {Object<string,string>} [options.topicTypes] - Optional default type per topic name (e.g. {"/chatter":"std_msgs/msg/String"}).
|
|
74
|
+
* @param {Object<string,string>} [options.serviceTypes] - Optional default type per service name.
|
|
75
|
+
* @param {(req: import('http').IncomingMessage) => boolean} [options.verifyClient] - Optional auth hook called with the raw HTTP upgrade request; return `false` to reject the connection.
|
|
76
|
+
* @returns {Promise<{wss: WebSocketServer, close: () => Promise<void>, port: number}>}
|
|
77
|
+
*/
|
|
78
|
+
function startRosocket(options = {}) {
|
|
79
|
+
const {
|
|
80
|
+
node,
|
|
81
|
+
port = 9000,
|
|
82
|
+
host = '0.0.0.0',
|
|
83
|
+
topicTypes = {},
|
|
84
|
+
serviceTypes = {},
|
|
85
|
+
verifyClient,
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
if (!node) throw new TypeError('startRosocket: options.node is required');
|
|
89
|
+
|
|
90
|
+
// ws's verifyClient is invoked with `info = { origin, secure, req }`,
|
|
91
|
+
// not the raw IncomingMessage. Wrap it so users can write a simple
|
|
92
|
+
// `(req) => boolean` hook as documented above.
|
|
93
|
+
const wsVerifyClient = verifyClient
|
|
94
|
+
? (info) => verifyClient(info.req)
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const wss = new WebSocketServer({
|
|
99
|
+
host,
|
|
100
|
+
port,
|
|
101
|
+
verifyClient: wsVerifyClient,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
wss.on('error', reject);
|
|
105
|
+
|
|
106
|
+
wss.on('connection', (ws, req) => {
|
|
107
|
+
const url = new URL(req.url, 'http://localhost');
|
|
108
|
+
const resource = parseResourcePath(url.pathname);
|
|
109
|
+
if (!resource) {
|
|
110
|
+
sendError(ws, `Unknown path: ${url.pathname}`);
|
|
111
|
+
ws.close(1008, 'unknown path');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const typeFromQuery = url.searchParams.get('type');
|
|
115
|
+
const typeFromMap =
|
|
116
|
+
resource.kind === 'topic'
|
|
117
|
+
? topicTypes[resource.name]
|
|
118
|
+
: serviceTypes[resource.name];
|
|
119
|
+
const typeName = typeFromQuery || typeFromMap;
|
|
120
|
+
if (!typeName) {
|
|
121
|
+
sendError(
|
|
122
|
+
ws,
|
|
123
|
+
`Missing message type. Specify ?type=<pkg>/${resource.kind === 'topic' ? 'msg' : 'srv'}/<Type> or configure it server-side.`
|
|
124
|
+
);
|
|
125
|
+
ws.close(1008, 'missing type');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
if (resource.kind === 'topic') {
|
|
131
|
+
handleTopic(ws, node, resource.name, typeName);
|
|
132
|
+
} else {
|
|
133
|
+
handleService(ws, node, resource.name, typeName);
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
debug('connection setup failed: %s', e.stack || e.message);
|
|
137
|
+
sendError(ws, e.message);
|
|
138
|
+
ws.close(1011, 'setup error');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
wss.on('listening', () => {
|
|
143
|
+
const addr = wss.address();
|
|
144
|
+
debug('rosocket listening on %s:%d', addr.address, addr.port);
|
|
145
|
+
resolve({
|
|
146
|
+
wss,
|
|
147
|
+
port: addr.port,
|
|
148
|
+
close: () =>
|
|
149
|
+
new Promise((res) => {
|
|
150
|
+
for (const client of wss.clients) {
|
|
151
|
+
try {
|
|
152
|
+
client.close();
|
|
153
|
+
} catch (_) {}
|
|
154
|
+
}
|
|
155
|
+
wss.close(() => res());
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleTopic(ws, node, topicName, typeName) {
|
|
163
|
+
// Lazily create both a publisher and a subscription on first need.
|
|
164
|
+
// The publisher is only created if/when the client publishes; the
|
|
165
|
+
// subscription is created immediately so the client receives messages.
|
|
166
|
+
let publisher = null;
|
|
167
|
+
const subscription = node.createSubscription(typeName, topicName, (msg) => {
|
|
168
|
+
safeSend(ws, toJSONSafe(msg));
|
|
169
|
+
});
|
|
170
|
+
debug('subscribed %s [%s]', topicName, typeName);
|
|
171
|
+
|
|
172
|
+
ws.on('message', (data, isBinary) => {
|
|
173
|
+
if (isBinary) {
|
|
174
|
+
sendError(ws, 'binary frames not supported on /topic/*');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let msg;
|
|
178
|
+
try {
|
|
179
|
+
msg = reviveBigInts(JSON.parse(data.toString('utf8')));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
sendError(ws, `invalid JSON: ${e.message}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if (!publisher) {
|
|
186
|
+
publisher = node.createPublisher(typeName, topicName);
|
|
187
|
+
}
|
|
188
|
+
publisher.publish(msg);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
sendError(ws, `publish failed: ${e.message}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
ws.on('close', () => {
|
|
195
|
+
try {
|
|
196
|
+
node.destroySubscription(subscription);
|
|
197
|
+
} catch (_) {}
|
|
198
|
+
if (publisher) {
|
|
199
|
+
try {
|
|
200
|
+
node.destroyPublisher(publisher);
|
|
201
|
+
} catch (_) {}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function handleService(ws, node, serviceName, typeName) {
|
|
207
|
+
const client = node.createClient(typeName, serviceName);
|
|
208
|
+
debug('service client created %s [%s]', serviceName, typeName);
|
|
209
|
+
|
|
210
|
+
ws.on('message', (data, isBinary) => {
|
|
211
|
+
if (isBinary) {
|
|
212
|
+
sendError(ws, 'binary frames not supported on /service/*');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let parsed;
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(data.toString('utf8'));
|
|
218
|
+
} catch (e) {
|
|
219
|
+
sendError(ws, `invalid JSON: ${e.message}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Allow either bare request {a:1,b:2} or wrapped {id, request}.
|
|
223
|
+
const id = parsed && typeof parsed === 'object' ? parsed.id : undefined;
|
|
224
|
+
const request = reviveBigInts(
|
|
225
|
+
parsed && parsed.request !== undefined ? parsed.request : parsed
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
client.sendRequest(request, (response) => {
|
|
230
|
+
const body = toJSONSafe(response);
|
|
231
|
+
safeSend(ws, id !== undefined ? { id, response: body } : body);
|
|
232
|
+
});
|
|
233
|
+
} catch (e) {
|
|
234
|
+
sendError(ws, `service call failed: ${e.message}`, { id });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
ws.on('close', () => {
|
|
239
|
+
try {
|
|
240
|
+
node.destroyClient(client);
|
|
241
|
+
} catch (_) {}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { startRosocket };
|
package/scripts/install.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const { execSync } = require('child_process');
|
|
18
|
+
const {
|
|
19
|
+
detectPrebuildRuntime,
|
|
20
|
+
getTaggedPrebuildFilename,
|
|
21
|
+
} = require('../lib/prebuilds');
|
|
18
22
|
const { detectUbuntuCodename } = require('../lib/utils');
|
|
19
23
|
|
|
20
24
|
function getRosDistro() {
|
|
@@ -24,6 +28,7 @@ function getRosDistro() {
|
|
|
24
28
|
function checkPrebuiltBinary() {
|
|
25
29
|
const platform = process.platform;
|
|
26
30
|
const arch = process.arch;
|
|
31
|
+
const runtime = detectPrebuildRuntime();
|
|
27
32
|
|
|
28
33
|
// Only Linux has prebuilt binaries
|
|
29
34
|
if (platform !== 'linux') {
|
|
@@ -50,18 +55,24 @@ function checkPrebuiltBinary() {
|
|
|
50
55
|
'prebuilds',
|
|
51
56
|
`${platform}-${arch}`
|
|
52
57
|
);
|
|
53
|
-
const expectedBinary =
|
|
58
|
+
const expectedBinary = getTaggedPrebuildFilename({
|
|
59
|
+
rosDistro,
|
|
60
|
+
ubuntuCodename,
|
|
61
|
+
arch,
|
|
62
|
+
runtime,
|
|
63
|
+
});
|
|
64
|
+
|
|
54
65
|
const binaryPath = path.join(prebuildDir, expectedBinary);
|
|
55
66
|
|
|
56
67
|
if (fs.existsSync(binaryPath)) {
|
|
57
68
|
console.log(`✓ Found prebuilt binary: ${expectedBinary}`);
|
|
58
|
-
console.log(` Platform: ${platform}, Arch: ${arch}`);
|
|
69
|
+
console.log(` Platform: ${platform}, Arch: ${arch}, Runtime: ${runtime}`);
|
|
59
70
|
console.log(` Ubuntu: ${ubuntuCodename}, ROS: ${rosDistro}`);
|
|
60
71
|
return true;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
console.log(
|
|
64
|
-
`✗ No prebuilt binary found for ${rosDistro}-${ubuntuCodename}-${arch}`
|
|
75
|
+
`✗ No ${runtime} prebuilt binary found for ${rosDistro}-${ubuntuCodename}-${arch}`
|
|
65
76
|
);
|
|
66
77
|
|
|
67
78
|
// List available binaries for debugging
|
package/scripts/tag_prebuilds.js
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
+
const {
|
|
18
|
+
getRuntimeFromGeneratedPrebuild,
|
|
19
|
+
getTaggedPrebuildFilename,
|
|
20
|
+
} = require('../lib/prebuilds');
|
|
17
21
|
const { detectUbuntuCodename } = require('../lib/utils');
|
|
18
22
|
|
|
19
23
|
function tagPrebuilds() {
|
|
@@ -38,23 +42,36 @@ function tagPrebuilds() {
|
|
|
38
42
|
return;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
const files = fs
|
|
45
|
+
const files = fs
|
|
46
|
+
.readdirSync(prebuildDir)
|
|
47
|
+
.filter(
|
|
48
|
+
(file) => file.endsWith('.node') && getRuntimeFromGeneratedPrebuild(file)
|
|
49
|
+
);
|
|
42
50
|
|
|
43
51
|
for (const file of files) {
|
|
44
52
|
const filePath = path.join(prebuildDir, file);
|
|
53
|
+
const runtime = getRuntimeFromGeneratedPrebuild(file);
|
|
45
54
|
|
|
46
|
-
// Create tagged version with format:
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
// Create tagged version with format:
|
|
56
|
+
// {ros_distro}-{linux-codename}-{cpu-arch}-{runtime}-rclnodejs.node
|
|
57
|
+
if (rosDistro && ubuntuCodename && runtime) {
|
|
58
|
+
const taggedName = getTaggedPrebuildFilename({
|
|
59
|
+
rosDistro,
|
|
60
|
+
ubuntuCodename,
|
|
61
|
+
arch,
|
|
62
|
+
runtime,
|
|
63
|
+
});
|
|
49
64
|
const taggedPath = path.join(prebuildDir, taggedName);
|
|
65
|
+
|
|
66
|
+
if (fs.existsSync(taggedPath)) {
|
|
67
|
+
fs.unlinkSync(taggedPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
fs.copyFileSync(filePath, taggedPath);
|
|
51
71
|
console.log(`Created tagged binary: ${taggedName}`);
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
fs.unlinkSync(filePath);
|
|
56
|
-
console.log(`Removed generic binary: ${file}`);
|
|
57
|
-
}
|
|
73
|
+
fs.unlinkSync(filePath);
|
|
74
|
+
console.log(`Removed generated binary: ${file}`);
|
|
58
75
|
} else {
|
|
59
76
|
console.log(
|
|
60
77
|
`Skipping tagging for ${file} - missing ROS_DISTRO or Ubuntu codename`
|