Skip to content

Commit 134e06b

Browse files
authored
[Web Runtime] Typed Browser SDK over WebSocket (#1511)
New `rclnodejs/web` subpath: a pure-JS, ESM Browser SDK that talks to the existing Web Runtime over WebSocket. Zero native deps; safe to bundle for browsers. Verb API: ```js import { connect } from 'rclnodejs/web'; const ros = await connect('ws://robot.local:9000/capability'); const reply = await ros.call('/add_two_ints', { a: '2n', b: '40n' }); const sub = await ros.subscribe('/scan', (m) => render(m)); await ros.publish('/cmd_vel', { linear: { x: 0.1 } }); ``` Today only WebSocket is wired up. HTTP support will land alongside the server-side `HttpTransport` in a follow-up PR; the SDK throws a clear error on `http://` URLs so callers fail fast. ## Files | Area | Path | |---|---| | SDK implementation (ESM, dynamic `ws` fallback in Node) | `web/client.js` | | Public re-export | `web/index.js` | | Typed surface (`call<'pkg/srv/Name'>` derives request/response from `ServicesMap`) | `web/index.d.ts` | | ESM mode marker | `web/package.json` (`{"type":"module"}`) | | Subpath export `./web` | `package.json` | | SDK-only integration tests (4 cases) | `test/test-web.js` | Fix: #1510
1 parent f5a4eb6 commit 134e06b

6 files changed

Lines changed: 700 additions & 0 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"types": "./types/index.d.ts",
1010
"default": "./index.js"
1111
},
12+
"./web": {
13+
"types": "./web/index.d.ts",
14+
"default": "./web/index.js"
15+
},
1216
"./web/server": {
1317
"types": "./lib/runtime/index.d.ts",
1418
"default": "./lib/runtime/index.js"

test/test-web.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
// SDK tests for `rclnodejs/web`. Runtime / wire protocol coverage
12+
// lives in test/test-runtime.js.
13+
14+
const assert = require('assert');
15+
const rclnodejs = require('../index.js');
16+
const { createRuntime, WebSocketTransport } = require('../lib/runtime');
17+
18+
// `web/` is ESM; await import() lets us pull it in from this CJS file.
19+
let connect;
20+
before(async function () {
21+
({ connect } = await import('../web/index.js'));
22+
});
23+
24+
describe('rclnodejs/web (Browser SDK)', function () {
25+
this.timeout(60 * 1000);
26+
27+
let node;
28+
let runtime;
29+
let endpoint;
30+
31+
before(async function () {
32+
await rclnodejs.init();
33+
node = rclnodejs.createNode('runtime_test_node');
34+
rclnodejs.spin(node);
35+
36+
runtime = createRuntime({
37+
node,
38+
transport: new WebSocketTransport({ port: 0, host: '127.0.0.1' }),
39+
});
40+
runtime.expose({
41+
call: { '/rt_add': 'example_interfaces/srv/AddTwoInts' },
42+
publish: { '/rt_chatter': 'std_msgs/msg/String' },
43+
subscribe: { '/rt_chatter': 'std_msgs/msg/String' },
44+
});
45+
await runtime.start();
46+
const port = runtime.transports[0].port;
47+
endpoint = `ws://127.0.0.1:${port}/capability`;
48+
});
49+
50+
after(async function () {
51+
if (runtime) await runtime.stop();
52+
rclnodejs.shutdown();
53+
});
54+
55+
it('round-trips a service call through the runtime', async function () {
56+
const svc = node.createService(
57+
'example_interfaces/srv/AddTwoInts',
58+
'/rt_add',
59+
(request, response) => {
60+
const reply = response.template;
61+
reply.sum = request.a + request.b;
62+
response.send(reply);
63+
}
64+
);
65+
const ros = await connect(endpoint);
66+
try {
67+
// BigInts arrive over the wire as the toJSONSafe "Nn" convention.
68+
const reply = await ros.call('/rt_add', { a: '2n', b: '40n' });
69+
assert.strictEqual(reply.sum, '42n');
70+
} finally {
71+
await ros.close();
72+
node.destroyService(svc);
73+
}
74+
});
75+
76+
it('publish + subscribe round-trip through the runtime', async function () {
77+
const ros = await connect(endpoint);
78+
let timer;
79+
try {
80+
let received;
81+
const sub = await ros.subscribe('/rt_chatter', (msg) => {
82+
if (!received) received = msg && msg.data;
83+
});
84+
85+
// Retry until subscription discovery completes.
86+
timer = setInterval(() => {
87+
ros.publish('/rt_chatter', { data: 'hello-runtime' }).catch(() => {});
88+
}, 100);
89+
90+
await waitFor(() => received === 'hello-runtime', 8000);
91+
await sub.close();
92+
} finally {
93+
if (timer) clearInterval(timer);
94+
await ros.close();
95+
}
96+
});
97+
98+
it('surfaces not_exposed errors with a structured `code` field', async function () {
99+
// Asserts the SDK propagates `code:` onto the rejected Promise.
100+
const ros = await connect(endpoint);
101+
try {
102+
await assertRejectsWithCode(
103+
ros.call('/never_exposed', {}),
104+
'not_exposed'
105+
);
106+
await assertRejectsWithCode(
107+
ros.publish('/never_exposed', {}),
108+
'not_exposed'
109+
);
110+
await assertRejectsWithCode(
111+
ros.subscribe('/never_exposed', () => {}),
112+
'not_exposed'
113+
);
114+
} finally {
115+
await ros.close();
116+
}
117+
});
118+
119+
it('assigns distinct UUID v4 ids for repeated subscribes', async function () {
120+
const ros = await connect(endpoint);
121+
try {
122+
const sub1 = await ros.subscribe('/rt_chatter', () => {});
123+
const sub2 = await ros.subscribe('/rt_chatter', () => {});
124+
assert.notStrictEqual(sub1.subId, sub2.subId);
125+
assert.match(
126+
sub1.subId,
127+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
128+
'subId should be a v4 UUID'
129+
);
130+
await sub1.close();
131+
await sub2.close();
132+
} finally {
133+
await ros.close();
134+
}
135+
});
136+
});
137+
138+
function waitFor(predicate, timeoutMs) {
139+
const started = Date.now();
140+
return new Promise((resolve, reject) => {
141+
const tick = () => {
142+
if (predicate()) return resolve();
143+
if (Date.now() - started > timeoutMs) {
144+
return reject(new Error('waitFor: timeout'));
145+
}
146+
setTimeout(tick, 50);
147+
};
148+
tick();
149+
});
150+
}
151+
152+
async function assertRejectsWithCode(promise, expectedCode) {
153+
let err;
154+
try {
155+
await promise;
156+
} catch (e) {
157+
err = e;
158+
}
159+
assert.ok(err, 'expected rejection');
160+
assert.strictEqual(
161+
err.code,
162+
expectedCode,
163+
`expected error code ${expectedCode}, got ${err.code}: ${err.message}`
164+
);
165+
}

0 commit comments

Comments
 (0)