serverless-ircd 0.3.0 → 0.5.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 (223) 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 +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  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 +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  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 +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -6,8 +6,8 @@
6
6
  * `HibernatingWebSocketHandler` so idle WebSocket connections cost
7
7
  * nothing between messages. When a message arrives the DO wakes, replays
8
8
  * the persisted {@link ConnectionState} from `state.storage`, runs the
9
- * frame through {@link ConnectionActor} with a stub {@link CfRuntime},
10
- * and persists the updated state back.
9
+ * frame through {@link ConnectionActor} with the {@link CfRuntime}, and
10
+ * persists the updated state back.
11
11
  *
12
12
  * Lifecycle:
13
13
  * - `fetch()` WebSocket upgrade; `acceptWebSocket`.
@@ -16,15 +16,15 @@
16
16
  * - `alarm()` PING sweep; missing PONG disconnects.
17
17
  *
18
18
  * Cross-DO coordination:
19
- * - `REGISTRY_DO` (RPC stub today, real in 034).
20
- * - `CHANNEL_DO` per lowercased channel (stub today, real in 035).
19
+ * - `REGISTRY_DO` sharded nick registry.
20
+ * - `CHANNEL_DO` per lowercased channel authoritative roster/modes.
21
21
  *
22
22
  * The actor's `ActorChannelAccess` is a passthrough that returns
23
23
  * throwaway {@link ChannelState} objects — reducers mutate the local
24
24
  * view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
25
- * to the authoritative ChannelDO. Sufficient for connection-authority
26
- * commands and JOIN/QUIT fan-out; richer channel reads (NAMES of remote
27
- * state) ship in 036.
25
+ * to the authoritative ChannelDO. Connection-authority commands and
26
+ * JOIN/QUIT fan-out are fully wired; richer channel reads (NAMES of
27
+ * remote state) are served via the ChannelDO snapshot RPC.
28
28
  */
29
29
 
30
30
  import { DurableObject } from 'cloudflare:workers';
@@ -37,46 +37,54 @@ import {
37
37
  type ConnectionState,
38
38
  ConsoleLogger,
39
39
  type IdFactory,
40
- InMemoryAccountStore,
41
40
  InMemoryMessageStore,
41
+ InMemoryMtlsIdentityProvider,
42
+ InMemoryNickHistoryStore,
42
43
  LogLevel,
43
44
  type Logger,
44
45
  type MessageStore,
45
46
  type MotdProvider,
47
+ type MtlsIdentityProvider,
48
+ type NickHistoryStore,
46
49
  type RawLine,
47
50
  type SaslAccountCredential,
48
51
  type ServerConfig,
49
52
  SystemClock,
50
53
  UuidIdFactory,
51
- applyChannelDelta,
54
+ type WsFrameMode,
52
55
  createChannel,
53
56
  createConnection,
57
+ frameToLines,
58
+ isWithinWsByteBudget,
59
+ parseSecWsProtocolOffers,
60
+ sanitizeForTextMode,
61
+ selectSubprotocol,
54
62
  toSnapshot,
63
+ wsFrameModeFor,
55
64
  } from '@serverless-ircd/irc-core';
56
- import { ConnectionActor } from '@serverless-ircd/irc-server';
65
+ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
57
66
  import { makeCfRuntime } from './cf-runtime.js';
58
67
  import type { CfConnectionHandlers } from './cf-runtime.js';
68
+ import { loadServerConfigFromCfEnv } from './config-loader.js';
69
+ import { resolveAccountStore } from './d1-account-store.js';
59
70
  import type { Env } from './env.js';
60
- import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
71
+ import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
61
72
  import type { PersistedConnectionState } from './serialize.js';
73
+ import { CfStats } from './stats.js';
62
74
 
63
75
  /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
64
76
  export const DEFAULT_PING_INTERVAL_MS = 60_000;
65
77
  /** Default no-PONG disconnect threshold (ms). */
66
78
  export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
67
79
 
