ros-mobile-bridge 0.1.3 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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.4] - 2026-06-14
8
+
9
+ ### Fixed
10
+
11
+ - **A malformed JSON control message no longer crashes the host.** A bad `advertise` / `unadvertise` / `advertiseServices` frame from a buggy or hostile bridge (for example a non-array `channels`) previously threw out of the WebSocket message handler, surfacing as an uncaughtException on Node and a fatal error on React Native release builds. The Foxglove dispatch now contains handler errors and routes them through the injected logger, and the affected handlers guard their field shapes so a half-valid frame degrades instead of throwing.
12
+ - **`FoxgloveClient.connect()` no longer hangs forever against a wrong or silent endpoint.** A socket that opened but never spoke the Foxglove protocol (the classic "rosbridge port in a Foxglove profile"), or that closed before the handshake completed, left the connect promise pending indefinitely. The connection timeout now covers the full handshake, and a pre-handshake close rejects the promise.
13
+ - **A failed initial `connect()` no longer leaves a background reconnect loop.** Both transports previously rejected the caller yet kept retrying in the background; because the manager only stores the client after a successful connect, that produced an unreachable client holding a socket nobody could disconnect. Auto-reconnect now runs only after a connection has previously succeeded, or while a reconnect cycle is already in progress; first-connect retry is the consumer's responsibility. `ProtocolManager.connect` also disconnects the client when `connect()` rejects.
14
+ - **Circuit-breaker cooldown timers no longer outlive the connection.** Connection teardown now destroys each subscription's breaker, so a tripped breaker's cooldown cannot fire into the next connection (where subscription ids are reused).
15
+ - **`RosbridgeClient` detaches its WebSocket handlers before closing**, so a late `onclose` from a stale socket cannot tear down a newer connection.
16
+ - **A pasted `wss://` or `https://` host no longer silently downgrades to plaintext.** When `secure` is unset the scheme is inferred from the host; an explicit `secure` (true or false) still wins.
17
+
18
+ ### Changed
19
+
20
+ - **`base64ToUint8` no longer uses `atob`.** It decodes with a small lookup table instead, removing a global that is outside the library's supported set and absent on older React Native (Hermes) runtimes. Only the legacy JSON service-response path is affected; the decoded bytes are unchanged.
21
+
22
+ ### Documented
23
+
24
+ - **Corrected the disconnect safety boundary.** The library publishes a zero-Twist stop on `/cmd_vel` only on an intentional disconnect while the socket is open. It cannot stop the robot on an unexpected loss of connectivity (network drop, app kill, crash) because the transport is already gone; network-loss halting requires a robot-side `cmd_vel` watchdog. Stated affirmatively in the README, both client class headers, and the `publishZeroTwist` TSDoc.
25
+ - **Documented post-reconnect behavior:** subscriptions are not re-established after an automatic reconnect, so the consumer watches `onStatusChange` and resubscribes.
26
+ - Added a SECURITY.md threat-model section: inbound size and count caps plus a fuzz harness are scheduled for a later hardening milestone, so connect only to bridges you trust until then. Removed a stale class-header line describing a keep-alive ping that was dropped in v0.1.1.
27
+ - Reordered the ROADMAP milestones (the read-only introspection surface before the Zenoh transport before the hardening milestone).
28
+
7
29
  ## [0.1.3] - 2026-06-02
8
30
 
9
31
  ### Added
@@ -70,6 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
70
92
 
71
93
  - `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.
72
94
 
95
+ [0.1.4]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.4
73
96
  [0.1.3]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.3
74
97
  [0.1.2]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.2
75
98
  [0.1.1]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.1
package/README.md CHANGED
@@ -75,6 +75,14 @@ A consumer can write against `IProtocolClient` once and pick the transport at ru
75
75
  client.publish('/cmd_vel', 'geometry_msgs/msg/Twist', zeroTwist, { priority: 'control' });
76
76
  ```
77
77
 
