feat(bouncer-networks): BIND lifecycle + child connections (2/3)#215
Conversation
Stacks on PR #214 (protocol + store). Wires the connection lifecycle for soju.im/bouncer-networks: one bouncer endpoint can now host N child connections, each bound to a different upstream network via BOUNCER BIND before CAP END. Still headless -- UI lands in PR C. What's in: - IRCClient.sendCapEnd(serverId): centralised CAP END sender that emits a queued 'BOUNCER BIND <netid>' first when the serverId was flagged as a bouncer child via setPendingBouncerBind(). All six CAP END call sites (timeout, no-caps-to-request, post-CAP-ACK, CAP NAK, SASL success/fail, LinkSecurityWarning resume, post- account-registration) now flow through this one helper. - IRCClient.pendingConnections dedup keyed by serverId when one is given, falling back to host:port. Without this, multiple child connections sharing the bouncer endpoint would collapse onto a single Promise<Server>. - Server / ServerConfig: bouncerServerId, bouncerNetid, isBouncerControl fields plumbed through the type, persistence, and the initial Server seed in connectToSavedServers. - store.bouncerConnectNetwork(parentServerId, netid): the public API. Resolves the parent's credentials, derives a deterministic childId via uuidv5(parent:netid, CHANNEL_NAMESPACE), saves the child ServerConfig, seeds the child Server row in 'connecting' state, calls setPendingBouncerBind, and dispatches the IRCClient connect with the explicit childId. - connectToSavedServers: detects bouncer children at restore time and bypasses the store-level connect() wrapper (which would resolve them onto the parent's id via host:port lookup), going direct to ircClient.connect() with the explicit child id and a setPendingBouncerBind call beforehand. What's NOT in (deliberate; PR C): - UI for managing bouncer networks (list / add / change / delete). - Server-list rendering that groups child connections under their parent. - Modal for picking which networks to connect to. Coverage: - tests/protocol/bouncerBind.test.ts -- sendCapEnd emits CAP END alone when no BIND queued, BOUNCER BIND <netid> immediately before CAP END when queued, BIND is consumed (single-use), BINDs are scoped per serverId. - tests/store/bouncerConnect.test.ts -- queues BIND on a fresh childId before calling connect, seeds the child Server with parent linkage + 'connecting' state + friendly name from the BOUNCER NETWORK attributes, persists the child ServerConfig with the parent's SASL credentials, repeated calls are idempotent for persistence, returns undefined when the parent has no saved config. Full suite green (821 / 1 skipped), build clean.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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 |
|
Automated deployment preview for the PR in the Cloudflare Pages. |
* feat(bouncer-networks): management UI (PR C of 3) Adds the user-facing UI for the soju.im/bouncer-networks extension: - BouncerNetworksModal: list/add/edit modes with state badges (green/yellow-pulsing/gray dots), connected-first sort, inline-confirm delete, success highlight on ack, empty state - BouncerNetworkForm: TLS-on-default toggle, only-changed-attrs diff for edits, field-level error surfacing tied to FAIL BOUNCER, read-only attribute filtering - ServerList: "Manage Networks" affordance on bouncer-control servers (only shown when supported && not a child binding) - store: bouncerNetworksModalServerId UI slot + toggle action - App: globally mounted modal driven by store state Includes tests for form helpers (attrsToValues/valuesToAttrs diff logic) and modal interactions, plus translations across all 18 supported locales. * fix(bouncer-networks): match CAP_ACKNOWLEDGED per-cap event shape The auth-side CAP ACK splitter re-emits CAP_ACKNOWLEDGED once per cap with the cap name in `key` (and any =value in `capabilities`). My handler was treating the event as a bulk "ACK"/"NEW" tag with a space-separated cap list, so `key !== "ACK"` always returned early and `supported` never flipped to true — the Manage Networks button never appeared even when soju acked the cap. Fix: just check `key` against the two cap names directly. Update test to fire one event per cap to match the real emission pattern. * feat(bouncer-networks): auto-bind each upstream as a child server Previously you could add a network in the management modal and watch it sit there as a row, but there was no way to actually use the connection -- the BIND lifecycle from PR B was wired up but never dispatched. Now: on every BOUNCER_NETWORK upsert from a control session, fire bouncerConnectNetwork. The action is made idempotent (early-return if a child server already exists for the bouncer+netid pair) so this is safe to call on every snapshot, every notify update, and on reconnects. Result: networks added in the modal show up as their own server bubbles in the sidebar, ready to /join channels on the upstream. * fix(bouncer-networks): return undefined from idempotent bouncerConnectNetwork The early-return for the already-bound case was returning childId (a string) but the action's declared signature is Promise<Server | undefined>, breaking tsc. Return undefined -- no caller inspects the value. * feat(bouncer-networks): inline network browser in chat area + connect URL fix Replaces the floating layer-group icon + modal with a proper chat-area panel: select the bouncer in the sidebar and the main pane becomes a list of upstream networks with explicit Connect/Open buttons per row. Auto-connect-everything is gone -- the user picks which networks to bind to. Also fixes the actual binding bug: child connections were inheriting parent.host (the bare hostname after URL parsing) instead of the saved WSS URL with its /socket path, so wss://host:6662/socket children were hitting wss://host:6662/ and getting refused by soju. Use savedParent to preserve the original URL form. Drops: - BouncerNetworksModal + its test (replaced by BouncerNetworksPanel) - bouncerNetworksModalServerId UI slot + toggleBouncerNetworksModal action - FaLayerGroup overlay button on ServerIcon Adds: - BouncerNetworksPanel rendered when selected server is bouncer-control and no channel is selected - "Connect" button per row -> dispatches bouncerConnectNetwork + switches the sidebar selection to the new child server - "Open" button when the child is already present (just selects it) * fix(bouncer-networks): gate child reconnects on parent ready event connectToSavedServers used to fire parent + child reconnects in parallel at app startup. Both opened WSS to the same soju endpoint and raced their SASL/BIND handshakes against each other, surfacing as 'invalid password' against the child sessions even though the credentials were identical and the manual click path worked fine. Split the reconnect into two phases: parents (and standalone servers) go first, each child Server row is seeded into state immediately so the UI doesn't blink, then a one-shot `ready` listener dispatches every child of a parent as soon as that parent finishes registration. If a parent fails outright the listener detaches and its children are marked disconnected so the UI stops spinning. Tests pin: seeded child row, deferred dispatch, ready-fire dispatch, and that unrelated ready events are ignored.
Part 2 of 3 — stacks on #214. Still headless; UI lands in PR 3.
What this PR is
Wires the connection lifecycle for
soju.im/bouncer-networks. One bouncer endpoint can now host N child connections, each bound to a different upstream network viaBOUNCER BIND <netid>beforeCAP END.Scope
sendCapEnd()— emits queuedBOUNCER BIND <netid>beforeCAP ENDIRCClient.ts,handlers/connection.ts,store/handlers/auth.ts,LinkSecurityWarningModal.tsxpendingConnectionsdedup keyed by serverId (when given), not just host:port — children sharing the bouncer endpoint no longer collapse onto one PromisebouncerServerId / bouncerNetid / isBouncerControlonServer+ServerConfigstore.bouncerConnectNetwork(parentServerId, netid)— public API to open a bound childconnectToSavedServersrecognises child entries and bypasses the host:port lookup that would otherwise resolve them onto the parent's saved idHow a child connection works end-to-end
bouncerConnectNetwork(parentServerId, "42")childId = uuidv5(parent:netid, …)ServerConfigwithbouncerServerId+bouncerNetidServerrow inconnectingstate with the same linkageircClient.setPendingBouncerBind(childId, "42")ircClient.connect(..., childId)— opens a new WS to the same bouncer endpointsendCapEnd(childId)→ server seesBOUNCER BIND 42thenCAP END001 RPL_WELCOMEarrives, normal IRC flow proceedsWhat's NOT in this PR
Test plan
npm run format,npm run check,npm run test(821 / 1 skipped),npm run buildcleantests/protocol/bouncerBind.test.ts—sendCapEndemits CAP END alone, emits BIND+END when queued, BIND is single-use, BINDs are scoped per serverIdtests/store/bouncerConnect.test.ts— queues BIND on freshchildIdbeforeconnect, seeds the child Server with linkage / connecting state / friendly name, persists with parent's credentials, idempotent for the same netid, returns undefined when parent has no saved configbouncerConnectNetworkshould open a real session bound to the chosen upstream