68
- /** Server config defaults; production deployments override via env. */
69
- const DEFAULT_SERVER_CONFIG: ServerConfig = {
70
- serverName: 'irc.example.com',
71
- networkName: 'ServerlessIRCd',
72
- maxChannelsPerUser: 30,
73
- maxTargetsPerCommand: 10,
74
- maxListEntries: 50,
75
- nickLen: 30,
76
- channelLen: 50,
77
- topicLen: 390,
78
- quitMessage: 'Client Quit',
79
- };
80
+ /**
81
+ * Wall-clock timestamp captured at this worker isolate's first module load
82
+ * (cold start). Used as the `uptimeStartedAt` anchor for {@link CfStats} so
83
+ * `STATS u` reports uptime for the current isolate. A dedicated stats DO
84
+ * would supply a deployment-wide uptime (future work); for now each
85
+ * isolate reports its own.
86
+ */
87
+ const WORKER_STARTUP_AT = Date.now();
80
88
 
81
89
  /**
82
90
  * ConnectionDO instance.
@@ -87,6 +95,13 @@ const DEFAULT_SERVER_CONFIG: ServerConfig = {
87
95
  export class ConnectionDO extends DurableObject<Env> {
88
96
  /** Cached connection state; reloaded from storage after hibernation. */
89
97
  private cached: ConnectionState | undefined;
98
+ /**
99
+ * Cached reducer-facing server config. Parsed once from `this.env`
100
+ * (bindings are stable for the DO's lifetime) so the schema-validated
101
+ * `loadServerConfigFromCfEnv` runs a single time rather than on every
102
+ * WebSocket frame.
103
+ */
104
+ private cachedServerConfig: ServerConfig | undefined;
90
105
  /**
91
106
  * Per-instance channel-state cache. Reused across every WebSocket frame
92
107
  * the connection handles (within a single hibernation cycle) so that
@@ -109,18 +124,54 @@ export class ConnectionDO extends DurableObject<Env> {
109
124
  */
110
125
  private messages: MessageStore | undefined;
111
126
  /**
112
- * In-worker SASL account store seeded from the `SASL_ACCOUNTS` env var.
113
- * Lazily constructed so the first frame that needs it parses the env once.
114
- * A Durable-Object / D1-backed persistent variant is a documented
115
- * follow-up; swapping it in means replacing {@link accountStore}.
127
+ * Per-connection nick-history store backing `WHOWAS`. The minimum-viable
128
+ * in-worker ring buffer is scoped to this connection: sign-offs this
129
+ * connection records are queryable by the same connection. A
130
+ * Durable-Object-backed persistent variant (shared across connections,
131
+ * keyed by nick) is a documented follow-up. Lazily constructed.
132
+ */
133
+ private history: NickHistoryStore | undefined;
134
+ /**
135
+ * SASL account store, resolved once via {@link loadAccountStore} before the
136
+ * first frame dispatches. The two-phase load mirrors AWS's
137
+ * `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
138
+ * rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
139
+ * env-var seed provides the fallback `InMemoryAccountStore`. The
140
+ * `AccountStore.verify` port is synchronous, so pre-loading MUST complete
141
+ * before the first reducer runs.
116
142
  */
117
143
  private accounts: AccountStore | undefined;
144
+ /** Guards {@link loadAccountStore} so the D1 query runs at most once. */
145
+ private accountsResolved = false;
146
+ /**
147
+ * Verified client-cert subject captured at WebSocket upgrade time when
148
+ * CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
149
+ * via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
150
+ * the cert subject to an account.
151
+ *
152
+ * CF API Shield mTLS setup:
153
+ * 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
154
+ * 2. Create an mTLS policy on the custom hostname that requires client certs.
155
+ * 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
156
+ * on every request, which the Worker reads at upgrade time.
157
+ * When unset (no mTLS configured), EXTERNAL auth fails with `904`.
158
+ */
159
+ private mtlsCertSubject: string | undefined;
118
160
  private readonly clock: Clock = SystemClock;
119
161
  private readonly ids: IdFactory = new UuidIdFactory();
120
162
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
121
163
  private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
