serverless-ircd 0.4.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 (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -35,9 +35,12 @@ import {
35
35
  type Nick,
36
36
  type RawLine,
37
37
  type RosterEntry,
38
+ type ServerConfig,
38
39
  toSnapshot,
39
40
  } from '@serverless-ircd/irc-core';
40
41
  import type { IrcRuntime } from '@serverless-ircd/irc-server';
42
+ import type { CfConfigEnv } from './config-loader.js';
43
+ import { loadServerConfigFromCfEnv } from './config-loader.js';
41
44
  import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
42
45
  import type { PersistedConnectionState } from './serialize.js';
43
46
  import { deserialize } from './serialize.js';
@@ -144,6 +147,48 @@ export class CfRuntime implements IrcRuntime {
144
147
  }
145
148
  }
146
149
 
150
+ /**
151
+ * Global cross-connection fan-out for `WALLOPS`. Enumerates every registry
152
+ * shard to collect all live connection ids, fetches each connection's
153
+ * state to honour the `+w` recipient gate, and delivers via the
154
+ * ConnectionDO `deliver` RPC. `except` (the originator) is skipped, as is
155
+ * the bound connection's own live state shortcut.
156
+ *
157
+ * Cost: this is N shard RPCs + one `getConnState` per online connection +
158
+ * one `deliver` per `+w` recipient. At very high connection counts the
159
+ * per-WALLOPS cost grows linearly with the fleet; deployments should bound
160
+ * the oper's WALLOPS rate (an oper flooding WALLOPS is a self-DoS). The
161
+ * shard walk is parallelised via `Promise.all` so wall-clock stays near one
162
+ * round-trip for the enumeration phase.
163
+ */
164
+ async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
165
+ // Phase 1 — enumerate every connId across all registry shards.
166
+ const shardEntries = await Promise.all(
167
+ Array.from({ length: this.registryShards }, (_, i) => this.registryShardRpc(i).listEntries()),
168
+ );
169
+ const seen = new Set<ConnId>();
170
+ for (const entries of shardEntries) {
171
+ for (const [, connId] of entries) {
172
+ seen.add(connId);
173
+ }
174
+ }
175
+
176
+ // Phase 2 — fetch each candidate's state, filter +w, deliver.
177
+ // Bound the per-call fan-out by skipping the excepted/bound connection
178
+ // up front (the bound connection's own +w never needs a self-RPC).
179
+ await Promise.all(
180
+ Array.from(seen, async (connId) => {
181
+ if (connId === except || connId === this.connId) return;
182
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(connId));
183
+ const dto = await (stub as unknown as ConnectionRpc).getConnState();
184
+ if (dto === null) return;
185
+ const state = deserialize(dto);
186
+ if (!state.userModes.wallops) return;
187
+ await (stub as unknown as ConnectionRpc).deliver(lines);
188
+ }),
189
+ );
190
+ }
191
+
147
192
  // -------------------------------------------------------------------------
148
193
  // Nick registry
149
194
  // -------------------------------------------------------------------------
@@ -265,6 +310,16 @@ export class CfRuntime implements IrcRuntime {
265
310
  return snapshots.filter((s): s is ChanSnapshot => s !== null);
266
311
  }
267
312
 
313
+ /**
314
+ * Re-reads the bound Workers env (vars + secrets, the config source for
315
+ * Cloudflare deployments) and reparses it through the shared schema. The
316
+ * actor swaps its live config with the result; a rejection leaves the
317
+ * previous config in effect.
318
+ */
319
+ async reloadConfig(): Promise<ServerConfig> {
320
+ return loadServerConfigFromCfEnv(this.env as unknown as CfConfigEnv);
321
+ }
322
+
268
323
  // -------------------------------------------------------------------------
269
324
  // Internal
270
325
  // -------------------------------------------------------------------------
@@ -281,6 +336,16 @@ export class CfRuntime implements IrcRuntime {
281
336
  return stub as unknown as RegistryRpc;
282
337
  }