78
+ ### Safety: stopping the robot on disconnect
79
+
80
+ `publishZeroTwist()` and the control-priority paths send a stop on `/cmd_vel` only while the connection is open, on an **intentional** `disconnect()`, app-background, or E-Stop. They cannot stop the robot on an *unexpected* loss of connectivity (network drop, app kill, crash): the transport is already gone, so no command can leave the device. Halting on network loss must be enforced robot-side, by a `cmd_vel` timeout or watchdog on the robot that stops when commands stop arriving. This library covers intentional teardown; it is not a substitute for that watchdog.
81
+
82
+ ### Reconnection
83
+
84
+ A connection that previously succeeded auto-reconnects with exponential backoff (up to 5 attempts). A failed *initial* `connect()` rejects and does not retry in the background; first-connect retry is the consumer's call. After an automatic reconnect, prior subscriptions are not re-established: watch `onStatusChange` and resubscribe.
85
+
78
86
  ### Adaptive throttle and circuit breaker
79
87
 
80
88
  Both reliability features are observable. Read the current throttle bucket per subscription:
package/ROADMAP.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `ros-mobile-bridge` follows a milestone-based roadmap. Each milestone targets a concrete release. Versions are not date-bound; they ship when the criteria for the milestone are met.
4
4
 
5
- The library reached `v0.1.0` with two production-ready transports (Foxglove WebSocket v1 and rosbridge v2) extracted from a real mobile application's protocol layer. The next two milestones expand the transport set, harden the library against real-world parse-side attacks, and grow the community of consumers.
5
+ The library reached `v0.1.0` with two production-ready transports (Foxglove WebSocket v1 and rosbridge v2) extracted from a real mobile application's protocol layer. The roadmap then exposes the robot's ROS 2 graph through a read-only introspection surface, adds a third transport (Zenoh), and finishes with parser-side hardening, community growth, and the v1.0 API freeze.
6
6
 
7
7
  ## v0.1.x — Stabilization (current)
8
8
 
@@ -17,9 +17,26 @@ The `v0.1.x` series consolidates the first public release. Patch releases addres
17
17
 
18
18
  **Out of scope:** any breaking API change, any new transport.
19
19
 
20
- ## v0.2.0 — Zenoh transport
20
+ ## v0.2.0 — Programmatic introspection surface
21
21
 
22
- The `ZenohClient` skeleton already exists in `src/ZenohClient.ts` as roadmap-as-code. `v0.2.0` lands the real implementation.
22
+ `v0.2.0` exposes a robot's ROS 2 graph to programmatic consumers, including AI coding agents. It lands before the Zenoh transport on purpose: the introspection schema interface must be frozen before a third transport implements against it, and it is the lower-risk milestone that front-loads value.
23
+
24
+ Hobbyist and academic ROS 2 users increasingly drive their workflow through AI coding agents (Claude Code, Cursor, Codex, OpenCode). A natural extension is agent-driven configuration of mobile dashboards: the agent already knows the robot's topic graph because it just wrote the nodes, so it can wire widget configurations headlessly and hand the user a ready-to-use dashboard. Prior art in adjacent ecosystems (Marimo's `marimo-pair` skill for collaborative notebook editing, shipped via the cross-tool `skills` package manager) validates the pattern shape.
25
+
26
+ This milestone does **not** build an MCP server inside `ros-mobile-bridge`. Hosting an MCP server requires a runtime-specific transport (stdio on Node, HTTP on browsers / Electron) and would break the four-runtimes-from-one-build promise. Instead, the library exposes the introspection surface an MCP server would need; integrators (mobile apps, CLI tools, agent harnesses) build the transport layer themselves. The command-dispatch safety boundary lives in the integrator that owns the robot connection, not in the library.
27
+
28
+ **Deliverables:**
29
+
30
+ - `getAvailableParameters(): Promise<ParameterInfo[]>` and `getParameter(name)` on `IProtocolClient`, mapped through both Foxglove WebSocket and rosbridge wire protocols. Today the library exposes topic and service introspection but has no concept of ROS parameters; agents driving dashboard configuration may want them for parameter-display widgets.
31
+ - `getSchemaDefinition(schemaName)` returning the parsed message definition (full type information per field), complementing the existing zero-value `getSchemaTemplate`. Returns `null` for transports that do not carry schemas inline (rosbridge), matching `getSchemaTemplate`'s rosbridge fallback shape.
32
+ - Documentation: a dedicated guide page covering "building an MCP server on top of `ros-mobile-bridge`" — the introspection-only contract, the command-dispatch safety boundary (which lives in the consumer, not the library), and worked examples for stdio and HTTP transports.
33
+ - A reference adapter under `examples/agent-mcp/` showing how to wrap the introspection methods as MCP tool definitions, runtime-agnostic, leaving the actual server hosting to the integrator.
34
+
35
+ **Why this matters:** open-source plus AI-agent accessibility for non-experts is what distinguishes a serious robotics library from a thin protocol wrapper. No existing JavaScript ROS 2 client surfaces enough metadata for an agent to drive a dashboard end-to-end. `v0.2.0` ships the contract; downstream projects (including Tinca) ship the experiences on top.
36
+
37
+ ## v0.3.0 — Zenoh transport
38
+
39
+ The `ZenohClient` skeleton already exists in `src/ZenohClient.ts` as roadmap-as-code. `v0.3.0` lands the real implementation, conforming to the `IProtocolClient` interface frozen in `v0.2.0`.
23
40
 
