serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -1,87 +0,0 @@
1
- # Observability
2
-
3
- The server ships a structured-logger port that every adapter uses to emit
4
- JSON log lines carrying a per-request `traceId` and per-connection
5
- `connectionId`. The canonical event names are the contract dashboards and
6
- log queries are built against.
7
-
8
- ## Log contract
9
-
10
- Every record is one physical line of JSON with at least:
11
-
12
- | Field | Source |
13
- |----------------|-------------------------------------------------|
14
- | `ts` | ISO-8601 timestamp from the injected `Clock`. |
15
- | `level` | `debug` / `info` / `warn` / `error`. |
16
- | `msg` | Event name (see below). |
17
- | `traceId` | Per-request id; fresh per inbound frame. |
18
- | `connectionId` | The bound connection's id (`ConnId`). |
19
-
20
- Additional structured fields appear per event. The `msg` catalogue:
21
-
22
- | `msg` | Level | Emitted by | Notes |
23
- |--------------------|-------|-------------------------|------------------------------------------------------|
24
- | `frame.receive` | debug | `ConnectionActor` | One per parsed IRC line. Fields: `command`, `lines`. |
25
- | `frame.parse-error`| warn | `ConnectionActor` | A line failed to parse. Fields: `line`, `token`. |
26
- | `dispatch` | debug | `dispatch()` | Before interpreting an effect list. Fields: `effects` (array of tags), `histogram`. |
27
- | `dispatch.error` | warn | `dispatch()` | A runtime method rejected. Fields: `effect`, `err`. |
28
-
29
- Adapters may emit their own events (e.g. `local-cli listening`, `shutdown`)
30
- following the same shape.
31
-
32
- ## Cloudflare Workers
33
-
34
- The `apps/cf-worker/wrangler.toml` enables `[observability]` so every
35
- `console.*` line emitted by the `ConsoleLogger` inside `ConnectionDO` is
36
- captured by Workers Analytics.
37
-
38
- - Tail the live feed: `pnpm --filter @serverless-ircd/cf-worker tail`
39
- - Filter by connection: `connectionId = "<DO hex id>"`
40
- - Filter by trace: `traceId = "<uuid>"`
41
- - Errors only: `level = "error" or msg = "dispatch.error"`
42
-
43
- Workers Analytics dashboards can be built on the Cloudflare dashboard
44
- under **Workers & Pages → <Worker> → Observability**.
45
-
46
- ## AWS (Lambda + API Gateway WebSocket)
47
-
48
- The Lambda handler writes to CloudWatch Logs via the global `console`.
49
- Each invocation emits one JSON line per log record; the log group name
50
- follows the standard `/aws/lambda/<function-name>` convention.
51
-
52
- A starter CloudWatch dashboard is checked in at
53
- `dashboards/cloudwatch-irc.json`. Provision it via:
54
-
55
- ```sh
56
- aws cloudwatch put-dashboard \
57
- --dashboard-name ServerlessIRCd-staging \
58
- --dashboard-body file://dashboards/cloudwatch-irc.json
59
- ```
60
-
61
- The dashboard variables (`API_ID`, `LAMBDA_NAME`, `LOG_GROUP_ARN`,
62
- `AWS_REGION`) are templated by CloudWatch's dashboard variables feature —
63
- set them once in the dashboard UI after import.
64
-
65
- Sample CloudWatch Logs Insights query — count dispatched effects by tag
66
- over the last hour:
67
-
68
- ```
69
- fields @timestamp, msg, fields.histogram
70
- | filter msg = "dispatch"
71
- | sort @timestamp desc
72
- ```
73
-
74
- ## Local CLI
75
-
76
- The local-cli server uses the same `ConsoleLogger` so the structured
77
- stream is visible while developing:
78
-
79
- ```
80
- {"ts":"2026-07-22T12:34:56.789Z","level":"debug","msg":"frame.receive","traceId":"…","connectionId":"…","command":"PRIVMSG","lines":1}
81
- ```
82
-
83
- Filter with `jq`:
84
-
85
- ```
86
- pnpm --filter local-cli start | jq 'select(.msg=="dispatch.error")'
87
- ```
@@ -1,489 +0,0 @@
1
- # ServerlessIRCd — IRCv3 WebSocket Extension Plan
2
-
3
- Bring the existing WebSocket entry points into compliance with the
4
- [IRCv3 WebSocket support spec](https://ircv3.net/specs/extensions/websocket).
5
- Today the server transports IRC over WebSocket but ignores the spec's
6
- defining features: **subprotocol negotiation**, **one-line-per-message
7
- framing**, the **510-byte message budget**, and **binary/text frame mode
8
- distinction**. This plan closes those gaps across the pure core and all
9
- three adapters (Cloudflare Workers, AWS, local CLI) plus the in-tree TCP
10
- bridge.
11
-
12
- > **Status:** planning. No code has landed. Seven tickets proposed in §7
13
- > under a new **Phase 9 — IRCv3 WebSocket extension** heading; once
14
- > ratified they will be appended to `tickets.md` / `progress.md` and
15
- > supersede the corresponding sections here.
16
-
17
- ---
18
-
19
- ## 1. Goals & non-goals
20
-
21
- **Goals**
22
- - Full compliance with the normative MUSTs of the IRCv3 WebSocket spec:
23
- - Negotiate the `binary.ircv3.net` **and** `text.ircv3.net` subprotocols,
24
- honoring the client's order of preference.
25
- - One IRC line per WebSocket message, with no trailing `\r\n`.
26
- - 510-byte default message budget (content; the server re-inserts
27
- `\r\n` internally for any TCP relay / line accounting).
28
- - Distinct binary vs text frame handling; never relay non-UTF-8 to a
29
- text-mode client.
30
- - Keep the change **inside the transport layer**. The hexagonal core
31
- (`irc-core` reducers, `irc-server` `ConnectionActor`) stays
32
- transport-agnostic; only framing/byte-boundary concerns move.
33
- - Preserve backward compatibility: clients that do **not** negotiate a
34
- subprotocol keep working via a legacy-tolerant path (the spec permits
35
- this).
36
- - Same TDD discipline as the rest of the monorepo: pure negotiation &
37
- framing helpers unit-tested; per-adapter integration tests against a
38
- real WS handshake.
39
-
40
- **Non-goals (v1 of this work)**
41
- - Non-UTF-8 *channel communities*. The core already reasons in JS strings
42
- (UTF-8 at the boundary); we validate/replace lone surrogates but do not
43
- introduce a raw-bytes code path through the reducers. Binary mode is
44
- supported at the frame layer only.
45
- - A second transport or SockJS gateway. `webircgateway` remains an
46
- optional sidecar; not in scope here.
47
- - WebSocket `Origin` allowlisting. That is CSWSH defense tracked separately
48
- (PLAN-WEB-CLIENT §7, TICKET-070). This plan assumes an Origin check, if
49
- added, layers on top of the upgrade handler untouched by subprotocol
50
- work.
51
- - Resumable sessions / `X-Connection-Id`. The upgrade handler will gain a
52
- clean seam for future subprotocol-derived routing, but session resume is
53
- out of scope.
54
-
55
- **Design-for-later**
56
- - The subprotocol selector returns the chosen mode as a value the adapters
57
- stash on connection state. A future resumable-session ticket can ride
58
- the same field instead of re-deriving it.
59
-
60
- ---
61
-
62
- ## 2. Current state (the compliance gap)
63
-
64
- Audited against the spec. Findings, with file:line references:
65
-
66
- | Spec requirement | Status | Evidence |
67
- |---|---|---|
68
- | 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. |
69
- | 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`). |
70
- | 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). |
71
- | Binary vs text frame mode | **Not distinguished** | `connection-do.ts:128-129` silently `TextDecoder`-decodes `ArrayBuffer`; no `binaryType`, no mode tracking. |
72
- | Don't relay non-UTF-8 to text clients | N/A until text mode exists | — |
73
- | PING/PONG keepalive | **Already compliant** | CF alarms (`connection-do.ts:185-204`); AWS EventBridge (`ping-checker.ts`). |
74
- | Impose message-size limits (security SHOULD) | **Present**, wrong threshold | 512 caps exist; spec wants 510 for WS. |
75
-
76
- The transport itself works today (CF Hibernating WebSockets, AWS API
77
- Gateway, local `ws`). What's missing is the *spec framing* layered on top.
78
-
79
- ---
80
-
81
- ## 3. Architecture
82
-
83
- WebSocket framing is a **transport-boundary** concern, so the work fans
84
- out across the hexagon but the *logic* stays pure and central:
85
-
86
- ```
87
- ┌──────────────────────────────────────────────────────────────────┐
88
- │ packages/irc-core (pure, no cloud deps) │
89
- │ ws-subprotocol.ts parseSecWsProtocolOffers / selectSubproto │
90
- │ ws-framing.ts frameMode, stripTrailingCrLf, │
91
- │ splitWsFrame (legacy), enforce 510 budget │
92
- │ protocol/outbound.ts WS-aware line-limit (510) + lone- │
93
- │ surrogate sanitization for text mode │
94
- └──────────────────────────────────────────────────────────────────┘
95
- ▲ used by
96
- ┌─────────────────────────────────────────────────────────────────┐
97
- │ packages/irc-server ConnectionActor │
98
- │ receiveFrame(text|bytes, mode) mode-aware entry: spec = │
99
- │ one line; legacy = split │
100
- └─────────────────────────────────────────────────────────────────┘
101
- ▲ ▲ ▲
102
- ┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
103
- │ cf-adapter │ │ aws-adapter │ │ local-cli │
104
- │ ConnectionDO.fetch: │ │ $connect: echo │ │ ws handleProtocols; │
105
- │ read header, echo │ │ Sec-WebSocket- │ │ per-conn mode; │
106
- │ subprotocol, store │ │ Protocol via CDK │ │ per-message send │
107
- │ mode; hibernation │ │ integration resp; │ │ │
108
- │ tag; per-message │ │ Connections.mode; │ │ │
109
- │ send; binary/text │ │ per-message send │ │ │
110
- └──────────────────────┘ └─────────────────────┘ └──────────────────────┘
111
-
112
- ┌─────────────────────────┐
113
- │ tools/tcp-ws-forwarder │ offer binary.ircv3.net upstream;
114
- │ │ strip/append CRLF at the TCP seam
115
- └─────────────────────────┘
116
- ```
117
-
118
- **Key invariant:** the *reducers never change*. Framing (how bytes become
119
- `IrcMessage`s and how `Effect`s become bytes) is the only thing touched.
120
-
121
- ---
122
-
123
- ## 4. Design decisions
124
-
125
- ### 4.1 Subprotocol selection (pure)
126
-
127
- A pure helper set in `irc-core`:
128
-
129
- ```ts
130
- export const WS_SUBPROTO_BINARY = 'binary.ircv3.net';
131
- export const WS_SUBPROTO_TEXT = 'text.ircv3.net';
132
- export type WsSubprotocol = typeof WS_SUBPROTO_BINARY | typeof WS_SUBPROTO_TEXT;
133
-
134
- /** Parse a `Sec-WebSocket-Protocol` header (comma list, trimmed). */
135
- export function parseSecWsProtocolOffers(header: string | null): string[];
136
-
137
- /**
138
- * Pick the first offered protocol we support, in client-preference order.
139
- * Returns null if none match (caller MAY still accept the connection per spec).
140
- */
141
- export function selectSubprotocol(offers: string[]): WsSubprotocol | null;
142
- ```
143
-
144
- Selection order is **the client's order** (spec: "respecting the client's
145
- order of preference"). We accept the first of `[binary.ircv3.net,
146
- text.ircv3.net]` the client listed. If the client offers neither, return
147
- `null` — the adapter still upgrades (legacy path) per the spec's MAY.
148
-
149
- ### 4.2 Frame mode on connection state
150
-
151
- Adapters store the negotiated mode on per-connection state. We do **not**
152
- thread it through the core reducers (they're mode-blind); only the
153
- framing entry point and the outbound serializer consult it.
154
-
155
- - CF: hibernation tag via `acceptWebSocket(ws, [tag])` where
156
- `tag = "ws:" + mode`; recovered in `webSocketMessage` via
157
- `ws.deserialize`-equivalent tag read. Persisted as a field in DO state
158
- for the outbound path.
159
- - AWS: new `mode` column on the `Connections` table (default `null` =
160
- legacy).
161
- - Local CLI: a `Map<WebSocket, WsSubprotocol | null>` on the server.
162
-
163
- ### 4.3 Inbound framing — spec mode vs legacy mode
164
-
165
- ```ts
166
- export type WsFrameMode = 'spec-text' | 'spec-binary' | 'legacy';
167
-
168
- /** One frame -> one line in spec mode; tolerant split in legacy mode. */
169
- export function frameToLines(frame: string, mode: WsFrameMode): string[];
170
- ```
171
-
172
- - **spec-text / spec-binary:** the frame *is* the line. Strip an optional
173
- single trailing `\r\n`/`\n` (defensive; clients SHOULD NOT send it), then
174
- return `[frame]` if non-empty else `[]`. Never split on embedded
175
- newlines.
176
- - **legacy:** current `splitFrameLines` behavior (`/\r?\n/`), preserved
177
- verbatim for clients that never negotiated.
178
-
179
- `ConnectionActor` gains `receiveFrame(payload: string, mode)`; existing
180
- `receiveTextFrame` becomes a thin caller passing `'legacy'` so all current
181
- call sites and tests keep their behavior until updated.
182
-
183
- ### 4.4 Outbound framing — one message per line in spec mode
184
-
185
- Today adapters batch `lines.map(l => l.text).join('\r\n') + '\r\n'` into a
186
- single `ws.send`. In **spec mode** each outbound IRC line is its own WS
187
- message with **no** trailing CRLF:
188
-
189
- ```ts
190
- // spec mode
191
- for (const line of outboundLines) ws.send(line.text); // one msg each, no CRLF
192
- // legacy mode (unchanged)
193
- ws.send(outboundLines.map(l => l.text).join('\r\n') + '\r\n');
194
- ```
195
-
196
- This is the largest mechanical change (three adapters + their tests), but
197
- localized to the delivery functions.
198
-
199
- ### 4.5 Size budget — 510 for WS, 512 for TCP
200
-
201
- Introduce `MAX_WS_MESSAGE_BYTES = 510` alongside the existing 512 TCP cap.
202
- The WS inbound path enforces 510 on the message (no CRLF to discount);
203
- `enforceLineLimit` gains a mode-aware budget so spec-mode clients get the
204
- correct outbound ceiling. TCP and legacy-WS paths are unchanged.
205
-
206
- ### 4.6 Binary vs text handling
207
-
208
- - **spec-binary (`binary.ircv3.net`):** frames are `ArrayBuffer`. CF
209
- `webSocketMessage` already receives `string | ArrayBuffer`; we pass
210
- bytes through `TextDecoder` (our core is string-based) **but only after**
211
- validating. Binary mode is the "any valid IRC line" mode; for our
212
- UTF-8-string core this collapses to the same decode as text, with the
213
- difference being that we *accept* a binary frame without complaint.
214
- - **spec-text (`text.ircv3.net`):** frames are `string`. If a binary frame
215
- arrives on a text-mode connection, reject per spec (close 1003 /
216
- unsupported data) rather than silently decoding.
217
- - **Non-UTF-8 to text clients:** our outbound is always JS strings → valid
218
- UTF-8. The residual risk is *lone surrogates* (unpaired halves of
219
- surrogate pairs, which `TextEncoder` rejects). `sanitizeForTextMode`
220
- replaces lone surrogates with U+FFFD before send on text-mode sockets.
221
-
222
- ### 4.7 Backward compatibility
223
-
224
- The web client (PLAN-WEB-CLIENT) and `tcp-ws-forwarder` currently rely on
225
- the legacy `\r\n`-batched framing. Because un-negotiated connections fall
226
- into `'legacy'` mode, **nothing breaks** until each client opts in by
227
- offering a subprotocol. The forwarder is updated in the same phase so the
228
- in-tree bridge immediately exercises the spec path.
229
-
230
- ---
231
-
232
- ## 5. Component placement
233
-
234
- No new workspace packages. Changes are additions/edits within existing
235
- packages:
236
-
237
- ```
238
- packages/irc-core/src/
239
- ├── ws-subprotocol.ts NEW — parseSecWsProtocolOffers, selectSubprotocol
240
- ├── ws-framing.ts NEW — WsFrameMode, frameToLines, stripTrailingCrLf,
241
- │ MAX_WS_MESSAGE_BYTES, sanitizeForTextMode
242
- └── protocol/outbound.ts EDIT — mode-aware enforceLineLimit (510 vs 512)
243
- packages/irc-core/tests/
244
- ├── ws-subprotocol.test.ts NEW
245
- └── ws-framing.test.ts NEW
246
-
247
- packages/irc-server/src/actor.ts EDIT — receiveFrame(text, mode);
248
- receiveTextFrame delegates
249
- packages/irc-server/tests/actor.*.test.ts EDIT — add spec-mode cases
250
-
251
- packages/cf-adapter/src/connection-do.ts EDIT — upgrade negotiation, mode
252
- tag/storage, per-msg send,
253
- binary/text branch
254
- packages/aws-adapter/src/handlers/connect.ts EDIT — read header, persist mode
255
- packages/aws-adapter/src/handlers/default.ts EDIT — per-message send
256
- apps/aws-stack/src/aws-stack.ts EDIT — integration response
257
- Sec-WebSocket-Protocol header
258
- apps/local-cli/src/server.ts EDIT — handleProtocols, mode map,
259
- per-message send
260
- tools/tcp-ws-forwarder/src/forwarder.ts EDIT — offer binary subproto,
261
- framing translation
262
- ```
263
-
264
- ---
265
-
266
- ## 6. Testing strategy
267
-
268
- - **Pure units (Red-first):** `ws-subprotocol.test.ts` and
269
- `ws-framing.test.ts` drive the helpers — header parsing edge cases
270
- (whitespace, duplicates, empty), preference ordering, spec-vs-legacy
271
- line splitting, 510-boundary, lone-surrogate replacement.
272
- - **Actor unit:** extend `actor.*.test.ts` to call `receiveFrame(...,
273
- 'spec-text')` and assert one-line-per-message semantics; keep existing
274
- `receiveTextFrame` tests green (legacy).
275
- - **CF integration (`vitest-pool-workers` / Miniflare):** open a real WS
276
- to `ConnectionDO` offering `text.ircv3.net`, assert the 101 response
277
- echoes the protocol, send `NICK`/`USER` as separate CRLF-less messages,
278
- assert registration replies arrive one-per-message. A second case
279
- offers neither protocol and asserts legacy batching still works.
280
- - **AWS:** unit-test the connect handler's header→mode mapping and the CDK
281
- integration-response header; local-stack smoke optional.
282
- - **Determinism:** all of the above are hermetic except an optional real-
283
- browser check, which stays behind a `--e2e` flag like the web client
284
- plan.
285
-
286
- ---
287
-
288
- ## 7. Proposed tickets
289
-
290
- Seven new tickets under a new **Phase 9 — IRCv3 WebSocket extension**
291
- heading in `tickets.md` / `progress.md`. Each follows the existing
292
- Red → Green → Refactor TDD outline.
293
-
294
- ### TICKET-073 — Core: WebSocket subprotocol negotiation helpers
295
- - **Phase:** 9
296
- - **Points:** 2
297
- - **Dependencies:** —
298
- - **Description:** Add `packages/irc-core/src/ws-subprotocol.ts` with
299
- `WS_SUBPROTO_BINARY`/`WS_SUBPROTO_TEXT` constants, the `WsSubprotocol`
300
- type, `parseSecWsProtocolOffers(header)`, and
301
- `selectSubprotocol(offers)` honoring client-preference order, returning
302
- `null` when neither is offered. Pure, zero deps.
303
- - **Acceptance Criteria:**
304
- - `parseSecWsProtocolOffers('text.ircv3.net, binary.ircv3.net')`
305
- yields `['text.ircv3.net','binary.ircv3.net']` (whitespace-tolerant).
306
- - `selectSubprotocol` returns the first supported entry in offer order.
307
- - Offering only unsupported protocols returns `null`.
308
- - `null`/empty header → `[]` offers → `null` selection.
309
- - **TDD Outline:** Red on preference-order test; green. Red on duplicate/
310
- whitespace cases; green. Red on no-match returns null; green.
311
-
312
- ### TICKET-074 — Core: WS frame framing & 510-byte budget
313
- - **Phase:** 9
314
- - **Points:** 3
315
- - **Dependencies:** TICKET-073
316
- - **Description:** Add `packages/irc-core/src/ws-framing.ts`: `WsFrameMode`
317
- (`'spec-text' | 'spec-binary' | 'legacy'`), `MAX_WS_MESSAGE_BYTES = 510`,
318
- `stripTrailingCrLf`, `frameToLines(frame, mode)` (spec = single line;
319
- legacy = current `splitFrameLines` behavior), and
320
- `sanitizeForTextMode(str)` replacing lone surrogates with U+FFFD. Move
321
- the existing `splitFrameLines` body here (or re-export) so legacy mode
322
- is literally the current code. Make `enforceLineLimit` in `outbound.ts`
323
- budget-aware (510 in spec modes, 512 otherwise).
324
- - **Acceptance Criteria:**
325
- - `frameToLines('NICK bob\r\n', 'spec-text')` → `['NICK bob']` (one
326
- line; CRLF stripped; not split).
327
- - Embedded `\n` in a spec-mode frame stays a single line.
328
- - `frameToLines('a\r\nb', 'legacy')` → `['a','b']` (unchanged behavior).
329
- - WS inbound rejects a 511-byte message; accepts 510.
330
- - `sanitizeForTextMode` turns a lone surrogate into U+FFFD.
331
- - **TDD Outline:** Red per bullet above; minimal green; refactor shared
332
- strip logic.
333
-
334
- ### TICKET-075 — CF adapter: negotiate subprotocol + per-message framing
335
- - **Phase:** 9
336
- - **Points:** 3
337
- - **Dependencies:** TICKET-073, TICKET-074
338
- - **Description:** In `ConnectionDO.fetch` read `Sec-WebSocket-Protocol`,
339
- run `selectSubprotocol`, echo the chosen protocol back in the `101`
340
- response headers, and tag the hibernated socket (`acceptWebSocket(ws,
341
- [\`ws:${mode}\`])`). Derive `WsFrameMode` in `webSocketMessage` (binary
342
- frame on a `spec-text` connection → close 1003). Store the mode in DO
343
- state for the outbound path. Change outbound delivery to one
344
- `ws.send(line)` per IRC line in spec mode (no CRLF); keep legacy
345
- batching otherwise. Apply `sanitizeForTextMode` on `spec-text` sends.
346
- - **Acceptance Criteria:**
347
- - 101 response to an offer of `text.ircv3.net` includes
348
- `Sec-WebSocket-Protocol: text.ircv3.net`.
349
- - A client offering both gets the first-listed supported protocol.
350
- - An un-negotiated client still connects (legacy mode) and existing CF
351
- WS tests stay green.
352
- - Registration replies to a spec-mode client arrive as N separate WS
353
- messages with no trailing CRLF.
354
- - Binary frame on a text-mode socket closes 1003.
355
- - **TDD Outline:** Red on a Miniflare WS asserting the echoed header;
356
- green. Red asserting per-message send (count messages); green. Red on
357
- binary-on-text close; green.
358
-
359
- ### TICKET-076 — Local CLI adapter: `handleProtocols` + per-message framing
360
- - **Phase:** 9
361
- - **Points:** 2
362
- - **Dependencies:** TICKET-073, TICKET-074
363
- - **Description:** Give `apps/local-cli/src/server.ts`'s `WebSocketServer`
364
- a `handleProtocols` callback backed by the core selector; keep a
365
- `Map<WebSocket, WsSubprotocol | null>` for mode. Route inbound through
366
- the actor's `receiveFrame`; switch the `ws.send` delivery to per-message
367
- in spec mode. Un-negotiated clients keep today's behavior.
368
- - **Acceptance Criteria:**
369
- - `wscat -s text.ircv3.net` against the local server negotiates the
370
- protocol and exchanges one-line-per-message frames.
371
- - Existing local-cli integration tests pass unchanged (legacy path).
372
- - **TDD Outline:** Red on a `ws` client asserting the echoed subprotocol
373
- and one-message-per-line; green.
374
-
375
- ### TICKET-077 — AWS adapter: subprotocol echo + per-message framing
376
- - **Phase:** 9
377
- - **Points:** 3
378
- - **Dependencies:** TICKET-073, TICKET-074
379
- - **Description:** API Gateway lets the integration choose the
380
- subprotocol via the `$connect` route's integration-response header
381
- `Sec-WebSocket-Protocol`. Read the offered header in `connect.ts`, run
382
- the selector, persist the chosen `mode` (new `Connections` column,
383
- default null = legacy), and emit the header in the CDK
384
- integration-response (`apps/aws-stack/src/aws-stack.ts`). In
385
- `default.ts`, deliver each outbound line as its own `postToConnection`
386
- in spec mode.
387
- - **Acceptance Criteria:**
388
- - A `$connect` offering `binary.ircv3.net` gets the protocol echoed and
389
- a non-null `mode` row.
390
- - `$default` replies to a spec-mode connection arrive one per
391
- `postToConnection`.
392
- - Legacy connections (no offer) are unaffected.
393
- - **TDD Outline:** Red on connect-handler unit asserting header→mode;
394
- green. Red on CDK template asserting the integration-response header;
395
- green. Red on default-handler per-message delivery; green.
396
-
397
- ### TICKET-078 — tcp-ws-forwarder: negotiate `binary.ircv3.net` upstream
398
- - **Phase:** 9
399
- - **Points:** 2
400
- - **Dependencies:** TICKET-075
401
- - **Description:** When `tools/tcp-ws-forwarder` dials the IRCd WebSocket,
402
- offer `['binary.ircv3.net', 'text.ircv3.net']`. Translate framing at the
403
- TCP seam: each TCP IRC line (CRLF-terminated) → one WS message **with
404
- the CRLF stripped**; each inbound WS message → one TCP line **with CRLF
405
- re-appended**. This makes the in-tree bridge exercise the spec path
406
- end-to-end against the CF worker.
407
- - **Acceptance Criteria:**
408
- - A real IRC TCP client (e.g. weechat/irssi) through the forwarder
409
- completes registration against a spec-mode worker.
410
- - Forwarder unit tests assert CRLF strip on send and re-append on
411
- receive.
412
- - **TDD Outline:** Red on send-translation (no trailing CRLF in WS msg);
413
- green. Red on receive-translation (CRLF appended to TCP); green.
414
-
415
- ### TICKET-079 — Integration: spec-compliance test + docs note
416
- - **Phase:** 9
417
- - **Points:** 2
418
- - **Dependencies:** TICKET-075, TICKET-076, TICKET-077
419
- - **Description:** Add a `vitest-pool-workers` integration test that
420
- negotiates `text.ircv3.net` against `ConnectionDO` end-to-end and
421
- asserts the full spec contract (echoed header, one-line-per-message
422
- both directions, 510 budget, legacy fallback still works). Add a short
423
- "WebSocket (IRCv3)" section to the README listing supported
424
- subprotocols and the legacy fallback behavior.
425
- - **Acceptance Criteria:**
426
- - The integration test is green and runs in the normal `pnpm test`
427
- suite (no `--e2e` flag needed — it's hermetic against Miniflare).
428
- - README documents `binary.ircv3.net` / `text.ircv3.net` support.
429
- - **TDD Outline:** Red on the end-to-end spec assertions; green as the
430
- dependency tickets land. Docs reviewed by reading the rendered README.
431
-
432
- ---
433
-
434
- ## 8. Risks
435
-
436
- | Risk | Mitigation |
437
- |---|---|
438
- | Per-message send (N `ws.send`/`postToConnection` calls) raises CF/AWS cost or latency vs one batched frame | Measure in TICKET-075/077; 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. |
439
- | 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. |
440
- | 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. |
441
- | API Gateway subprotocol echo has regional/CDK quirks | Validate on a real stack before merging TICKET-077; keep legacy (no-offer) path as the safe default. |
442
- | 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. |
443
- | Lone-surrogate sanitization alters message content | Applies to text mode only and matches the spec's U+FFFD guidance; binary-mode clients are unaffected. |
444
-
445
- ---
446
-
447
- ## 9. Out of scope for v1 of this work
448
-
449
- - Raw-bytes / non-UTF-8 channel communities through the reducers. Binary
450
- mode is honored at the frame layer; the core stays string-based.
451
- - A new transport (SockJS / `webircgateway` integration). The gateway
452
- remains an optional sidecar.
453
- - WebSocket `Origin` allowlisting (tracked in PLAN-WEB-CLIENT
454
- TICKET-070).
455
- - Resumable sessions / `X-Connection-Id` routing.
456
- - Per-message compression (permessage-deflate) — a Cloudflare/AWS platform
457
- concern, not an IRCv3 WebSocket spec item.
458
-
459
- ---
460
-
461
- ## 10. Deliverables checklist (definition of done)
462
-
463
- - [ ] `ws-subprotocol.ts` + `ws-framing.ts` land in `irc-core` with unit
464
- tests green and ≥90% coverage.
465
- - [ ] `ConnectionActor.receiveFrame` supports spec/legacy modes; all
466
- existing actor tests pass.
467
- - [ ] CF, AWS, and local-cli adapters negotiate a subprotocol, echo it,
468
- and send one message per IRC line in spec mode; legacy path unchanged.
469
- - [ ] `tcp-ws-forwarder` offers the binary subprotocol and translates
470
- framing at the TCP seam.
471
- - [ ] Hermetic Miniflare integration test asserts the full spec contract.
472
- - [ ] README documents IRCv3 WebSocket support and the legacy fallback.
473
- - [ ] All new tickets green in `progress.md`; PLAN §2 gap table updated to
474
- show compliance.
475
-
476
- ---
477
-
478
- ## 11. Open questions
479
-
480
- - Should un-negotiated connections default to **text** mode (stricter,
481
- simpler) instead of legacy-tolerant? Lean toward legacy for max compat;
482
- revisit once the web client adopts subprotocol negotiation.
483
- - Do we want to advertise WS subprotocol support via an IRCv3 capability
484
- (there isn't a standard one) or purely at the HTTP layer? Lean HTTP-only
485
- — the spec defines no IRC-level CAP for this.
486
- - For AWS, is the per-message `postToConnection` cost acceptable at scale,
487
- or do we need a spec-compliant batching strategy (e.g. one message per
488
- IRC line but coalesced within a single Lambda response where API
489
- Gateway permits)? Defer until measured.