feat(client): extract ChromelessSession — the answerer machine, importable without the demo page (Track 5) #47

Merged
triform-admin merged 1 commit from feat/client-session-split into main 2026-07-30 05:53:02 +00:00

client/main.ts was 754 lines of which ~350 were the reusable answerer state machine — exported nowhere. A third party integrating chromeless had to re-derive it by reading the demo page (the original Track 5 finding).

client/src/session.tsChromelessSession, DOM-free: signaling dial (token → ICE → WS), offer/answer/ICE pump, reconnect with per-generation pc rebuild, stats sampling+emission, pre-call probe, codec-outcome classification, bye/teardown. Event surface (on()/detach — same contract as StatsSampler/ReconnectingWebSocket): log status track dataChannel pcCreated stats codecOutcome signaling + 4 pc-state mirrors + closed. Injection seams (socketCtor/pcFactory/fetchToken/fetchIce/probe) mirror reconnect.ts's existing pattern.

client/main.ts — now the 349-line demo it always claimed to be: DOM, log pane, video element, input/cursor/file-upload wiring, T81 passthrough button. Per-pc-generation DOM attachments drop on pcCreated/closed — preserving the §T10 rule that passthrough never survives a rebuild without a fresh click.

Load-bearing behavior preserved verbatim: hello frame on every open, probe_result after hello, no pc teardown on underlyingClose, null-ICE end-of-candidates, echo guard, codec_fallback stats event, bye-then-close teardown ordering.

Integration recipe (the thing this PR makes possible):

const session = new ChromelessSession({ signalingBase });
session.on("track", (_t, stream) => video.srcObject = stream);
session.on("dataChannel", (dc) => { /* input/cursor/files */ });
await session.connect(sessionId);

Verification: 10 new vitest cases drive the machine through fake socket + fake pc (dial shape, hello, offer→answer with the codec munge asserted on the wire, stats internalization vs forwarding, ICE relay + null marker, echo guard, bye teardown incl. idempotent disconnect, reconnect rebuild with fresh hello, status transitions, double-connect throw). tsc --noEmit clean · vitest 217/217 (was 207) · esbuild bundle OK.

🤖 Generated with Claude Code

`client/main.ts` was 754 lines of which ~350 were the reusable answerer state machine — exported nowhere. A third party integrating chromeless had to re-derive it by reading the demo page (the original Track 5 finding). **`client/src/session.ts`** — `ChromelessSession`, DOM-free: signaling dial (token → ICE → WS), offer/answer/ICE pump, reconnect with per-generation pc rebuild, stats sampling+emission, pre-call probe, codec-outcome classification, bye/teardown. Event surface (`on()`/detach — same contract as StatsSampler/ReconnectingWebSocket): `log status track dataChannel pcCreated stats codecOutcome signaling` + 4 pc-state mirrors + `closed`. Injection seams (`socketCtor/pcFactory/fetchToken/fetchIce/probe`) mirror reconnect.ts's existing pattern. **`client/main.ts`** — now the 349-line demo it always claimed to be: DOM, log pane, video element, input/cursor/file-upload wiring, T81 passthrough button. Per-pc-generation DOM attachments drop on `pcCreated`/`closed` — preserving the §T10 rule that passthrough never survives a rebuild without a fresh click. Load-bearing behavior preserved verbatim: hello frame on every open, probe_result after hello, no pc teardown on underlyingClose, null-ICE end-of-candidates, echo guard, codec_fallback stats event, bye-then-close teardown ordering. **Integration recipe** (the thing this PR makes possible): ```ts const session = new ChromelessSession({ signalingBase }); session.on("track", (_t, stream) => video.srcObject = stream); session.on("dataChannel", (dc) => { /* input/cursor/files */ }); await session.connect(sessionId); ``` **Verification:** 10 new vitest cases drive the machine through fake socket + fake pc (dial shape, hello, offer→answer with the codec munge asserted on the wire, stats internalization vs forwarding, ICE relay + null marker, echo guard, bye teardown incl. idempotent disconnect, reconnect rebuild with fresh hello, status transitions, double-connect throw). `tsc --noEmit` clean · vitest **217/217** (was 207) · esbuild bundle OK. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): extract ChromelessSession — the answerer machine, importable without the demo page (Track 5)
All checks were successful
CI / Lint (pull_request) Successful in 1m0s
CodeQL / Analyze go (pull_request) Has been skipped
CodeQL / Analyze javascript-typescript (pull_request) Has been skipped
E2E / docker-compose + Playwright (pull_request) Has been skipped
CI / Docs link check (pull_request) Successful in 48s
native-peer-gate / native-peer-gate-strict (M7 gate) (pull_request) Successful in 25s
CI / Container smoke test (pull_request) Successful in 1m52s
native-peer-gate / native-peer-gate-scaffold (permissive) (pull_request) Successful in 5m51s
3c0dd81f2b
client/main.ts had grown to 754 lines of which ~350 were the reusable
answerer state machine — signaling dial (token → ICE config → WS),
offer/answer/ICE envelope pump, reconnect with per-generation pc
rebuild, stats sampling + emission, pre-call probe, codec-outcome
classification, bye/teardown semantics — exported nowhere. A third
party integrating chromeless had to re-derive the machine by reading
the demo page (the original Track 5 finding).

client/src/session.ts: ChromelessSession, DOM-free. Event surface
(on()/detach, same contract as StatsSampler/ReconnectingWebSocket)
instead of a callback bag: log, status, track, dataChannel, pcCreated,
stats, codecOutcome, signaling, 4 pc-state mirrors, closed. Injection
seams (socketCtor/pcFactory/fetchToken/fetchIce/probe) mirror
reconnect.ts's existing RWSocketCtor pattern — production callers pass
nothing and get browser globals.

main.ts becomes the 349-line demo it always claimed to be: DOM
lookups, log pane, video element, input/cursor/file-upload wiring
(DOM-coupled by nature), T81 passthrough button. Channel wiring hangs
off session events; per-pc-generation DOM attachments are dropped on
pcCreated (rebuild) and closed — preserving the §T10 rule that
passthrough never survives a rebuild without a fresh click.

Behavior preserved from the pre-split code verbatim where it was
load-bearing: hello frame on every open, probe_result after hello,
no pc teardown on underlyingClose (rebuild on next open), null-ICE
end-of-candidates handling, echo guard, codec_fallback stats event,
bye-then-close ordering in teardown.

10 new vitest cases drive the machine through fake socket + fake pc:
dial URL shape, hello frame, offer→answer with codec munge asserted
on the wire (VP9 moved to front), stats-channel internalization vs
dataChannel forwarding, ICE relay + null marker, echo guard, bye
teardown (bye reply + single closed emission + idempotent
disconnect), reconnect pc rebuild (old pc closed, fresh hello),
status transitions, double-connect throw.

npx tsc --noEmit clean · vitest 217/217 (was 207) · esbuild bundle OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
triform/chromeless!47
No description provided.