24
41
  **Deliverables:**
25
42
 
@@ -33,13 +50,13 @@ The `ZenohClient` skeleton already exists in `src/ZenohClient.ts` as roadmap-as-
33
50
  **Open questions resolved first (a spike opens this milestone):**
34
51
 
35
52
  - **React Native support is unverified.** `@eclipse-zenoh/zenoh-ts` ships a WebAssembly module for key-expression handling. WASM runs cleanly in browsers, Node, and Electron, but React Native (Hermes) WASM support is limited. If the dependency does not run under React Native, the Zenoh transport may initially target browser / Node / Electron with React Native to follow.
36
- - **Schema acquisition differs from Foxglove WS.** `rmw_zenoh` does not distribute message definitions inline, so CDR decoding needs definitions sourced another way (bundled common interfaces, consumer-provided, or a registry). The spike scopes this.
53
+ - **Schema acquisition differs from Foxglove WS.** `rmw_zenoh` does not distribute message definitions inline, so CDR decoding needs definitions sourced another way (bundled common interfaces, consumer-provided, or a registry). The introspection surface from `v0.2.0` informs this; the spike scopes it.
37
54
 
38
- **Why this matters:** Zenoh is the recommended middleware for the next generation of ROS 2 deployments (it underlies `rmw_zenoh`, which the OSRF has positioned as a serious alternative to the default DDS-based middleware). No existing JavaScript or TypeScript library provides a ROS 2 client over Zenoh for these runtimes; `v0.2.0` aims to close that gap, with mobile-runtime support contingent on the verification above.
55
+ **Why this matters:** Zenoh is the recommended middleware for the next generation of ROS 2 deployments (it underlies `rmw_zenoh`, which the OSRF has positioned as a serious alternative to the default DDS-based middleware). No existing JavaScript or TypeScript library provides a ROS 2 client over Zenoh for these runtimes; `v0.3.0` aims to close that gap, with mobile-runtime support contingent on the verification above.
39
56
 
40
- ## v0.3.0 — Hardening and community
57
+ ## v0.4.0 — Hardening, community & v1.0
41
58
 
42
- `v0.3.0` is the security-and-community milestone. It assumes the library has been in use for several months across multiple consumers and that real-world inputs have surfaced edge cases the test suite did not anticipate.
59
+ `v0.4.0` is the security-and-community milestone, and it carries the library into the `v1.0` API freeze. It assumes the library has been in use for several months across multiple consumers and that real-world inputs have surfaced edge cases the test suite did not anticipate.
43
60
 
44
61
  **Deliverables:**
45
62
 
