ros-mobile-bridge 0.1.1 → 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,33 @@ 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
+
19
+ ## [0.1.2] - 2026-06-01
20
+
21
+ ### Performance
22
+
23
+ - **Zero-copy payload view on Foxglove WS binary topic ingest.** Previously every inbound `messageData` frame allocated a fresh `ArrayBuffer` (`ArrayBuffer.prototype.slice`) for the payload, costing roughly 10 MB/frame on raw 1080p image streams even on frames that the throttle subsequently dropped before parse. The library now uses a zero-copy `Uint8Array` view; downstream consumers (CDR reader, JSON decoder, byte-size accounting) see no behavior change. Verified end-to-end against a sustained 138 MB/s harness: `max` lag dropped from 96 ms to 29 ms (3.4× tail reduction), `mean` and `p50` unchanged.
24
+
25
+ ### Changed
26
+
27
+ - **Control-priority publishes now coalesce by destination.** When multiple `priority: 'control'` publishes for the same topic queue in the outbox during JS-thread saturation, only the latest drains rather than every queued tick in FIFO order. The release-the-joystick zero-Twist that follows N stale-value Twists on `/cmd_vel` now sends in one WebSocket frame, so the robot stops within one round-trip of release regardless of how deep the queue grew during the block. Insertion order across **distinct** topics is preserved (intra-topic conflation only). Reduced the worst-case stop latency by orders of magnitude under sustained-overload scenarios.
28
+ - **Tightened CircuitBreaker defaults for CDR-realistic workloads.** The breaker's `lagThresholdMs` (`250 → 150`), `tripDwellMs` (`5000 → 2000`), and the internal `WARMUP_MS` grace period (`2000 → 500`) were originally tuned against JSON-sim spike patterns (transient 100–333 ms spikes interleaved with healthy stretches). CDR sensor streams on real hardware fail in a different regime — sustained multi-second freezes during the cold-start window — which warrants faster detection. Total subscribe-to-unsubscribe floor on a genuinely saturating topic drops from approximately 7 s to approximately 2.5 s, comfortably below the threshold at which a user perceives the app as frozen.
29
+
30
+ ### Documented
31
+
32
+ - **Zero-copy contract on `RosMessage.data` when it is a `Uint8Array`.** The byte values delivered to a subscriber callback as `Uint8Array` are now views into the inbound WebSocket frame's `ArrayBuffer`, not copies. The view's `byteOffset` is significant. Consumers handing `data` directly to native bindings that ignore `byteOffset` (some Skia binding paths, some FFI calls) must first materialize an owned copy via `new Uint8Array(data)`. A `materializeBytes(view)` helper that performs this copy conditionally is planned for the next release; until then the explicit copy is the recommended idiom. TSDoc on `RosMessage` carries the full contract.
33
+
7
34
  ## [0.1.1] - 2026-05-27
8
35
 
9
36
  ### Fixed
@@ -43,5 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
43
70
 
44
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.
45
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
46
75
  [0.1.1]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.1
47
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
@@ -10,7 +10,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
10
10
 
11
11
  // src/CircuitBreaker.ts
12
12
  var HEALTHY_DEBOUNCE_MS = 4e3;
13
- var WARMUP_MS = 2e3;
13
+ var WARMUP_MS = 500;
14
14
  var CircuitBreaker = class {
15
15
  constructor(config) {
16
16
  __publicField(this, "config", config);
@@ -151,8 +151,8 @@ var CircuitBreaker = class {
151
151
  }
152
152
  };
153
153
  var DEFAULT_BREAKER_CONFIG = {
154
- lagThresholdMs: 250,
155
- tripDwellMs: 5e3,
154
+ lagThresholdMs: 150,
155
+ tripDwellMs: 2e3,
156
156
  recoveryDwellMs: 1e4,
157
157
  cooldownsMs: [3e4, 6e4, 12e4, 3e5]
158
158
  };
@@ -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);
@@ -927,7 +955,12 @@ var _FoxgloveClient = class _FoxgloveClient {
927
955
  return;
928
956
  }
