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.
Files changed (108) hide show
  1. package/.prettierignore +4 -0
  2. package/README.md +2 -2
  3. package/binding.gyp +6 -5
  4. package/index.js +10 -0
  5. package/lib/action/client.js +5 -4
  6. package/lib/action/server_goal_handle.js +26 -1
  7. package/lib/action/uuid.js +1 -1
  8. package/lib/client.js +0 -45
  9. package/lib/distro.js +11 -4
  10. package/lib/interface_loader.js +1 -1
  11. package/lib/message_introspector.js +1 -29
  12. package/lib/message_serialization.js +2 -2
  13. package/lib/native_loader.js +21 -9
  14. package/lib/node.js +209 -12
  15. package/lib/parameter_event_handler.js +98 -0
  16. package/lib/prebuilds.js +47 -0
  17. package/lib/qos_overriding_options.js +358 -0
  18. package/lib/rmw.js +6 -1
  19. package/lib/subscription.js +2 -2
  20. package/lib/timer.js +1 -1
  21. package/package.json +11 -6
  22. package/prebuilds/linux-arm64/humble-jammy-arm64-electron-rclnodejs.node +0 -0
  23. package/prebuilds/linux-arm64/humble-jammy-arm64-node-rclnodejs.node +0 -0
  24. package/prebuilds/linux-arm64/jazzy-noble-arm64-electron-rclnodejs.node +0 -0
  25. package/prebuilds/linux-arm64/jazzy-noble-arm64-node-rclnodejs.node +0 -0
  26. package/prebuilds/linux-arm64/kilted-noble-arm64-electron-rclnodejs.node +0 -0
  27. package/prebuilds/linux-arm64/kilted-noble-arm64-node-rclnodejs.node +0 -0
  28. package/prebuilds/linux-arm64/lyrical-resolute-arm64-electron-rclnodejs.node +0 -0
  29. package/prebuilds/linux-arm64/lyrical-resolute-arm64-node-rclnodejs.node +0 -0
  30. package/prebuilds/linux-x64/humble-jammy-x64-electron-rclnodejs.node +0 -0
  31. package/prebuilds/linux-x64/humble-jammy-x64-node-rclnodejs.node +0 -0
  32. package/prebuilds/linux-x64/jazzy-noble-x64-electron-rclnodejs.node +0 -0
  33. package/prebuilds/linux-x64/jazzy-noble-x64-node-rclnodejs.node +0 -0
  34. package/prebuilds/linux-x64/kilted-noble-x64-electron-rclnodejs.node +0 -0
  35. package/prebuilds/linux-x64/kilted-noble-x64-node-rclnodejs.node +0 -0
  36. package/prebuilds/linux-x64/lyrical-resolute-x64-electron-rclnodejs.node +0 -0
  37. package/prebuilds/linux-x64/lyrical-resolute-x64-node-rclnodejs.node +0 -0
  38. package/rosidl_gen/packages.js +4 -4
  39. package/rosidl_gen/templates/message-template.js +20 -6
  40. package/rosocket/README.md +152 -0
  41. package/rosocket/cli.js +168 -0
  42. package/rosocket/index.js +245 -0
  43. package/scripts/install.js +14 -3
  44. package/scripts/tag_prebuilds.js +26 -9
  45. package/src/rcl_action_client_bindings.cpp +4 -4
  46. package/src/rcl_graph_bindings.cpp +8 -8
  47. package/src/rcl_lifecycle_bindings.cpp +1 -1
  48. package/src/rcl_subscription_bindings.cpp +2 -2
  49. package/src/rcl_timer_bindings.cpp +21 -2
  50. package/src/rcl_utilities.cpp +4 -4
  51. package/src/rcl_utilities.h +2 -2
  52. package/types/distro.d.ts +15 -1
  53. package/types/node.d.ts +69 -5
  54. package/types/parameter_event_handler.d.ts +11 -0
  55. package/types/qos.d.ts +55 -0
  56. package/types/timer.d.ts +3 -2
  57. package/prebuilds/linux-arm64/humble-jammy-arm64-rclnodejs.node +0 -0
  58. package/prebuilds/linux-arm64/jazzy-noble-arm64-rclnodejs.node +0 -0
  59. package/prebuilds/linux-arm64/kilted-noble-arm64-rclnodejs.node +0 -0
  60. package/prebuilds/linux-x64/humble-jammy-x64-rclnodejs.node +0 -0
  61. package/prebuilds/linux-x64/jazzy-noble-x64-rclnodejs.node +0 -0
  62. package/prebuilds/linux-x64/kilted-noble-x64-rclnodejs.node +0 -0
  63. package/tools/jsdoc/Makefile +0 -5
  64. package/tools/jsdoc/README.md +0 -96
  65. package/tools/jsdoc/build-index.js +0 -610
  66. package/tools/jsdoc/publish.js +0 -854
  67. package/tools/jsdoc/regenerate-published-docs.js +0 -605
  68. package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.eot +0 -0
  69. package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.svg +0 -1830
  70. package/tools/jsdoc/static/fonts/OpenSans-Bold-webfont.woff +0 -0
  71. package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  72. package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.svg +0 -1830
  73. package/tools/jsdoc/static/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  74. package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.eot +0 -0
  75. package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.svg +0 -1830
  76. package/tools/jsdoc/static/fonts/OpenSans-Italic-webfont.woff +0 -0
  77. package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.eot +0 -0
  78. package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.svg +0 -1831
  79. package/tools/jsdoc/static/fonts/OpenSans-Light-webfont.woff +0 -0
  80. package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  81. package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.svg +0 -1835
  82. package/tools/jsdoc/static/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  83. package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.eot +0 -0
  84. package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.svg +0 -1831
  85. package/tools/jsdoc/static/fonts/OpenSans-Regular-webfont.woff +0 -0
  86. package/tools/jsdoc/static/scripts/linenumber.js +0 -25
  87. package/tools/jsdoc/static/scripts/prettify/Apache-License-2.0.txt +0 -202
  88. package/tools/jsdoc/static/scripts/prettify/lang-css.js +0 -36
  89. package/tools/jsdoc/static/scripts/prettify/prettify.js +0 -738
  90. package/tools/jsdoc/static/styles/jsdoc-default.css +0 -1012
  91. package/tools/jsdoc/static/styles/prettify-jsdoc.css +0 -111
  92. package/tools/jsdoc/static/styles/prettify-tomorrow.css +0 -132
  93. package/tools/jsdoc/tmpl/augments.tmpl +0 -10
  94. package/tools/jsdoc/tmpl/container.tmpl +0 -193
  95. package/tools/jsdoc/tmpl/details.tmpl +0 -143
  96. package/tools/jsdoc/tmpl/example.tmpl +0 -2
  97. package/tools/jsdoc/tmpl/examples.tmpl +0 -13
  98. package/tools/jsdoc/tmpl/exceptions.tmpl +0 -17
  99. package/tools/jsdoc/tmpl/layout.tmpl +0 -83
  100. package/tools/jsdoc/tmpl/mainpage.tmpl +0 -163
  101. package/tools/jsdoc/tmpl/members.tmpl +0 -43
  102. package/tools/jsdoc/tmpl/method.tmpl +0 -124
  103. package/tools/jsdoc/tmpl/params.tmpl +0 -133
  104. package/tools/jsdoc/tmpl/properties.tmpl +0 -110
  105. package/tools/jsdoc/tmpl/returns.tmpl +0 -12
  106. package/tools/jsdoc/tmpl/source.tmpl +0 -8
  107. package/tools/jsdoc/tmpl/tutorial.tmpl +0 -19
  108. 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.
@@ -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 };
@@ -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 = `${rosDistro}-${ubuntuCodename}-${arch}-rclnodejs.node`;
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
@@ -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.readdirSync(prebuildDir).filter((f) => f.endsWith('.node'));
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: {ros_distro}-{linux-codename}-{cpu-arch}-rclnodejs.node
47
- if (rosDistro && ubuntuCodename) {
48
- const taggedName = `${rosDistro}-${ubuntuCodename}-${arch}-rclnodejs.node`;
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
- // Remove the original generic binary file if it's the basic rclnodejs.node
54
- if (file === 'rclnodejs.node') {
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`