From 19cc0d95846a8855768185481246a35bfd9262b8 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Fri, 8 Aug 2025 12:38:35 -0400 Subject: [PATCH 1/8] refactor(cbor): Switch to `cbor2` library. cbor-js was last published 10+ years ago, stable support is provided by `cbor`, and now the `cbor2` package. The `cbor2` library is written natively in typescript and supports all the tags we were shimming with cborTypedArrayTags.js. It also supports adding new encoders/decoders later as part of its feature set. Should be fixed up in the future to address loss of precision expected by old cbor typing functions Signed-off-by: Drew Hoener --- package-lock.json | 26 ++++++-- package.json | 2 +- src/core/SocketAdapter.js | 5 +- src/util/cbor-utils.ts | 53 +++++++++++++++ src/util/cborTypedArrayTags.js | 115 --------------------------------- test/cbor.test.js | 23 ++++--- 6 files changed, 88 insertions(+), 136 deletions(-) create mode 100644 src/util/cbor-utils.ts delete mode 100644 src/util/cborTypedArrayTags.js diff --git a/package-lock.json b/package-lock.json index d6c697b6e..030cfca58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.9.0", - "cbor-js": "^0.1.0", + "cbor2": "^2.0.1", "eventemitter3": "^5.0.1", "pngparse": "^2.0.0", "ws": "^8.0.0" @@ -241,6 +241,15 @@ "node": ">=18" } }, + "node_modules/@cto.af/wtf8": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-0.0.2.tgz", + "integrity": "sha512-ATm4UQiKrdm5GnU6BvIwUDN+LDEtt23zuzKFpnfDT59ULAd0aMYm/nSFzbSO02garLcXumRC13PzNfa7BsfvSg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -2421,10 +2430,17 @@ "node": ">= 10" } }, - "node_modules/cbor-js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cbor-js/-/cbor-js-0.1.0.tgz", - "integrity": "sha1-yAzmEg84fo+qdDcN/aIdlluPx/k=" + "node_modules/cbor2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.0.1.tgz", + "integrity": "sha512-9bE8+tueGxONyxpttNKkAKKcGVtAPeoSJ64AjVTTjEuBOuRaeeP76EN9BbmQqkz1ZeTP0QPvksNBKwvEutIUzQ==", + "license": "MIT", + "dependencies": { + "@cto.af/wtf8": "0.0.2" + }, + "engines": { + "node": ">=20" + } }, "node_modules/chai": { "version": "5.2.0", diff --git a/package.json b/package.json index 1eaf0c486..673c03971 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@xmldom/xmldom": "^0.9.0", - "cbor-js": "^0.1.0", + "cbor2": "^2.0.1", "eventemitter3": "^5.0.1", "pngparse": "^2.0.0", "ws": "^8.0.0" diff --git a/src/core/SocketAdapter.js b/src/core/SocketAdapter.js index e7633bae4..01a9e51d6 100644 --- a/src/core/SocketAdapter.js +++ b/src/core/SocketAdapter.js @@ -7,8 +7,7 @@ * @fileOverview */ -import CBOR from 'cbor-js'; -import typedArrayTagger from '../util/cborTypedArrayTags.js'; +import {decode} from 'cbor2'; var BSON = null; // @ts-expect-error -- Workarounds for not including BSON in bundle. need to revisit if (typeof bson !== 'undefined') { @@ -132,7 +131,7 @@ export default function SocketAdapter(client) { handlePng(message, handleMessage); }); } else if (data.data instanceof ArrayBuffer) { - var decoded = CBOR.decode(data.data, typedArrayTagger); + var decoded = decode(data.data); handleMessage(decoded); } else { var message = JSON.parse(typeof data === 'string' ? data : data.data); diff --git a/src/util/cbor-utils.ts b/src/util/cbor-utils.ts new file mode 100644 index 000000000..751abc851 --- /dev/null +++ b/src/util/cbor-utils.ts @@ -0,0 +1,53 @@ +import { decode, DecodeOptions } from 'cbor2'; + +let warnedPrecision = false; +function warnPrecision() { + if (!warnedPrecision) { + warnedPrecision = true; + console.warn( + 'CBOR 64-bit integer array values may lose precision. No further warnings.' + ); + } +} + +function isBigIntTypedArray(obj: unknown): obj is BigUint64Array | BigInt64Array { + return obj instanceof BigUint64Array || obj instanceof BigInt64Array; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function cborDecode(data: string | ArrayBuffer | ArrayBufferView, decodeOptions?: DecodeOptions): T { + let binary: Uint8Array | string; + if (ArrayBuffer.isView(data)) { + const view = data; + binary = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } else if (data instanceof ArrayBuffer) { + binary = new Uint8Array(data); + } else { + binary = data; + } + + return decode(binary, decodeOptions); +} + +/** + * Decode a CBOR-encoded buffer, expecting a number array in return. + * + * This function is a hack to maintain compatibility with existing behavior. + * `cbor2` will return a BigInt64Array or BigUint64Array if decoded properly, + * but existing tests expect that the `bigint` will be truncated and returned as an array of numbers. + * + * FIXME: Determine if this behavior is still needed + * + * @param data + */ +export function cborDecodeTruncate(data: string | ArrayBuffer | ArrayBufferView): unknown { + const decoded = cborDecode(data); + + if (isBigIntTypedArray(decoded)) { + // Convert bigints to numbers (with potential precision loss) + warnPrecision(); + return Array.from(decoded, (bigintVal) => Number(bigintVal)); + } + + return decoded; +} diff --git a/src/util/cborTypedArrayTags.js b/src/util/cborTypedArrayTags.js deleted file mode 100644 index faec1f65a..000000000 --- a/src/util/cborTypedArrayTags.js +++ /dev/null @@ -1,115 +0,0 @@ -var UPPER32 = Math.pow(2, 32); - -var warnedPrecision = false; -function warnPrecision() { - if (!warnedPrecision) { - warnedPrecision = true; - console.warn( - 'CBOR 64-bit integer array values may lose precision. No further warnings.' - ); - } -} - -/** - * Unpack 64-bit unsigned integer from byte array. - * @param {Uint8Array} bytes - */ -function decodeUint64LE(bytes) { - warnPrecision(); - - var byteLen = bytes.byteLength; - var offset = bytes.byteOffset; - var arrLen = byteLen / 8; - - var buffer = bytes.buffer.slice(offset, offset + byteLen); - var uint32View = new Uint32Array(buffer); - - var arr = new Array(arrLen); - for (var i = 0; i < arrLen; i++) { - var si = i * 2; - var lo = uint32View[si]; - var hi = uint32View[si + 1]; - arr[i] = lo + UPPER32 * hi; - } - - return arr; -} - -/** - * Unpack 64-bit signed integer from byte array. - * @param {Uint8Array} bytes - */ -function decodeInt64LE(bytes) { - warnPrecision(); - - var byteLen = bytes.byteLength; - var offset = bytes.byteOffset; - var arrLen = byteLen / 8; - - var buffer = bytes.buffer.slice(offset, offset + byteLen); - var uint32View = new Uint32Array(buffer); - var int32View = new Int32Array(buffer); - - var arr = new Array(arrLen); - for (var i = 0; i < arrLen; i++) { - var si = i * 2; - var lo = uint32View[si]; - var hi = int32View[si + 1]; - arr[i] = lo + UPPER32 * hi; - } - - return arr; -} - -/** - * Unpack typed array from byte array. - * @param {Uint8Array} bytes - * @param {ArrayConstructor} ArrayType - Desired output array type - */ -function decodeNativeArray(bytes, ArrayType) { - var byteLen = bytes.byteLength; - var offset = bytes.byteOffset; - var buffer = bytes.buffer.slice(offset, offset + byteLen); - return new ArrayType(buffer); -} - -/** - * Supports a subset of draft CBOR typed array tags: - * - * - * Only supports little-endian tags for now. - */ -var nativeArrayTypes = { - 64: Uint8Array, - 69: Uint16Array, - 70: Uint32Array, - 72: Int8Array, - 77: Int16Array, - 78: Int32Array, - 85: Float32Array, - 86: Float64Array -}; - -/** - * We can also decode 64-bit integer arrays, since ROS has these types. - */ -var conversionArrayTypes = { - 71: decodeUint64LE, - 79: decodeInt64LE -}; - -/** - * Handle CBOR typed array tags during decoding. - * @param {Uint8Array} data - * @param {Number} tag - */ -export default function cborTypedArrayTagger(data, tag) { - if (tag in nativeArrayTypes) { - var arrayType = nativeArrayTypes[tag]; - return decodeNativeArray(data, arrayType); - } - if (tag in conversionArrayTypes) { - return conversionArrayTypes[tag](data); - } - return data; -} diff --git a/test/cbor.test.js b/test/cbor.test.js index 4c2d3824a..c9f9ddfbb 100644 --- a/test/cbor.test.js +++ b/test/cbor.test.js @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; -import CBOR from 'cbor-js'; -import cborTypedArrayTagger from '../src/util/cborTypedArrayTags.js'; +import { cborDecode, cborDecodeTruncate } from '../src/util/cbor-utils.js'; /** Convert hex string to ArrayBuffer. */ function hexToBuffer(hex) { @@ -16,7 +15,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Uint16Array', function() { var data = hexToBuffer('d84546010002000300'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Uint16Array'); expect(msg).to.have.lengthOf(3); @@ -27,7 +26,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Uint32Array', function() { var data = hexToBuffer('d8464c010000000200000003000000'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Uint32Array'); expect(msg).to.have.lengthOf(3); @@ -38,7 +37,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Uint64Array', function() { var data = hexToBuffer('d8475818010000000000000002000000000000000300000000000000'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Array'); expect(msg).to.have.lengthOf(3); @@ -49,7 +48,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Int8Array', function() { var data = hexToBuffer('d8484301fe03'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Int8Array'); expect(msg).to.have.lengthOf(3); @@ -60,7 +59,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Int16Array', function() { var data = hexToBuffer('d84d460100feff0300'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Int16Array'); expect(msg).to.have.lengthOf(3); @@ -71,7 +70,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Int32Array', function() { var data = hexToBuffer('d84e4c01000000feffffff03000000'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Int32Array'); expect(msg).to.have.lengthOf(3); @@ -82,7 +81,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Int64Array', function() { var data = hexToBuffer('d84f58180100000000000000feffffffffffffff0300000000000000'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Array'); expect(msg).to.have.lengthOf(3); @@ -93,7 +92,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Float32Array', function() { var data = hexToBuffer('d8554ccdcc8c3fcdcc0cc033335340'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Float32Array'); expect(msg).to.have.lengthOf(3); @@ -104,7 +103,7 @@ describe('CBOR Typed Array Tagger', function() { it('should convert tagged Float64Array', function() { var data = hexToBuffer('d85658189a9999999999f13f9a999999999901c06666666666660a40'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecodeTruncate(data); expect(msg).to.be.a('Float64Array'); expect(msg).to.have.lengthOf(3); @@ -115,7 +114,7 @@ describe('CBOR Typed Array Tagger', function() { it('should be able to unpack two typed arrays', function() { var data = hexToBuffer('82d8484308fe05d84d460100feff0300'); - var msg = CBOR.decode(data, cborTypedArrayTagger); + var msg = cborDecode(data); expect(msg).to.be.a('Array'); expect(msg).to.have.lengthOf(2); From f68a68b9b611b1684000710de4057ad5565130a9 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Fri, 15 Aug 2025 20:33:29 -0400 Subject: [PATCH 2/8] refactor(SocketAdapter): Rename from JS to TS. Signed-off-by: Drew Hoener --- src/core/{SocketAdapter.js => SocketAdapter.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/{SocketAdapter.js => SocketAdapter.ts} (100%) diff --git a/src/core/SocketAdapter.js b/src/core/SocketAdapter.ts similarity index 100% rename from src/core/SocketAdapter.js rename to src/core/SocketAdapter.ts From b6bd560b8fa5f46b2d22b61b6502e06215600ff5 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Fri, 15 Aug 2025 21:19:07 -0400 Subject: [PATCH 3/8] refactor(BSON): Add type extension for global bson object since it's not included or bundled in any way Signed-off-by: Drew Hoener --- src/types/bson-global.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/types/bson-global.d.ts diff --git a/src/types/bson-global.d.ts b/src/types/bson-global.d.ts new file mode 100644 index 000000000..711c8f72b --- /dev/null +++ b/src/types/bson-global.d.ts @@ -0,0 +1,8 @@ +declare global { + interface Window { + bson?: () => { BSON: { deserialize(data: Uint8Array): unknown } }; + } + + const bson: undefined | (() => { BSON: { deserialize(data: Uint8Array): unknown } }); +} +export {}; From 1d3b5caf6346c267047f8d864725bf2476b67ee5 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Mon, 18 Aug 2025 16:21:24 -0400 Subject: [PATCH 4/8] refactor(types): Add types to describe the RosBridge protocol and relevant ROS2 messages. Signed-off-by: Drew Hoener --- src/types/CallbackTypes.ts | 5 + src/types/ProtocolTypes.ts | 536 +++++++++++++++++++++++++++++++++++ src/types/RosMessageTypes.ts | 45 +++ 3 files changed, 586 insertions(+) create mode 100644 src/types/CallbackTypes.ts create mode 100644 src/types/ProtocolTypes.ts create mode 100644 src/types/RosMessageTypes.ts diff --git a/src/types/CallbackTypes.ts b/src/types/CallbackTypes.ts new file mode 100644 index 000000000..05b8d7274 --- /dev/null +++ b/src/types/CallbackTypes.ts @@ -0,0 +1,5 @@ +export type MessageCallback = (message: T) => void; + +export type ValueCallback = (value: T) => unknown; + +export type NodeDetailsCallback = (subscriptions: string[], publications: string[], services: string[]) => unknown; diff --git a/src/types/ProtocolTypes.ts b/src/types/ProtocolTypes.ts new file mode 100644 index 000000000..2001ecacd --- /dev/null +++ b/src/types/ProtocolTypes.ts @@ -0,0 +1,536 @@ +// Base interface for common fields across all operations + +import { hasOwn } from '../util/type-utils.ts'; +import { GoalStatus } from './RosMessageTypes.ts'; + +export type RequiredFields = T & Required>; + +export type BridgeCompressionType = 'none' | 'png' | 'cbor' | 'cbor-raw'; +export type BridgeStatusLevel = 'info' | 'warning' | 'error' | 'none'; + +export interface BaseOp { + op: T; + id?: string; +} + +export interface QoSOp extends BaseOp { + /** + * ROS1: Passed to publisher + * ROS2: Sets QoS to infinite lifespan and depth of 1 + */ + latch?: boolean; + /** + * ROS1: Passed to publisher + * ROS2: Sets the QoS Queue Depth + */ + queue_size?: number; +} + +export interface AuthOp extends BaseOp<'auth'> { + mac: string; + client: string; + dest: string; + rand: string; + t: Date | number; + level: string; + end: Date | number; +} + +// Data transformation operations +export interface FragmentOp extends BaseOp<'fragment'> { + /** + * An id is required for fragmented messages, in order to identify corresponding fragments for the fragmented message. + */ + id: string; + /** + * A fragment of data that, when combined with other fragments of data, makes up another message + */ + data: string; + /** + * The index of the fragment in the message + */ + num: number; + /** + * The total number of fragments + */ + total: number; +} + +export interface PngOp extends BaseOp<'png'> { + /** + * Only required if the message is fragmented. Identifies the fragments for the fragmented message. + */ + id?: string; + /** + * A fragment of a PNG-encoded message or an entire message. + */ + data: string; + /** + * Only required if the message is fragmented. The index of the fragment. + */ + num?: number; + /** + * Only required if the message is fragmented. The total number of fragments. + */ + total?: number; +} + +// Status operations +export interface SetStatusLevelOp extends BaseOp<'set_level'> { + level: BridgeStatusLevel; +} + +export interface StatusOp extends BaseOp<'status'> { + /** + * If the status message was the result of some operation that had an id, then that id is included + */ + id?: string; + /** + * The level of this status message + */ + level: BridgeStatusLevel; + /** + * The string message being logged + */ + msg: string; +} + +// Topic operations + +/** + * + * If the topic does not already exist, and the type specified is a valid type, then the topic will be established with this type. + * + * If the topic already exists with a different type, an error status message is sent and this message is dropped. + * + * If the topic already exists with the same type, the sender of this message is registered as another publisher. + * + * If the topic doesn't already exist but the type cannot be resolved, then an error status message is sent and this message is dropped. + */ +export interface AdvertiseOp extends QoSOp<'advertise'> { + /** + * The string name of the topic to advertise + */ + topic: string; + /** + * The string type to advertise for the topic + */ + type: string; +} + +/** + * If the topic does not exist, a warning status message is sent and this message is dropped + * + * If the topic exists and there are still clients left advertising it, rosbridge will continue to advertise it until all of them have unadvertised + * + * If the topic exists but rosbridge is not advertising it, a warning status message is sent and this message is dropped + */ +export interface UnadvertiseOp extends BaseOp<'unadvertise'> { + /** + * The string name of the topic being unadvertised + */ + topic: string; +} + +/** + * The publish command publishes a message on a topic. + * + * If the topic does not exist, then an error status message is sent and this message is dropped + * + * If the msg does not conform to the type of the topic, then an error status message is sent and this message is dropped + * + * If the msg is a subset of the type of the topic, then a warning status message is sent and the unspecified fields are filled in with defaults + * + * Special case: if the type being published has a 'header' field, then the client can optionally omit the header from the msg. + * If this happens, rosbridge will automatically populate the header with a frame id of "" and the timestamp as the current time. + * Alternatively, just the timestamp field can be omitted, and then the current time will be automatically inserted. + */ +export interface PublishOp extends QoSOp<'publish'> { + topic: string; + msg: unknown; +} + +/** + * This command subscribes the client to the specified topic. + * It is recommended that if the client has multiple components subscribing to the same topic, that each component + * makes its own subscription request providing an ID. That way, each can individually unsubscribe and rosbridge can select the correct rate at which to send messages. + * + * If queue_length is specified, then messages are placed into the queue before being sent. + * Messages are sent from the head of the queue. If the queue gets full, the oldest message is removed and + * replaced by the newest message. + * + * If a client has multiple subscriptions to the same topic, then messages are sent at the lowest throttle_rate, + * with the lowest fragmentation size, and highest queue_length. + * It is recommended that the client provides IDs for its subscriptions to enable rosbridge to effectively + * choose the appropriate fragmentation size and publishing rate. + */ +export interface SubscribeOp extends BaseOp<'subscribe'> { + /** + * If specified, then this specific subscription can be unsubscribed by referencing the ID. + */ + id?: string; + /** + * The (expected) type of the topic to subscribe to. + * If left off, type will be inferred, and if the topic doesn't exist then the command to subscribe will fail + */ + topic: string; + /** + * The name of the topic to subscribe to + */ + type: string; + /** + * The minimum amount of time (in ms) that must elapse between messages being sent. Defaults to 0 + */ + throttle_rate?: number; + /** + * the size of the queue to buffer messages. Messages are buffered as a result of the throttle_rate. Defaults to 0 (no queueing). + */ + queue_length?: number; + /** + * The maximum size that a message can take before it is to be fragmented. + */ + fragment_size?: number; + /** + * An optional string to specify the compression scheme to be used on messages. + * Valid values are "none", "png", "cbor", and "cbor-raw". + */ + compression?: BridgeCompressionType; +} + +export interface UnsubscribeOp extends BaseOp<'unsubscribe'> { + /** + * An id of the subscription to unsubscribe. + * If an id is provided, then only the corresponding subscription is unsubscribed. + * If no ID is provided, then all subscriptions are unsubscribed. + */ + id?: string; + /** + * The name of the topic to unsubscribe from + */ + topic: string; +} + +// Service operations + +/** + * Advertises an external ROS service server. Requests come to the client via Call Service. + */ +export interface AdvertiseServiceOp extends BaseOp<'advertise_service'> { + /** + * The name of the service to advertise + */ + service: string; + /** + * The advertised service message type + */ + type: string; +} + +/** + * Stops advertising an external ROS service server + */ +export interface UnadvertiseServiceOp extends BaseOp<'unadvertise_service'> { + /** + * The name of the service to unadvertise + */ + service: string; +} + +/** + * Calls a ROS service. + */ +export interface CallServiceOp extends BaseOp<'call_service'> { + /** + * An optional id to distinguish this service call + */ + id?: string; + /** + * The name of the service to call + */ + service: string; + /** + * if the service has no args, then args does not have to be provided, though an empty list is equally acceptable. + * Args should be a list of json objects representing the arguments to the service + */ + args?: TRequest; + /** + * The maximum size that the response message can take before it is fragmented + */ + fragment_size?: number; + /** + * An optional string to specify the compression scheme to be used on messages. Valid values are "none" and "png" + */ + compression?: Extract; + /** + * The time, in seconds, to wait for a response from the server + */ + timeout?: number; +} + +/** + * Operation sent by the Bridge to RosLibJS to call a locally advertised service. + */ +export type IncomingCallServiceOp = RequiredFields, 'args'> + +export interface ServiceResponseSuccessOp extends BaseOp<'service_response'> { + /** + * If an ID was provided to the service request, then the service response will contain the ID + */ + id: string; + /** + * The name of the service that was called + */ + service: string; + /** + * The return values. If the service had no return values, then this field can be + * omitted (and will be by the rosbridge server) + */ + values?: TResponse; + /** + * Return value of service callback. true means success, false failure. + */ + result: true; +} + +export interface ServiceResponseFailedOp extends BaseOp<'service_response'> { + /** + * If an ID was provided to the service request, then the service response will contain the ID + */ + id: string; + /** + * The name of the service that was called + */ + service: string; + /** + * The return values. If the service had no return values, then this field can be + * omitted (and will be by the rosbridge server) + */ + values: string; + /** + * Return value of service callback. true means success, false failure. + */ + result: false; +} + +export interface ServiceResponseOp extends BaseOp<'service_response'> { + /** + * If an ID was provided to the service request, then the service response will contain the ID + */ + id: string; + /** + * The name of the service that was called + */ + service: string; + /** + * The return values. If the service had no return values, then this field can be + * omitted (and will be by the rosbridge server) + */ + values: (TResponse | undefined) | string; + /** + * Return value of service callback. true means success, false failure. + */ + result: boolean; +} + +export type AnyServiceResponseOp = + ServiceResponseSuccessOp + | ServiceResponseFailedOp; + +// Action operations + +/** + * Advertises an external ROS action server. + */ +export interface AdvertiseActionOp extends BaseOp<'advertise_action'> { + /** + * The name of the action to advertise + */ + action: string; + /** + * The advertised action message type + */ + type: string; +} + +/** + * Unadvertises an external ROS action server. + */ +export interface UnadvertiseActionOp extends BaseOp<'unadvertise_action'> { + /** + * The name of the action to unadvertise + */ + action: string; +} + +/** + * Sends a goal to a ROS action server. + */ +export interface SendActionGoalOp extends BaseOp<'send_action_goal'> { + /** + * An optional id to distinguish this goal handle + */ + id?: string; + /** + * The name of the action to send a goal to + */ + action: string; + /** + * The action message type + */ + action_type: string; + /** + * If the goal has no args, then args does not have to be provided, though an empty list is equally acceptable. + * Args should be a list of json objects representing the arguments to the service. + */ + args: TGoal; + /** + * If true, sends feedback messages over rosbridge. Defaults to false. + */ + feedback?: boolean; + /** + * The maximum size that the result and feedback messages can take before they are fragmented + */ + fragment_size?: number; + /** + * An optional string to specify the compression scheme to be used on messages. Valid values are "none" and "png" + */ + compression?: Extract; +} + +export interface CancelActionGoalOp extends BaseOp<'cancel_action_goal'> { + /** + * The id representing the goal handle to cancel. + * The id field must match an already in-progress goal. + */ + id: string; + /** + * The name of the action to cancel + */ + action: string; +} + +/** + * Used to send action feedback for a specific goal handle. + */ +export interface ActionFeedbackOp extends BaseOp<'action_feedback'> { + /** + * The id representing the goal handle. + * The id field must match an already in-progress goal. + */ + id: string; + /** + * The name of the action to cancel + */ + action: string; + /** + * The feedback values + */ + values: TFeedback; +} + +/** + * A result for a ROS action. + */ +export interface ActionResultSuccessOp extends BaseOp<'action_result'> { + /** + * If an ID was provided to the action goal, then the action result will contain the ID + */ + id: string; + /** + * The name of the action that was executed + */ + action: string; + /** + * The result values. If the service had no return values, then this field can be omitted (and will be by the rosbridge server) + */ + values: TResult; + /** + * Return status of the action. This matches the enumeration in the action_msgs/msg/GoalStatus ROS message. + */ + status: GoalStatus; + /** + * Return value of action. True means success, false failure. + */ + result: true; +} + +/** + * A result for a ROS action. + */ +export interface ActionResultFailedOp extends BaseOp<'action_result'> { + /** + * If an ID was provided to the action goal, then the action result will contain the ID + */ + id: string; + /** + * The name of the action that was executed + */ + action: string; + /** + * The result values. If the service had no return values, then this field can be omitted (and will be by the rosbridge server) + */ + values?: string; + /** + * Return status of the action. This matches the enumeration in the action_msgs/msg/GoalStatus ROS message. + */ + status: GoalStatus; + /** + * Return value of action. True means success, false failure. + */ + result: false; +} + +export type AnyActionOp = + AdvertiseActionOp + | UnadvertiseActionOp + | SendActionGoalOp + | CancelActionGoalOp + | ActionFeedbackOp + | ActionResultSuccessOp + | ActionResultFailedOp; + + +// The discriminated union type for all RosBridge operations +export type BridgeProtoOp = + | AuthOp + | FragmentOp + | PngOp + | SetStatusLevelOp + | StatusOp + | AdvertiseOp + | UnadvertiseOp + | PublishOp + | SubscribeOp + | UnsubscribeOp + | AdvertiseServiceOp + | UnadvertiseServiceOp + | CallServiceOp + | ServiceResponseSuccessOp + | ServiceResponseFailedOp + | ServiceResponseOp + | AdvertiseActionOp + | UnadvertiseActionOp + | SendActionGoalOp + | CancelActionGoalOp + | ActionFeedbackOp + | ActionResultSuccessOp + | ActionResultFailedOp; + +export type BridgeProtoOpKey = BridgeProtoOp['op']; + +// Type guard to check if an unknown object is a valid BridgeProtoOp +export function isBridgeProtoOp(obj: unknown): obj is BridgeProtoOp { + return ( + typeof obj === 'object' && + obj !== null && + hasOwn(obj, 'op') && + typeof obj.op === 'string' + ); +} + +export function isServiceResponseSuccess(obj: unknown): obj is ServiceResponseSuccessOp { + return isBridgeProtoOp(obj) && + obj.op === 'service_response' && + hasOwn(obj, 'result') && + obj.result; +} diff --git a/src/types/RosMessageTypes.ts b/src/types/RosMessageTypes.ts new file mode 100644 index 000000000..9e4c7a22c --- /dev/null +++ b/src/types/RosMessageTypes.ts @@ -0,0 +1,45 @@ +/** + * @fileOverview Useful message implementations built into ROS2, needed for comprehensive typing. + * @author Andrew Hoener + */ + +// region Actions & ActionLib + +/** + * Represents the status of an action goal + * This is directly based on the action_msgs/GoalStatus ROS message: + * https://docs.ros2.org/latest/api/action_msgs/msg/GoalStatus.html + */ +export enum GoalStatus { + /** + * Indicates status has not been properly set. + */ + Unknown = 0, + /** + * The goal has been accepted and is awaiting execution. + */ + Accepted = 1, + /** + * The goal is currently being executed by the action server. + */ + Executing = 2, + /** + * The client has requested that the goal be canceled and the action server has + * accepted the cancel request. + */ + Canceling = 3, + /** + * The goal was achieved successfully by the action server. + */ + Succeeded = 4, + /** + * The goal was canceled after an external request from an action client. + */ + Canceled = 5, + /** + * The goal was terminated by the action server without an external request. + */ + Aborted = 6, +} + +// endregion From 763400b489a505abb6143fd6b0f9cd9d704c24ce Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Mon, 18 Aug 2025 16:21:54 -0400 Subject: [PATCH 5/8] feat(type-utils): Add type utils module Signed-off-by: Drew Hoener --- src/util/type-utils.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/util/type-utils.ts diff --git a/src/util/type-utils.ts b/src/util/type-utils.ts new file mode 100644 index 000000000..aae47427b --- /dev/null +++ b/src/util/type-utils.ts @@ -0,0 +1,3 @@ +export function hasOwn(obj: X, prop: Y): obj is X & Record { + return Object.hasOwn(obj, prop); +} From 988cb0b29c7a337716464396f86cddd2894c4619 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Mon, 18 Aug 2025 16:22:10 -0400 Subject: [PATCH 6/8] refactor(SocketAdapter): Convert SocketAdapter module to typescript Signed-off-by: Drew Hoener --- src/core/SocketAdapter.ts | 127 +++++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/src/core/SocketAdapter.ts b/src/core/SocketAdapter.ts index 01a9e51d6..091ccdbe4 100644 --- a/src/core/SocketAdapter.ts +++ b/src/core/SocketAdapter.ts @@ -7,14 +7,38 @@ * @fileOverview */ -import {decode} from 'cbor2'; -var BSON = null; -// @ts-expect-error -- Workarounds for not including BSON in bundle. need to revisit +import type { MessageEvent as NodeSocketMessageEvent, ErrorEvent as NodeSocketErrorEvent, CloseEvent as NodeSocketCloseEvent, Event as NodeSocketEvent } from 'ws'; +import { decode } from 'cbor2'; +import type Ros from './Ros.js'; +import { isBridgeProtoOp } from '../types/ProtocolTypes.ts'; +import { MessageCallback } from '../types/CallbackTypes.ts'; +import { Nullable } from '../types/interface-types.ts'; + +export type SocketOpenEvent = Event | NodeSocketEvent; +export type SocketCloseEvent = CloseEvent | Event | NodeSocketCloseEvent; +export type SocketErrorEvent = RTCErrorEvent | Event | NodeSocketErrorEvent; +export type SocketMessageEvent = MessageEvent | NodeSocketMessageEvent; + +export interface ISocketAdapter { + onopen: (event: SocketOpenEvent) => void; + onclose: (event: SocketCloseEvent) => void; + onerror: (event: SocketErrorEvent) => void; + onmessage: (event: SocketMessageEvent) => void; +} + +// This is very weird, but BSON is never bundled by us, so let's define a type for it. +let BSON: Nullable<{ deserialize(data: Uint8Array): unknown }> = null; if (typeof bson !== 'undefined') { - // @ts-expect-error -- Workarounds for not including BSON in bundle. need to revisit BSON = bson().BSON; } +/** + * FIXME: Need Answers: + * 1. onopen emits 'connection' event with an object, the typedoc says it's a function, but no examples use it. What is this type? + * 2. onclose emits 'close' event with an object, the typedoc says it's a function, but no examples use it. What is this type? + * 3. onerror emits 'error' event with an object, the typedoc says it's a function, but every example uses it like a string. What is this type? + */ + /** * Event listeners for a WebSocket or TCP socket to a JavaScript * ROS Client. Sets up Messages for a given topic to trigger an @@ -23,13 +47,20 @@ if (typeof bson !== 'undefined') { * @namespace SocketAdapter * @private */ -export default function SocketAdapter(client) { - var decoder = null; +export default function SocketAdapter(client: Ros): ISocketAdapter { + let decoder: Nullable<((raw: unknown, outputFunc: (message: object) => void) => void)> = null; + // FIXME: Ros Client types not ready yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (client.transportOptions.decoder) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access decoder = client.transportOptions.decoder; } - function handleMessage(message) { + function handleMessage(message: unknown) { + if (!isBridgeProtoOp(message)) { + return; + } + if (message.op === 'publish') { client.emit(message.topic, message.msg); } else if (message.op === 'service_response') { @@ -46,36 +77,40 @@ export default function SocketAdapter(client) { client.emit(message.id, message); } else if (message.op === 'status') { if (message.id) { - client.emit('status:' + message.id, message); + client.emit(`status:${message.id}`, message); } else { client.emit('status', message); } } } - function handlePng(message, callback) { - if (message.op === 'png') { - // If in Node.js.. + function handlePng(message: unknown, decodedCallback: MessageCallback) { + if (isBridgeProtoOp(message) && message.op === 'png') { + // If in Node.js... if (typeof window === 'undefined') { - import('../util/decompressPng.js').then(({ default: decompressPng }) => decompressPng(message.data, callback)); + void import('../util/decompressPng.js').then(({ default: decompressPng }) => { + decompressPng(message.data, decodedCallback); + }); } else { - // if in browser.. - import('../util/shim/decompressPng.js').then(({default: decompressPng}) => decompressPng(message.data, callback)); + // if in browser... + void import('../util/shim/decompressPng.js').then(({ default: decompressPng }) => { + decompressPng(message.data, decodedCallback); + }); } } else { - callback(message); + decodedCallback(message); } } - function decodeBSON(data, callback) { + function decodeBSON(data: Blob, callback: MessageCallback) { if (!BSON) { - throw 'Cannot process BSON encoded message without BSON header.'; + throw new Error('Cannot process BSON encoded message without BSON header.'); } - var reader = new FileReader(); - reader.onload = function () { + const reader = new FileReader(); + reader.onload = function (this: FileReader) { // @ts-expect-error -- this doesn't seem right, but don't want to break current type coercion assumption - var uint8Array = new Uint8Array(this.result); - var msg = BSON.deserialize(uint8Array); + const uint8Array = new Uint8Array(this.result); + const msg = BSON.deserialize(uint8Array); callback(msg); }; reader.readAsArrayBuffer(data); @@ -85,10 +120,10 @@ export default function SocketAdapter(client) { /** * Emit a 'connection' event on WebSocket connection. * - * @param {function} event - The argument to emit with the event. + * @param event - The argument to emit with the event. * @memberof SocketAdapter */ - onopen: function onOpen(event) { + onopen: function onOpen(event: SocketOpenEvent) { client.isConnected = true; client.emit('connection', event); }, @@ -96,10 +131,10 @@ export default function SocketAdapter(client) { /** * Emit a 'close' event on WebSocket disconnection. * - * @param {function} event - The argument to emit with the event. + * @param event - The argument to emit with the event. * @memberof SocketAdapter */ - onclose: function onClose(event) { + onclose: function onClose(event: SocketCloseEvent) { client.isConnected = false; client.emit('close', event); }, @@ -107,10 +142,10 @@ export default function SocketAdapter(client) { /** * Emit an 'error' event whenever there was an error. * - * @param {function} event - The argument to emit with the event. + * @param event - The argument to emit with the event. * @memberof SocketAdapter */ - onerror: function onError(event) { + onerror: function onError(event: SocketErrorEvent) { client.emit('error', event); }, @@ -121,22 +156,40 @@ export default function SocketAdapter(client) { * @param {Object} data - The raw JSON message from rosbridge. * @memberof SocketAdapter */ - onmessage: function onMessage(data) { + onmessage: function onMessage(data: SocketMessageEvent) { if (decoder) { - decoder(data.data, function (message) { - handleMessage(message); - }); - } else if (typeof Blob !== 'undefined' && data.data instanceof Blob) { + // FIXME: Ros Client types not ready yet + + decoder(data.data, handleMessage); + return; + } + + if (typeof Blob !== 'undefined' && data.data instanceof Blob) { decodeBSON(data.data, function (message) { handlePng(message, handleMessage); }); - } else if (data.data instanceof ArrayBuffer) { - var decoded = decode(data.data); + return; + } + + if (data.data instanceof ArrayBuffer || ArrayBuffer.isView(data.data)) { + let binary: Uint8Array; + if (data.data instanceof ArrayBuffer) { + binary = new Uint8Array(data.data); + } else { + const view = data.data; + binary = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + const decoded = decode(binary); handleMessage(decoded); - } else { - var message = JSON.parse(typeof data === 'string' ? data : data.data); - handlePng(message, handleMessage); + return; } + + if(typeof data.data !== 'string') { + throw new Error('Expected incoming data to be a string at this branch'); + } + + const message: unknown = JSON.parse(data.data); + handlePng(message, handleMessage); } }; } From 7068a0d2b8f9d57e8cf2256b580c4ef7fe418dd3 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Mon, 18 Aug 2025 16:22:16 -0400 Subject: [PATCH 7/8] refactor(eslint): Allow explicit any for types we might have to coerce in the future. Signed-off-by: Drew Hoener --- eslint.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 6aa12ab57..fa9aeedd6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,9 @@ export default tseslint.config( parserOptions: { projectService: true, }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 0, } }, { From 8004243befbee0bc09ce97fa548030b7401d7528 Mon Sep 17 00:00:00 2001 From: Drew Hoener Date: Tue, 19 Aug 2025 01:19:41 -0400 Subject: [PATCH 8/8] refactor(GoalStatus): Remove old GoalStatus enum, point to new types module Signed-off-by: Drew Hoener --- src/core/Action.js | 8 ++++---- src/core/GoalStatus.ts | 14 -------------- 2 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 src/core/GoalStatus.ts diff --git a/src/core/Action.js b/src/core/Action.js index 9e8a8da33..8e12bbe22 100644 --- a/src/core/Action.js +++ b/src/core/Action.js @@ -5,7 +5,7 @@ import { EventEmitter } from 'eventemitter3'; import Ros from '../core/Ros.js'; -import { GoalStatus } from '../core/GoalStatus.ts'; +import { GoalStatus } from '../types/RosMessageTypes.js'; /** * A ROS 2 action client. @@ -217,7 +217,7 @@ export default class Action extends EventEmitter { id: id, action: this.name, values: result, - status: GoalStatus.STATUS_SUCCEEDED, + status: GoalStatus.Succeeded, result: true }; this.ros.callOnConnection(call); @@ -235,7 +235,7 @@ export default class Action extends EventEmitter { id: id, action: this.name, values: result, - status: GoalStatus.STATUS_CANCELED, + status: GoalStatus.Canceled, result: true }; this.ros.callOnConnection(call); @@ -251,7 +251,7 @@ export default class Action extends EventEmitter { op: 'action_result', id: id, action: this.name, - status: GoalStatus.STATUS_ABORTED, + status: GoalStatus.Aborted, result: false }; this.ros.callOnConnection(call); diff --git a/src/core/GoalStatus.ts b/src/core/GoalStatus.ts deleted file mode 100644 index 7b2a3cd11..000000000 --- a/src/core/GoalStatus.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * An enumeration for goal statuses. - * This is directly based on the action_msgs/GoalStatus ROS message: - * https://docs.ros2.org/latest/api/action_msgs/msg/GoalStatus.html - */ -export enum GoalStatus { - STATUS_UNKNOWN = 0, - STATUS_ACCEPTED = 1, - STATUS_EXECUTING = 2, - STATUS_CANCELING = 3, - STATUS_SUCCEEDED = 4, - STATUS_CANCELED = 5, - STATUS_ABORTED = 6 -}