ServerlessIRCd Docs

Docs home

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.md and supersede the corresponding sections here.


1. Goals & non-goals

Goals

Non-goals (v1 of this work)

Design-for-later


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.

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[];

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

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


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

TICKET-096 — Core: WS frame framing & 510-byte budget

TICKET-097 — CF adapter: negotiate subprotocol + per-message framing

Implementation note (done): The negotiated WsFrameMode is persisted via the hibernation tag passed to acceptWebSocket(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 in ConnectionDO.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 a spec-text socket 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

TICKET-099 — AWS adapter: subprotocol echo + per-message framing

TICKET-100 — tcp-ws-forwarder: negotiate binary.ircv3.net upstream

TICKET-101 — Integration: spec-compliance test + docs note


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


10. Deliverables checklist (definition of done)


11. Open questions