@@ -49,29 +66,16 @@ The `ZenohClient` skeleton already exists in `src/ZenohClient.ts` as roadmap-as-
49
66
  - Community onboarding work: contributor guide expansion, "good first issue" labels, response-time commitments documented in `CONTRIBUTING.md`, monthly maintainer triage cadence.
50
67
  - A discoverable presence in the ROS 2 ecosystem: ROS Discourse announcement, link from `index.ros.org` if accepted, listing in `awesome-ros2`.
51
68
 
52
- **Why this matters:** the library will by then be a parser of arbitrary input from untrusted bridges. Security-grade quality is what distinguishes a library a serious robotics company will adopt from a hobby project.
53
-
54
- ## v0.4.0 (provisional) — agent-driven introspection surface
55
-
56
- Hobbyist and academic ROS 2 users increasingly drive their workflow through AI coding agents (Claude Code, Cursor, Codex, OpenCode). A natural extension is agent-driven configuration of mobile dashboards: the agent already knows the robot's topic graph because it just wrote the nodes, so it can wire widget configurations headlessly and hand the user a ready-to-use dashboard. Prior art in adjacent ecosystems (Marimo's `marimo-pair` skill for collaborative notebook editing, shipped via the cross-tool `skills` package manager) validates the pattern shape.
57
-
58
- This milestone does **not** build an MCP server inside `ros-mobile-bridge`. Hosting an MCP server requires a runtime-specific transport (stdio on Node, HTTP on browsers / Electron) and would break the four-runtimes-from-one-build promise. Instead, the library exposes the introspection surface an MCP server would need; integrators (mobile apps, CLI tools, agent harnesses) build the transport layer themselves. The command-dispatch safety boundary lives in the integrator that owns the robot connection, not in the library.
59
-
60
- **Deliverables:**
69
+ > Note: individual crash and robustness fixes against malformed input ship as `v0.1.x` bug-fixes whenever a consumer surfaces them (e.g. "don't let one malformed control frame kill the host"). This milestone is the *systematic* pass: the third-party audit, the property-based fuzz harness, and the oversized-message / DoS / prototype-pollution coverage as a whole. The two are complementary, not a contradiction.
61
70
 
62
- - `getAvailableParameters(): Promise<ParameterInfo[]>` and `getParameter(name)` on `IProtocolClient`, mapped through both Foxglove WebSocket and rosbridge wire protocols. Today the library exposes topic and service introspection but has no concept of ROS parameters; agents driving dashboard configuration may want them for parameter-display widgets.
63
- - `getSchemaDefinition(schemaName)` returning the parsed message definition (full type information per field), complementing the existing zero-value `getSchemaTemplate`. Returns `null` for transports that do not carry schemas inline (rosbridge), matching `getSchemaTemplate`'s rosbridge fallback shape.
64
- - Documentation: a dedicated guide page covering "building an MCP server on top of `ros-mobile-bridge`" — the introspection-only contract, the command-dispatch safety boundary (which lives in the consumer, not the library), and worked examples for stdio and HTTP transports.
65
- - A reference adapter under `examples/agent-mcp/` showing how to wrap the introspection methods as MCP tool definitions, runtime-agnostic, leaving the actual server hosting to the integrator.
66
-
67
- **Why this matters:** open-source plus AI-agent accessibility for non-experts is what distinguishes a serious robotics library from a thin protocol wrapper. No existing JavaScript ROS 2 client surfaces enough metadata for an agent to drive a dashboard end-to-end. `v0.4.0` ships the contract; downstream projects (including Tinca) ship the experiences on top.
71
+ **Why this matters:** the library will by then be a parser of arbitrary input from untrusted bridges. Security-grade quality is what distinguishes a library a serious robotics company will adopt from a hobby project.
68
72
 
69
73
  ## Beyond v1.0
70
74
 
71
75
  `v1.0.0` is not date-bound. It represents the point at which the public API has stabilized enough that breaking changes become exceptional rather than routine. Reaching `v1.0` requires:
72
76
 
73
77
  - At least six months of `v0.x` series usage across multiple consumers without API-breaking pressure.
