ros-mobile-bridge 0.1.2 → 0.1.3

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `ros-mobile-bridge` will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.1.3] - 2026-06-02
8
+
9
+ ### Added
10
+
11
+ - **`SubscribeOptions.dispatchMode`** controls how throttle-surviving messages reach the callback. `'immediate'` (the default, and the only prior behavior) parses and delivers every surviving message synchronously on the message-handler tick. `'latest-only'` delivers only the newest message under back-pressure: superseded messages are dropped *before* being parsed and delivery is deferred off the message-handler tick, so a high-bandwidth topic like a raw camera stream decodes only the frame you will actually render. The conflation happens upstream of the CDR/JSON decode, which an external wrapper cannot do because it only ever receives already-parsed messages. Available on both Foxglove WS and rosbridge. On a binary (CDR) topic the surviving payload is copied to outlive the deferral, so `'latest-only'` is parse-cheap but not allocation-free; on rosbridge the stashed frame is an immutable string and the stash is copy-free. It composes below the throttle (`maxFrequency` and the adaptive cap decide eligibility, then `'latest-only'` keeps the newest of those). A callback that throws is logged and never wedges the subscription; on unsubscribe, disconnect, or a circuit-breaker trip any pending message is dropped rather than delivered.
12
+ - **`materializeBytes(view: Uint8Array): Uint8Array`** returns an owned, offset-0 copy of a `Uint8Array`. `RosMessage.data`, when it is a `Uint8Array`, is a zero-copy view into the inbound WebSocket frame (v0.1.2); call this before retaining the bytes past the callback or handing them to a native binding that ignores `byteOffset` (some Skia paths, `node-canvas`, `sharp`, FFI). It always copies and never returns the input view, so the result is always safe to retain. This supersedes the v0.1.2 note that anticipated a conditional (skip-when-already-owned) copy: a full-span view can still alias the shared frame buffer, so the conditional form would have skipped the copy in exactly the unsafe case.
13
+ - **`matchesSchema(a: string, b: string): boolean`** compares two ROS schema names tolerant of the ROS 1 / ROS 2 `msg/` (and `srv/`, `action/`) asymmetry, so `sensor_msgs/msg/Image` matches `sensor_msgs/Image`. It is kind-agnostic: it strips the interface-kind segment and compares `pkg` + `Type`. A `normalizeSchema` counterpart is deliberately not exported, because a 2-part name cannot be safely expanded to canonical 3-part form (the interface kind cannot be inferred from the string).
14
+
15
+ ### Changed
16
+
17
+ - **Bundled service-schema lookup now tolerates the 2-part / 3-part name asymmetry.** A bridge advertising a well-known system service as `rcl_interfaces/ListParameters` (without the `srv/` segment) now resolves to the bundled schema, so parameter operations and goal cancellation work regardless of which form the bridge reports. Bridge-advertised schemas remain authoritative.
18
+
7
19
  ## [0.1.2] - 2026-06-01
8
20
 
9
21
  ### Performance
@@ -58,5 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
58
70
 
59
71
  - `ZenohClient` ships as an unimplemented skeleton (every method throws). `ProtocolManager.connect` throws a clear "Zenoh support is planned for v0.2.0" error for `protocol: 'zenoh'`. The class is not exported from `index.ts` in v0.1.0.
60
72
 
73
+ [0.1.3]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.3
74
+ [0.1.2]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.2
61
75
  [0.1.1]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.1
62
76
  [0.1.0]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.0