283
338
 
339
+ /**
340
+ * Returns the registry DO RPC stub for shard index `i` (`0 <= i < shards`).
341
+ * Used by global fan-out paths (WALLOPS) that must enumerate every shard
342
+ * rather than address the one owning a specific nick.
343
+ */
344
+ private registryShardRpc(i: number): RegistryRpc {
345
+ const stub = this.env.REGISTRY_DO.get(this.env.REGISTRY_DO.idFromName(`s${i}`));
346
+ return stub as unknown as RegistryRpc;
347
+ }
348
+
284
349
  /** Returns the channel DO RPC stub for `name` (keyed by lowercased name). */
285
350
  private channelRpc(name: ChanName): {
286
351
  broadcast(lines: unknown[], except?: string): Promise<void>;
@@ -319,7 +384,7 @@ export function makeCfRuntime(
319
384
  handlers: CfConnectionHandlers,
320
385
  ): IrcRuntime {
321
386
  // `_registryKey` is ignored — the production CfRuntime always shards the
322
- // registry by nick (the prior per-conn key was a 033 scaffolding
387
+ // registry by nick (the prior per-conn key was a scaffolding
323
388
  // convention that the real RegistryDO does not use).
324
389
  void _registryKey;
325
390
  return new CfRuntime(env, connId, handlers);
@@ -30,7 +30,7 @@
30
30
  * RPC return-type note: snapshot reads return the JSON-friendly
31
31
  * {@link ChannelSnapshotDto} (members/banMasks/pendingInvites as
32
32
  * arrays) so they survive the DO RPC boundary without relying on
33
- * structured-clone of `Map`/`Set`. The CfRuntime (036)
33
+ * structured-clone of `Map`/`Set`. The CfRuntime
34
34
  * rehydrates these into a full {@link ChanSnapshot} at the call site.
35
35
  */
36
36
 
@@ -89,7 +89,7 @@ export interface ConnectionDeliveryRpc {
89
89
  * JSON-friendly snapshot DTO returned by `getChannelSnapshot`. Maps and
90
90
  * Sets are flattened to arrays so the value survives the DO RPC
91
91
  * boundary in both production and the test pool. The CfRuntime
92
- * (036) rehydrates this into a {@link ChanSnapshot} at the
92
+ * rehydrates this into a {@link ChanSnapshot} at the
93
93
  * call site.
94
94
  */
95
95
  export interface ChannelSnapshotDto {
@@ -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,18 @@ 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),
380
469
  ...(accounts !== undefined ? { accounts } : {}),
381
470
  ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
382
471
  logger,
@@ -387,7 +476,7 @@ export class ConnectionDO extends DurableObject<Env> {
387
476
  private async tearDown(ws: WebSocket): Promise<void> {
388
477
  const state = await this.loadState();
389
478
  await this.loadAccountStore();
390
- const actor = this.buildActor(ws, state);
479
+ const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
391
480
  // Drive QUIT through the actor so the same effect pipeline handles
392
481
  // fanout as during normal operation.
393
482
  await actor.receiveTextFrame('QUIT\r\n');
@@ -415,13 +504,17 @@ export class ConnectionDO extends DurableObject<Env> {
415
504
  }
416
505
 
417
506
  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
- };
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;
425
518
  }
426
519
 
427
520
  private motdProvider(): MotdProvider {
@@ -522,6 +615,7 @@ export class ConnectionDO extends DurableObject<Env> {
522
615
  runtime,
523
616
  channels: this.channelAccess,
524
617
  serverConfig: this.serverConfig(),
618
+ configSource: 'KV',
525
619
  clock: this.clock,
526
620
  ids: this.ids,
527
621
  motd: this.motdProvider(),
@@ -724,3 +818,37 @@ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCreden
724
818
  }
725
819
  return out;
726
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
+ }
@@ -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
  }
@@ -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
  /**