122
- /** Outstanding outbound lines, drained when the WS sends complete. */
123
- // (Reserved for a future batching optimization; kept off until 036.)
164
+ // Outbound lines are batched per call (legacy mode), not via a reserved
165
+ // instance field: a legacy `deliver()` joins every line of a ChannelDO
166
+ // broadcast into a single `WebSocket.send()`, and the legacy actor `send`
167
+ // handler in `buildActor` does the same for one frame's response. An
168
+ // IRCv3 spec-mode connection instead sends one line per WS message with
169
+ // no trailing CR-LF (see `sendOutbound`). Cross-call per-microtask
170
+ // coalescing (the optimization previously reserved here) was dropped — it
171
+ // would add a drain queue and ordering hazards between independent
172
+ // fan-out calls for no measured benefit (see ADR-003 and the CF adapter
173
+ // load-test report). Revisit only if WS round-trips become a demonstrated
174
+ // bottleneck.
124
175
 
125
176
  // -------------------------------------------------------------------------
126
177
  // WebSocket upgrade
@@ -134,12 +185,31 @@ export class ConnectionDO extends DurableObject<Env> {
134
185
  const pair = new WebSocketPair();
135
186
  const server = pair[0];
136
187
  const client = pair[1];
188
+ // Capture the verified client-cert subject when CF API Shield mTLS is
189
+ // active. `request.cf` is a Cloudflare-specific extension; the property
190
+ // is present only when a client certificate was verified against the
191
+ // uploaded CA pool.
192
+ const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
193
+ if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
194
+ this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
195
+ }
196
+ // IRCv3 WebSocket subprotocol negotiation: read the offered
197
+ // `Sec-WebSocket-Protocol` list, select the first supported entry, and
198
+ // echo it back when one was agreed. The negotiated frame mode is tagged
199
+ // onto the hibernated socket so it survives eviction and can be
200
+ // recovered in every event handler (webSocketMessage / deliver / alarm).
201
+ const offerHeader = request.headers.get('Sec-WebSocket-Protocol');
202
+ const chosen = selectSubprotocol(parseSecWsProtocolOffers(offerHeader));
203
+ const mode = wsFrameModeFor(chosen ?? undefined);
137
204
  // Hibernation: `acceptWebSocket` accepts the server side implicitly.
138
- // Calling `server.accept()` first throws "already accepted".
139
- this.ctx.acceptWebSocket(server);
205
+ // Calling `server.accept()` first throws "already accepted". The tag
206
+ // records the negotiated frame mode for the outbound + inbound paths.
207
+ this.ctx.acceptWebSocket(server, [`ws:${mode}`]);
140
208
  // Schedule the first PING sweep.
141
209
  await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
142
- return new Response(null, { status: 101, webSocket: client });
210
+ const headers = new Headers();
211
+ if (chosen !== null) headers.set('Sec-WebSocket-Protocol', chosen);
212
+ return new Response(null, { status: 101, webSocket: client, headers });
143
213
  }
144
214
 
145
215
  // -------------------------------------------------------------------------
