serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -16,9 +16,8 @@
16
16
  * - `alarm()` PING sweep; missing PONG disconnects.
17
17
  *
18
18
  * Cross-DO coordination:
19
- * - `REGISTRY_DO` — sharded nick registry (034).
20
- * - `CHANNEL_DO` per lowercased channel — authoritative roster/modes
21
- * (035).
19
+ * - `REGISTRY_DO` — sharded nick registry.
20
+ * - `CHANNEL_DO` per lowercased channel — authoritative roster/modes.
22
21
  *
23
22
  * The actor's `ActorChannelAccess` is a passthrough that returns
24
23
  * throwaway {@link ChannelState} objects — reducers mutate the local
@@ -52,35 +51,40 @@ import {
52
51
  type ServerConfig,
53
52
  SystemClock,
54
53
  UuidIdFactory,
54
+ type WsFrameMode,
55
55
  createChannel,
56
56
  createConnection,
57
+ frameToLines,
58
+ isWithinWsByteBudget,
59
+ parseSecWsProtocolOffers,
60
+ sanitizeForTextMode,
61
+ selectSubprotocol,
57
62
  toSnapshot,
63
+ wsFrameModeFor,
58
64
  } from '@serverless-ircd/irc-core';
59
- import { ConnectionActor } from '@serverless-ircd/irc-server';
65
+ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
60
66
  import { makeCfRuntime } from './cf-runtime.js';
61
67
  import type { CfConnectionHandlers } from './cf-runtime.js';
68
+ import { loadServerConfigFromCfEnv } from './config-loader.js';
62
69
  import { resolveAccountStore } from './d1-account-store.js';
63
70
  import type { Env } from './env.js';
64
71
  import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
65
72
  import type { PersistedConnectionState } from './serialize.js';
73
+ import { CfStats } from './stats.js';
66
74
 
67
75
  /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
68
76
  export const DEFAULT_PING_INTERVAL_MS = 60_000;
69
77
  /** Default no-PONG disconnect threshold (ms). */
70
78
  export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
71
79
 
72
- /** Server config defaults; production deployments override via env. */
73
- const DEFAULT_SERVER_CONFIG: ServerConfig = {
74
- serverName: 'irc.example.com',
75
- networkName: 'ServerlessIRCd',
76
- maxChannelsPerUser: 30,
77
- maxTargetsPerCommand: 10,
78
- maxListEntries: 50,
79
- nickLen: 30,
80
- channelLen: 50,
81
- topicLen: 390,
82
- quitMessage: 'Client Quit',
83
- };
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();
84
88
 
85
89
  /**
86
90
  * ConnectionDO instance.
@@ -91,6 +95,13 @@ const DEFAULT_SERVER_CONFIG: ServerConfig = {
91
95
  export class ConnectionDO extends DurableObject<Env> {
92
96
  /** Cached connection state; reloaded from storage after hibernation. */
93
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;
94
105
  /**
95
106
  * Per-instance channel-state cache. Reused across every WebSocket frame
96
107
  * the connection handles (within a single hibernation cycle) so that
@@ -150,15 +161,17 @@ export class ConnectionDO extends DurableObject<Env> {
150
161
  private readonly ids: IdFactory = new UuidIdFactory();
151
162
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
152
163
  private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
153
- // Outbound lines are batched per call, not via a reserved instance
154
- // field: `deliver()` joins every line of a ChannelDO broadcast into a
155
- // single `WebSocket.send()`, and the actor `send` handler in
156
- // `buildActor` does the same for one frame's response. Cross-call
157
- // per-microtask coalescing (the optimization previously reserved here)
158
- // was dropped it would add a drain queue and ordering hazards between
159
- // independent fan-out calls for no measured benefit (see ADR-003 and
160
- // the CF adapter load-test report). Revisit only if WS round-trips
161
- // become a demonstrated bottleneck.
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.
162
175
 
163
176
  // -------------------------------------------------------------------------
164
177
  // WebSocket upgrade
@@ -180,12 +193,23 @@ export class ConnectionDO extends DurableObject<Env> {
180
193
  if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
181
194
  this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
182
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);
183
204
  // Hibernation: `acceptWebSocket` accepts the server side implicitly.
184
- // Calling `server.accept()` first throws "already accepted".
185
- 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}`]);
186
208
  // Schedule the first PING sweep.
187
209
  await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
188
- 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 });
189
213
  }
190
214
 
191
215
  // -------------------------------------------------------------------------
@@ -194,12 +218,33 @@ export class ConnectionDO extends DurableObject<Env> {
194
218
 
195
219
  /** Frame → actor → dispatch → persist. */