74
- - The security audit from `v0.3.0` completed with no unresolved high-severity findings.
78
+ - The security audit from `v0.4.0` completed with no unresolved high-severity findings.
75
79
  - Documentation site complete: every public symbol with TSDoc, every public concept with a dedicated guide page, every transport with a runnable example.
76
80
  - Stable performance characteristics documented (throughput per transport, memory footprint, supported message sizes).
77
81
 
package/SECURITY.md CHANGED
@@ -36,6 +36,14 @@ A CVE is requested when applicable.
36
36
  - Vulnerabilities in transitive dependencies that do not affect this library's runtime behavior. Report those to the relevant upstream package.
37
37
  - Misconfigurations in the host application that consumes this library.
38
38
 
39
+ ## Threat Model and Current Limitations
40
+
41
+ The library is designed to connect to bridges the consumer chooses (a robot they own), not arbitrary untrusted endpoints. With that in mind:
42
+
43
+ - **Malformed frames are contained.** A malformed control message from a buggy or hostile bridge is caught and logged rather than crashing the host application.
44
+ - **Inbound size and count limits are not yet enforced.** A hostile endpoint could stream unbounded channel or service advertisements, or a single very large frame, to exhaust memory or block the JS thread. Per-message size caps, channel-count limits, and a property-based fuzz harness over the parser entry points are scheduled for the systematic hardening milestone (v0.4.0). Until then, connect only to bridges you trust, and keep untrusted bridges behind a network boundary you control.
45
+ - **Transport encryption is opt-in.** Plaintext `ws://` is the default; pass `secure: true` (or paste a `wss://` host) for TLS. On untrusted networks, use `wss://`.
46
+
39
47
  ## Supported Versions
40
48
 
41
49
  | Version | Supported |
package/dist/index.cjs CHANGED
@@ -607,13 +607,30 @@ var NOOP_LOGGER = { log() {
607
607
  } };
608
608
  var TEXT_ENCODER = new TextEncoder();
609
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
+ })();
610
616
  function base64ToUint8(b64) {
611
- const binary = atob(b64);
612
- const out = new Uint8Array(binary.length);
613
- for (let i = 0; i < binary.length; i++) {
614
- out[i] = binary.charCodeAt(i);
615
- }
616
- 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);
617
634
  }
618
635
  var CDR_LE_HEADER = new Uint8Array([0, 1, 0, 0]);
