diff --git a/package.json b/package.json index 61641c3e..f21dc334 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "types": "./types/index.d.ts", "default": "./index.js" }, + "./web": { + "types": "./web/index.d.ts", + "default": "./web/index.js" + }, "./web/server": { "types": "./lib/runtime/index.d.ts", "default": "./lib/runtime/index.js" diff --git a/test/test-web.js b/test/test-web.js new file mode 100644 index 00000000..e7d3cc33 --- /dev/null +++ b/test/test-web.js @@ -0,0 +1,165 @@ +// 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'; + +// SDK tests for `rclnodejs/web`. Runtime / wire protocol coverage +// lives in test/test-runtime.js. + +const assert = require('assert'); +const rclnodejs = require('../index.js'); +const { createRuntime, WebSocketTransport } = require('../lib/runtime'); + +// `web/` is ESM; await import() lets us pull it in from this CJS file. +let connect; +before(async function () { + ({ connect } = await import('../web/index.js')); +}); + +describe('rclnodejs/web (Browser SDK)', function () { + this.timeout(60 * 1000); + + let node; + let runtime; + let endpoint; + + before(async function () { + await rclnodejs.init(); + node = rclnodejs.createNode('runtime_test_node'); + rclnodejs.spin(node); + + runtime = createRuntime({ + node, + transport: new WebSocketTransport({ port: 0, host: '127.0.0.1' }), + }); + runtime.expose({ + call: { '/rt_add': 'example_interfaces/srv/AddTwoInts' }, + publish: { '/rt_chatter': 'std_msgs/msg/String' }, + subscribe: { '/rt_chatter': 'std_msgs/msg/String' }, + }); + await runtime.start(); + const port = runtime.transports[0].port; + endpoint = `ws://127.0.0.1:${port}/capability`; + }); + + after(async function () { + if (runtime) await runtime.stop(); + rclnodejs.shutdown(); + }); + + it('round-trips a service call through the runtime', async function () { + const svc = node.createService( + 'example_interfaces/srv/AddTwoInts', + '/rt_add', + (request, response) => { + const reply = response.template; + reply.sum = request.a + request.b; + response.send(reply); + } + ); + const ros = await connect(endpoint); + try { + // BigInts arrive over the wire as the toJSONSafe "Nn" convention. + const reply = await ros.call('/rt_add', { a: '2n', b: '40n' }); + assert.strictEqual(reply.sum, '42n'); + } finally { + await ros.close(); + node.destroyService(svc); + } + }); + + it('publish + subscribe round-trip through the runtime', async function () { + const ros = await connect(endpoint); + let timer; + try { + let received; + const sub = await ros.subscribe('/rt_chatter', (msg) => { + if (!received) received = msg && msg.data; + }); + + // Retry until subscription discovery completes. + timer = setInterval(() => { + ros.publish('/rt_chatter', { data: 'hello-runtime' }).catch(() => {}); + }, 100); + + await waitFor(() => received === 'hello-runtime', 8000); + await sub.close(); + } finally { + if (timer) clearInterval(timer); + await ros.close(); + } + }); + + it('surfaces not_exposed errors with a structured `code` field', async function () { + // Asserts the SDK propagates `code:` onto the rejected Promise. + const ros = await connect(endpoint); + try { + await assertRejectsWithCode( + ros.call('/never_exposed', {}), + 'not_exposed' + ); + await assertRejectsWithCode( + ros.publish('/never_exposed', {}), + 'not_exposed' + ); + await assertRejectsWithCode( + ros.subscribe('/never_exposed', () => {}), + 'not_exposed' + ); + } finally { + await ros.close(); + } + }); + + it('assigns distinct UUID v4 ids for repeated subscribes', async function () { + const ros = await connect(endpoint); + try { + const sub1 = await ros.subscribe('/rt_chatter', () => {}); + const sub2 = await ros.subscribe('/rt_chatter', () => {}); + assert.notStrictEqual(sub1.subId, sub2.subId); + assert.match( + sub1.subId, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + 'subId should be a v4 UUID' + ); + await sub1.close(); + await sub2.close(); + } finally { + await ros.close(); + } + }); +}); + +function waitFor(predicate, timeoutMs) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + if (predicate()) return resolve(); + if (Date.now() - started > timeoutMs) { + return reject(new Error('waitFor: timeout')); + } + setTimeout(tick, 50); + }; + tick(); + }); +} + +async function assertRejectsWithCode(promise, expectedCode) { + let err; + try { + await promise; + } catch (e) { + err = e; + } + assert.ok(err, 'expected rejection'); + assert.strictEqual( + err.code, + expectedCode, + `expected error code ${expectedCode}, got ${err.code}: ${err.message}` + ); +} diff --git a/web/client.js b/web/client.js new file mode 100644 index 00000000..dc123f1a --- /dev/null +++ b/web/client.js @@ -0,0 +1,331 @@ +// 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 + +// Pure-JavaScript browser SDK for the rclnodejs Web Runtime. +// +// Authored as **ESM** so the browser's native module loader (and any +// modern bundler) can consume it directly without a CJS-to-ESM +// transform. Ships with a `type: module` web/package.json so Node +// also treats this file as ESM. +// +// In a real browser, `WebSocket` is a global. In Node — used by the +// integration tests and SSR scenarios — we fall back to the optional +// `ws` package via dynamic import. +// +// This module never imports rclnodejs itself — zero native deps, +// safe to bundle for the browser. +// +// Today the SDK only speaks the WebSocket transport. An HTTP transport +// is planned and will be added behind a `connect({http, ws})` form +// once the server-side `HttpTransport` ships. + +let WS = globalThis.WebSocket; +if (!WS) { + try { + const wsModule = await import('ws'); + WS = wsModule.WebSocket || wsModule.default; + } catch { + // No WebSocket implementation available in this environment. + } +} + +// Frame ids are UUIDs so they never collide across sessions or modules +// that share a single RosClient. Use the platform RNG when available +// (browsers and Node ≥ 16.7) and fall back to a small RFC-4122 v4 +// implementation otherwise. +const _genId = + globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function' + ? () => globalThis.crypto.randomUUID() + : () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + }; + +// ------------------------------------------------------------------- +// Internal "link" — the WebSocket transport. Hidden from the public API. +// ------------------------------------------------------------------- + +/** + * WebSocket link. Speaks the capability frame protocol used by + * `WebSocketTransport` on the server. Long-lived; supports `call`, + * `publish`, `subscribe`, `unsubscribe` (and reserved `action`). + */ +class _WsLink { + constructor(url) { + this.url = url; + this._ws = null; + this._pending = new Map(); + this._subs = new Map(); + this._closed = false; + } + + connect() { + return new Promise((resolve, reject) => { + if (!WS) { + return reject( + new Error( + 'no WebSocket implementation available; provide a global WebSocket or install the optional `ws` package' + ) + ); + } + const ws = new WS(this.url); + this._ws = ws; + const onOpen = () => { + ws.removeEventListener + ? ws.removeEventListener('error', onError) + : ws.off && ws.off('error', onError); + resolve(); + }; + const onError = (err) => { + if (this._pending.size === 0 && !this._closed) { + reject(err && err.error ? err.error : err); + } else { + this._failAll(err); + } + }; + const onClose = () => { + this._closed = true; + this._failAll(new Error('connection closed')); + this._subs.clear(); + }; + const onMessage = (ev) => this._onMessage(ev); + + if (ws.addEventListener) { + ws.addEventListener('open', onOpen, { once: true }); + ws.addEventListener('error', onError); + ws.addEventListener('close', onClose); + ws.addEventListener('message', onMessage); + } else { + ws.once('open', onOpen); + ws.on('error', onError); + ws.on('close', onClose); + ws.on('message', (data) => onMessage({ data })); + } + }); + } + + call(capability, payload) { + return this._request({ kind: 'call', capability, payload }); + } + publish(capability, payload) { + return this._request({ kind: 'publish', capability, payload }); + } + subscribe(capability, callback) { + const id = _genId(); + return new Promise((resolve, reject) => { + this._pending.set(id, { + resolve: () => { + this._subs.set(id, { capability, callback }); + resolve({ + subId: id, + close: () => this._unsubscribe(id), + }); + }, + reject, + }); + this._sendRaw({ id, kind: 'subscribe', capability }); + }); + } + _unsubscribe(subId) { + this._subs.delete(subId); + return this._request({ kind: 'unsubscribe', subId }); + } + + async close() { + if (!this._ws || this._closed) return; + return new Promise((resolve) => { + const ws = this._ws; + const onClose = () => { + if (ws.removeEventListener) ws.removeEventListener('close', onClose); + else ws.off && ws.off('close', onClose); + resolve(); + }; + if (ws.addEventListener) + ws.addEventListener('close', onClose, { once: true }); + else ws.once('close', onClose); + try { + ws.close(); + } catch (_) { + resolve(); + } + }); + } + + _request(frame) { + return new Promise((resolve, reject) => { + if (this._closed) return reject(new Error('connection closed')); + const id = _genId(); + frame.id = id; + this._pending.set(id, { resolve, reject }); + this._sendRaw(frame); + }); + } + + _sendRaw(frame) { + try { + this._ws.send(JSON.stringify(frame)); + } catch (e) { + const pend = this._pending.get(frame.id); + if (pend) { + this._pending.delete(frame.id); + pend.reject(e); + } + } + } + + _onMessage(ev) { + let frame; + try { + const data = + typeof ev.data === 'string' + ? ev.data + : ev.data && ev.data.toString + ? ev.data.toString('utf8') + : String(ev.data); + frame = JSON.parse(data); + } catch (_) { + return; + } + if (frame.event === 'message') { + const sub = this._subs.get(frame.subId); + if (sub) { + try { + sub.callback(frame.payload); + } catch (_) { + // user callback errors don't break the dispatch loop + } + } + return; + } + const pend = this._pending.get(frame.id); + if (!pend) return; + this._pending.delete(frame.id); + if (frame.ok) pend.resolve(frame.payload); + else + pend.reject( + Object.assign(new Error(frame.error || 'request failed'), { + code: frame.code, + }) + ); + } + + _failAll(err) { + const cause = err instanceof Error ? err : new Error(String(err)); + for (const p of this._pending.values()) { + try { + p.reject(cause); + } catch (_) {} + } + this._pending.clear(); + } +} + +// ------------------------------------------------------------------- +// Public surface +// ------------------------------------------------------------------- + +/** + * Thin client for the rclnodejs Web Runtime capability protocol. + * + * Today the only supported transport is WebSocket — pass a `ws://` or + * `wss://` URL. HTTP support is planned and will be wired in once the + * server-side `HttpTransport` ships. + * + * **Path conventions.** The default `WebSocketTransport` listens on + * `/capability`, so `connect('ws://host:9000/capability')` is the + * normal form. If you change `--path` on the server (or sit it behind + * a path-rewriting proxy), pass the full URL accordingly. + * + * @example + * 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' }); + */ +export class RosClient { + /** + * @param {string} url WebSocket URL (`ws://` or `wss://`). + * @param {object} [options] + * @param {boolean} [options.reconnect=false] Reserved; not yet implemented. + */ + constructor(url, options = {}) { + this.options = options; + if (options.reconnect) { + // eslint-disable-next-line no-console + console.warn( + 'rclnodejs/web: reconnect is not yet implemented; ignoring option' + ); + } + this.url = _resolveWsUrl(url); + this._ws = new _WsLink(this.url); + } + + /** Open the WebSocket. */ + async connect() { + await this._ws.connect(); + return this; + } + + /** Close the WebSocket. */ + async close() { + await this._ws.close(); + } + + // -- verb API -- + + call(capability, payload) { + return this._ws.call(capability, payload); + } + + publish(capability, payload) { + return this._ws.publish(capability, payload); + } + + async subscribe(capability, callback) { + if (typeof callback !== 'function') { + throw new TypeError('subscribe(capability, callback): callback required'); + } + return this._ws.subscribe(capability, callback); + } +} + +/** + * Validate the user-supplied `connect()` URL. Only `ws://` and `wss://` + * are accepted today; anything else throws so HTTP-only callers fail + * fast at construction time instead of much later on first verb. + */ +function _resolveWsUrl(url) { + if (typeof url !== 'string' || !url) { + throw new TypeError('connect(url): url must be a non-empty string'); + } + if (!/^wss?:\/\//i.test(url)) { + throw new TypeError( + `connect(url): unsupported URL scheme: ${url} (expected ws:// or wss://; HTTP transport is not yet supported)` + ); + } + return url; +} + +/** + * Open a connection to a Web Runtime capability endpoint. + * + * See {@link RosClient} for path conventions. Today only WebSocket + * URLs (`ws://`, `wss://`) are accepted. + * + * @param {string} url + * @param {object} [options] + * @returns {Promise} + */ +export async function connect(url, options) { + const c = new RosClient(url, options); + await c.connect(); + return c; +} diff --git a/web/index.d.ts b/web/index.d.ts new file mode 100644 index 00000000..47c12fb1 --- /dev/null +++ b/web/index.d.ts @@ -0,0 +1,188 @@ +// 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 +// +// TypeScript surface for the rclnodejs Web Runtime browser SDK. +// +// Goal: minimum setup. The user supplies one string per call — the +// ROS interface name — and the SDK derives the request, response, and +// message shapes from rclnodejs's auto-generated MessagesMap / ServicesMap. +// +// const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( +// '/add_two_ints', { a: '2n', b: '40n' } // typed +// ); +// +// const sub = await ros.subscribe<'std_msgs/msg/String'>( +// '/scan', (m) => render(m.data) // m.data: string +// ); +// +// Untyped fallbacks (no generic) are kept so capabilities not in the +// generated maps still work. + +declare module 'rclnodejs/web' { + // -------- Wire-shape mapping ---------------------------------------- + + /** Wire encoding of a ROS 2 64-bit integer field. */ + export type Int64Wire = `${number}n`; + + /** + * Map an in-process rclnodejs message shape to its on-wire JSON shape. + * + * The Web Runtime serialises messages as JSON with one transformation: + * 64-bit integer fields (`bigint` in the generated types) become the + * string `"n"` so they survive `JSON.stringify`. Everything else + * passes through unchanged. + * + * Cases (checked in order): + * - `bigint` → {@link Int64Wire} (the `"n"` string) + * - `ReadonlyArray` → `WireType[]` (recurse per element) + * - `Date` → `string` (ISO string on the wire) + * - `object` → field-wise recursion + * - everything else → passes through unchanged + * + * @experimental — exposed for advanced consumers that want to derive + * wire shapes by hand. Most users should rely on the type-name + * generics on {@link RosClient.call} / `subscribe` / etc., which + * apply this mapping internally. + */ + // The bigint check uses `[T] extends [bigint]` (tuple wrapper) instead + // of bare `T extends bigint` so the conditional doesn't distribute + // across union members like `bigint | string` (which would map only + // the bigint half and silently drop the rest). + export type WireType = [T] extends [bigint] ? Int64Wire : _WireRecurse; + + /** Recursion step for {@link WireType}; pulled out to keep the cascade flat. */ + type _WireRecurse = + T extends ReadonlyArray + ? WireType[] + : T extends Date + ? string + : T extends object + ? { [K in keyof T]: WireType } + : T; + + // -------- Type-name lookup helpers ---------------------------------- + + // The maps below are declared by rclnodejs's own .d.ts files via a + // `declare module 'rclnodejs' { type MessagesMap = {...} }` augmentation. + // Reaching them across the module boundary requires the inline + // `import('rclnodejs')` form — a default-style `import type rclnodejs + // from 'rclnodejs'` would resolve to the runtime's *value* export, not + // the namespace, and `MessagesMap` would silently collapse to `never`. + + /** ROS 2 message type names available in the sourced environment. */ + export type MessageName = keyof import('rclnodejs').MessagesMap; + + /** ROS 2 service type names available in the sourced environment. */ + export type ServiceName = keyof import('rclnodejs').ServicesMap; + + /** Wire shape of the named message. `Msg<'std_msgs/msg/String'>` → `{ data: string }`. */ + export type Msg = WireType< + import('rclnodejs').MessagesMap[TName] + >; + + type _SvcCtor = + import('rclnodejs').ServicesMap[TName]; + + /** Wire shape of the named service request. */ + export type SvcReq = WireType< + InstanceType<_SvcCtor['Request']> + >; + + /** Wire shape of the named service response. */ + export type SvcResp = WireType< + InstanceType<_SvcCtor['Response']> + >; + + // -------- SDK ------------------------------------------------------ + + /** + * Subscription handle returned by {@link RosClient.subscribe}. + * Closing the handle cancels the subscription on the runtime side. + */ + export interface Subscription { + readonly subId: string; + close(): Promise; + } + + export interface ConnectOptions { + /** + * Reserved for future reconnect-on-close support. Setting this to + * `true` today has no effect; the SDK warns and continues with + * single-shot connection semantics. + */ + reconnect?: boolean; + } + + /** + * Browser-native Web Runtime client. + * + * Today the only supported transport is WebSocket — pass a `ws://` + * or `wss://` URL. HTTP support is planned and will be added once + * the server-side `HttpTransport` ships; the SDK will then accept + * `http://`/`https://` URLs (and a `{http, ws}` endpoint pair) on + * the same `connect()` entry point. + * + * **Path conventions.** The default `WebSocketTransport` listens on + * `/capability`, so `connect('ws://host:9000/capability')` is the + * normal form. If you change `--path` on the server (or sit it + * behind a path-rewriting proxy), pass the full URL accordingly. + */ + export class RosClient { + readonly url: string; + constructor(url: string, options?: ConnectOptions); + connect(): Promise; + close(): Promise; + + /** + * Invoke a service capability. + * + * @example Typed via the ROS service type name (preferred) + * const reply = await ros.call<'example_interfaces/srv/AddTwoInts'>( + * '/add_two_ints', { a: '2n', b: '40n' } + * ); + * console.log(reply.sum); + * + * @example Untyped (capability with a custom type not in the + * generated maps, or quick prototyping) + * const reply = await ros.call('/whatever', { foo: 1 }); + */ + call( + capability: string, + request: SvcReq + ): Promise>; + call(capability: string, request: unknown): Promise; + + /** Publish a message to a topic capability. */ + publish( + capability: string, + message: Msg + ): Promise; + publish(capability: string, message: unknown): Promise; + + /** Subscribe to messages on a topic capability. */ + subscribe( + capability: string, + callback: (msg: Msg) => void + ): Promise; + subscribe( + capability: string, + callback: (msg: unknown) => void + ): Promise; + } + + /** + * Open a connection to a Web Runtime capability endpoint. + * Convenience wrapper around `new RosClient(...).connect()`. + * + * @param url WebSocket URL (`ws://` or `wss://`). + */ + export function connect( + url: string, + options?: ConnectOptions + ): Promise; +} diff --git a/web/index.js b/web/index.js new file mode 100644 index 00000000..004291ef --- /dev/null +++ b/web/index.js @@ -0,0 +1,9 @@ +// 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 + +export { connect, RosClient } from './client.js'; diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/web/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}