ros-mobile-bridge 0.1.2 → 0.1.4

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/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
@@ -591,13 +607,30 @@ var NOOP_LOGGER = { log() {
591
607
  } };
592
608
  var TEXT_ENCODER = new TextEncoder();
593
609
  var TEXT_DECODER = new TextDecoder();
610
+ var B64_LOOKUP = (() => {
611
+ const table = new Int16Array(256).fill(-1);
612
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
613
+ for (let i = 0; i < chars.length; i++) table[chars.charCodeAt(i)] = i;
614
+ return table;
615
+ })();
594
616
  function base64ToUint8(b64) {
595
- const binary = atob(b64);
596
- const out = new Uint8Array(binary.length);
597
- for (let i = 0; i < binary.length; i++) {
598
- out[i] = binary.charCodeAt(i);
599
- }
600
- return out;
617
+ let len = b64.length;
618
+ while (len > 0 && b64.charCodeAt(len - 1) === 61) len--;
619
+ const out = new Uint8Array(len * 3 >> 2);
620
+ let acc = 0;
621
+ let bits = 0;
622
+ let o = 0;
623
+ for (let i = 0; i < len; i++) {
624
+ const v = B64_LOOKUP[b64.charCodeAt(i)] ?? -1;
625
+ if (v < 0) continue;
626
+ acc = acc << 6 | v;
627
+ bits += 6;
628
+ if (bits >= 8) {
629
+ bits -= 8;
630
+ out[o++] = acc >>> bits & 255;
631
+ }
632
+ }
633
+ return o === out.length ? out : out.subarray(0, o);
601
634
  }
602
635
  var CDR_LE_HEADER = new Uint8Array([0, 1, 0, 0]);