619
636
  function isEmptyRequest(request) {
@@ -1212,7 +1229,6 @@ var _FoxgloveClient = class _FoxgloveClient {
1212
1229
  this.log(
1213
1230
  `WebSocket handshake successful (protocol: ${negotiated}), waiting for serverInfo...`
1214
1231
  );
1215
- this.clearConnectionTimeout();
1216
1232
  };
1217
1233
  this.ws.onmessage = (event) => {
1218
1234
  this.handleWsMessage(event);
@@ -1242,10 +1258,14 @@ var _FoxgloveClient = class _FoxgloveClient {
1242
1258
  if (this.controlOutbox.length > 0) {
1243
1259
  this.flushControlOutbox();
1244
1260
  }
1245
- if (typeof event.data === "string") {
1246
- this.handleJsonMessage(event.data);
1247
- } else if (event.data instanceof ArrayBuffer) {
1248
- 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);
1249
1269
  }
1250
1270
  }
1251
1271
  handleJsonMessage(raw) {
@@ -1436,7 +1456,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1436
1456
  this.serverInfoReceived = true;
1437
1457
  }
1438
1458
  handleAdvertise(msg) {
1439
- for (const ch of msg.channels) {
1459
+ const channels = Array.isArray(msg.channels) ? msg.channels : [];
1460
+ for (const ch of channels) {
1440
1461
  this.channels.set(ch.id, ch);
1441
1462
  this.topicToChannelId.set(ch.topic, ch.id);
1442
1463
  this.log(
@@ -1444,7 +1465,7 @@ var _FoxgloveClient = class _FoxgloveClient {
1444
1465
  );
1445
1466
  }
1446
1467
  if (this.connectResolve && this.serverInfoReceived) {
1447
- this.log(`Connection established with ${msg.channels.length} initial topics.`);
1468
+ this.log(`Connection established with ${channels.length} initial topics.`);
1448
1469
  this.clearConnectionTimeout();
1449
1470
  this.reconnectAttempts = 0;
1450
1471
  this.setStatus("connected");
@@ -1456,7 +1477,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1456
1477
  }
1457
1478
  }
1458
1479
  handleUnadvertise(msg) {
1459
- for (const id of msg.channelIds) {
1480
+ const channelIds = Array.isArray(msg.channelIds) ? msg.channelIds : [];
1481
+ for (const id of channelIds) {
1460
1482
  const ch = this.channels.get(id);
1461
1483
  if (ch) {
1462
1484
  this.topicToChannelId.delete(ch.topic);
@@ -1466,7 +1488,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1466
1488
  this.notifyTopicsChanged();
1467
1489
  }
1468
1490
  handleAdvertiseServices(msg) {
1469
- for (const svc of msg.services) {
1491
+ const services = Array.isArray(msg.services) ? msg.services : [];
1492
+ for (const svc of services) {
1470
1493
  this.availableServices.set(svc.name, svc);
1471
1494
  }
1472
1495
  this.notifyServicesChanged();
@@ -1590,6 +1613,8 @@ var _FoxgloveClient = class _FoxgloveClient {
1590
1613
  // ── Private: reconnection ────────────────────────────────────────────────
1591
1614
  handleConnectionError(error) {
1592
1615
  this.clearConnectionTimeout();
1616
+ const wasConnected = this.status === "connected";
1617
+ const reconnecting = this.reconnectAttempts > 0;
1593
1618
  if (this.connectReject) {
1594
1619
  this.connectReject(error);
1595
1620
  this.connectResolve = null;
@@ -1597,10 +1622,17 @@ var _FoxgloveClient = class _FoxgloveClient {
1597
1622
  }
1598
1623
  this.setStatus("error");
1599
1624
  this.cleanup();
1600
- this.scheduleReconnect();
1625
+ if (wasConnected || reconnecting) {
1626
+ this.scheduleReconnect();
1627
+ }
1601
1628
  }
1602
1629
  handleClose(_code, _reason) {
1603
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
+ }
1604
1636
  if (wasConnected && this.hasPublishedTwist && !this.intentionalDisconnect) {
1605
1637
  this.safePublishZeroTwist();
1606
1638
  }
@@ -1735,7 +1767,10 @@ var _FoxgloveClient = class _FoxgloveClient {
1735
1767
  }
1736
1768
  this.ws = null;
1737
1769
  }
1738
- for (const sub of this.subscriptions.values()) this.cancelAllDrains(sub);
1770
+ for (const sub of this.subscriptions.values()) {
1771
+ this.cancelAllDrains(sub);
1772
+ sub.breaker.destroy();
1773
+ }
1739
1774
  this.channels.clear();
1740
1775
  this.topicToChannelId.clear();
1741
1776
  this.subscriptions.clear();
@@ -2198,10 +2233,11 @@ var _RosbridgeClient = class _RosbridgeClient {
2198
2233
  this.ws = new WebSocket(this.url);
2199
2234
  this.connectionTimeoutTimer = setTimeout(() => {
2200
2235
  this.log(`Connection timeout after ${CONNECTION_TIMEOUT_MS2}ms`);
2236
+ const reconnecting = this.reconnectAttempts > 0;
2201
2237
  reject(new Error(`Connection timeout after ${CONNECTION_TIMEOUT_MS2}ms`));
2202
2238
  this.cleanup();
2203
2239
  this.setStatus("error");
2204
- this.scheduleReconnect();
2240
+ if (reconnecting) this.scheduleReconnect();
2205
2241
  }, CONNECTION_TIMEOUT_MS2);
2206
2242
  this.ws.onopen = () => {
2207
2243
  this.clearConnectionTimeout();
@@ -2217,11 +2253,12 @@ var _RosbridgeClient = class _RosbridgeClient {
2217
2253
  this.log(`Rosbridge error: ${detail}`);
2218
2254
  this.logger.error("[RosbridgeClient] Error:", event);
2219
2255
  if (this.status === "connecting") {
2256
+ const reconnecting = this.reconnectAttempts > 0;
2220
2257
  this.clearConnectionTimeout();
2221
2258
  reject(new Error(`Rosbridge error: ${detail}`));
2222
2259
  this.cleanup();
2223
2260
  this.setStatus("error");
2224
- this.scheduleReconnect();
2261
+ if (reconnecting) this.scheduleReconnect();
2225
2262
  }
2226
2263
  };
2227
2264
  this.ws.onclose = () => {
@@ -2573,7 +2610,10 @@ var _RosbridgeClient = class _RosbridgeClient {
2573
2610
  cleanupConnection() {
2574
2611
  this.clearConnectionTimeout();
2575
2612
  this.stopServicesPoll();
2576
- for (const sub of this.activeSubscriptions.values()) this.cancelAllDrains(sub);
2613
+ for (const sub of this.activeSubscriptions.values()) {
2614
+ this.cancelAllDrains(sub);
2615
+ sub.breaker.destroy();
2616
+ }
2577
2617
  this.activeSubscriptions.clear();
2578
2618
  for (const [, pending] of this.pendingServiceCalls) {
2579
2619
  clearTimeout(pending.timer);
@@ -2599,6 +2639,10 @@ var _RosbridgeClient = class _RosbridgeClient {
2599
2639
  this.reconnectTimer = null;
2600
2640
  }
2601
2641
  if (this.ws) {
2642
+ this.ws.onopen = null;
2643
+ this.ws.onmessage = null;
2644
+ this.ws.onerror = null;
2645
+ this.ws.onclose = null;
2602
2646
  try {
2603
2647
  this.ws.close();
2604
2648
  } catch {
@@ -2695,7 +2739,7 @@ var ProtocolManager = class {
2695
2739
  /**
2696
2740
  * Create and connect a protocol client for the given options.
2697
2741
  *
2698
- * 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 —
2699
2743
  * the v0.1.0 release does not ship a Zenoh implementation.
2700
2744
  */
2701
2745
  async connect(options) {
@@ -2703,7 +2747,8 @@ var ProtocolManager = class {
2703
2747
  await this.activeClient.disconnect();
2704
2748
  }
2705
2749
  const client = this.createClient(options);
2706
- const scheme = options.secure ? "wss" : "ws";
2750
+ const pastedTls = /^\s*(wss|https):\/\//i.test(options.host);
2751
+ const scheme = options.secure ?? pastedTls ? "wss" : "ws";
2707
2752
  const port = options.port || DEFAULT_PORTS[options.protocol];
2708
2753
  const host = sanitizeHost(options.host);
2709
2754
  const url = `${scheme}://${host}:${port}`;
@@ -2714,7 +2759,13 @@ var ProtocolManager = class {
2714
2759
  `Invalid connection URL "${url}" \u2014 check host and port. Hosts should not include "ws://" or ":port"; use the port field instead.`
2715
2760
  );
2716
2761
  }
2717
- await client.connect(url);
2762
+ try {
2763
+ await client.connect(url);
2764
+ } catch (err) {
2765
+ await client.disconnect().catch(() => {
2766
+ });
2767
+ throw err;
2768
+ }
2718
2769
  this.activeClient = client;
2719
2770
  this.activeOptions = options;
2720
2771
  return client;
@@ -2748,7 +2799,7 @@ var ProtocolManager = class {
2748
2799
  case "rosbridge":
2749
2800
  return new RosbridgeClient(this.clientOptions);
2750
2801
  case "zenoh":
2751
- throw new Error("Zenoh support is planned for v0.2.0");
2802
+ throw new Error("Zenoh support is planned for v0.3.0");
2752
2803
  default: {
2753
2804
  const exhaustive = options.protocol;
2754
2805
  throw new Error(`Unknown protocol: ${String(exhaustive)}`);