package/README.md CHANGED
@@ -98,6 +98,27 @@ const unwatch = client.onBreakerStateChange('/camera/compressed', (state) => {
98
98
 
99
99
  Manual breaker controls (`breakerRetry`, `breakerDisable`) let consumers expose user-driven recovery in their UI.
100
100
 
101
+ ### High-throughput subscriptions: `dispatchMode`
102
+
103
+ `SubscribeOptions.dispatchMode` controls how messages reach your callback when they arrive faster than you can use them. The default, `'immediate'`, parses and delivers every message that survives the throttle, synchronously on the message-handler tick. For a high-bandwidth topic where only the freshest message matters (a raw camera stream feeding a viewport), `'latest-only'` delivers just the newest message and drops the rest *before* they are parsed, so you decode only the frame you actually render:
104
+
105
+ ```typescript
106
+ import { materializeBytes } from 'ros-mobile-bridge';
107
+
108
+ client.subscribe(
109
+ '/camera/raw',
110
+ (msg) => {
111
+ if (msg.data instanceof Uint8Array) {
112
+ // msg.data is a zero-copy view; copy it before retaining past the callback.
113
+ render(materializeBytes(msg.data));
114
+ }
115
+ },
116
+ { dispatchMode: 'latest-only' },
117
+ );
118
+ ```
119
+
120
+ Delivery is deferred off the message-handler tick, and the conflation happens upstream of the decode, which a wrapper around `subscribe` cannot do because it only ever sees already-parsed messages. `'latest-only'` composes below the throttle: `maxFrequency` and the adaptive cap still decide which messages are eligible, then the newest of those is delivered. For lossless-but-deferred delivery, keep your own bounded queue in the callback (the `dispatchMode` TSDoc shows the pattern); the bound and drop policy are yours to set because they depend on the device.
121
+
101
122
  ### Host-app injection
102
123
 
103
124
  Construct clients with `ProtocolClientOptions` to receive latency callbacks, route logs, and tell the throttle which mode the user picked:
package/dist/index.cjs CHANGED
@@ -478,6 +478,16 @@ function jsonSchemaToTemplate(schema) {
478
478
  return null;
479
479
  }
480
480
 
481
+ // src/schemaName.ts
482
+ var INTERFACE_KIND = /^([^/]+)\/(?:msg|srv|action)\/([^/]+)$/;
483
+ function stripInterfaceKind(name) {
484
+ const m = INTERFACE_KIND.exec(name);
485
+ return m ? `${m[1]}/${m[2]}` : name;
486
+ }
487
+ function matchesSchema(a, b) {
488
+ return stripInterfaceKind(a) === stripInterfaceKind(b);
489
+ }
490
+
481
491
  // src/builtinSchemas.ts
482
492
  var SEP = "\n================================================================================\n";
483
493
  var MSG_PARAMETER_VALUE = `MSG: rcl_interfaces/ParameterValue
@@ -581,7 +591,13 @@ var BUNDLED = {
581
591
  }
582
592
  };
583
593
  function getBundledServiceSchema(serviceType) {
584
- return BUNDLED[serviceType] ?? null;
594
+ const exact = BUNDLED[serviceType];
595
+ if (exact) return exact;
596
+ const target = stripInterfaceKind(serviceType);
597
+ for (const key of Object.keys(BUNDLED)) {
598
+ if (stripInterfaceKind(key) === target) return BUNDLED[key] ?? null;
599
+ }
600
+ return null;
585
601
  }
586
602
 
587
603
  // src/FoxgloveClient.ts
@@ -789,6 +805,7 @@ var _FoxgloveClient = class _FoxgloveClient {
789
805
  subscribe(topic, onMessage, options) {
790
806
  const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
791
807
  const disableAdaptive = options?.disableAdaptive ?? false;
808
+ const dispatchMode = options?.dispatchMode ?? "immediate";
792
809
  const existingSubId = this.topicToSubscriptionId.get(topic);
793
810
  if (existingSubId !== void 0) {
794
811
  const sub = this.subscriptions.get(existingSubId);
@@ -796,9 +813,14 @@ var _FoxgloveClient = class _FoxgloveClient {
796
813
  sub.callbacks.set(onMessage, {
797
814
  userMinIntervalMs,
798
815
  disableAdaptive,
799
- lastDeliveredAt: 0
816
+ lastDeliveredAt: 0,
817
+ dispatchMode,
818
+ pending: null,
819
+ drainTimer: null
800
820
  });
801
821
  return () => {
822
+ const entry = sub.callbacks.get(onMessage);
823
+ if (entry) this.cancelDrain(entry);
802
824
  sub.callbacks.delete(onMessage);
803
825
  if (sub.callbacks.size === 0) {
804
826
  this.unsubscribeTopic(topic, existingSubId);
@@ -819,7 +841,10 @@ var _FoxgloveClient = class _FoxgloveClient {
819
841
  callbacks.set(onMessage, {
820
842
  userMinIntervalMs,
821
843
  disableAdaptive,
822
- lastDeliveredAt: 0
844
+ lastDeliveredAt: 0,
845
+ dispatchMode,
846
+ pending: null,
847
+ drainTimer: null
823
848
  });
824
849
  const breaker = new CircuitBreaker({
825
850
  ...DEFAULT_BREAKER_CONFIG,
@@ -827,6 +852,7 @@ var _FoxgloveClient = class _FoxgloveClient {
827
852
  const sub = this.subscriptions.get(subscriptionId);
828
853
  if (!sub) return;
829
854
  sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
855
+ if (sub.isPaused) this.cancelAllDrains(sub);
830
856
  if (newState === "tripped_auto") {
831
857
  if (this.ws && this.status === "connected") {
832
858
  this.sendJson({ op: "unsubscribe", subscriptionIds: [subscriptionId] });
@@ -892,6 +918,8 @@ var _FoxgloveClient = class _FoxgloveClient {
892
918
  subscriptions: [{ id: subscriptionId, channelId }]
893
919
  });
894
920
  return () => {
921
+ const entry = callbacks.get(onMessage);
922
+ if (entry) this.cancelDrain(entry);
895
923
  callbacks.delete(onMessage);
896
924
  if (callbacks.size === 0) {
897
925
  this.unsubscribeTopic(topic, subscriptionId);
@@ -1306,46 +1334,103 @@ var _FoxgloveClient = class _FoxgloveClient {
1306
1334
  }
1307
1335
  }
1308
1336
  if (deliverTo.length === 0) return;
1309
- const channelInfo = this.channels.get(sub.channelId);
1310
- const schemaName = channelInfo?.schemaName ?? "";
1311
- const encoding = channelInfo?.encoding ?? "json";
1312
- let data;
1337
+ let parsed = null;
1338
+ for (const [cb, entry] of deliverTo) {
1339
+ entry.lastDeliveredAt = now;
1340
+ if (entry.dispatchMode === "latest-only") {
1341
+ entry.pending = { payload: new Uint8Array(payload), sec, nsec };
1342
+ if (entry.drainTimer === null) {
1343
+ entry.drainTimer = setTimeout(
1344
+ () => this.drainLatestOnly(subscriptionId, cb, entry),
1345
+ 0
1346
+ );
1347
+ }
1348
+ continue;
1349
+ }
1350
+ if (parsed === null) {
1351
+ const channelInfo = this.channels.get(sub.channelId);
1352
+ const encoding = channelInfo?.encoding ?? "json";
1353
+ parsed = {
1354
+ topic: sub.topic,
1355
+ schemaName: channelInfo?.schemaName ?? "",
1356
+ encoding: encoding === "json" ? "json" : "cdr",
1357
+ data: this.decodePayload(payload, subscriptionId, encoding),
1358
+ receiveTime: { sec, nsec },
1359
+ byteSize: payload.byteLength
1360
+ };
1361
+ }
1362
+ try {
1363
+ cb(parsed);
1364
+ } catch (err) {
1365
+ this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1366
+ }
1367
+ }
1368
+ }
1369
+ /**
1370
+ * Decode a `messageData` payload to its delivered shape: a parsed object when
1371
+ * a CDR reader or JSON decode succeeds, the raw bytes otherwise. Shared by
1372
+ * the synchronous `immediate` path and the deferred `latest-only` drain.
1373
+ */
1374
+ decodePayload(payload, subscriptionId, encoding) {
1313
1375
  if (encoding === "json") {
1314
1376
  try {
1315
- const text = TEXT_DECODER.decode(payload);
1316
- data = JSON.parse(text);
1377
+ return JSON.parse(TEXT_DECODER.decode(payload));
1317
1378
  } catch {
1318
- data = payload;
1379
+ return payload;
1319
1380
  }
1320
- } else {
1321
- const reader = this.messageReaders.get(subscriptionId);
1322
- if (reader) {
1323
- try {
1324
- data = reader.readMessage(payload);
1325
- } catch {
1326
- data = payload;
1327
- }
1328
- } else {
1329
- data = payload;
1381
+ }
1382
+ const reader = this.messageReaders.get(subscriptionId);
1383
+ if (reader) {
1384
+ try {
1385
+ return reader.readMessage(payload);
1386
+ } catch {
1387
+ return payload;
1330
1388
  }
1331
1389
  }
1390
+ return payload;
1391
+ }
1392
+ /**
1393
+ * Drain one `latest-only` callback's pending payload: parse the survivor and
1394
+ * deliver it. Cleared state (`pending`, `drainTimer`) is reset *before* the
1395
+ * callback runs, so a throwing callback never wedges future delivery — the
1396
+ * next arrival re-arms normally. Bails if the subscription was torn down or
1397
+ * paused while the drain was armed (no post-teardown delivery).
1398
+ */
1399
+ drainLatestOnly(subscriptionId, cb, entry) {
1400
+ entry.drainTimer = null;
1401
+ const pending = entry.pending;
1402
+ entry.pending = null;
1403
+ if (!pending) return;
1404
+ const sub = this.subscriptions.get(subscriptionId);
1405
+ if (!sub || sub.isPaused) return;
1406
+ const channelInfo = this.channels.get(sub.channelId);
1407
+ const encoding = channelInfo?.encoding ?? "json";
1332
1408
  const rosMsg = {
1333
1409
  topic: sub.topic,
1334
- schemaName,
1410
+ schemaName: channelInfo?.schemaName ?? "",
1335
1411
  encoding: encoding === "json" ? "json" : "cdr",
1336
- data,
1337
- receiveTime: { sec, nsec },
1338
- byteSize: payload.byteLength
1412
+ data: this.decodePayload(pending.payload, subscriptionId, encoding),
1413
+ receiveTime: { sec: pending.sec, nsec: pending.nsec },
1414
+ byteSize: pending.payload.byteLength
1339
1415
  };
1340
- for (const [cb, entry] of deliverTo) {
1341
- entry.lastDeliveredAt = now;
1342
- try {
1343
- cb(rosMsg);
1344
- } catch (err) {
1345
- this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1346
- }
1416
+ try {
1417
+ cb(rosMsg);
1418
+ } catch (err) {
1419
+ this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1347
1420
  }
1348
1421
  }
1422
+ /** Cancel one callback's armed drain and drop its pending payload. */
1423
+ cancelDrain(entry) {
1424
+ if (entry.drainTimer !== null) {
1425
+ clearTimeout(entry.drainTimer);
1426
+ entry.drainTimer = null;
1427
+ }
1428
+ entry.pending = null;
1429
+ }
1430
+ /** Cancel every armed drain on a subscription (teardown / pause). */
1431
+ cancelAllDrains(sub) {
1432
+ for (const entry of sub.callbacks.values()) this.cancelDrain(entry);
1433
+ }
1349
1434
  handleServerInfo(info) {
1350
1435
  this.log(`Received serverInfo: ${info.name} (sessionId: ${info.sessionId ?? "none"})`);
1351
1436
  this.serverInfoReceived = true;
@@ -1553,6 +1638,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1553
1638
  // ── Private: unsubscribe ─────────────────────────────────────────────────
1554
1639
  unsubscribeTopic(topic, subscriptionId) {
1555
1640
  const sub = this.subscriptions.get(subscriptionId);
1641
+ if (sub) this.cancelAllDrains(sub);
1556
1642
  sub?.breaker.destroy();
1557
1643
  this.subscriptions.delete(subscriptionId);
1558
1644
  this.topicToSubscriptionId.delete(topic);
@@ -1649,6 +1735,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1649
1735
  }
1650
1736
  this.ws = null;
1651
1737
  }
1738
+ for (const sub of this.subscriptions.values()) this.cancelAllDrains(sub);
1652
1739
  this.channels.clear();
1653
1740
  this.topicToChannelId.clear();
1654
1741
  this.subscriptions.clear();
@@ -1839,14 +1926,20 @@ var _RosbridgeClient = class _RosbridgeClient {
1839
1926
  }
1840
1927
  const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
1841
1928
  const disableAdaptive = options?.disableAdaptive ?? false;
1929
+ const dispatchMode = options?.dispatchMode ?? "immediate";
1842
1930
  const existing = this.activeSubscriptions.get(topic);
1843
1931
  if (existing) {
1844
1932
  existing.callbacks.set(onMessage, {
1845
1933
  userMinIntervalMs,
1846
1934
  disableAdaptive,
1847
- lastDeliveredAt: 0
1935
+ lastDeliveredAt: 0,
1936
+ dispatchMode,
1937
+ pending: null,
1938
+ drainTimer: null
1848
1939
  });
1849
1940
  return () => {
1941
+ const entry = existing.callbacks.get(onMessage);
1942
+ if (entry) this.cancelDrain(entry);
1850
1943
  existing.callbacks.delete(onMessage);
1851
1944
  if (existing.callbacks.size === 0) {
1852
1945
  this.unsubscribeTopic(topic);
@@ -1862,7 +1955,10 @@ var _RosbridgeClient = class _RosbridgeClient {
1862
1955
  callbacks.set(onMessage, {
1863
1956
  userMinIntervalMs,
1864
1957
  disableAdaptive,
1865
- lastDeliveredAt: 0
1958
+ lastDeliveredAt: 0,
1959
+ dispatchMode,
1960
+ pending: null,
1961
+ drainTimer: null
1866
1962
  });
1867
1963
  const breaker = new CircuitBreaker({
1868
1964
  ...DEFAULT_BREAKER_CONFIG,
@@ -1870,6 +1966,7 @@ var _RosbridgeClient = class _RosbridgeClient {
1870
1966
  const sub = this.activeSubscriptions.get(topic);
1871
1967
  if (!sub) return;
1872
1968
  sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
1969
+ if (sub.isPaused) this.cancelAllDrains(sub);
1873
1970
  if (newState === "tripped_auto") {
1874
1971
  if (this.ws && this.status === "connected") {
1875
1972
  this.send({ op: "unsubscribe", topic });
@@ -1919,6 +2016,8 @@ var _RosbridgeClient = class _RosbridgeClient {
1919
2016
  queue_length: 1
1920
2017
  });
1921
2018
  return () => {
2019
+ const entry = callbacks.get(onMessage);
2020
+ if (entry) this.cancelDrain(entry);
1922
2021
  callbacks.delete(onMessage);
1923
2022
  if (callbacks.size === 0) {
1924
2023
  this.unsubscribeTopic(topic);
@@ -2218,19 +2317,26 @@ var _RosbridgeClient = class _RosbridgeClient {
2218
2317
  recordBytes(sub.bandwidth, now, byteSize, mode);
2219
2318
  return true;
2220
2319
  }
2221
- let anyCallbackWantsThis = false;
2222
- for (const entry of sub.callbacks.values()) {
2320
+ let anyImmediateWantsThis = false;
2321
+ for (const [cb, entry] of sub.callbacks) {
2223
2322
  const interval = effectiveMinInterval(
2224
2323
  entry.userMinIntervalMs,
2225
2324
  entry.disableAdaptive,
2226
2325
  sub.bandwidth
2227
2326
  );
2228
- if (interval <= 0 || now - entry.lastDeliveredAt >= interval) {
2229
- anyCallbackWantsThis = true;
2230
- break;
2327
+ const eligible = interval <= 0 || now - entry.lastDeliveredAt >= interval;
2328
+ if (!eligible) continue;
2329
+ if (entry.dispatchMode === "latest-only") {
2330
+ entry.lastDeliveredAt = now;
2331
+ entry.pending = { raw: data, receivedAt: now };
2332
+ if (entry.drainTimer === null) {
2333
+ entry.drainTimer = setTimeout(() => this.drainLatestOnly(topic, cb, entry), 0);
2334
+ }
2335
+ } else {
2336
+ anyImmediateWantsThis = true;
2231
2337
  }
2232
2338
  }
2233
- if (anyCallbackWantsThis) return false;
2339
+ if (anyImmediateWantsThis) return false;
2234
2340
  recordBytes(sub.bandwidth, now, byteSize, mode);
2235
2341
  sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
2236
2342
  return true;
@@ -2246,6 +2352,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2246
2352
  sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
2247
2353
  const deliverTo = [];
2248
2354
  for (const [cb, entry] of sub.callbacks) {
2355
+ if (entry.dispatchMode === "latest-only") continue;
2249
2356
  const interval = effectiveMinInterval(
2250
2357
  entry.userMinIntervalMs,
2251
2358
  entry.disableAdaptive,
@@ -2276,6 +2383,55 @@ var _RosbridgeClient = class _RosbridgeClient {
2276
2383
  }
2277
2384
  }
2278
2385
  }
2386
+ /**
2387
+ * Drain one `latest-only` callback's pending frame: parse the survivor and
2388
+ * deliver it. State (`pending`, `drainTimer`) is cleared *before* the
2389
+ * callback runs, so a throwing callback never wedges future delivery. Bails
2390
+ * if the subscription was torn down or paused while the drain was armed (no
2391
+ * post-teardown delivery).
2392
+ */
2393
+ drainLatestOnly(topic, cb, entry) {
2394
+ entry.drainTimer = null;
2395
+ const pending = entry.pending;
2396
+ entry.pending = null;
2397
+ if (!pending) return;
2398
+ const sub = this.activeSubscriptions.get(topic);
2399
+ if (!sub || sub.isPaused) return;
2400
+ let parsed;
2401
+ try {
2402
+ parsed = JSON.parse(pending.raw);
2403
+ } catch {
2404
+ return;
2405
+ }
2406
+ const rosMsg = {
2407
+ topic,
2408
+ schemaName: sub.schemaName,
2409
+ encoding: "json",
2410
+ data: parsed.msg ?? {},
2411
+ receiveTime: {
2412
+ sec: Math.floor(pending.receivedAt / 1e3),
2413
+ nsec: pending.receivedAt % 1e3 * 1e6
2414
+ },
2415
+ byteSize: pending.raw.length
2416
+ };
2417
+ try {
2418
+ cb(rosMsg);
2419
+ } catch (err) {
2420
+ this.logger.error("[RosbridgeClient] Subscriber callback error:", err);
2421
+ }
2422
+ }
2423
+ /** Cancel one callback's armed drain and drop its pending frame. */
2424
+ cancelDrain(entry) {
2425
+ if (entry.drainTimer !== null) {
2426
+ clearTimeout(entry.drainTimer);
2427
+ entry.drainTimer = null;
2428
+ }
2429
+ entry.pending = null;
2430
+ }
2431
+ /** Cancel every armed drain on a subscription (teardown / pause). */
2432
+ cancelAllDrains(sub) {
2433
+ for (const entry of sub.callbacks.values()) this.cancelDrain(entry);
2434
+ }
2279
2435
  handleServiceResponse(msg) {
2280
2436
  const id = msg.id;
2281
2437
  const pending = this.pendingServiceCalls.get(id);
@@ -2294,6 +2450,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2294
2450
  // ── Private: topic management ──────────────────────────────────────────
2295
2451
  unsubscribeTopic(topic) {
2296
2452
  const sub = this.activeSubscriptions.get(topic);
2453
+ if (sub) this.cancelAllDrains(sub);
2297
2454
  sub?.breaker.destroy();
2298
2455
  this.activeSubscriptions.delete(topic);
2299
2456
  if (this.ws && this.status === "connected" && !sub?.isPaused) {
@@ -2416,6 +2573,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2416
2573
  cleanupConnection() {
2417
2574
  this.clearConnectionTimeout();
2418
2575
  this.stopServicesPoll();
2576
+ for (const sub of this.activeSubscriptions.values()) this.cancelAllDrains(sub);
2419
2577
  this.activeSubscriptions.clear();
2420
2578
  for (const [, pending] of this.pendingServiceCalls) {
2421
2579
  clearTimeout(pending.timer);
@@ -2599,6 +2757,11 @@ var ProtocolManager = class {
2599
2757
  }
2600
2758
  };
2601
2759
 
2760
+ // src/materializeBytes.ts
2761
+ function materializeBytes(view) {
2762
+ return new Uint8Array(view);
2763
+ }
2764
+
2602
2765
  exports.DEFAULT_PORTS = DEFAULT_PORTS;
2603
2766
  exports.DEFAULT_PRESETS = DEFAULT_PRESETS;
2604
2767
  exports.FoxgloveClient = FoxgloveClient;
@@ -2610,6 +2773,8 @@ exports.getLagHistoryCsv = getLagHistoryCsv;
2610
2773
  exports.getLagStats = getLagStats;
2611
2774
  exports.getMaxLagMs = getMaxLagMs;
2612
2775
  exports.jsonSchemaToTemplate = jsonSchemaToTemplate;
2776
+ exports.matchesSchema = matchesSchema;
2777
+ exports.materializeBytes = materializeBytes;
2613
2778
  exports.schemaToTemplate = schemaToTemplate;
2614
2779
  //# sourceMappingURL=index.cjs.map
2615
2780
  //# sourceMappingURL=index.cjs.map