feat(triki-controller): pluggable transport + Node NobleTransport (receive outside the browser)#4
Conversation
📝 WalkthroughWalkthroughRefactors ChangesTransport-agnostic controller and Node.js BLE support
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Extract a small TrikiTransport interface so TrikiController is no longer hardwired to Web Bluetooth. The browser path stays the default (WebBluetoothTransport); a NobleTransport (Node, @abandonware/noble, optional + lazily imported) lets the token be received outside the browser via the new `triki-controller/node` entry point. Adds a Node example and docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ec99faa to
228a26d
Compare
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/guide/library.md`:
- Around line 197-199: The TrikiTransport contract description is missing
onBattery(handler), so the documented interface is out of sync with the actual
transport API. Update the TrikiTransport section in the library guide to include
onBattery(handler) alongside the existing methods, and make sure the transport
examples and wording around NobleTransport and WebBluetoothTransport reflect the
full required contract.
In `@packages/triki-controller/src/controller.ts`:
- Around line 147-155: The connect/reconnect flow in controller.ts leaves the
BLE transport connected if `#startStreaming`() fails after
`#transport.connect`()/reconnect() succeeds. Update the catch blocks in connect()
and reconnect() to explicitly tear down the transport on START failure, then
reset controller state as today; use the existing `#transport` and `#cleanup`
helpers so the failure path fully disconnects instead of only cleaning up local
state.
In `@packages/triki-controller/src/transports/noble.ts`:
- Around line 164-166: The failed-connect error path in noble.ts only calls
`#teardown`(), which clears local state but does not disconnect the peripheral, so
a partially connected BLE session can remain open. Update the catch block in the
connect flow to disconnect the peripheral before calling `#teardown`(), and ensure
`#teardown`() still handles local cleanup while the disconnect logic is handled in
the failed connection path around `#teardown`() and the connect() catch handling.
- Around line 191-193: The reconnect flow in TrikiTransport.noble is not
preserving the originally selected device, since reconnect() just delegates to
connect() and can pick a different TRIKI* device. Update the TrikiTransport
implementation to cache a stable device identifier or token during the first
successful connect() and have reconnect() reuse that stored value instead of
rescanning generically. Use the existing connect() and reconnect() methods in
NobleTransport/TrikiTransport to locate the change.
- Around line 205-208: The `NobleTransport.disconnect()` method should be a true
no-op when `#peripheral` is null, so remove the fallback call to
`#onDisconnected()` and only disconnect when a peripheral exists. Keep the logic
in `disconnect()` limited to the connected case and preserve `#onDisconnected()`
for actual disconnect events from the transport, not for failed or never-started
sessions.
In `@packages/triki-controller/src/transports/web-bluetooth.ts`:
- Around line 100-102: The Web Bluetooth session setup catch in connect() only
calls this.#teardown(), so if discovery or notification setup fails after the
GATT link is established the peripheral can stay connected. Update the catch in
web-bluetooth.ts to explicitly disconnect the active GATT/server or device
connection before tearing down local state, using the same connection objects
created earlier in connect() and ensuring teardown still runs afterward.
- Around line 72-75: The `WebBluetoothTransport.disconnect()` method is invoking
`#onGattDisconnected()` even when there is no active GATT session, which causes
spurious disconnect callbacks. Update `disconnect()` so it only calls
`#gatt.disconnect()` when `#gatt` exists and is connected, and otherwise returns
without triggering the disconnect handler; use the `disconnect()` method and
`#onGattDisconnected` as the key symbols to locate the fix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f596fc07-9385-487e-ac97-aa02e0c3eff7
📒 Files selected for processing (11)
docs/guide/library.mdpackages/triki-controller/example/node.mjspackages/triki-controller/package.jsonpackages/triki-controller/src/controller.tspackages/triki-controller/src/events.tspackages/triki-controller/src/index.tspackages/triki-controller/src/node.tspackages/triki-controller/src/transport.tspackages/triki-controller/src/transports/noble.tspackages/triki-controller/src/transports/web-bluetooth.tspackages/triki-controller/tsup.config.ts
…ontroller hardening
The transport interface becomes explicitly owned and its contract honest:
- attach(handlers)/detach() replace the three single-slot handler setters: a
transport receives one handlers object (onFrame/onDisconnect/onBattery) and
only calls what it has data for, so a plain-JS custom transport can no longer
crash the controller constructor, and a second controller can't silently
hijack an attached transport (attach throws).
- The disconnect() contract is now implementable: onDisconnect fires exactly
once per successful connect and NEVER when no connect succeeded; a pending
connect() must reject when disconnect() is called mid-flight.
- Optional preflight(op) hook: sync precondition checks (no Web Bluetooth,
reconnect-before-connect) run before setState("pairing"), so those failures
reject with zero spurious connectionchange events, as on main.
Controller state machine fixes:
- connect()/reconnect() share one #open() flow; a failure after the transport
connected (e.g. the START write rejects) now also closes the transport, so no
live link keeps feeding events into a "disconnected" controller.
- disconnect() lost its state early-return — a leaked link is always closable —
and gained a local-cleanup fallback against buggy transports.
- #ingest ignores frames outside "streaming"; #handleBattery drops readings
after cleanup (but keeps pairing-time reads); #setState dedupes transitions.
- assertTransport() validates a custom transport's shape up front with a
TypeError naming every missing member.
protocol.ts now carries canonical 128-bit battery UUIDs (usable beyond Web
Bluetooth's alias strings) and a shared DEVICE_NAME_PREFIX.
New coverage: scripted FakeTransport testkit + 12 controller<->transport seam
tests (failure cleanup, cancellation, guards, ownership) and 2 zero-event
preflight regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ct, scan timeout, true reconnect
The Node transport graduates from "working skeleton" to the full TrikiTransport
contract:
- Cancellable, time-bounded connect(): one budget (scanTimeoutMs, default 15 s,
Infinity to disable) covers adapter power-on, the scan, and link setup; every
exit path — match, timeout, cancel, startScanning failure — removes the
discover listener and stops scanning, so retried connects never stack
listeners on the module-level noble emitter. Rejections use named errors
(ScanTimeoutError / ConnectAbortedError, exported from triki-controller/node).
- disconnect() during a pending connect() cancels it (the promise rejects; a
half-connected peripheral is dropped) — previously the abandoned scan could
resurrect the session into "streaming" after an explicit disconnect.
- Terminal adapter states ("unsupported"/"unauthorized") are checked
synchronously: noble emits stateChange once per transition and never replays
it, so waiting for an event that already happened hung connect() forever.
- reconnect() re-links to the cached peripheral of the previous successful
connect — no scan, so it can never latch onto a different token and silently
mis-apply per-device calibration — and preflight("reconnect") throws the
documented error (with zero events) when there is nothing to reconnect to.
- NUS + Battery are discovered in ONE combined GATT round trip, and the battery
read/subscribe is genuinely fire-and-forget with per-session staleness guards
(the old code awaited it under a "never blocks streaming" comment).
- onDisconnect fires exactly once per session, structurally: teardown removes
the peripheral's disconnect listener before the handler runs. Fire-and-forget
noble calls carry .catch(() => {}) so a rejection can't crash the process.
- Battery UUIDs derive from protocol.ts via the exported nobleUuid() (which now
shortens Bluetooth-base UUIDs to noble's 16-bit form) instead of hardcoded
"180f"/"2a19"; scanServiceUuids lets callers opt into adapter-side filtering.
New coverage: fakeNoble testkit (faithful to noble where it matters — one-shot
stateChange, fresh Characteristic instances per discovery, async disconnect
echo) + 27 lifecycle tests, all running with an injected mock so CI never needs
the native module.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…clean example shutdown - library.md: the Custom Transports section now documents the real surface — attach(handlers)/detach, optional reconnect/preflight, the exactly-once onDisconnect + reject-on-cancel + idempotent-disconnect contract (the old list omitted onBattery entirely, so a transport written from the docs crashed the controller constructor) — plus a minimal replay-transport example. The Node section gains scanTimeoutMs / scanServiceUuids, the ScanTimeoutError / ConnectAbortedError semantics, and the reconnect guarantee. The API section states the event guarantees (precondition failures reject with zero connectionchange events). - README: was never updated for transports — broaden the Web-Bluetooth-only framing, document the transport option, add a Node.js & custom transports section pointing at the guide. - overview.md: the "native BLE transport" half of the bridge roadmap item is done; reflect that. - example/node.mjs: exit through connectionchange after disconnect() (with an unref'd 1 s fallback) instead of process.exit right after firing the disconnect — the old handler killed Node before the HCI disconnect was sent, leaving the token connected until its supervision timeout. Wrap the top-level connect() in try/catch: Ctrl+C mid-scan now rejects with ConnectAbortedError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/triki-controller/src/controller.transport.test.ts (1)
54-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated minimal-transport literal across two tests.
The "per the docs" minimal
TrikiTransportobject literal (attach/detach/connect/writeRx/writeCtrl/hasLed/disconnect) is duplicated verbatim in bothdescribeblocks. Extracting a small factory would prevent the two copies drifting apart as the contract evolves.♻️ Suggested extraction
+/** The exact minimal member set the Custom Transports docs list — no battery, no reconnect. */ +function makeMinimalTransport(): { + transport: TrikiTransport; + getHandlers: () => Parameters<TrikiTransport["attach"]>[0] | null; +} { + let handlers: Parameters<TrikiTransport["attach"]>[0] | null = null; + const transport: TrikiTransport = { + attach: (h) => { + handlers = h; + }, + detach: () => { + handlers = null; + }, + connect: async () => {}, + writeRx: async () => {}, + writeCtrl: async () => {}, + hasLed: false, + disconnect: () => { + handlers?.onDisconnect(); + }, + }; + return { transport, getHandlers: () => handlers }; +}Then both tests become
const { transport: minimal, getHandlers } = makeMinimalTransport();.Also applies to: 211-234
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/triki-controller/src/controller.transport.test.ts` around lines 54 - 82, The minimal TrikiTransport object literal is duplicated in controller.transport.test.ts across both test cases, so extract it into a small helper/factory to keep the docs-aligned contract in one place. Create a reusable helper like makeMinimalTransport that returns the transport and a way to access the attached handlers, then update both tests to use it instead of inlining the same attach/detach/connect/writeRx/writeCtrl/hasLed/disconnect shape. Use the existing TrikiTransport, attach, detach, and handlers references to keep the refactor easy to locate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/guide/library.md`:
- Around line 252-260: The replay transport lifecycle is incomplete because
`disconnect()` only calls `#handlers?.onDisconnect()` and leaves `#handlers`
attached, so it is not truly detached after disconnect. Update the replay
transport implementation in the class that defines `detach()`, `disconnect()`,
and `feed()` so `disconnect()` also clears `#handlers` (or delegates to
`detach()`), making repeated disconnects idempotent and preventing `feed()` from
delivering frames after disconnection.
---
Nitpick comments:
In `@packages/triki-controller/src/controller.transport.test.ts`:
- Around line 54-82: The minimal TrikiTransport object literal is duplicated in
controller.transport.test.ts across both test cases, so extract it into a small
helper/factory to keep the docs-aligned contract in one place. Create a reusable
helper like makeMinimalTransport that returns the transport and a way to access
the attached handlers, then update both tests to use it instead of inlining the
same attach/detach/connect/writeRx/writeCtrl/hasLed/disconnect shape. Use the
existing TrikiTransport, attach, detach, and handlers references to keep the
refactor easy to locate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f1cbc4ed-b1c0-4472-9d67-17da5f7efced
📒 Files selected for processing (17)
docs/guide/library.mddocs/guide/overview.mdpackages/triki-controller/README.mdpackages/triki-controller/example/node.mjspackages/triki-controller/src/controller.test.tspackages/triki-controller/src/controller.transport.test.tspackages/triki-controller/src/controller.tspackages/triki-controller/src/events.tspackages/triki-controller/src/index.tspackages/triki-controller/src/node.tspackages/triki-controller/src/protocol.tspackages/triki-controller/src/testkit/fakeNoble.tspackages/triki-controller/src/testkit/fakeTransport.tspackages/triki-controller/src/transport.tspackages/triki-controller/src/transports/noble.test.tspackages/triki-controller/src/transports/noble.tspackages/triki-controller/src/transports/web-bluetooth.ts
✅ Files skipped from review due to trivial changes (1)
- docs/guide/overview.md
🚧 Files skipped from review as they are similar to previous changes (5)
- packages/triki-controller/src/node.ts
- packages/triki-controller/src/events.ts
- packages/triki-controller/src/index.ts
- packages/triki-controller/example/node.mjs
- packages/triki-controller/src/controller.ts
0.2.0 is live on npm without the ./node subpath or the v2 transport interface; this branch adds new public API (triki-controller/node, NobleTransport, attach/detach transport contract, DEVICE_NAME_PREFIX) and breaks the just-shipped TrikiTransport shape, so the next release must be 0.3.0 — publish.yml also refuses a tag that does not match package.json, and npm rejects a republish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansport contract The example's disconnect() fired onDisconnect unconditionally (even when never connected) and feed() kept delivering frames after disconnect — both violations of the contract the surrounding section documents. Track a #connected flag: disconnect() is now idempotent and never fires onDisconnect unconnected, and feed() only forwards frames while active. Deliberately NOT clearing #handlers in disconnect(): attach/detach is ownership (the controller stays attached across sessions), not connection state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…indings A recall-mode multi-agent review of the finished branch surfaced a wave of lifecycle races and platform gaps; all confirmed findings are fixed here, each with a regression test. Controller: - #open gains an epoch token and closes the transport BEFORE emitting 'disconnected': an auto-retry started synchronously from a connectionchange listener no longer gets cancelled by the failed attempt's own cleanup (it used to spiral into a self-sustaining cancel loop), and a superseded attempt's late rejection can no longer reset a newer attempt's state. - disconnect()'s local fallback is epoch-guarded too, so a connect() started from the 'disconnected' listener survives the stack unwind. - New dispose(): disconnect + detach + drop listeners — the missing release path for long-lived transports (a second controller can then attach). - assertTransport rejects primitives with the friendly member-list TypeError instead of crashing on the `in` operator. - ConnectAbortedError/ScanTimeoutError now live in transport.ts (contract level, browser-safe) and are exported from the main entry as well. WebBluetoothTransport: - Per-attempt cancellation object + in-progress/already-connected guards replace the instance-wide #connecting/#cancelRequested pair: a retry connect() can no longer un-cancel and resurrect an earlier attempt, and two attempts can no longer interleave on shared session fields. - Session state is built in locals and committed only after every setup step succeeds — a failed/cancelled attempt never leaves half-built state. - #emitBattery guards byteLength: a 0-byte ATT value (protocol-legal) used to throw an uncaught RangeError per notification, or silently kill battery support for the session via the outer catch. NobleTransport: - disconnect() clears #attempt synchronously, so a same-tick disconnect()-then-connect() retry is no longer refused with a spurious "connect() is already in progress." - Session commit moved to the end of #openSession (locals until then): the abort path only drops the attempt's own peripheral, never touches instance state, and — because our 'disconnect' listener is attached only at commit — no longer strips noble's own promisify resolver (which leaked one forever-pending disconnectAsync promise per failed attempt) nor issues a duplicate HCI disconnect. - disconnect() checks the active session before the pending attempt, closing the microtask window where a just-committed session made connect() fulfil after a cancelling disconnect(). - #teardown removes ONLY the transport's own peripheral 'disconnect' listener (removeListener by reference): noble caches Peripheral objects module-wide, and removeAllListeners was silently clobbering third-party listeners. - The TX 'data' listener is session-guarded like the battery one: buffered notifications arriving after onDisconnect no longer violate the handlers contract for custom transport owners. - Scanning uses allowDuplicates=true — with duplicate filtering noble emits 'discover' once per peripheral per scan, so a first report without localName (name-in-scan-response, common on macOS) made the token permanently unmatchable; the scan is also now STOPPED (awaited) before connectAsync, which some stacks reject while scanning (BlueZ "Command Disallowed"). - Writes are bounded: noble never settles pending GATT callbacks on a dropped link, so writeRx/writeCtrl race the session teardown signal and the scanTimeoutMs budget instead of hanging await connect()/setRate()/setLed() forever. - address matching also accepts the noble peripheral id (macOS hides MAC addresses — address:"" there made the option unmatchable), documented. Packaging / docs / example: - peerDependencies range ">=1.9.2-26 <2.0.0": the old ^1.9.2-26 prerelease comparator only matched prereleases of exactly 1.9.2, guaranteeing ERESOLVE the moment noble ships its next (always prerelease-tagged) version. - tsup splitting:true — dist/node.js embedded a full second copy of the core (instanceof TrikiController was false across entries; 57 KB -> 18 KB + one shared chunk). - example/node.mjs: process.exit ran synchronously inside the 'disconnected' emit — before the transport even issued disconnectAsync — leaving the token connected; now exits after a short delay, and Ctrl+C mid-scan exits 0 via ConnectAbortedError. - Docs: reconnect() re-links to "the peripheral matched by the previous connect() scan" (the cache is armed at scan match, deliberately), dispose() documented, isSupported() scoped to the default transport, absolute example link for npm, coverage excludes the node re-export barrel. Refuted by verification (no change needed): stale-battery leak across reconnect (session guards + noble's fresh-characteristic dispatch close every window), gattserverdisconnected async race (the spec fires it synchronously inside gatt.disconnect()). 123 tests (9 new regression cases), typecheck and build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Make
TrikiControllertransport-agnostic so the token can be received outside the browser, not just via Web Bluetooth in Chrome — with an explicit, tested transport contract and a production-grade Node transport.Transport layer (
TrikiTransportv2)Small, explicitly-owned interface: the controller claims a transport with
attach(handlers)(one owner at a time;detach()releases) and hands overonFrame/onDisconnect/onBatterycallbacks — a transport only calls what it has data for, so minimal plain-JS transports can't crash the controller.Honest contract, enforced by both built-ins and the test suite:
onDisconnectfires exactly once per successful connect and never when no connect succeeded;disconnect()is idempotent and cancels a pendingconnect()(which then rejects); optionalpreflight(op)runs sync precondition checks before any state change, so failures like "no Web Bluetooth" or "reconnect before connect" reject with zero spuriousconnectionchangeevents.All Triki protocol knowledge (START/LED commands, frame parsing, fusion) stays in the controller; transports are dumb NUS pipes.
WebBluetoothTransport— the existing GATT code, still the default in the browser. PublicTrikiControllerAPI and behaviour are unchanged.NobleTransport— production Node transport backed by@abandonware/noble(optional peer dep, lazily imported, externalized from the build), exposed fromtriki-controller/node:connect()— onescanTimeoutMsbudget (default 15 s,Infinityto disable) covers adapter power-on + scan + link setup; named errorsScanTimeoutError/ConnectAbortedError;unsupported/unauthorized) rejected synchronously (noble never replaysstateChange, so waiting on it hung forever);discoverlistener and stops scanning — no listener stacking on the module-level noble emitter, no zombie session resurrecting intostreamingafter an explicit disconnect;reconnect(): re-links to the cached peripheral of the previous successful connect — no scan, so it can never latch onto a different token and mis-apply per-device calibration;.catch()so a rejection can't crash the process.Controller hardening
connect()/reconnect()share one flow; a failure after the transport connected (e.g. the START write rejects) now also closes the transport — previously the live link kept feedingframe/orientationevents into a"disconnected"controller, anddisconnect()was a permanent no-op.#ingestignores frames outsidestreaming; late battery readings after cleanup are dropped;connectionchangetransitions are deduped.TypeErrornaming every missing member.Testing
FakeTransport(failure cleanup, cancellation, state guards, ownership) and the fullNobleTransportlifecycle through an injected mock noble (timeouts, cancellation, terminal states, reconnect caching, battery non-blocking, exactly-once disconnect, bounded writes, listener hygiene). CI needs no native module — the mock is injected via the publicnobleoption.npm ci+typecheck+buildclean; the browser bundle has zero noble imports;dist/node.jskeeps noble as a runtimeimport().node example/node.mjswithout noble installed prints the install hint and exits 1 (no crash, no hang); Ctrl+C exits through the BLE teardown instead of killing Node mid-disconnect.Docs
docs/guide/library.md: fullTrikiTransportcontract (the old list omittedonBattery— a transport written from the docs crashed the constructor), Node options (scanTimeoutMs,scanServiceUuids), error semantics, event guarantees, a minimal replay-transport example.transportoption and a Node section.protocol.tscarries canonical 128-bit battery UUIDs +DEVICE_NAME_PREFIX(noble derives its short IDs via the exportednobleUuid()instead of hardcoding"180f"/"2a19").Post-review hardening
A second recall-mode multi-agent review of the finished branch (10 finder angles → 14 adversarial verifiers → gap sweep) confirmed another wave of lifecycle races; all fixed with regression tests (see
fix(triki-controller): harden the connect lifecycle):#openis epoch-aware and closes the transport before emittingdisconnected— an auto-retry from aconnectionchangelistener no longer spirals into a self-cancelling loop, and a superseded attempt can't clobber a newer one. Newdispose()releases the transport for a replacement controller.disconnect()→connect()retry works; the abort path no longer leaks a forever-pendingdisconnectAsyncpromise (verified against noble 1.9.2-26 sources) nor double-disconnects; teardown removes only its own listener on noble's shared cached peripheral; TX notifications are session-guarded;allowDuplicates=true(name-in-scan-response tokens were permanently unmatchable); the scan is stopped beforeconnectAsync(BlueZ rejects connects mid-scan); writes are bounded (noble never settles GATT callbacks on a dropped link — the START write could hangconnect()forever);addressalso matches the peripheral id (macOS hides MACs).>=1.9.2-26 <2.0.0(the^prerelease comparator would ERESOLVE on noble's next release); tsupsplitting: true(dist/node.js embedded a second copy of the core —instanceofbroke across entries); version 0.3.0 (0.2.0 is already on npm).gattserverdisconnectedasync race (spec fires it synchronously).Notes
NobleTransport({ noble })accepts an injected noble-compatible module (e.g.@stoprocent/noble) for Node versions where@abandonware/noble's native build fails.0.3.0(0.2.0is already published on npm without the./nodesubpath).🤖 Generated with Claude Code