Skip to content

feat(bouncer-networks): BIND lifecycle + child connections (2/3)#215

Merged
ValwareIRC merged 2 commits into
bouncer-networks/protocolfrom
bouncer-networks/bind
Jun 5, 2026
Merged

feat(bouncer-networks): BIND lifecycle + child connections (2/3)#215
ValwareIRC merged 2 commits into
bouncer-networks/protocolfrom
bouncer-networks/bind

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

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 via BOUNCER BIND <netid> before CAP END.

Scope

What Where
Centralised sendCapEnd() — emits queued BOUNCER BIND <netid> before CAP END src/lib/irc/IRCClient.ts
All six existing CAP END callers routed through it IRCClient.ts, handlers/connection.ts, store/handlers/auth.ts, LinkSecurityWarningModal.tsx
pendingConnections dedup keyed by serverId (when given), not just host:port — children sharing the bouncer endpoint no longer collapse onto one Promise src/lib/irc/IRCClient.ts
bouncerServerId / bouncerNetid / isBouncerControl on Server + ServerConfig src/types/index.ts
store.bouncerConnectNetwork(parentServerId, netid) — public API to open a bound child src/store/index.ts
connectToSavedServers recognises child entries and bypasses the host:port lookup that would otherwise resolve them onto the parent's saved id src/store/index.ts

How a child connection works end-to-end

  1. UI (PR 3) or test calls bouncerConnectNetwork(parentServerId, "42")
  2. Store loads the parent's saved credentials, derives a deterministic childId = uuidv5(parent:netid, …)
  3. Persists a child ServerConfig with bouncerServerId + bouncerNetid
  4. Seeds an in-memory Server row in connecting state with the same linkage
  5. Calls ircClient.setPendingBouncerBind(childId, "42")
  6. Calls ircClient.connect(..., childId) — opens a new WS to the same bouncer endpoint
  7. CAP LS → CAP REQ → CAP ACK → (SASL if applicable) → sendCapEnd(childId) → server sees BOUNCER BIND 42 then CAP END
  8. Server completes registration as if this WS were the upstream network's session; 001 RPL_WELCOME arrives, normal IRC flow proceeds

What's NOT in this PR

  • No UI — PR 3 builds the management surface (bouncer expanded in the server list, add/edit/delete modals, network state badges).
  • Server list grouping — visual hierarchy of parent + children lives in PR 3.

Test plan

  • npm run format, npm run check, npm run test (821 / 1 skipped), npm run build clean
  • 9 new tests:
    • tests/protocol/bouncerBind.test.tssendCapEnd emits CAP END alone, emits BIND+END when queued, BIND is single-use, BINDs are scoped per serverId
    • tests/store/bouncerConnect.test.ts — queues BIND on fresh childId before connect, 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 config
  • Manual against soju (after PR 1 + this lands): bouncerConnectNetwork should open a real session bound to the chosen upstream

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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56eb9268-0a1c-4161-b639-bd261e90c945

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bouncer-networks/bind

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

Pages Preview
Preview URL: https://bouncer-networks-bind.obsidianirc.pages.dev

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.
@ValwareIRC ValwareIRC merged commit a9c464d into bouncer-networks/protocol Jun 5, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants