Skip to content

Commit bcbbffa

Browse files
committed
feat(web): typed Browser SDK over WebSocket
Adds the rclnodejs/web subpath with the L4 client SDK that talks to the capability runtime introduced in the previous commit: - web/client.js: pure-JS, ESM, zero native deps. Verb API (call, publish, subscribe), UUID frame ids. Today only the WebSocket transport is wired up; HTTP support will land alongside the server-side HttpTransport in a follow-up. Ships with web/package.json (`type: module`) so Node also treats it as ESM. - web/index.js: re-exports the public surface (`connect`, `RosClient`). - web/index.d.ts: typed surface. Single string generic per call: `ros.call<'pkg/srv/Name'>(name, request)` derives request and response shapes from rclnodejs's auto-generated MessagesMap / ServicesMap via an internal WireType<T> helper. Zero glue code or shared types module needed. - test/test-web.js: 8 tests covering CapabilityRegistry units + end-to-end round-trips through the SDK (call, pub/sub, allow-list rejection, distinct-id, action reservation). Subpath export added: `rclnodejs/web` -> ./web/index.js (with types mapped to ./web/index.d.ts). In a real browser, `WebSocket` is a global; in Node the SDK falls back to the optional `ws` package via dynamic import.
1 parent f5a4eb6 commit bcbbffa

6 files changed

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

0 commit comments

Comments
 (0)