603
636
  function isEmptyRequest(request) {
@@ -789,6 +822,7 @@ var _FoxgloveClient = class _FoxgloveClient {
789
822
  subscribe(topic, onMessage, options) {
790
823
  const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
791
824
  const disableAdaptive = options?.disableAdaptive ?? false;
825
+ const dispatchMode = options?.dispatchMode ?? "immediate";
792
826
  const existingSubId = this.topicToSubscriptionId.get(topic);
793
827
  if (existingSubId !== void 0) {
794
828
  const sub = this.subscriptions.get(existingSubId);
@@ -796,9 +830,14 @@ var _FoxgloveClient = class _FoxgloveClient {
796
830
  sub.callbacks.set(onMessage, {
797
831
  userMinIntervalMs,
798
832
  disableAdaptive,
799
- lastDeliveredAt: 0
833
+ lastDeliveredAt: 0,
834
+ dispatchMode,
835
+ pending: null,
836
+ drainTimer: null
800
837
  });
801
838
  return () => {
839
+ const entry = sub.callbacks.get(onMessage);
840
+ if (entry) this.cancelDrain(entry);
802
841
  sub.callbacks.delete(onMessage);
803
842
  if (sub.callbacks.size === 0) {
804
843
  this.unsubscribeTopic(topic, existingSubId);
@@ -819,7 +858,10 @@ var _FoxgloveClient = class _FoxgloveClient {
819
858
  callbacks.set(onMessage, {
820
859
  userMinIntervalMs,
821
860
  disableAdaptive,
822
- lastDeliveredAt: 0
861
+ lastDeliveredAt: 0,
862
+ dispatchMode,
863
+ pending: null,
864
+ drainTimer: null
823
865
  });
824
866
  const breaker = new CircuitBreaker({
825
867
  ...DEFAULT_BREAKER_CONFIG,
@@ -827,6 +869,7 @@ var _FoxgloveClient = class _FoxgloveClient {
827
869
  const sub = this.subscriptions.get(subscriptionId);
828
870
  if (!sub) return;
829
871
  sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
872
+ if (sub.isPaused) this.cancelAllDrains(sub);
830
873
  if (newState === "tripped_auto") {
831
874
  if (this.ws && this.status === "connected") {
832
875
  this.sendJson({ op: "unsubscribe", subscriptionIds: [subscriptionId] });
@@ -892,6 +935,8 @@ var _FoxgloveClient = class _FoxgloveClient {
892
935
  subscriptions: [{ id: subscriptionId, channelId }]
893
936
  });
894
937
  return () => {
938
+ const entry = callbacks.get(onMessage);
939
+ if (entry) this.cancelDrain(entry);
895
940
  callbacks.delete(onMessage);
896
941
  if (callbacks.size === 0) {
897
942
  this.unsubscribeTopic(topic, subscriptionId);
@@ -1184,7 +1229,6 @@ var _FoxgloveClient = class _FoxgloveClient {
1184
1229
  this.log(
1185
1230
  `WebSocket handshake successful (protocol: ${negotiated}), waiting for serverInfo...`
1186
1231
  );
1187
- this.clearConnectionTimeout();
1188
1232
  };
1189
1233
  this.ws.onmessage = (event) => {
1190
1234
  this.handleWsMessage(event);
@@ -1214,10 +1258,14 @@ var _FoxgloveClient = class _FoxgloveClient {
1214
1258
  if (this.controlOutbox.length > 0) {
1215
1259
  this.flushControlOutbox();
1216
1260
  }
1217
- if (typeof event.data === "string") {
1218
- this.handleJsonMessage(event.data);
1219
- } else if (event.data instanceof ArrayBuffer) {
1220
- this.handleBinaryMessage(event.data);
1261
+ try {
1262
+ if (typeof event.data === "string") {
1263
+ this.handleJsonMessage(event.data);
1264
+ } else if (event.data instanceof ArrayBuffer) {
1265
+ this.handleBinaryMessage(event.data);
1266
+ }
1267
+ } catch (err) {
1268
+ this.logger.error("[FoxgloveClient] Error handling inbound message:", err);
1221
1269
  }
1222
1270
  }
1223
1271
  handleJsonMessage(raw) {
@@ -1306,52 +1354,110 @@ var _FoxgloveClient = class _FoxgloveClient {
1306
1354
  }
1307
1355
  }
1308
1356
  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;
1357
+ let parsed = null;
1358
+ for (const [cb, entry] of deliverTo) {
1359
+ entry.lastDeliveredAt = now;
1360
+ if (entry.dispatchMode === "latest-only") {
1361
+ entry.pending = { payload: new Uint8Array(payload), sec, nsec };
1362
+ if (entry.drainTimer === null) {
1363
+ entry.drainTimer = setTimeout(
1364
+ () => this.drainLatestOnly(subscriptionId, cb, entry),
1365
+ 0
1366
+ );
1367
+ }
1368
+ continue;
1369
+ }
1370
+ if (parsed === null) {
1371
+ const channelInfo = this.channels.get(sub.channelId);
1372
+ const encoding = channelInfo?.encoding ?? "json";
1373
+ parsed = {
1374
+ topic: sub.topic,
1375
+ schemaName: channelInfo?.schemaName ?? "",
1376
+ encoding: encoding === "json" ? "json" : "cdr",
1377
+ data: this.decodePayload(payload, subscriptionId, encoding),
1378
+ receiveTime: { sec, nsec },
1379
+ byteSize: payload.byteLength
1380
+ };
1381
+ }
1382
+ try {
1383
+ cb(parsed);
1384
+ } catch (err) {
1385
+ this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1386
+ }
1387
+ }
1388
+ }
1389
+ /**
1390
+ * Decode a `messageData` payload to its delivered shape: a parsed object when
1391
+ * a CDR reader or JSON decode succeeds, the raw bytes otherwise. Shared by
1392
+ * the synchronous `immediate` path and the deferred `latest-only` drain.
1393
+ */
1394
+ decodePayload(payload, subscriptionId, encoding) {
1313
1395
  if (encoding === "json") {
1314
1396
  try {
1315
- const text = TEXT_DECODER.decode(payload);
1316
- data = JSON.parse(text);
1397
+ return JSON.parse(TEXT_DECODER.decode(payload));
1317
1398
  } catch {
1318
- data = payload;
1399
+ return payload;
1319
1400
  }
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;
1401
+ }
1402
+ const reader = this.messageReaders.get(subscriptionId);
1403
+ if (reader) {
1404
+ try {
1405
+ return reader.readMessage(payload);
1406
+ } catch {
1407
+ return payload;
1330
1408
  }
1331
1409
  }
1410
+ return payload;
1411
+ }
1412
+ /**
1413
+ * Drain one `latest-only` callback's pending payload: parse the survivor and
1414
+ * deliver it. Cleared state (`pending`, `drainTimer`) is reset *before* the
1415
+ * callback runs, so a throwing callback never wedges future delivery — the
1416
+ * next arrival re-arms normally. Bails if the subscription was torn down or
1417
+ * paused while the drain was armed (no post-teardown delivery).
1418
+ */
1419
+ drainLatestOnly(subscriptionId, cb, entry) {
1420
+ entry.drainTimer = null;
1421
+ const pending = entry.pending;
1422
+ entry.pending = null;
1423
+ if (!pending) return;
1424
+ const sub = this.subscriptions.get(subscriptionId);
1425
+ if (!sub || sub.isPaused) return;
1426
+ const channelInfo = this.channels.get(sub.channelId);
1427
+ const encoding = channelInfo?.encoding ?? "json";
1332
1428
  const rosMsg = {
1333
1429
  topic: sub.topic,
1334
- schemaName,
1430
+ schemaName: channelInfo?.schemaName ?? "",
1335
1431
  encoding: encoding === "json" ? "json" : "cdr",
1336
- data,
1337
- receiveTime: { sec, nsec },
1338
- byteSize: payload.byteLength
1432
+ data: this.decodePayload(pending.payload, subscriptionId, encoding),
1433
+ receiveTime: { sec: pending.sec, nsec: pending.nsec },
1434
+ byteSize: pending.payload.byteLength
1339
1435
  };
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
- }
1436
+ try {
1437
+ cb(rosMsg);
1438
+ } catch (err) {
1439
+ this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1440
+ }
1441
+ }
1442
+ /** Cancel one callback's armed drain and drop its pending payload. */
1443
+ cancelDrain(entry) {
1444
+ if (entry.drainTimer !== null) {
1445
+ clearTimeout(entry.drainTimer);
1446
+ entry.drainTimer = null;
1347
1447
  }
1448
+ entry.pending = null;
1449
+ }
1450
+ /** Cancel every armed drain on a subscription (teardown / pause). */
1451
+ cancelAllDrains(sub) {
1452
+ for (const entry of sub.callbacks.values()) this.cancelDrain(entry);
1348
1453
  }
1349
1454
  handleServerInfo(info) {
1350
1455
  this.log(`Received serverInfo: ${info.name} (sessionId: ${info.sessionId ?? "none"})`);
1351
1456
  this.serverInfoReceived = true;
1352
1457
  }
1353
1458
  handleAdvertise(msg) {
1354
- for (const ch of msg.channels) {
1459
+ const channels = Array.isArray(msg.channels) ? msg.channels : [];
1460
+ for (const ch of channels) {
1355
1461
  this.channels.set(ch.id, ch);
1356
1462
  this.topicToChannelId.set(ch.topic, ch.id);
1357
1463
  this.log(
@@ -1359,7 +1465,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1359
1465
  );
1360
1466
  }
1361
1467
  if (this.connectResolve && this.serverInfoReceived) {
1362
- this.log(`Connection established with ${msg.channels.length} initial topics.`);
1468
+ this.log(`Connection established with ${channels.length} initial topics.`);
1363
1469
  this.clearConnectionTimeout();
1364
1470
  this.reconnectAttempts = 0;
1365
1471
  this.setStatus("connected");
@@ -1371,7 +1477,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1371
1477
  }
1372
1478
  }
1373
1479
  handleUnadvertise(msg) {
1374
- for (const id of msg.channelIds) {
1480
+ const channelIds = Array.isArray(msg.channelIds) ? msg.channelIds : [];
1481
+ for (const id of channelIds) {
1375
1482
  const ch = this.channels.get(id);
1376
1483
  if (ch) {
1377
1484
  this.topicToChannelId.delete(ch.topic);
@@ -1381,7 +1488,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1381
1488
  this.notifyTopicsChanged();
1382
1489
  }
1383
1490
  handleAdvertiseServices(msg) {
1384
- for (const svc of msg.services) {
1491
+ const services = Array.isArray(msg.services) ? msg.services : [];
1492
+ for (const svc of services) {
1385
1493
  this.availableServices.set(svc.name, svc);
1386
1494
  }
1387
1495
  this.notifyServicesChanged();
@@ -1505,6 +1613,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1505
1613
  // ── Private: reconnection ────────────────────────────────────────────────
1506
1614
  handleConnectionError(error) {
1507
1615
  this.clearConnectionTimeout();
1616
+ const wasConnected = this.status === "connected";
1617
+ const reconnecting = this.reconnectAttempts > 0;
1508
1618
  if (this.connectReject) {
1509
1619
  this.connectReject(error);
1510
1620
  this.connectResolve = null;
@@ -1512,10 +1622,17 @@ var _FoxgloveClient = class _FoxgloveClient {
1512
1622
  }
1513
1623
  this.setStatus("error");
1514
1624
  this.cleanup();
1515
- this.scheduleReconnect();
1625
+ if (wasConnected || reconnecting) {
1626
+ this.scheduleReconnect();
1627
+ }
1516
1628
  }
1517
1629
  handleClose(_code, _reason) {
1518
1630
  const wasConnected = this.status === "connected";
1631
+ if (this.connectReject) {
1632
+ this.connectReject(new Error("Connection closed before the handshake completed"));
1633
+ this.connectResolve = null;
1634
+ this.connectReject = null;
1635
+ }
1519
1636
  if (wasConnected && this.hasPublishedTwist && !this.intentionalDisconnect) {
1520
1637
  this.safePublishZeroTwist();
1521
1638
  }
@@ -1553,6 +1670,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1553
1670
  // ── Private: unsubscribe ─────────────────────────────────────────────────
1554
1671
  unsubscribeTopic(topic, subscriptionId) {
1555
1672
  const sub = this.subscriptions.get(subscriptionId);
1673
+ if (sub) this.cancelAllDrains(sub);
1556
1674
  sub?.breaker.destroy();
1557
1675
  this.subscriptions.delete(subscriptionId);
1558
1676
  this.topicToSubscriptionId.delete(topic);
@@ -1649,6 +1767,10 @@ var _FoxgloveClient = class _FoxgloveClient {
1649
1767
  }
1650
1768
  this.ws = null;
1651
1769
  }
1770
+ for (const sub of this.subscriptions.values()) {
1771
+ this.cancelAllDrains(sub);
1772
+ sub.breaker.destroy();
1773
+ }
1652
1774
  this.channels.clear();
1653
1775
  this.topicToChannelId.clear();
1654
1776
  this.subscriptions.clear();
@@ -1839,14 +1961,20 @@ var _RosbridgeClient = class _RosbridgeClient {
1839
1961
  }
1840
1962
  const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
1841
1963
  const disableAdaptive = options?.disableAdaptive ?? false;
1964
+ const dispatchMode = options?.dispatchMode ?? "immediate";
1842
1965
  const existing = this.activeSubscriptions.get(topic);
1843
1966
  if (existing) {
1844
1967
  existing.callbacks.set(onMessage, {
1845
1968
  userMinIntervalMs,
1846
1969
  disableAdaptive,
1847
- lastDeliveredAt: 0
1970
+ lastDeliveredAt: 0,
1971
+ dispatchMode,
1972
+ pending: null,
1973
+ drainTimer: null
1848
1974
  });
1849
1975
  return () => {
1976
+ const entry = existing.callbacks.get(onMessage);
1977
+ if (entry) this.cancelDrain(entry);
1850
1978
  existing.callbacks.delete(onMessage);
1851
1979
  if (existing.callbacks.size === 0) {
1852
1980
  this.unsubscribeTopic(topic);
@@ -1862,7 +1990,10 @@ var _RosbridgeClient = class _RosbridgeClient {
1862
1990
  callbacks.set(onMessage, {
1863
1991
  userMinIntervalMs,
1864
1992
  disableAdaptive,
1865
- lastDeliveredAt: 0
1993
+ lastDeliveredAt: 0,
1994
+ dispatchMode,
1995
+ pending: null,
1996
+ drainTimer: null
1866
1997
  });
1867
1998
  const breaker = new CircuitBreaker({
1868
1999
  ...DEFAULT_BREAKER_CONFIG,
@@ -1870,6 +2001,7 @@ var _RosbridgeClient = class _RosbridgeClient {
1870
2001
  const sub = this.activeSubscriptions.get(topic);
1871
2002
  if (!sub) return;
1872
2003
  sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
2004
+ if (sub.isPaused) this.cancelAllDrains(sub);
1873
2005
  if (newState === "tripped_auto") {
1874
2006
  if (this.ws && this.status === "connected") {
1875
2007
  this.send({ op: "unsubscribe", topic });
@@ -1919,6 +2051,8 @@ var _RosbridgeClient = class _RosbridgeClient {
1919
2051
  queue_length: 1
1920
2052
  });
1921
2053
  return () => {
2054
+ const entry = callbacks.get(onMessage);
2055
+ if (entry) this.cancelDrain(entry);
1922
2056
  callbacks.delete(onMessage);
1923
2057
  if (callbacks.size === 0) {
1924
2058
  this.unsubscribeTopic(topic);
@@ -2099,10 +2233,11 @@ var _RosbridgeClient = class _RosbridgeClient {
2099
2233
  this.ws = new WebSocket(this.url);
2100
2234
  this.connectionTimeoutTimer = setTimeout(() => {
2101
2235
  this.log(`Connection timeout after ${CONNECTION_TIMEOUT_MS2}ms`);
2236
+ const reconnecting = this.reconnectAttempts > 0;
2102
2237
  reject(new Error(`Connection timeout after ${CONNECTION_TIMEOUT_MS2}ms`));
2103
2238
  this.cleanup();
2104
2239
  this.setStatus("error");
2105
- this.scheduleReconnect();
2240
+ if (reconnecting) this.scheduleReconnect();
2106
2241
  }, CONNECTION_TIMEOUT_MS2);
2107
2242
  this.ws.onopen = () => {
2108
2243
  this.clearConnectionTimeout();
@@ -2118,11 +2253,12 @@ var _RosbridgeClient = class _RosbridgeClient {
2118
2253
  this.log(`Rosbridge error: ${detail}`);
2119
2254
  this.logger.error("[RosbridgeClient] Error:", event);
2120
2255
  if (this.status === "connecting") {
2256
+ const reconnecting = this.reconnectAttempts > 0;
2121
2257
  this.clearConnectionTimeout();
2122
2258
  reject(new Error(`Rosbridge error: ${detail}`));
2123
2259
  this.cleanup();
2124
2260
  this.setStatus("error");
2125
- this.scheduleReconnect();
2261
+ if (reconnecting) this.scheduleReconnect();
2126
2262
  }
2127
2263
  };
2128
2264
  this.ws.onclose = () => {
@@ -2218,19 +2354,26 @@ var _RosbridgeClient = class _RosbridgeClient {
2218
2354
  recordBytes(sub.bandwidth, now, byteSize, mode);
2219
2355
  return true;
2220
2356
  }
2221
- let anyCallbackWantsThis = false;
2222
- for (const entry of sub.callbacks.values()) {
2357
+ let anyImmediateWantsThis = false;
2358
+ for (const [cb, entry] of sub.callbacks) {
2223
2359
  const interval = effectiveMinInterval(
2224
2360
  entry.userMinIntervalMs,
2225
2361
  entry.disableAdaptive,
2226
2362
  sub.bandwidth
2227
2363
  );
2228
- if (interval <= 0 || now - entry.lastDeliveredAt >= interval) {
2229
- anyCallbackWantsThis = true;
2230
- break;
2364
+ const eligible = interval <= 0 || now - entry.lastDeliveredAt >= interval;
2365
+ if (!eligible) continue;
2366
+ if (entry.dispatchMode === "latest-only") {
2367
+ entry.lastDeliveredAt = now;
2368
+ entry.pending = { raw: data, receivedAt: now };
2369
+ if (entry.drainTimer === null) {
2370
+ entry.drainTimer = setTimeout(() => this.drainLatestOnly(topic, cb, entry), 0);
2371
+ }
2372
+ } else {
2373
+ anyImmediateWantsThis = true;
2231
2374
  }
2232
2375
  }
2233
- if (anyCallbackWantsThis) return false;
2376
+ if (anyImmediateWantsThis) return false;
2234
2377
  recordBytes(sub.bandwidth, now, byteSize, mode);
2235
2378
  sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
2236
2379
  return true;
@@ -2246,6 +2389,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2246
2389
  sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
2247
2390
  const deliverTo = [];
2248
2391
  for (const [cb, entry] of sub.callbacks) {
2392
+ if (entry.dispatchMode === "latest-only") continue;
2249
2393
  const interval = effectiveMinInterval(
2250
2394
  entry.userMinIntervalMs,
2251
2395
  entry.disableAdaptive,
@@ -2276,6 +2420,55 @@ var _RosbridgeClient = class _RosbridgeClient {
2276
2420
  }
2277
2421
  }
2278
2422
  }
2423
+ /**
2424
+ * Drain one `latest-only` callback's pending frame: parse the survivor and
2425
+ * deliver it. State (`pending`, `drainTimer`) is cleared *before* the
2426
+ * callback runs, so a throwing callback never wedges future delivery. Bails
2427
+ * if the subscription was torn down or paused while the drain was armed (no
2428
+ * post-teardown delivery).
2429
+ */
2430
+ drainLatestOnly(topic, cb, entry) {
2431
+ entry.drainTimer = null;
2432
+ const pending = entry.pending;
2433
+ entry.pending = null;
2434
+ if (!pending) return;
2435
+ const sub = this.activeSubscriptions.get(topic);
2436
+ if (!sub || sub.isPaused) return;
2437
+ let parsed;
2438
+ try {
2439
+ parsed = JSON.parse(pending.raw);
2440
+ } catch {
2441
+ return;
2442
+ }
2443
+ const rosMsg = {
2444
+ topic,
2445
+ schemaName: sub.schemaName,
2446
+ encoding: "json",
2447
+ data: parsed.msg ?? {},
2448
+ receiveTime: {
2449
+ sec: Math.floor(pending.receivedAt / 1e3),
2450
+ nsec: pending.receivedAt % 1e3 * 1e6
2451
+ },
2452
+ byteSize: pending.raw.length
2453
+ };
2454
+ try {
2455
+ cb(rosMsg);
2456
+ } catch (err) {
2457
+ this.logger.error("[RosbridgeClient] Subscriber callback error:", err);
2458
+ }
2459
+ }
2460
+ /** Cancel one callback's armed drain and drop its pending frame. */
2461
+ cancelDrain(entry) {
2462
+ if (entry.drainTimer !== null) {
2463
+ clearTimeout(entry.drainTimer);
2464
+ entry.drainTimer = null;
2465
+ }
2466
+ entry.pending = null;
2467
+ }
2468
+ /** Cancel every armed drain on a subscription (teardown / pause). */
2469
+ cancelAllDrains(sub) {
2470
+ for (const entry of sub.callbacks.values()) this.cancelDrain(entry);
2471
+ }
2279
2472
  handleServiceResponse(msg) {
2280
2473
  const id = msg.id;
2281
2474
  const pending = this.pendingServiceCalls.get(id);
@@ -2294,6 +2487,7 @@ var _RosbridgeClient = class _RosbridgeClient {
2294
2487
  // ── Private: topic management ──────────────────────────────────────────
2295
2488
  unsubscribeTopic(topic) {
2296
2489
  const sub = this.activeSubscriptions.get(topic);
2490
+ if (sub) this.cancelAllDrains(sub);
2297
2491
  sub?.breaker.destroy();
2298
2492
  this.activeSubscriptions.delete(topic);
2299
2493
  if (this.ws && this.status === "connected" && !sub?.isPaused) {
@@ -2416,6 +2610,10 @@ var _RosbridgeClient = class _RosbridgeClient {
2416
2610
  cleanupConnection() {
2417
2611
  this.clearConnectionTimeout();
2418
2612
  this.stopServicesPoll();
2613
+ for (const sub of this.activeSubscriptions.values()) {
2614
+ this.cancelAllDrains(sub);
2615
+ sub.breaker.destroy();
2616
+ }
2419
2617
  this.activeSubscriptions.clear();
2420
2618
  for (const [, pending] of this.pendingServiceCalls) {
2421
2619
  clearTimeout(pending.timer);
@@ -2441,6 +2639,10 @@ var _RosbridgeClient = class _RosbridgeClient {
2441
2639
  this.reconnectTimer = null;
2442
2640
  }
2443
2641
  if (this.ws) {
2642
+ this.ws.onopen = null;
2643
+ this.ws.onmessage = null;
2644
+ this.ws.onerror = null;
2645
+ this.ws.onclose = null;
2444
2646
  try {
2445
2647
  this.ws.close();
2446
2648
  } catch {
@@ -2537,7 +2739,7 @@ var ProtocolManager = class {
2537
2739
  /**
2538
2740
  * Create and connect a protocol client for the given options.
2539
2741
  *
2540
- * For `protocol: 'zenoh'`, throws a clear "planned for v0.2.0" error —
2742
+ * For `protocol: 'zenoh'`, throws a clear "planned for v0.3.0" error —
2541
2743
  * the v0.1.0 release does not ship a Zenoh implementation.
2542
2744
  */
2543
2745
  async connect(options) {
@@ -2545,7 +2747,8 @@ var ProtocolManager = class {
2545
2747
  await this.activeClient.disconnect();
2546
2748
  }
2547
2749
  const client = this.createClient(options);
2548
- const scheme = options.secure ? "wss" : "ws";
2750
+ const pastedTls = /^\s*(wss|https):\/\//i.test(options.host);
2751
+ const scheme = options.secure ?? pastedTls ? "wss" : "ws";
2549
2752
  const port = options.port || DEFAULT_PORTS[options.protocol];
2550
2753
  const host = sanitizeHost(options.host);
2551
2754
  const url = `${scheme}://${host}:${port}`;
@@ -2556,7 +2759,13 @@ var ProtocolManager = class {
2556
2759
  `Invalid connection URL "${url}" \u2014 check host and port. Hosts should not include "ws://" or ":port"; use the port field instead.`
2557
2760
  );
2558
2761
  }
2559
- await client.connect(url);
2762
+ try {
2763
+ await client.connect(url);
2764
+ } catch (err) {
2765
+ await client.disconnect().catch(() => {
2766
+ });
2767
+ throw err;
2768
+ }
2560
2769
  this.activeClient = client;
2561
2770
  this.activeOptions = options;
2562
2771
  return client;
@@ -2590,7 +2799,7 @@ var ProtocolManager = class {
2590
2799
  case "rosbridge":
2591
2800
  return new RosbridgeClient(this.clientOptions);
2592
2801
  case "zenoh":
2593
- throw new Error("Zenoh support is planned for v0.2.0");
2802
+ throw new Error("Zenoh support is planned for v0.3.0");
2594
2803
  default: {
2595
2804
  const exhaustive = options.protocol;
2596
2805
  throw new Error(`Unknown protocol: ${String(exhaustive)}`);
@@ -2599,6 +2808,11 @@ var ProtocolManager = class {
2599
2808
  }
2600
2809
  };
2601
2810
 
2811
+ // src/materializeBytes.ts
2812
+ function materializeBytes(view) {
2813
+ return new Uint8Array(view);
2814
+ }
2815
+
2602
2816
  exports.DEFAULT_PORTS = DEFAULT_PORTS;
2603
2817
  exports.DEFAULT_PRESETS = DEFAULT_PRESETS;
2604
2818
  exports.FoxgloveClient = FoxgloveClient;
@@ -2610,6 +2824,8 @@ exports.getLagHistoryCsv = getLagHistoryCsv;
2610
2824
  exports.getLagStats = getLagStats;
2611
2825
  exports.getMaxLagMs = getMaxLagMs;
2612
2826
  exports.jsonSchemaToTemplate = jsonSchemaToTemplate;
2827
+ exports.matchesSchema = matchesSchema;
2828
+ exports.materializeBytes = materializeBytes;
2613
2829
  exports.schemaToTemplate = schemaToTemplate;
2614
2830
  //# sourceMappingURL=index.cjs.map
2615
2831
  //# sourceMappingURL=index.cjs.map