929
957
  if (options?.priority === "control") {
930
- this.controlOutbox.push({ channelId: clientChannelId, data });
958
+ const existing = this.controlOutbox.findIndex((e) => e.channelId === clientChannelId);
959
+ if (existing >= 0) {
960
+ this.controlOutbox[existing] = { channelId: clientChannelId, data };
961
+ } else {
962
+ this.controlOutbox.push({ channelId: clientChannelId, data });
963
+ }
931
964
  this.scheduleControlFlush();
932
965
  return;
933
966
  }
@@ -1281,7 +1314,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1281
1314
  const sec = Math.floor(timestampNs / 1e9);
1282
1315
  const nsec = timestampNs % 1e9;
1283
1316
  const payloadOffset = 13;
1284
- const payload = buffer.slice(payloadOffset);
1317
+ const payload = new Uint8Array(buffer, payloadOffset);
1285
1318
  const sub = this.subscriptions.get(subscriptionId);
1286
1319
  if (!sub) return;
1287
1320
  if (sub.isPaused) return;
@@ -1301,46 +1334,103 @@ var _FoxgloveClient = class _FoxgloveClient {
1301
1334
  }
1302
1335
  }
1303
1336
  if (deliverTo.length === 0) return;
1304
- const channelInfo = this.channels.get(sub.channelId);
1305
- const schemaName = channelInfo?.schemaName ?? "";
1306
- const encoding = channelInfo?.encoding ?? "json";
1307
- 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) {
1308
1375
  if (encoding === "json") {
1309
1376
  try {
1310
- const text = TEXT_DECODER.decode(payload);
1311
- data = JSON.parse(text);
1377
+ return JSON.parse(TEXT_DECODER.decode(payload));
1312
1378
  } catch {
1313
- data = new Uint8Array(payload);
1379
+ return payload;
1314
1380
  }
1315
- } else {
1316
- const reader = this.messageReaders.get(subscriptionId);
1317
- if (reader) {
1318
- try {
1319
- data = reader.readMessage(new Uint8Array(payload));
1320
- } catch {
1321
- data = new Uint8Array(payload);
1322
- }
1323
- } else {
1324
- data = new Uint8Array(payload);
1381
+ }
1382
+ const reader = this.messageReaders.get(subscriptionId);
1383
+ if (reader) {
1384
+ try {
1385
+ return reader.readMessage(payload);
1386
+ } catch {
1387
+ return payload;
1325
1388
  }
1326
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";
1327
1408
  const rosMsg = {
1328
1409
  topic: sub.topic,
1329
- schemaName,
1410
+ schemaName: channelInfo?.schemaName ?? "",
1330
1411
  encoding: encoding === "json" ? "json" : "cdr",
1331
- data,
1332
- receiveTime: { sec, nsec },
1333
- byteSize: payload.byteLength
1412
+ data: this.decodePayload(pending.payload, subscriptionId, encoding),
1413
+ receiveTime: { sec: pending.sec, nsec: pending.nsec },
1414
+ byteSize: pending.payload.byteLength
1334
1415
  };
1335
- for (const [cb, entry] of deliverTo) {
1336
- entry.lastDeliveredAt = now;
1337
- try {
1338
- cb(rosMsg);
1339
- } catch (err) {
1340
- this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1341
- }
1416
+ try {
1417
+ cb(rosMsg);
1418
+ } catch (err) {
1419
+ this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1342
1420
  }
1343
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
+ }
1344
1434
  handleServerInfo(info) {
1345
1435
  this.log(`Received serverInfo: ${info.name} (sessionId: ${info.sessionId ?? "none"})`);
1346
1436
  this.serverInfoReceived = true;
@@ -1548,6 +1638,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1548
1638
  // ── Private: unsubscribe ─────────────────────────────────────────────────
1549
1639
  unsubscribeTopic(topic, subscriptionId) {
1550
1640
  const sub = this.subscriptions.get(subscriptionId);
1641
+ if (sub) this.cancelAllDrains(sub);
1551
1642
  sub?.breaker.destroy();
1552
1643
  this.subscriptions.delete(subscriptionId);
1553
1644
  this.topicToSubscriptionId.delete(topic);
@@ -1644,6 +1735,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1644
1735
  }
1645
1736
  this.ws = null;
1646
1737
  }
1738
+ for (const sub of this.subscriptions.values()) this.cancelAllDrains(sub);
1647
1739
  this.channels.clear();
1648
1740
  this.topicToChannelId.clear();
1649
1741
  this.subscriptions.clear();
@@ -1834,14 +1926,20 @@ var _RosbridgeClient = class _RosbridgeClient {
1834
1926
  }
1835
1927
  const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
1836
1928
  const disableAdaptive = options?.disableAdaptive ?? false;
1929
+ const dispatchMode = options?.dispatchMode ?? "immediate";
1837
1930
  const existing = this.activeSubscriptions.get(topic);
1838
1931
  if (existing) {
1839
1932
  existing.callbacks.set(onMessage, {
1840
1933
  userMinIntervalMs,
1841
1934
  disableAdaptive,
1842
- lastDeliveredAt: 0
1935
+ lastDeliveredAt: 0,
1936
+ dispatchMode,
1937
+ pending: null,
1938
+ drainTimer: null
1843
1939
  });
1844
1940
  return () => {
1941
+ const entry = existing.callbacks.get(onMessage);
1942
+ if (entry) this.cancelDrain(entry);
1845
1943
  existing.callbacks.delete(onMessage);
1846
1944
  if (existing.callbacks.size === 0) {
1847
1945
  this.unsubscribeTopic(topic);
@@ -1857,7 +1955,10 @@ var _RosbridgeClient = class _RosbridgeClient {
1857
1955
  callbacks.set(onMessage, {
1858
1956
  userMinIntervalMs,
1859
1957
  disableAdaptive,
1860
- lastDeliveredAt: 0
1958
+ lastDeliveredAt: 0,
1959
+ dispatchMode,
1960
+ pending: null,
1961
+ drainTimer: null
1861
1962
  });
1862
1963
  const breaker = new CircuitBreaker({
1863
1964
  ...DEFAULT_BREAKER_CONFIG,
@@ -1865,6 +1966,7 @@ var _RosbridgeClient = class _RosbridgeClient {
1865
1966
  const sub = this.activeSubscriptions.get(topic);
1866
1967
  if (!sub) return;
1867
1968
  sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
1969
+ if (sub.isPaused) this.cancelAllDrains(sub);
1868
1970
  if (newState === "tripped_auto") {
1869
1971
  if (this.ws && this.status === "connected") {
1870
1972
  this.send({ op: "unsubscribe", topic });
@@ -1914,6 +2016,8 @@ var _RosbridgeClient = class _RosbridgeClient {
1914
2016
  queue_length: 1
1915
2017
  });
1916
2018
  return () => {
2019
+ const entry = callbacks.get(onMessage);
2020
+ if (entry) this.cancelDrain(entry);
1917
2021
  callbacks.delete(onMessage);
1918
2022
  if (callbacks.size === 0) {
1919
2023
  this.unsubscribeTopic(topic);
@@ -1937,7 +2041,12 @@ var _RosbridgeClient = class _RosbridgeClient {
1937
2041
  }
1938
2042
  const payload = { op: "publish", topic, msg: data };
1939
2043
  if (options?.priority === "control") {
1940
- this.controlOutbox.push(payload);
2044
+ const existing = this.controlOutbox.findIndex((e) => e.topic === topic);
2045
+ if (existing >= 0) {
2046
+ this.controlOutbox[existing] = payload;
2047
+ } else {
2048
+ this.controlOutbox.push(payload);
2049
+ }
1941
2050
  this.scheduleControlFlush();
1942
2051
  return;
1943
2052
  }
@@ -2208,19 +2317,26 @@ var _RosbridgeClient = class _RosbridgeClient {
2208
2317
  recordBytes(sub.bandwidth, now, byteSize, mode);
2209
2318
  return true;
2210
2319
  }
2211
- let anyCallbackWantsThis = false;
2212
- for (const entry of sub.callbacks.values()) {
2320
+ let anyImmediateWantsThis = false;
2321
+ for (const [cb, entry] of sub.callbacks) {
2213
2322
  const interval = effectiveMinInterval(
2214
2323
  entry.userMinIntervalMs,
2215
2324
  entry.disableAdaptive,
2216
2325
  sub.bandwidth
2217
2326
  );
2218
- if (interval <= 0 || now - entry.lastDeliveredAt >= interval) {
2219
- anyCallbackWantsThis = true;
2220
- 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;
2221
2337
  }
2222
2338
  }
2223
- if (anyCallbackWantsThis) return false;
2339
+ if (anyImmediateWantsThis) return false;
2224
2340
  recordBytes(sub.bandwidth, now, byteSize, mode);
2225
2341
  sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
2226
2342
  return true;
@@ -2236,6 +2352,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2236
2352
  sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
2237
2353
  const deliverTo = [];
2238
2354
  for (const [cb, entry] of sub.callbacks) {
2355
+ if (entry.dispatchMode === "latest-only") continue;
2239
2356
  const interval = effectiveMinInterval(
2240
2357
  entry.userMinIntervalMs,
2241
2358
  entry.disableAdaptive,
@@ -2266,6 +2383,55 @@ var _RosbridgeClient = class _RosbridgeClient {
2266
2383
  }
2267
2384
  }
2268
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
+ }
2269
2435
  handleServiceResponse(msg) {
2270
2436
  const id = msg.id;
2271
2437
  const pending = this.pendingServiceCalls.get(id);
@@ -2284,6 +2450,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2284
2450
  // ── Private: topic management ──────────────────────────────────────────
2285
2451
  unsubscribeTopic(topic) {
2286
2452
  const sub = this.activeSubscriptions.get(topic);
2453
+ if (sub) this.cancelAllDrains(sub);
2287
2454
  sub?.breaker.destroy();
2288
2455
  this.activeSubscriptions.delete(topic);
2289
2456
  if (this.ws && this.status === "connected" && !sub?.isPaused) {
@@ -2406,6 +2573,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2406
2573
  cleanupConnection() {
2407
2574
  this.clearConnectionTimeout();
2408
2575
  this.stopServicesPoll();
2576
+ for (const sub of this.activeSubscriptions.values()) this.cancelAllDrains(sub);
2409
2577
  this.activeSubscriptions.clear();
2410
2578
  for (const [, pending] of this.pendingServiceCalls) {
2411
2579
  clearTimeout(pending.timer);
@@ -2589,6 +2757,11 @@ var ProtocolManager = class {
2589
2757
  }
2590
2758
  };
2591
2759
 
2760
+ // src/materializeBytes.ts
2761
+ function materializeBytes(view) {
2762
+ return new Uint8Array(view);
2763
+ }
2764
+
2592
2765
  exports.DEFAULT_PORTS = DEFAULT_PORTS;
2593
2766
  exports.DEFAULT_PRESETS = DEFAULT_PRESETS;
2594
2767
  exports.FoxgloveClient = FoxgloveClient;
@@ -2600,6 +2773,8 @@ exports.getLagHistoryCsv = getLagHistoryCsv;
2600
2773
  exports.getLagStats = getLagStats;
2601
2774
  exports.getMaxLagMs = getMaxLagMs;
2602
2775
  exports.jsonSchemaToTemplate = jsonSchemaToTemplate;
2776
+ exports.matchesSchema = matchesSchema;
2777
+ exports.materializeBytes = materializeBytes;
2603
2778
  exports.schemaToTemplate = schemaToTemplate;
2604
2779
  //# sourceMappingURL=index.cjs.map
2605
2780
  //# sourceMappingURL=index.cjs.map