@@ -148,12 +218,33 @@ export class ConnectionDO extends DurableObject<Env> {
148
218
 
149
219
  /** Frame → actor → dispatch → persist. */
150
220
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
151
- const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
221
+ const mode = this.wsFrameMode(ws);
222
+ // text.ircv3.net is a text-only subprotocol: a binary frame on such a
223
+ // connection is a protocol violation. RFC 6455 close code 1003 (unsupported
224
+ // data) is the spec-mandated response.
225
+ if (mode === 'spec-text' && typeof message !== 'string') {
226
+ ws.close(1003, 'binary frame not permitted on text.ircv3.net');
227
+ return;
228
+ }
229
+ const raw = typeof message === 'string' ? message : new TextDecoder().decode(message);
230
+ // IRCv3 WebSocket spec: a single message MUST fit the 510-byte budget
231
+ // (the 512-byte IRC line limit minus the omitted trailing CR-LF). An
232
+ // oversize message is a protocol violation; RFC 6455 close code 1009
233
+ // (Message Too Big) is the spec-mandated response. Legacy connections
234
+ // keep the parser's existing 512-byte line cap, so the budget is
235
+ // enforced for negotiated spec modes only.
236
+ if ((mode === 'spec-text' || mode === 'spec-binary') && !isWithinWsByteBudget(raw)) {
237
+ ws.close(1009, 'Message Too Big');
238
+ return;
239
+ }
240
+ // text.ircv3.net frames are UTF-8 text; lone surrogates have no valid
241
+ // encoding and are substituted with U+FFFD before framing.
242
+ const text = mode === 'spec-text' ? sanitizeForTextMode(raw) : raw;
152
243
  const state = await this.loadState();
153
- const before = new Set(state.joinedChannels);
154
244
  const beforeNick = state.nick;
155
245
 
156
- const actor = this.buildActor(ws, state);
246
+ await this.loadAccountStore();
247
+ const actor = this.buildActor(ws, state, mode);
157
248
  try {
158
249
  await actor.receiveTextFrame(text);
159
250
  } finally {
@@ -165,17 +256,11 @@ export class ConnectionDO extends DurableObject<Env> {
165
256
  if (beforeNick === undefined && state.nick !== undefined) {
166
257
  await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
167
258
  }
168
- // Detect newly joined channels so we can register them with the
169
- // channel DO necessary for the close handler to fan out QUIT to
170
- // them. The stub channel DO records the call; the real ChannelDO
171
- // (035) maintains authoritative roster state.
172
- for (const chan of state.joinedChannels) {
173
- if (!before.has(chan)) {
174
- // joinReducer already emitted an ApplyChannelDelta; the stub
175
- // ChannelDO records it. No extra work needed here for 033.
176
- void chan;
177
- }
178
- }
259
+ // No per-ConnectionDO channel-registration step is needed here: channel
260
+ // membership is driven solely by the `ApplyChannelDelta` effect the
261
+ // joinReducer emits, which the CfRuntime forwards to the authoritative
262
+ // ChannelDO. The ChannelDO roster IS the fan-out registration target, so
263
+ // a second loop over newly joined channels here would be redundant.
179
264
  }
180
265
 
181
266
  /** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
@@ -202,7 +287,7 @@ export class ConnectionDO extends DurableObject<Env> {
202
287
  * been idle past the PONG timeout, closes it.
203
288
  *
204
289
  * PLAN §6.1 — DO alarms are the canonical PING/idle mechanism on CF;
205
- * equivalent to EventBridge Scheduler on AWS (042).
290
+ * equivalent to EventBridge Scheduler on AWS.
206
291
  */
207
292
  override async alarm(): Promise<void> {
208
293
  const state = await this.loadState();
@@ -220,7 +305,8 @@ export class ConnectionDO extends DurableObject<Env> {
220
305
  // Send PING. PONG replies update lastSeen via the actor.
221
306
  const token = this.ids.nonce();
222
307
  for (const ws of this.ctx.getWebSockets()) {
223
- (ws as WebSocket).send(`PING :${token}\r\n`);
308
+ const socket = ws as WebSocket;
309
+ this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
224
310
  }
225
311
  await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
226
312
  }
@@ -251,14 +337,11 @@ export class ConnectionDO extends DurableObject<Env> {
251
337
  */
252
338
  async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
253
339
  let delivered = 0;
254
- let text: string | undefined;
255
- if (lines.length > 0) {
256
- text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
257
- }
258
340
  for (const ws of this.ctx.getWebSockets()) {
259
341
  const socket = ws as WebSocket;
260
342
  if (socket.readyState !== WebSocket.OPEN) continue;
261
- if (text !== undefined) socket.send(text);
343
+ const mode = this.wsFrameMode(socket);
344
+ this.sendOutbound(socket, lines, mode);
262
345
  delivered++;
263
346
  }
264
347
  return { delivered };
@@ -268,6 +351,44 @@ export class ConnectionDO extends DurableObject<Env> {
268
351
  // Internal helpers
269
352
  // -------------------------------------------------------------------------
270
353
 
354
+ /**
355
+ * Recovers the negotiated WebSocket frame mode for `ws` from its
356
+ * hibernation tag. The tag is written once in `fetch` and survives DO
357
+ * eviction, so this returns the correct mode on every wake. Defaults to
358
+ * the legacy-tolerant mode when no tag is present (e.g. a socket accepted
359
+ * before subprotocol negotiation existed).
360
+ */
361
+ private wsFrameMode(ws: WebSocket): WsFrameMode {
362
+ return wsFrameModeFromTags(this.ctx.getTags(ws));
363
+ }
364
+
365
+ /**
366
+ * Writes `lines` to `ws` according to `mode`:
367
+ * - `spec-text` — one `ws.send` per line, no trailing CR-LF, lone
368
+ * surrogates substituted with U+FFFD (UTF-8 safety).
369
+ * - `spec-binary` — one `ws.send` per line as a UTF-8 binary frame,
370
+ * no trailing CR-LF.
371
+ * - `legacy` — all lines joined with `\r\n` plus a trailing
372
+ * `\r\n`, sent as a single WebSocket message.
373
+ *
374
+ * No-ops when the socket is closed or `lines` is empty (preserving the
375
+ * empty-payload probe contract of {@link deliver}).
376
+ */
377
+ private sendOutbound(ws: WebSocket, lines: RawLine[], mode: WsFrameMode): void {
378
+ if (ws.readyState !== WebSocket.OPEN) return;
379
+ if (lines.length === 0) return;
380
+ if (mode === 'spec-text') {
381
+ for (const line of lines) ws.send(sanitizeForTextMode(line.text));
382
+ return;
383
+ }
384
+ if (mode === 'spec-binary') {
385
+ const enc = new TextEncoder();
386
+ for (const line of lines) ws.send(enc.encode(line.text));
387
+ return;
388
+ }
389
+ ws.send(`${lines.map((l) => l.text).join('\r\n')}\r\n`);
390
+ }
391
+
271
392
  /** Loads the connection state from storage, caching for the event. */
272
393
  private async loadState(): Promise<ConnectionState> {
273
394
  if (this.cached !== undefined) return this.cached;
@@ -290,29 +411,30 @@ export class ConnectionDO extends DurableObject<Env> {
290
411
  }
291
412
 
292
413
  /**
293
- * Constructs an actor wired to this DO's CfRuntime.
414
+ * Constructs an actor wired to this DO's CfRuntime. `mode` drives both the
415
+ * inbound line-framing transport (spec mode: one IRC message per frame,
416
+ * never split; legacy: split on `\r\n`) and the outbound delivery shape
417
+ * (spec mode: one `WebSocket.send` per line, no trailing CR-LF; legacy:
418
+ * batched into a single `\r\n`-joined frame).
294
419
  */
295
- private buildActor(ws: WebSocket, state: ConnectionState): ConnectionActor {
420
+ private buildActor(ws: WebSocket, state: ConnectionState, mode: WsFrameMode): ConnectionActor {
296
421
  const serverConfig = this.serverConfig();
297
422
  const motd = this.motdProvider();
298
423
  const handlers = {
299
424
  send: (lines: RawLine[]): void => {
300
- if (ws.readyState === WebSocket.OPEN) {
301
- const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
302
- ws.send(text);
303
- }
425
+ this.sendOutbound(ws, lines, mode);
304
426
  },
305
427
  disconnect: (reason?: string): void => {
306
- if (reason !== undefined && ws.readyState === WebSocket.OPEN) {
307
- ws.send(`ERROR :Closing link: (${reason})\r\n`);
428
+ if (reason !== undefined) {
429
+ this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
308
430
  }
309
431
  ws.close();
310
432
  },
311
433
  snapshot: (): ConnectionState | undefined => this.cached,
312
434
  };
313
- // For 033 stub routing we look up the per-connection registry
314
- // stub by the DO instance's hex id. Production (034) shards the
315
- // registry by nick instead, at which point `registryKey` is unused.
435
+ // The CfRuntime shards the registry by nick; the per-connection
436
+ // key passed here is ignored by `makeCfRuntime` (kept in the signature
437
+ // for call-site stability).
316
438
  const registryKey = this.ctx.id.toString();
317
439
  const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
318
440
  if (this.channelAccess === undefined) {
@@ -323,16 +445,29 @@ export class ConnectionDO extends DurableObject<Env> {
323
445
  // per-connection in the Workers dashboard.
324
446
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
325
447
  const accounts = this.accountStore();
448
+ const mtlsIdentity: MtlsIdentityProvider | undefined =
449
+ this.mtlsCertSubject !== undefined
450
+ ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
451
+ : undefined;
326
452
  return new ConnectionActor({
327
453
  state,
328
454
  runtime,
329
455
  channels: this.channelAccess,
330
456
  serverConfig,
457
+ configSource: 'KV',
331
458
  clock: this.clock,
332
459
  ids: this.ids,
333
460
  motd,
334
461
  messages: this.messageStore(),
462
+ history: this.historyStore(),
463
+ // CfStats fans out across the channel + connection RPCs the runtime
464
+ // already exposes. `uptimeStartedAt` is captured at the worker's
465
+ // module load (cold start); a dedicated stats DO would give a
466
+ // deployment-wide uptime, tracked as future work.
467
+ stats: new CfStats(runtime, WORKER_STARTUP_AT),
468
+ transport: makeWsFrameTransport(mode),
335
469
  ...(accounts !== undefined ? { accounts } : {}),
470
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
336
471
  logger,
337
472
  });
338
473
  }
@@ -340,7 +475,8 @@ export class ConnectionDO extends DurableObject<Env> {
340
475
  /** Tears down a connection: emit QUIT effects, release nick, close WS. */
341
476
  private async tearDown(ws: WebSocket): Promise<void> {
342
477
  const state = await this.loadState();
343
- const actor = this.buildActor(ws, state);
478
+ await this.loadAccountStore();
479
+ const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
344
480
  // Drive QUIT through the actor so the same effect pipeline handles
345
481
  // fanout as during normal operation.
346
482
  await actor.receiveTextFrame('QUIT\r\n');
@@ -368,11 +504,17 @@ export class ConnectionDO extends DurableObject<Env> {
368
504
  }
369
505
 
370
506
  private serverConfig(): ServerConfig {
371
- return {
372
- ...DEFAULT_SERVER_CONFIG,
373
- ...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
374
- ...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
375
- };
507
+ // Single source of truth: delegate to the schema-validated loader so
508
+ // every reducer-facing field (operCreds, serverPassword, motdLines,
509
+ // limit knobs, …) is populated from `this.env` exactly as the shared
510
+ // `ServerConfigSchema` prescribes. The previous hand-rolled literal
511
+ // silently dropped fields like operCreds, which left OPER unable to
512
+ // authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
513
+ // were bound. See config-loader.ts and config.ts.
514
+ if (this.cachedServerConfig === undefined) {
515
+ this.cachedServerConfig = loadServerConfigFromCfEnv(this.env);
516
+ }
517
+ return this.cachedServerConfig;
376
518
  }
377
519
 
378
520
  private motdProvider(): MotdProvider {
@@ -398,17 +540,37 @@ export class ConnectionDO extends DurableObject<Env> {
398
540
  }
399
541
 
400
542
  /**
401
- * Lazily constructs the in-worker SASL account store from the
402
- * `SASL_ACCOUNTS` env var. Returns `undefined` when no accounts are
403
- * configured so the actor's `ctx.accounts` stays unset (preserving the
404
- * no-store behaviour: `AUTHENTICATE PLAIN` → `904`). A Durable-Object /
405
- * D1-backed persistent variant is a documented follow-up.
543
+ * Lazily constructs the per-connection nick-history store. A future
544
+ * persistent variant (DO/KV-backed, keyed by nick) will replace this
545
+ * without touching the actor wiring.
546
+ */
547
+ private historyStore(): NickHistoryStore {
548
+ if (this.history === undefined) {
549
+ this.history = new InMemoryNickHistoryStore(this.clock);
550
+ }
551
+ return this.history;
552
+ }
553
+
554
+ /**
555
+ * Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
556
+ * caching the result. Safe to call on every frame; the D1 query runs at
557
+ * most once per ConnectionDO instance. Must complete before the first
558
+ * reducer dispatches so the synchronous `AccountStore.verify` port has its
559
+ * data ready.
560
+ */
561
+ private async loadAccountStore(): Promise<void> {
562
+ if (this.accountsResolved) return;
563
+ this.accountsResolved = true;
564
+ this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
565
+ }
566
+
567
+ /**
568
+ * Returns the resolved account store, or `undefined` when no accounts are
569
+ * configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
570
+ * PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
571
+ * `await loadAccountStore()` before reading this.
406
572
  */
407
573
  private accountStore(): AccountStore | undefined {
408
- if (this.accounts !== undefined) return this.accounts;
409
- const creds = parseSaslAccountsEnv(this.env.SASL_ACCOUNTS);
410
- if (creds.length === 0) return undefined;
411
- this.accounts = new InMemoryAccountStore(creds);
412
574
  return this.accounts;
413
575
  }
414
576
 
@@ -437,6 +599,7 @@ export class ConnectionDO extends DurableObject<Env> {
437
599
  private async tearDownNoSocket(state: ConnectionState): Promise<void> {
438
600
  // Emit QUIT effects without dispatching transport calls (the socket
439
601
  // is already gone). The cross-DO fanout still runs.
602
+ await this.loadAccountStore();
440
603
  const noOpHandlers: CfConnectionHandlers = {
441
604
  send: (): void => {},
442
605
  disconnect: (): void => {},
@@ -452,10 +615,12 @@ export class ConnectionDO extends DurableObject<Env> {
452
615
  runtime,
453
616
  channels: this.channelAccess,
454
617
  serverConfig: this.serverConfig(),
618
+ configSource: 'KV',
455
619
  clock: this.clock,
456
620
  ids: this.ids,
457
621
  motd: this.motdProvider(),
458
622
  messages: this.messageStore(),
623
+ history: this.historyStore(),
459
624
  });
460
625
  await actor.receiveTextFrame('QUIT\r\n');
461
626
  await this.releaseAndPersist(state);
@@ -629,12 +794,6 @@ class PassthroughChannelAccess {
629
794
  }
630
795
  }
631
796
 
632
- // Reference `applyChannelDelta` to ensure the import is used downstream by
633
- // tests that exercise state migrations; harmless if the symbol is unused
634
- // in production today. (Removed from the ConnectionDO path because the
635
- // authoritative delta application lives in the ChannelDO stub today.)
636
- export { applyChannelDelta, PERSISTED_STATE_VERSION };
637
-
638
797
  /**
639
798
  * Parses the `SASL_ACCOUNTS` env var into credential entries.
640
799
  *
@@ -659,3 +818,37 @@ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCreden
659
818
  }
660
819
  return out;
661
820
  }
821
+
822
+ /**
823
+ * Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
824
+ * encodes the negotiated {@link WsFrameMode} so it survives DO eviction and
825
+ * can be recovered in every WebSocket event handler via `ctx.getTags(ws)`.
826
+ */
827
+ const WS_TAG_LEGACY = 'ws:legacy';
828
+ const WS_TAG_SPEC_TEXT = 'ws:spec-text';
829
+ const WS_TAG_SPEC_BINARY = 'ws:spec-binary';
830
+
831
+ /**
832
+ * Recovers the {@link WsFrameMode} from a hibernated socket's tags.
833
+ * Defaults to the legacy-tolerant mode when no recognized tag is present.
834
+ */
835
+ export function wsFrameModeFromTags(tags: readonly string[]): WsFrameMode {
836
+ for (const tag of tags) {
837
+ if (tag === WS_TAG_SPEC_TEXT) return 'spec-text';
838
+ if (tag === WS_TAG_SPEC_BINARY) return 'spec-binary';
839
+ if (tag === WS_TAG_LEGACY) return 'legacy';
840
+ }
841
+ return 'legacy';
842
+ }
843
+
844
+ /**
845
+ * Builds the inbound line-framing {@link Transport} for `mode`. Spec modes
846
+ * treat one WebSocket message as exactly one IRC line (a single trailing
847
+ * CR-LF is stripped and the frame is never split); legacy mode splits the
848
+ * frame on `\r\n` so older clients that concatenate messages keep working.
849
+ */
850
+ export function makeWsFrameTransport(mode: WsFrameMode): Transport {
851
+ return {
852
+ feed: (chunk: string): string[] => frameToLines(chunk, mode),
853
+ };
854
+ }