196
220
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
197
- 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;
198
243
  const state = await this.loadState();
199
244
  const beforeNick = state.nick;
200
245
 
201
246
  await this.loadAccountStore();
202
- const actor = this.buildActor(ws, state);
247
+ const actor = this.buildActor(ws, state, mode);
203
248
  try {
204
249
  await actor.receiveTextFrame(text);
205
250
  } finally {
@@ -242,7 +287,7 @@ export class ConnectionDO extends DurableObject<Env> {
242
287
  * been idle past the PONG timeout, closes it.
243
288
  *
244
289
  * PLAN §6.1 — DO alarms are the canonical PING/idle mechanism on CF;
245
- * equivalent to EventBridge Scheduler on AWS (042).
290
+ * equivalent to EventBridge Scheduler on AWS.
246
291
  */
247
292
  override async alarm(): Promise<void> {
248
293
  const state = await this.loadState();
@@ -260,7 +305,8 @@ export class ConnectionDO extends DurableObject<Env> {
260
305
  // Send PING. PONG replies update lastSeen via the actor.
261
306
  const token = this.ids.nonce();
262
307
  for (const ws of this.ctx.getWebSockets()) {
263
- (ws as WebSocket).send(`PING :${token}\r\n`);
308
+ const socket = ws as WebSocket;
309
+ this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
264
310
  }
265
311
  await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
266
312
  }
@@ -291,14 +337,11 @@ export class ConnectionDO extends DurableObject<Env> {
291
337
  */
292
338
  async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
293
339
  let delivered = 0;
294
- let text: string | undefined;
295
- if (lines.length > 0) {
296
- text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
297
- }
298
340
  for (const ws of this.ctx.getWebSockets()) {
299
341
  const socket = ws as WebSocket;
300
342
  if (socket.readyState !== WebSocket.OPEN) continue;
301
- if (text !== undefined) socket.send(text);
343
+ const mode = this.wsFrameMode(socket);
344
+ this.sendOutbound(socket, lines, mode);
302
345
  delivered++;
303
346
  }
304
347
  return { delivered };
@@ -308,6 +351,44 @@ export class ConnectionDO extends DurableObject<Env> {
308
351
  // Internal helpers
309
352
  // -------------------------------------------------------------------------
310
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
+
311
392
  /** Loads the connection state from storage, caching for the event. */
312
393
  private async loadState(): Promise<ConnectionState> {
313
394
  if (this.cached !== undefined) return this.cached;
@@ -330,27 +411,28 @@ export class ConnectionDO extends DurableObject<Env> {
330
411
  }
331
412
 
332
413
  /**
333
- * 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).
334
419
  */
335
- private buildActor(ws: WebSocket, state: ConnectionState): ConnectionActor {
420
+ private buildActor(ws: WebSocket, state: ConnectionState, mode: WsFrameMode): ConnectionActor {
336
421
  const serverConfig = this.serverConfig();
337
422
  const motd = this.motdProvider();
338
423
  const handlers = {
339
424
  send: (lines: RawLine[]): void => {
340
- if (ws.readyState === WebSocket.OPEN) {
341
- const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
342
- ws.send(text);
343
- }
425
+ this.sendOutbound(ws, lines, mode);
344
426
  },
345
427
  disconnect: (reason?: string): void => {
346
- if (reason !== undefined && ws.readyState === WebSocket.OPEN) {
347
- ws.send(`ERROR :Closing link: (${reason})\r\n`);
428
+ if (reason !== undefined) {
429
+ this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
348
430
  }
349
431
  ws.close();
350
432
  },
351
433
  snapshot: (): ConnectionState | undefined => this.cached,
352
434
  };
353
- // The CfRuntime shards the registry by nick (034); the per-connection
435
+ // The CfRuntime shards the registry by nick; the per-connection
354
436
  // key passed here is ignored by `makeCfRuntime` (kept in the signature
355
437
  // for call-site stability).
356
438
  const registryKey = this.ctx.id.toString();
@@ -372,11 +454,21 @@ export class ConnectionDO extends DurableObject<Env> {
372
454
  runtime,
373
455
  channels: this.channelAccess,
374
456
  serverConfig,
457
+ configSource: 'KV',
375
458
  clock: this.clock,
376
459
  ids: this.ids,
377
460
  motd,
378
461
  messages: this.messageStore(),
379
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),
469
+ // Cloudflare terminates TLS at the edge before the WebSocket reaches
470
+ // the DO, so every WS connection is secure → user mode `S`.
471
+ secure: true,
380
472
  ...(accounts !== undefined ? { accounts } : {}),
381
473
  ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
382
474
  logger,
@@ -387,7 +479,7 @@ export class ConnectionDO extends DurableObject<Env> {
387
479
  private async tearDown(ws: WebSocket): Promise<void> {
388
480
  const state = await this.loadState();
389
481
  await this.loadAccountStore();
390
- const actor = this.buildActor(ws, state);
482
+ const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
391
483
  // Drive QUIT through the actor so the same effect pipeline handles
392
484
  // fanout as during normal operation.
393
485
  await actor.receiveTextFrame('QUIT\r\n');
@@ -415,13 +507,17 @@ export class ConnectionDO extends DurableObject<Env> {
415
507
  }
416
508
 
417
509
  private serverConfig(): ServerConfig {
418
- return {
419
- ...DEFAULT_SERVER_CONFIG,
420
- ...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
421
- ...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
422
- ...(this.env.SERVER_VERSION !== undefined ? { serverVersion: this.env.SERVER_VERSION } : {}),
423
- ...(this.env.CREATED_AT !== undefined ? { createdAt: this.env.CREATED_AT } : {}),
424
- };
510
+ // Single source of truth: delegate to the schema-validated loader so
511
+ // every reducer-facing field (operCreds, serverPassword, motdLines,
512
+ // limit knobs, …) is populated from `this.env` exactly as the shared
513
+ // `ServerConfigSchema` prescribes. The previous hand-rolled literal
514
+ // silently dropped fields like operCreds, which left OPER unable to
515
+ // authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
516
+ // were bound. See config-loader.ts and config.ts.
517
+ if (this.cachedServerConfig === undefined) {
518
+ this.cachedServerConfig = loadServerConfigFromCfEnv(this.env);
519
+ }
520
+ return this.cachedServerConfig;
425
521
  }
426
522
 
427
523
  private motdProvider(): MotdProvider {
@@ -522,6 +618,7 @@ export class ConnectionDO extends DurableObject<Env> {
522
618
  runtime,
523
619
  channels: this.channelAccess,
524
620
  serverConfig: this.serverConfig(),
621
+ configSource: 'KV',
525
622
  clock: this.clock,
526
623
  ids: this.ids,
527
624
  motd: this.motdProvider(),
@@ -724,3 +821,37 @@ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCreden
724
821
  }
725
822
  return out;
726
823
  }
824
+
825
+ /**
826
+ * Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
827
+ * encodes the negotiated {@link WsFrameMode} so it survives DO eviction and
828
+ * can be recovered in every WebSocket event handler via `ctx.getTags(ws)`.
829
+ */
830
+ const WS_TAG_LEGACY = 'ws:legacy';
831
+ const WS_TAG_SPEC_TEXT = 'ws:spec-text';
832
+ const WS_TAG_SPEC_BINARY = 'ws:spec-binary';
833
+
834
+ /**
835
+ * Recovers the {@link WsFrameMode} from a hibernated socket's tags.
836
+ * Defaults to the legacy-tolerant mode when no recognized tag is present.
837
+ */
838
+ export function wsFrameModeFromTags(tags: readonly string[]): WsFrameMode {
839
+ for (const tag of tags) {
840
+ if (tag === WS_TAG_SPEC_TEXT) return 'spec-text';
841
+ if (tag === WS_TAG_SPEC_BINARY) return 'spec-binary';
842
+ if (tag === WS_TAG_LEGACY) return 'legacy';
843
+ }
844
+ return 'legacy';
845
+ }
846
+
847
+ /**
848
+ * Builds the inbound line-framing {@link Transport} for `mode`. Spec modes
849
+ * treat one WebSocket message as exactly one IRC line (a single trailing
850
+ * CR-LF is stripped and the frame is never split); legacy mode splits the
851
+ * frame on `\r\n` so older clients that concatenate messages keep working.
852
+ */
853
+ export function makeWsFrameTransport(mode: WsFrameMode): Transport {
854
+ return {
855
+ feed: (chunk: string): string[] => frameToLines(chunk, mode),
856
+ };
857
+ }
@@ -9,9 +9,9 @@
9
9
  /**
10
10
  * The production {@link Env} for `apps/cf-worker`. ConnectionDO uses the
11
11
  * four DO namespaces below to coordinate with the registry, channel, and
12
- * channel-registry authorities. The CfRuntime (036) wires them together
12
+ * channel-registry authorities. The CfRuntime wires them together
13
13
  * behind the platform-agnostic {@link IrcRuntime} port; ConnectionDO
14
- * (033) only needs `CONNECTION_DO` itself plus the three collaborator
14
+ * only needs `CONNECTION_DO` itself plus the three collaborator
15
15
  * namespaces.
16
16
  *
17
17
  * RPC method shapes on {@link RegistryRpc} and {@link ChannelRpc} are
@@ -23,7 +23,7 @@ export interface Env {
23
23
  CONNECTION_DO: DurableObjectNamespace;
24
24
  /**
25
25
  * Nick registry authority. RPC: reserveNick / changeNick / releaseNick /
26
- * lookupNick. Backed by the sharded {@link RegistryDO} (034), which keys
26
+ * lookupNick. Backed by the sharded {@link RegistryDO}, which keys
27
27
  * instances by `hash(nick) % N` so the single-threaded DO gives the nick
28
28
  * uniqueness invariant for free.
29
29
  *
@@ -34,7 +34,7 @@ export interface Env {
34
34
  /**
35
35
  * Channel authority, keyed by lowercased channel name. RPC: broadcast /
36
36
  * applyChannelDelta / getChannelSnapshot / listMembers. Backed by the
37
- * {@link ChannelDO} (035), which owns the authoritative roster, modes,
37
+ * {@link ChannelDO}, which owns the authoritative roster, modes,
38
38
  * topic, and ban list and fans out broadcasts to each member's
39
39
  * ConnectionDO.
40
40
  */
@@ -77,22 +77,41 @@ export interface Env {
77
77
  * end-to-end. Empty/undefined disables SASL account verification.
78
78
  */
79
79
  SASL_ACCOUNTS?: string;
80
+ /**
81
+ * IRC operator name consulted by the `OPER` reducer. Pair with
82
+ * {@link OPER_PASSWORD}; when either is set the connection-do threads
83
+ * both into the reducer-facing `ServerConfig.operCreds` via
84
+ * `loadServerConfigFromCfEnv`.
85
+ */
86
+ OPER_USER?: string;
87
+ /**
88
+ * IRC operator password paired with {@link OPER_USER}. Treat as a
89
+ * Cloudflare secret (never a plaintext `[vars]` entry) in production.
90
+ */
91
+ OPER_PASSWORD?: string;
80
92
  }
81
93
 
82
94
  /**
83
95
  * RPC contract ConnectionDO expects from the registry. The real
84
- * RegistryDO (034) and the test stub both implement this.
96
+ * RegistryDO and the test stub both implement this.
85
97
  */
86
98
  export interface RegistryRpc {
87
99
  reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
88
100
  changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
89
101
  releaseNick(nick: string): Promise<void>;
90
102
  lookupNick(nick: string): Promise<string | null>;
103
+ /**
104
+ * Enumerates every `(nickLower, connectionId)` entry in this shard. Used
105
+ * by global fan-out paths (e.g. `WALLOPS`) that need to reach connections
106
+ * regardless of shared channel membership. Returns a plain array so the
107
+ * RPC marshals cleanly across the DO boundary.
108
+ */
109
+ listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
91
110
  }
92
111
 
93
112
  /**
94
113
  * RPC contract ConnectionDO expects from each channel instance. The real
95
- * ChannelDO (035) and the test stub both implement this.
114
+ * ChannelDO and the test stub both implement this.
96
115
  *
97
116
  * Note: `broadcast` accepts already-formatted raw line texts (no
98
117
  * `RawLine` wrapper) because that's what `cf-runtime.broadcast`
@@ -23,6 +23,8 @@ export {
23
23
  } from './sharding.js';
24
24
  export { makeCfRuntime } from './cf-runtime.js';
25
25
  export type { CfConnectionHandlers } from './cf-runtime.js';
26
+ export { CfStats } from './stats.js';
27
+ export type { CfStatsRuntime } from './stats.js';
26
28
  export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
27
29
  export {
28
30
  CREATE_ACCOUNTS_TABLE_SQL,
@@ -23,8 +23,8 @@
23
23
  * and the second sees the new owner and fails.
24
24
  *
25
25
  * RPC surface — implements {@link RegistryRpc}, the same shape the
26
- * stub `RecordingRegistryDO` exposed for 033. Consumers
27
- * (`CfRuntime`, 036) call these methods via `env.REGISTRY_DO`
26
+ * stub `RecordingRegistryDO` exposed for the connection tests. Consumers
27
+ * (`CfRuntime`) call these methods via `env.REGISTRY_DO`
28
28
  * after computing the shard key with {@link registryKeyForNick}.
29
29
  */
30
30
 
@@ -85,7 +85,7 @@ export class RegistryDO extends DurableObject implements RegistryRpc {
85
85
  * `oldNick` after this conn abandoned it (defence in depth).
86
86
  *
87
87
  * Cross-shard changes (old and new hash to different shards) are
88
- * orchestrated by `CfRuntime` in 036 as a two-phase
88
+ * orchestrated by `CfRuntime` as a two-phase
89
89
  * reserve-new-then-release-old. This DO only sees single-shard
90
90
  * changes and crash-recovery paths (where `oldNick` may not be
91
91
  * present); the latter is why the conditional delete is a soft check.
@@ -127,4 +127,23 @@ export class RegistryDO extends DurableObject implements RegistryRpc {
127
127
  const owner = await this.ctx.storage.get<string>(nickKey(nick.toLowerCase()));
128
128
  return owner ?? null;
129
129
  }
130
+
131
+ /**
132
+ * Enumerates every `(nickLower, connectionId)` pair in this shard.
133
+ * Backs the global WALLOPS fan-out: the caller scans every shard, collects
134
+ * all connection ids, and delivers to those carrying the `+w` mode. The
135
+ * walk is a single `list` over the `nick:` prefix; cost is proportional to
136
+ * the slice's live nick count (sharded, so bounded per DO).
137
+ */
138
+ async listEntries(): Promise<Array<[nickLower: string, connId: string]>> {
139
+ const out: Array<[nickLower: string, connId: string]> = [];
140
+ // `storage.list` resolves to a Map of `key → value` for the prefix.
141
+ // Each key is `nick:<lower>`; we strip the prefix to recover the nick.
142
+ const entries = await this.ctx.storage.list<string>({ prefix: NICK_KEY_PREFIX });
143
+ for (const [key, connId] of entries) {
144
+ const nickLower = key.slice(NICK_KEY_PREFIX.length);
145
+ out.push([nickLower, connId]);
146
+ }
147
+ return out;
148
+ }
130
149
  }
@@ -34,6 +34,13 @@ export interface PersistedConnectionState {
34
34
  id: string;
35
35
  registration: RegistrationState;
36
36
  capNegotiating: boolean;
37
+ /**
38
+ * Transport TLS fact (user mode `S`). Optional on the persisted shape so
39
+ * records written before the field shipped (schema is otherwise
40
+ * unchanged) deserialize with a `false` default via the coalescing in
41
+ * {@link deserialize}. Always written by {@link serialize}.
42
+ */
43
+ secure?: boolean;
37
44
  caps: string[];
38
45
  joinedChannels: ChanName[];
39
46
  userModes: UserModes;
@@ -61,6 +68,7 @@ export function serialize(state: ConnectionState): PersistedConnectionState {
61
68
  id: state.id,
62
69
  registration: state.registration,
63
70
  capNegotiating: state.capNegotiating,
71
+ secure: state.secure,
64
72
  caps: [...state.caps],
65
73
  joinedChannels: [...state.joinedChannels],
66
74
  userModes: { ...state.userModes },
@@ -88,6 +96,9 @@ export function deserialize(record: unknown): ConnectionState {
88
96
  id: migrated.id,
89
97
  registration: migrated.registration,
90
98
  capNegotiating: migrated.capNegotiating,
99
+ // `secure` is absent on records persisted before user mode `S` shipped;
100
+ // `migrate` backfills it to false so the cast below is truthful.
101
+ secure: migrated.secure ?? false,
91
102
  caps: new Set<string>(migrated.caps),
92
103
  joinedChannels: new Set<ChanName>(migrated.joinedChannels),
93
104
  userModes: { ...migrated.userModes },
@@ -119,8 +130,19 @@ export function migrate(record: unknown): PersistedConnectionState {
119
130
  if (!isPlainObject(record)) {
120
131
  throw new Error('persisted connection state is not an object');
121
132
  }
133
+ // Backfill additive fields introduced by user mode `S` (TLS connected):
134
+ // `secure` and `userModes.tls`. Both default to false (plain transport),
135
+ // preserving the pre-feature behaviour for records persisted before the
136
+ // mode shipped. The schema version is unchanged because the additions are
137
+ // purely additive with safe defaults — no row transformation is needed
138
+ // beyond coalescing the missing keys.
139
+ if (record.secure === undefined) record.secure = false;
140
+ if (isPlainObject(record.userModes)) {
141
+ const um = record.userModes as Record<string, unknown>;
142
+ if (um.tls === undefined) um.tls = false;
143
+ }
122
144
  const version = typeof record.version === 'number' ? record.version : 0;
123
- // No migrations yet — v1 is the initial schema. Future work adds steps:
145
+ // No versioned migrations yet — v1 is the initial schema. Future work:
124
146
  // if (version < 2) record = migrate_v1_v2(record);
125
147
  // if (version < 3) record = migrate_v2_v3(record);
126
148
  if (version > PERSISTED_STATE_VERSION) {
@@ -131,9 +153,8 @@ export function migrate(record: unknown): PersistedConnectionState {
131
153
  }
132
154
  // The cast below is safe: we accept any JSON shape the storage layer
133
155
  // returns; production code wrote it via `serialize`, tests wrote it
134
- // through the same path. If a future field is missing, the
135
- // `exactOptionalPropertyTypes` discipline in `serialize` keeps
136
- // undefined keys out of the persisted record entirely.
156
+ // through the same path. Backfilling above keeps the additive
157
+ // `secure` / `userModes.tls` keys present even on pre-feature records.
137
158
  return record as unknown as PersistedConnectionState;
138
159
  }
139
160
 
@@ -22,8 +22,7 @@
22
22
  * `N` defaults to {@link DEFAULT_REGISTRY_SHARDS} (32) and is
23
23
  * configurable per deployment via the worker env. Changing N after
24
24
  * production traffic requires a migration (re-sharding every existing
25
- * nick→conn mapping); document this in `docs/deployment-cf.md`
26
- * (038).
25
+ * nick→conn mapping); document this in `docs/deployment-cf.md`.
27
26
  */
28
27
 
29
28
  /**
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cloudflare Workers-flavoured {@link ServerStats} backend.
3
+ *
4
+ * Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
5
+ * by fanning out across the existing CF RPC surface: `listChannels` (via
6
+ * the {@link ChannelRegistryDO}) plus per-channel `listMembers` and
7
+ * per-member `getConnState`. Classification (oper / invisible / unknown)
8
+ * is delegated to the shared {@link computeStatsSnapshot} helper in
9
+ * irc-core so the classification rules live in exactly one place.
10
+ *
11
+ * **Limitation:** the CF runtime does not currently expose an
12
+ * "enumerate all connections" RPC, so a connection that has not joined
13
+ * any channel is not counted. This matches the ticket's "aggregate via
14
+ * listChannels / connection-enumeration RPCs" guidance; a future
15
+ * dedicated stats DO would close the gap (every connection would be
16
+ * counted regardless of channel membership).
17
+ *
18
+ * Constructed per `LUSERS` / `STATS` invocation alongside the
19
+ * {@link CfRuntime}; cheap to build, no caching. `uptimeStartedAt` is
20
+ * supplied by the caller (typically the worker's startup timestamp).
21
+ */
22
+
23
+ import type {
24
+ ChanName,
25
+ ConnId,
26
+ ConnectionState,
27
+ ServerStatsSnapshot,
28
+ } from '@serverless-ircd/irc-core';
29
+ import { computeStatsSnapshot } from '@serverless-ircd/irc-core';
30
+
31
+ /**
32
+ * The minimal slice of {@link CfRuntime} (or any adapter runtime) that
33
+ * {@link CfStats} needs. Defined structurally so unit tests can pass a
34
+ * stub without spinning up workerd / miniflare.
35
+ */
36
+ export interface CfStatsRuntime {
37
+ listChannels(): Promise<ReadonlyArray<{ nameLower: string }>>;
38
+ getChannelConnections(name: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
39
+ }
40
+
41
+ export class CfStats {
42
+ private readonly runtime: CfStatsRuntime;
43
+ private readonly uptimeStartedAt: number;
44
+
45
+ constructor(runtime: CfStatsRuntime, uptimeStartedAt: number) {
46
+ this.runtime = runtime;
47
+ this.uptimeStartedAt = uptimeStartedAt;
48
+ }
49
+
50
+ async getStats(): Promise<ServerStatsSnapshot> {
51
+ const channels = await this.runtime.listChannels();
52
+ // Fan out: gather every channel's members into one deduplicated map.
53
+ // A user in N channels is counted once.
54
+ const unique = new Map<ConnId, ConnectionState>();
55
+ await Promise.all(
56
+ channels.map(async (chan) => {
57
+ const members = await this.runtime.getChannelConnections(chan.nameLower as ChanName);
58
+ for (const [id, state] of members) {
59
+ unique.set(id, state);
60
+ }
61
+ }),
62
+ );
63
+ return computeStatsSnapshot(unique.values(), channels.length, this.uptimeStartedAt);
64
+ }
65
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * CF-side {@link IrcHarnessFactory} — registers the parametrized IRC
3
- * scenario suite (032) against the Cloudflare adapter.
3
+ * scenario suite from `@serverless-ircd/irc-test-support` against the Cloudflare adapter.
4
4
  *
5
5
  * Each {@link CfHarness} instance is hermetic at the vitest-pool-workers
6
6
  * level: every test gets an isolated storage namespace