ServerlessIRCd — IRCv3 WebSocket Extension Plan
Bring the existing WebSocket entry points into compliance with the IRCv3 WebSocket support spec. Today the server transports IRC over WebSocket but ignores the spec's defining features: subprotocol negotiation, one-line-per-message framing, the 510-byte message budget, and binary/text frame mode distinction. This plan closes those gaps across the pure core and all three adapters (Cloudflare Workers, AWS, local CLI) plus the in-tree TCP bridge.
Status: planning. No code has landed. Seven tickets proposed in §7 under a new Phase 10 — IRCv3 WebSocket extension heading; once ratified they will be appended to
tickets.md/progress.mdand supersede the corresponding sections here.
1. Goals & non-goals
Goals
- Full compliance with the normative MUSTs of the IRCv3 WebSocket spec:
- Negotiate the
binary.ircv3.netandtext.ircv3.netsubprotocols, honoring the client's order of preference. - One IRC line per WebSocket message, with no trailing
\r\n. - 510-byte default message budget (content; the server re-inserts
\r\ninternally for any TCP relay / line accounting). - Distinct binary vs text frame handling; never relay non-UTF-8 to a text-mode client.
- Negotiate the
- Keep the change inside the transport layer. The hexagonal core
(
irc-corereducers,irc-serverConnectionActor) stays transport-agnostic; only framing/byte-boundary concerns move. - Preserve backward compatibility: clients that do not negotiate a subprotocol keep working via a legacy-tolerant path (the spec permits this).
- Same TDD discipline as the rest of the monorepo: pure negotiation & framing helpers unit-tested; per-adapter integration tests against a real WS handshake.
Non-goals (v1 of this work)
- Non-UTF-8 channel communities. The core already reasons in JS strings (UTF-8 at the boundary); we validate/replace lone surrogates but do not introduce a raw-bytes code path through the reducers. Binary mode is supported at the frame layer only.
- A second transport or SockJS gateway.
webircgatewayremains an optional sidecar; not in scope here. - WebSocket
Originallowlisting. That is CSWSH defense tracked separately (PLAN-WEB-CLIENT §7, TICKET-070). This plan assumes an Origin check, if added, layers on top of the upgrade handler untouched by subprotocol work. - Resumable sessions /
X-Connection-Id. The upgrade handler will gain a clean seam for future subprotocol-derived routing, but session resume is out of scope.
Design-for-later
- The subprotocol selector returns the chosen mode as a value the adapters stash on connection state. A future resumable-session ticket can ride the same field instead of re-deriving it.
2. Current state (the compliance gap)
Audited against the spec. Findings, with file:line references:
| Spec requirement | Status | Evidence |
|---|---|---|
Negotiate binary.ircv3.net / text.ircv3.net |
Missing | packages/cf-adapter/src/connection-do.ts:108-121 returns 101 with no protocol; apps/cf-worker/src/worker.ts:45-49 checks only Upgrade. No Sec-WebSocket-Protocol / subprotocol strings anywhere. |
| One IRC line per WS message, no trailing CRLF | Violated (legacy-tolerant instead) | packages/irc-server/src/actor.ts:150-159 receiveTextFrame splits every frame on \r?\n (splitFrameLines, line 454-457); outbound batches multiple lines into one frame joined by \r\n (connection-do.ts:234, apps/local-cli/src/server.ts:507, packages/aws-adapter/src/handlers/default.ts:127). |
| 510-byte message budget | Off by 2 | packages/irc-core/src/protocol/parser.ts:30 MAX_INPUT_BYTES = 512; packages/irc-core/src/protocol/outbound.ts:27 MAX_LINE_BYTES = 512 (defines TRAILING_CRLF_LEN = 2 at line 30 but never subtracts it). |
| Binary vs text frame mode | Not distinguished | connection-do.ts:128-129 silently TextDecoder-decodes ArrayBuffer; no binaryType, no mode tracking. |
| Don't relay non-UTF-8 to text clients | N/A until text mode exists | — |
| PING/PONG keepalive | Already compliant | CF alarms (connection-do.ts:185-204); AWS EventBridge (ping-checker.ts). |
| Impose message-size limits (security SHOULD) | Present, wrong threshold | 512 caps exist; spec wants 510 for WS. |
The transport itself works today (CF Hibernating WebSockets, AWS API
Gateway, local ws). What's missing is the spec framing layered on top.
3. Architecture
WebSocket framing is a transport-boundary concern, so the work fans out across the hexagon but the logic stays pure and central:
┌──────────────────────────────────────────────────────────────────┐
│ packages/irc-core (pure, no cloud deps) │
│ ws-subprotocol.ts parseSecWsProtocolOffers / selectSubproto │
│ ws-framing.ts frameMode, stripTrailingCrLf, │
│ splitWsFrame (legacy), enforce 510 budget │
│ protocol/outbound.ts WS-aware line-limit (510) + lone- │
│ surrogate sanitization for text mode │
└──────────────────────────────────────────────────────────────────┘
▲ used by
┌─────────────────────────────────────────────────────────────────┐
│ packages/irc-server ConnectionActor │
│ receiveFrame(text|bytes, mode) mode-aware entry: spec = │
│ one line; legacy = split │
└─────────────────────────────────────────────────────────────────┘
▲ ▲ ▲
┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ cf-adapter │ │ aws-adapter │ │ local-cli │
│ ConnectionDO.fetch: │ │ $connect: echo │ │ ws handleProtocols; │
│ read header, echo │ │ Sec-WebSocket- │ │ per-conn mode; │
│ subprotocol, store │ │ Protocol via CDK │ │ per-message send │
│ mode; hibernation │ │ integration resp; │ │ │
│ tag; per-message │ │ Connections.mode; │ │ │
│ send; binary/text │ │ per-message send │ │ │
└──────────────────────┘ └─────────────────────┘ └──────────────────────┘
▲
┌─────────────────────────┐
│ tools/tcp-ws-forwarder │ offer binary.ircv3.net upstream;
│ │ strip/append CRLF at the TCP seam
└─────────────────────────┘
Key invariant: the reducers never change. Framing (how bytes become
IrcMessages and how Effects become bytes) is the only thing touched.
4. Design decisions
4.1 Subprotocol selection (pure)
A pure helper set in irc-core:
export const WS_SUBPROTO_BINARY = 'binary.ircv3.net';
export const WS_SUBPROTO_TEXT = 'text.ircv3.net';
export type WsSubprotocol = typeof WS_SUBPROTO_BINARY | typeof WS_SUBPROTO_TEXT;
/** Parse a `Sec-WebSocket-Protocol` header (comma list, trimmed). */
export function parseSecWsProtocolOffers(header: string | null): string[];
/**
* Pick the first offered protocol we support, in client-preference order.
* Returns null if none match (caller MAY still accept the connection per spec).
*/
export function selectSubprotocol(offers: string[]): WsSubprotocol | null;
Selection order is the client's order (spec: "respecting the client's
order of preference"). We accept the first of [binary.ircv3.net, text.ircv3.net] the client listed. If the client offers neither, return
null — the adapter still upgrades (legacy path) per the spec's MAY.
4.2 Frame mode on connection state
Adapters store the negotiated mode on per-connection state. We do not thread it through the core reducers (they're mode-blind); only the framing entry point and the outbound serializer consult it.
- CF: hibernation tag via
acceptWebSocket(ws, [tag])wheretag = "ws:" + mode; recovered inwebSocketMessageviaws.deserialize-equivalent tag read. Persisted as a field in DO state for the outbound path. - AWS: new
modecolumn on theConnectionstable (defaultnull= legacy). - Local CLI: a
Map<WebSocket, WsSubprotocol | null>on the server.
4.3 Inbound framing — spec mode vs legacy mode
export type WsFrameMode = 'spec-text' | 'spec-binary' | 'legacy';
/** One frame -> one line in spec mode; tolerant split in legacy mode. */
export function frameToLines(frame: string, mode: WsFrameMode): string[];
- spec-text / spec-binary: the frame is the line. Strip an optional
single trailing
\r\n/\n(defensive; clients SHOULD NOT send it), then return[frame]if non-empty else[]. Never split on embedded newlines. - legacy: current
splitFrameLinesbehavior (/\r?\n/), preserved verbatim for clients that never negotiated.
ConnectionActor gains receiveFrame(payload: string, mode); existing
receiveTextFrame becomes a thin caller passing 'legacy' so all current
call sites and tests keep their behavior until updated.
4.4 Outbound framing — one message per line in spec mode
Today adapters batch lines.map(l => l.text).join('\r\n') + '\r\n' into a
single ws.send. In spec mode each outbound IRC line is its own WS
message with no trailing CRLF:
// spec mode
for (const line of outboundLines) ws.send(line.text); // one msg each, no CRLF
// legacy mode (unchanged)
ws.send(outboundLines.map(l => l.text).join('\r\n') + '\r\n');
This is the largest mechanical change (three adapters + their tests), but localized to the delivery functions.
4.5 Size budget — 510 for WS, 512 for TCP
Introduce MAX_WS_MESSAGE_BYTES = 510 alongside the existing 512 TCP cap.
The WS inbound path enforces 510 on the message (no CRLF to discount);
enforceLineLimit gains a mode-aware budget so spec-mode clients get the
correct outbound ceiling. TCP and legacy-WS paths are unchanged.
4.6 Binary vs text handling
- spec-binary (
binary.ircv3.net): frames areArrayBuffer. CFwebSocketMessagealready receivesstring | ArrayBuffer; we pass bytes throughTextDecoder(our core is string-based) but only after validating. Binary mode is the "any valid IRC line" mode; for our UTF-8-string core this collapses to the same decode as text, with the difference being that we accept a binary frame without complaint. - spec-text (
text.ircv3.net): frames arestring. If a binary frame arrives on a text-mode connection, reject per spec (close 1003 / unsupported data) rather than silently decoding. - Non-UTF-8 to text clients: our outbound is always JS strings → valid
UTF-8. The residual risk is lone surrogates (unpaired halves of
surrogate pairs, which
TextEncoderrejects).sanitizeForTextModereplaces lone surrogates with U+FFFD before send on text-mode sockets.
4.7 Backward compatibility
The web client (PLAN-WEB-CLIENT) and tcp-ws-forwarder currently rely on
the legacy \r\n-batched framing. Because un-negotiated connections fall
into 'legacy' mode, nothing breaks until each client opts in by
offering a subprotocol. The forwarder is updated in the same phase so the
in-tree bridge immediately exercises the spec path.
5. Component placement
No new workspace packages. Changes are additions/edits within existing packages:
packages/irc-core/src/
├── ws-subprotocol.ts NEW — parseSecWsProtocolOffers, selectSubprotocol
├── ws-framing.ts NEW — WsFrameMode, frameToLines, stripTrailingCrLf,
│ MAX_WS_MESSAGE_BYTES, sanitizeForTextMode
└── protocol/outbound.ts EDIT — mode-aware enforceLineLimit (510 vs 512)
packages/irc-core/tests/
├── ws-subprotocol.test.ts NEW
└── ws-framing.test.ts NEW
packages/irc-server/src/actor.ts EDIT — receiveFrame(text, mode);
receiveTextFrame delegates
packages/irc-server/tests/actor.*.test.ts EDIT — add spec-mode cases
packages/cf-adapter/src/connection-do.ts EDIT — upgrade negotiation, mode
tag/storage, per-msg send,
binary/text branch
packages/aws-adapter/src/handlers/connect.ts EDIT — read header, persist mode
packages/aws-adapter/src/handlers/default.ts EDIT — per-message send
apps/aws-stack/src/aws-stack.ts EDIT — integration response
Sec-WebSocket-Protocol header
apps/local-cli/src/server.ts EDIT — handleProtocols, mode map,
per-message send
tools/tcp-ws-forwarder/src/forwarder.ts EDIT — offer binary subproto,
framing translation
6. Testing strategy
- Pure units (Red-first):
ws-subprotocol.test.tsandws-framing.test.tsdrive the helpers — header parsing edge cases (whitespace, duplicates, empty), preference ordering, spec-vs-legacy line splitting, 510-boundary, lone-surrogate replacement. - Actor unit: extend
actor.*.test.tsto callreceiveFrame(..., 'spec-text')and assert one-line-per-message semantics; keep existingreceiveTextFrametests green (legacy). - CF integration (
vitest-pool-workers/ Miniflare): open a real WS toConnectionDOofferingtext.ircv3.net, assert the 101 response echoes the protocol, sendNICK/USERas separate CRLF-less messages, assert registration replies arrive one-per-message. A second case offers neither protocol and asserts legacy batching still works. - AWS: unit-test the connect handler's header→mode mapping and the CDK integration-response header; local-stack smoke optional.
- Determinism: all of the above are hermetic except an optional real-
browser check, which stays behind a
--e2eflag like the web client plan.
7. Proposed tickets
Seven new tickets under a new Phase 10 — IRCv3 WebSocket extension
heading in tickets.md / progress.md. Each follows the existing
Red → Green → Refactor TDD outline.
TICKET-095 — Core: WebSocket subprotocol negotiation helpers
- Phase: 10
- Points: 2
- Dependencies: —
- Description: Add
packages/irc-core/src/ws-subprotocol.tswithWS_SUBPROTO_BINARY/WS_SUBPROTO_TEXTconstants, theWsSubprotocoltype,parseSecWsProtocolOffers(header), andselectSubprotocol(offers)honoring client-preference order, returningnullwhen neither is offered. Pure, zero deps. - Acceptance Criteria:
parseSecWsProtocolOffers('text.ircv3.net, binary.ircv3.net')yields['text.ircv3.net','binary.ircv3.net'](whitespace-tolerant).selectSubprotocolreturns the first supported entry in offer order.- Offering only unsupported protocols returns
null. null/empty header →[]offers →nullselection.
- TDD Outline: Red on preference-order test; green. Red on duplicate/ whitespace cases; green. Red on no-match returns null; green.
TICKET-096 — Core: WS frame framing & 510-byte budget
- Phase: 10
- Points: 3
- Dependencies: TICKET-095
- Description: Add
packages/irc-core/src/ws-framing.ts:WsFrameMode('spec-text' | 'spec-binary' | 'legacy'),MAX_WS_MESSAGE_BYTES = 510,stripTrailingCrLf,frameToLines(frame, mode)(spec = single line; legacy = currentsplitFrameLinesbehavior), andsanitizeForTextMode(str)replacing lone surrogates with U+FFFD. Move the existingsplitFrameLinesbody here (or re-export) so legacy mode is literally the current code. MakeenforceLineLimitinoutbound.tsbudget-aware (510 in spec modes, 512 otherwise). - Acceptance Criteria:
frameToLines('NICK bob\r\n', 'spec-text')→['NICK bob'](one line; CRLF stripped; not split).- Embedded
\nin a spec-mode frame stays a single line. frameToLines('a\r\nb', 'legacy')→['a','b'](unchanged behavior).- WS inbound rejects a 511-byte message; accepts 510.
sanitizeForTextModeturns a lone surrogate into U+FFFD.
- TDD Outline: Red per bullet above; minimal green; refactor shared strip logic.
TICKET-097 — CF adapter: negotiate subprotocol + per-message framing
- Phase: 10
- Points: 3
- Dependencies: TICKET-095, TICKET-096
- Description: In
ConnectionDO.fetchreadSec-WebSocket-Protocol, runselectSubprotocol, echo the chosen protocol back in the101response headers, and tag the hibernated socket (acceptWebSocket(ws, [\ws:${mode}`])). DeriveWsFrameModeinwebSocketMessage(binary frame on aspec-textconnection → close 1003). Store the mode in DO state for the outbound path. Change outbound delivery to onews.send(line)per IRC line in spec mode (no CRLF); keep legacy batching otherwise. ApplysanitizeForTextModeonspec-text` sends. - Acceptance Criteria:
- 101 response to an offer of
text.ircv3.netincludesSec-WebSocket-Protocol: text.ircv3.net. - A client offering both gets the first-listed supported protocol.
- An un-negotiated client still connects (legacy mode) and existing CF WS tests stay green.
- Registration replies to a spec-mode client arrive as N separate WS messages with no trailing CRLF.
- Binary frame on a text-mode socket closes 1003.
- 101 response to an offer of
- TDD Outline: Red on a Miniflare WS asserting the echoed header; green. Red asserting per-message send (count messages); green. Red on binary-on-text close; green.
Implementation note (done): The negotiated
WsFrameModeis persisted via the hibernation tag passed toacceptWebSocket(server, [\ws:${mode}`])and recovered in every event handler throughctx.getTags(ws)(theDurableObjectState.getTagsAPI). This survives DO eviction without touching the persistedConnectionState. All outbound writes are centralized inConnectionDO.sendOutbound(ws, lines, mode)— legacy mode keeps the per-call\r\nbatched frame;spec-textsends one sanitized line per WS message (no CRLF);spec-binarysends one UTF-8 binary frame per line. Inbound framing for spec modes usesframeToLines(chunk, mode)via aTransportinjected intoConnectionActor, so one WS message is exactly one IRC line. Binary frames on aspec-textsocket close with 1003. The two pure helpers (wsFrameModeFromTags,makeWsFrameTransport`) are reused-ready for the local-cli (098) and AWS (099) adapters.
TICKET-098 — Local CLI adapter: handleProtocols + per-message framing
- Phase: 10
- Points: 2
- Dependencies: TICKET-095, TICKET-096
- Description: Give
apps/local-cli/src/server.ts'sWebSocketServerahandleProtocolscallback backed by the core selector; keep aMap<WebSocket, WsSubprotocol | null>for mode. Route inbound through the actor'sreceiveFrame; switch thews.senddelivery to per-message in spec mode. Un-negotiated clients keep today's behavior. - Acceptance Criteria:
wscat -s text.ircv3.netagainst the local server negotiates the protocol and exchanges one-line-per-message frames.- Existing local-cli integration tests pass unchanged (legacy path).
- TDD Outline: Red on a
wsclient asserting the echoed subprotocol and one-message-per-line; green.
TICKET-099 — AWS adapter: subprotocol echo + per-message framing
- Phase: 10
- Points: 3
- Dependencies: TICKET-095, TICKET-096
- Description: API Gateway lets the integration choose the
subprotocol via the
$connectroute's integration-response headerSec-WebSocket-Protocol. Read the offered header inconnect.ts, run the selector, persist the chosenmode(newConnectionscolumn, default null = legacy), and emit the header in the CDK integration-response (apps/aws-stack/src/aws-stack.ts). Indefault.ts, deliver each outbound line as its ownpostToConnectionin spec mode. - Acceptance Criteria:
- A
$connectofferingbinary.ircv3.netgets the protocol echoed and a non-nullmoderow. $defaultreplies to a spec-mode connection arrive one perpostToConnection.- Legacy connections (no offer) are unaffected.
- A
- TDD Outline: Red on connect-handler unit asserting header→mode; green. Red on CDK template asserting the integration-response header; green. Red on default-handler per-message delivery; green.
TICKET-100 — tcp-ws-forwarder: negotiate binary.ircv3.net upstream
- Phase: 10
- Points: 2
- Dependencies: TICKET-097
- Description: When
tools/tcp-ws-forwarderdials the IRCd WebSocket, offer['binary.ircv3.net', 'text.ircv3.net']. Translate framing at the TCP seam: each TCP IRC line (CRLF-terminated) → one WS message with the CRLF stripped; each inbound WS message → one TCP line with CRLF re-appended. This makes the in-tree bridge exercise the spec path end-to-end against the CF worker. - Acceptance Criteria:
- A real IRC TCP client (e.g. weechat/irssi) through the forwarder completes registration against a spec-mode worker.
- Forwarder unit tests assert CRLF strip on send and re-append on receive.
- TDD Outline: Red on send-translation (no trailing CRLF in WS msg); green. Red on receive-translation (CRLF appended to TCP); green.
TICKET-101 — Integration: spec-compliance test + docs note
- Phase: 10
- Points: 2
- Dependencies: TICKET-097, TICKET-098, TICKET-099
- Description: Add a
vitest-pool-workersintegration test that negotiatestext.ircv3.netagainstConnectionDOend-to-end and asserts the full spec contract (echoed header, one-line-per-message both directions, 510 budget, legacy fallback still works). Add a short "WebSocket (IRCv3)" section to the README listing supported subprotocols and the legacy fallback behavior. - Acceptance Criteria:
- The integration test is green and runs in the normal
pnpm testsuite (no--e2eflag needed — it's hermetic against Miniflare). - README documents
binary.ircv3.net/text.ircv3.netsupport.
- The integration test is green and runs in the normal
- TDD Outline: Red on the end-to-end spec assertions; green as the dependency tickets land. Docs reviewed by reading the rendered README.
8. Risks
| Risk | Mitigation |
|---|---|
Per-message send (N ws.send/postToConnection calls) raises CF/AWS cost or latency vs one batched frame |
Measure in TICKET-097/099; batching IRC lines into one message is itself spec-non-compliant, so the cost is accepted. Cap bursts if needed via the existing rate limiter. |
Hibernation tag API (acceptWebSocket(ws, [tag])) surface drift across runtime versions |
Confirm against current compatibility_date in apps/cf-worker/wrangler.toml; fall back to persisting mode in DO state if the tag read is unavailable. |
| Existing clients (Kiwi via PLAN-WEB-CLIENT, forwarder) break if legacy mode regresses | Legacy mode is literally the moved splitFrameLines/batched-send code; keep its tests unchanged and gate spec behavior strictly on a negotiated subprotocol. |
| API Gateway subprotocol echo has regional/CDK quirks | Validate on a real stack before merging TICKET-099; keep legacy (no-offer) path as the safe default. |
| 510 vs 512 boundary rejects a currently-accepted 511-byte legacy line | The 510 budget applies only to spec-mode WS; TCP and legacy-WS keep 512. No regression for existing traffic. |
| Lone-surrogate sanitization alters message content | Applies to text mode only and matches the spec's U+FFFD guidance; binary-mode clients are unaffected. |
9. Out of scope for v1 of this work
- Raw-bytes / non-UTF-8 channel communities through the reducers. Binary mode is honored at the frame layer; the core stays string-based.
- A new transport (SockJS /
webircgatewayintegration). The gateway remains an optional sidecar. - WebSocket
Originallowlisting (tracked in PLAN-WEB-CLIENT TICKET-070). - Resumable sessions /
X-Connection-Idrouting. - Per-message compression (permessage-deflate) — a Cloudflare/AWS platform concern, not an IRCv3 WebSocket spec item.
10. Deliverables checklist (definition of done)
-
ws-subprotocol.ts+ws-framing.tsland inirc-corewith unit tests green and ≥90% coverage. -
ConnectionActor.receiveFramesupports spec/legacy modes; all existing actor tests pass. - CF, AWS, and local-cli adapters negotiate a subprotocol, echo it, and send one message per IRC line in spec mode; legacy path unchanged.
-
tcp-ws-forwarderoffers the binary subprotocol and translates framing at the TCP seam. - Hermetic Miniflare integration test asserts the full spec contract.
- README documents IRCv3 WebSocket support and the legacy fallback.
- All new tickets green in
progress.md; PLAN §2 gap table updated to show compliance.
11. Open questions
- Should un-negotiated connections default to text mode (stricter, simpler) instead of legacy-tolerant? Lean toward legacy for max compat; revisit once the web client adopts subprotocol negotiation.
- Do we want to advertise WS subprotocol support via an IRCv3 capability (there isn't a standard one) or purely at the HTTP layer? Lean HTTP-only — the spec defines no IRC-level CAP for this.
- For AWS, is the per-message
postToConnectioncost acceptable at scale, or do we need a spec-compliant batching strategy (e.g. one message per IRC line but coalesced within a single Lambda response where API Gateway permits)? Defer until measured.