serverless-ircd 0.1.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 (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Cloudflare {@link IrcRuntime} adapter.
3
+ *
4
+ * Wires the three Durable Object namespaces (ConnectionDO, RegistryDO,
5
+ * ChannelDO) together behind a single {@link IrcRuntime} surface. Each
6
+ * method issues the correct DO `stub()` call:
7
+ *
8
+ * - Nick registry ops route to `REGISTRY_DO` sharded by
9
+ * {@link registryKeyForNick} so two clients racing on the same nick
10
+ * always land on the same shard.
11
+ * - Channel ops route to `CHANNEL_DO` keyed by lowercased channel name.
12
+ * - Cross-connection `send` / `getConnectionInfo` route to the target
13
+ * connection's `ConnectionDO` via RPC (`deliver`, `getConnSnapshot`).
14
+ * - Calls whose target *is* the bound connection shortcut through the
15
+ * injected {@link CfConnectionHandlers} — no RPC-to-self.
16
+ *
17
+ * The class is constructed per `ConnectionDO.webSocketMessage` event:
18
+ *
19
+ * ```ts
20
+ * const runtime = new CfRuntime(this.env, state.id, handlers);
21
+ * ```
22
+ *
23
+ * `state.id` is the DO's hex id (`ctx.id.toString()`), which is the same
24
+ * string other connections see when they look up a nick in the registry
25
+ * — so cross-connection routing is symmetric.
26
+ */
27
+
28
+ import {
29
+ type ChanName,
30
+ type ChanSnapshot,
31
+ type ChannelDelta,
32
+ type ConnId,
33
+ type ConnSnapshot,
34
+ type ConnectionState,
35
+ type Nick,
36
+ type RawLine,
37
+ type RosterEntry,
38
+ toSnapshot,
39
+ } from '@serverless-ircd/irc-core';
40
+ import type { IrcRuntime } from '@serverless-ircd/irc-server';
41
+ import type { Env, RegistryRpc } from './env.js';
42
+ import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
43
+
44
+ /** Per-connection transport callbacks the ConnectionDO wires up. */
45
+ export interface CfConnectionHandlers {
46
+ /** Outbound IRC lines (already framed by the caller) written to the WS. */
47
+ send: (lines: RawLine[]) => void;
48
+ /** Close the underlying transport. */
49
+ disconnect: (reason?: string) => void;
50
+ /** Snapshot of the live ConnectionState, used by getConnectionInfo. */
51
+ snapshot(): ConnectionState | undefined;
52
+ }
53
+
54
+ /**
55
+ * RPC contract the {@link CfRuntime} expects on every `ConnectionDO`
56
+ * instance. ConnectionDO implements this in addition to its WebSocket
57
+ * handlers so that *other* connections' runtimes can route `Send` and
58
+ * `GetConnectionInfo` effects to it. The shape matches the
59
+ * {@link ConnectionDeliveryRpc} the ChannelDO uses, so a single RPC
60
+ * method (`deliver`) covers both fanout and point-to-point send paths.
61
+ */
62
+ export interface ConnectionRpc {
63
+ /** Delivers `lines` to the live (or hibernated) WebSocket. */
64
+ deliver(lines: RawLine[]): Promise<{ delivered: number }>;
65
+ /** Returns a serializable snapshot of the connection state. */
66
+ getConnSnapshot(): Promise<ConnSnapshot | null>;
67
+ }
68
+
69
+ /**
70
+ * Cloudflare {@link IrcRuntime} bound to one connection.
71
+ *
72
+ * Construct one per inbound frame: cheap to build, no caching across
73
+ * events (every call hits the authoritative DO so state can never drift
74
+ * between the runtime and the DOs).
75
+ */
76
+ export class CfRuntime implements IrcRuntime {
77
+ private readonly env: Env;
78
+ private readonly connId: ConnId;
79
+ private readonly handlers: CfConnectionHandlers;
80
+ private readonly registryShards: number;
81
+
82
+ constructor(
83
+ env: Env,
84
+ connId: ConnId,
85
+ handlers: CfConnectionHandlers,
86
+ opts: { registryShards?: number } = {},
87
+ ) {
88
+ this.env = env;
89
+ this.connId = connId;
90
+ this.handlers = handlers;
91
+ this.registryShards = opts.registryShards ?? DEFAULT_REGISTRY_SHARDS;
92
+ }
93
+
94
+ // -------------------------------------------------------------------------
95
+ // Transport
96
+ // -------------------------------------------------------------------------
97
+
98
+ async send(to: ConnId, lines: RawLine[]): Promise<void> {
99
+ if (to === this.connId) {
100
+ this.handlers.send(lines);
101
+ return;
102
+ }
103
+ // Cross-connection: route via the target's ConnectionDO RPC. The DO
104
+ // id (hex string) is exactly what the registry stores as the conn
105
+ // id, so `idFromString` resolves it directly.
106
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(to));
107
+ await (stub as unknown as ConnectionRpc).deliver(lines);
108
+ }
109
+
110
+ async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
111
+ const payload = lines.map((l) => l.text);
112
+ await this.channelRpc(chan).broadcast(payload, except);
113
+ }
114
+
115
+ async disconnect(conn: ConnId, reason?: string): Promise<void> {
116
+ if (conn === this.connId) {
117
+ this.handlers.disconnect(reason);
118
+ return;
119
+ }
120
+ // Cross-connection disconnect is a teardown signal; we route it via
121
+ // the target's ConnectionDO RPC as well. (ConnectionDO doesn't expose
122
+ // a dedicated `disconnect` RPC today — the canonical teardown path is
123
+ // the WebSocket close, which is owned by the connection itself. We
124
+ // therefore no-op for cross-connection disconnects; this preserves
125
+ // the IrcRuntime contract without inventing a new RPC.)
126
+ }
127
+
128
+ async sendToNick(
129
+ nick: Nick,
130
+ _sender: ConnId,
131
+ lines: RawLine[],
132
+ notFoundLines?: RawLine[],
133
+ ): Promise<void> {
134
+ const owner = await this.registryRpc(nick).lookupNick(nick);
135
+ if (owner !== null) {
136
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(owner));
137
+ await (stub as unknown as ConnectionRpc).deliver(lines);
138
+ return;
139
+ }
140
+ if (notFoundLines !== undefined) {
141
+ this.handlers.send(notFoundLines);
142
+ }
143
+ }
144
+
145
+ // -------------------------------------------------------------------------
146
+ // Nick registry
147
+ // -------------------------------------------------------------------------
148
+
149
+ async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
150
+ return this.registryRpc(nick).reserveNick(nick, conn);
151
+ }
152
+
153
+ async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
154
+ // Two-phase when the shards differ: reserve-new first; on success,
155
+ // release-old on the old shard. The new-nick shard wins the race;
156
+ // any concurrent contender that beats us to `newNick` makes the
157
+ // first phase fail and we leave `oldNick` untouched.
158
+ if (this.shardOf(oldNick) === this.shardOf(newNick)) {
159
+ return this.registryRpc(newNick).changeNick(conn, oldNick, newNick);
160
+ }
161
+ const reserved = await this.registryRpc(newNick).reserveNick(newNick, conn);
162
+ if (!reserved.ok) return false;
163
+ await this.registryRpc(oldNick).releaseNick(oldNick);
164
+ return true;
165
+ }
166
+
167
+ async releaseNick(nick: Nick): Promise<void> {
168
+ await this.registryRpc(nick).releaseNick(nick);
169
+ }
170
+
171
+ async lookupNick(nick: Nick): Promise<ConnId | null> {
172
+ return this.registryRpc(nick).lookupNick(nick);
173
+ }
174
+
175
+ // -------------------------------------------------------------------------
176
+ // Channel ownership
177
+ // -------------------------------------------------------------------------
178
+
179
+ async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
180
+ await this.channelRpc(chan).applyChannelDelta(delta);
181
+ }
182
+
183
+ async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
184
+ // ChannelDO returns a JSON-friendly DTO (members/banMasks/pendingInvites
185
+ // as arrays). Rehydrate into a proper ChanSnapshot so callers can treat
186
+ // `members` as a ReadonlyMap keyed by ConnId and `banMasks`/pendingInvites
187
+ // as Sets.
188
+ const dto = (await this.channelRpc(chan).getChannelSnapshot()) as {
189
+ name: ChanName;
190
+ nameLower: string;
191
+ modes: ChanSnapshot['modes'];
192
+ banMasks: string[];
193
+ topic?: ChanSnapshot['topic'];
194
+ members: Array<RosterEntry & { conn: ConnId }>;
195
+ pendingInvites: string[];
196
+ createdAt: number;
197
+ } | null;
198
+ if (dto === null) return null;
199
+ const members = new Map<ConnId, RosterEntry>();
200
+ for (const entry of dto.members) {
201
+ members.set(entry.conn, {
202
+ conn: entry.conn,
203
+ nick: entry.nick,
204
+ op: entry.op,
205
+ voice: entry.voice,
206
+ });
207
+ }
208
+ const snap: ChanSnapshot = {
209
+ name: dto.name,
210
+ nameLower: dto.nameLower,
211
+ modes: { ...dto.modes },
212
+ banMasks: new Set(dto.banMasks),
213
+ ...(dto.topic !== undefined ? { topic: dto.topic } : {}),
214
+ members,
215
+ pendingInvites: new Set(dto.pendingInvites),
216
+ createdAt: dto.createdAt,
217
+ };
218
+ return snap;
219
+ }
220
+
221
+ // -------------------------------------------------------------------------
222
+ // Lookups
223
+ // -------------------------------------------------------------------------
224
+
225
+ async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
226
+ if (conn === this.connId) {
227
+ const live = this.handlers.snapshot();
228
+ return live === undefined ? null : toSnapshot(live);
229
+ }
230
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
231
+ return (stub as unknown as ConnectionRpc).getConnSnapshot();
232
+ }
233
+
234
+ async getConnection(conn: ConnId): Promise<ConnectionState | null> {
235
+ // CF stores ConnectionState inside each ConnectionDO; the only
236
+ // authoritative view this runtime can hand out without a new RPC is
237
+ // the local connection's live state. Remote connections return null
238
+ // until a dedicated `getConnState` RPC lands alongside the broader
239
+ // WHOIS-over-CF work — the actor degrades to "unknown nick" for them.
240
+ if (conn === this.connId) {
241
+ return this.handlers.snapshot() ?? null;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ async getChannelConnections(_chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
247
+ // CF has no single source of truth for full ConnectionStates across
248
+ // all ConnectionDOs. The WHO command (the only current consumer)
249
+ // ships in a later ticket; return an empty map until then.
250
+ return new Map();
251
+ }
252
+
253
+ async listChannels(): Promise<ChanSnapshot[]> {
254
+ // CF has no central channel registry; each ChannelDO is independent.
255
+ // The LIST command (the only current consumer) ships in a later
256
+ // ticket; return an empty array until then.
257
+ return [];
258
+ }
259
+
260
+ // -------------------------------------------------------------------------
261
+ // Internal
262
+ // -------------------------------------------------------------------------
263
+
264
+ /** Shard index for a nick under the configured fleet size. */
265
+ private shardOf(nick: Nick): number {
266
+ return shardNick(nick, this.registryShards);
267
+ }
268
+
269
+ /** Returns the registry DO RPC stub for the shard owning `nick`. */
270
+ private registryRpc(nick: Nick): RegistryRpc {
271
+ const key = registryKeyForNick(nick, this.registryShards);
272
+ const stub = this.env.REGISTRY_DO.get(this.env.REGISTRY_DO.idFromName(key));
273
+ return stub as unknown as RegistryRpc;
274
+ }
275
+
276
+ /** Returns the channel DO RPC stub for `name` (keyed by lowercased name). */
277
+ private channelRpc(name: ChanName): {
278
+ broadcast(lines: unknown[], except?: string): Promise<void>;
279
+ applyChannelDelta(delta: unknown): Promise<void>;
280
+ getChannelSnapshot(): Promise<unknown>;
281
+ } {
282
+ const lower = name.toLowerCase();
283
+ const stub = this.env.CHANNEL_DO.get(this.env.CHANNEL_DO.idFromName(lower));
284
+ return stub as unknown as {
285
+ broadcast(lines: unknown[], except?: string): Promise<void>;
286
+ applyChannelDelta(delta: unknown): Promise<void>;
287
+ getChannelSnapshot(): Promise<unknown>;
288
+ };
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Legacy factory retained for backward compatibility with code that
294
+ * constructs the per-connection runtime inline (e.g. {@link ConnectionDO}'s
295
+ * own build path). Equivalent to `new CfRuntime(env, connId, handlers)`.
296
+ */
297
+ export function makeCfRuntime(
298
+ env: Env,
299
+ connId: ConnId,
300
+ _registryKey: string,
301
+ handlers: CfConnectionHandlers,
302
+ ): IrcRuntime {
303
+ // `_registryKey` is ignored — the production CfRuntime always shards the
304
+ // registry by nick (the prior per-conn key was a 033 scaffolding
305
+ // convention that the real RegistryDO does not use).
306
+ void _registryKey;
307
+ return new CfRuntime(env, connId, handlers);
308
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * `ChannelDO` — Cloudflare Durable Object owning one IRC channel's
3
+ * authoritative state: roster, modes, topic, ban list.
4
+ *
5
+ * PLAN §6.1 — one DO instance per lowercased channel name
6
+ * (`env.CHANNEL_DO.idFromName(chanLower)`). The DO is the single
7
+ * serialized authority for that channel's roster; concurrent JOINs,
8
+ * MODEs, and broadcasts are serialized by the DO's request loop, so
9
+ * invariants (unique membership, consistent ban list, atomic topic
10
+ * swaps) hold without external locking.
11
+ *
12
+ * State is persisted to {@link DurableObjectState["storage"]} so it
13
+ * survives eviction. The persisted shape is a JSON-friendly
14
+ * {@link PersistedChannelState}; `Set`s and `Map`s are rebuilt on
15
+ * load (mirrors the trick used by `ConnectionDO`'s serialize module).
16
+ *
17
+ * Fanout model:
18
+ * - `broadcast(lines, except)` walks the live roster and calls each
19
+ * member's `ConnectionDO.deliver(lines)` via service-binding RPC.
20
+ * - `deliver` returns `{ delivered: number }`; zero means the member
21
+ * has no live WebSocket. The ChannelDO prunes those entries lazily
22
+ * on every broadcast (and via the explicit {@link sweep} method)
23
+ * so the roster cannot accumulate ghost members.
24
+ *
25
+ * Coordinates with ConnectionDO via the `applyChannelDelta` RPC: the
26
+ * ConnectionDO-side CfRuntime forwards every `ApplyChannelDelta` effect
27
+ * here, where the mutation lands on the authoritative state. The same
28
+ * delta shape from `@serverless-ircd/irc-core` flows both ways.
29
+ *
30
+ * RPC return-type note: snapshot reads return the JSON-friendly
31
+ * {@link ChannelSnapshotDto} (members/banMasks/pendingInvites as
32
+ * arrays) so they survive the DO RPC boundary without relying on
33
+ * structured-clone of `Map`/`Set`. The CfRuntime (036)
34
+ * rehydrates these into a full {@link ChanSnapshot} at the call site.
35
+ */
36
+
37
+ import { DurableObject } from 'cloudflare:workers';
38
+ import {
39
+ type ChanName,
40
+ type ChannelDelta,
41
+ type ChannelState,
42
+ type ChannelTopic,
43
+ type ConnId,
44
+ type RosterEntry,
45
+ applyChannelDelta,
46
+ createChannel,
47
+ toSnapshot,
48
+ } from '@serverless-ircd/irc-core';
49
+ import type { Env } from './env.js';
50
+
51
+ /** Storage key under which the per-channel state is persisted. */
52
+ const STATE_STORAGE_KEY = 'channel-state';
53
+
54
+ /**
55
+ * JSON-friendly persisted shape. `members` (a `Map`) and `banMasks`
56
+ * (a `Set`) are serialized as arrays; everything else survives
57
+ * `JSON.stringify` verbatim. `pendingInvites` is similarly serialized
58
+ * as an array of lowercased nicks.
59
+ */
60
+ interface PersistedChannelState {
61
+ name: ChanName;
62
+ nameLower: string;
63
+ modes: ChannelState['modes'];
64
+ banMasks: string[];
65
+ topic?: ChannelTopic;
66
+ members: Array<Omit<RosterEntry, 'conn'> & { conn: ConnId }>;
67
+ pendingInvites: string[];
68
+ createdAt: number;
69
+ }
70
+
71
+ /**
72
+ * Result returned by `ConnectionDO.deliver`. The ChannelDO prunes any
73
+ * member whose `deliver` returns `delivered: 0`.
74
+ */
75
+ export interface DeliverResult {
76
+ delivered: number;
77
+ }
78
+
79
+ /**
80
+ * RPC contract `ChannelDO` calls on each member's `ConnectionDO`. The
81
+ * real `ConnectionDO` implements this; tests use the same shape against
82
+ * the production `ConnectionDO` instance.
83
+ */
84
+ export interface ConnectionDeliveryRpc {
85
+ deliver(lines: Array<{ text: string }>): Promise<DeliverResult>;
86
+ }
87
+
88
+ /**
89
+ * JSON-friendly snapshot DTO returned by `getChannelSnapshot`. Maps and
90
+ * Sets are flattened to arrays so the value survives the DO RPC
91
+ * boundary in both production and the test pool. The CfRuntime
92
+ * (036) rehydrates this into a {@link ChanSnapshot} at the
93
+ * call site.
94
+ */
95
+ export interface ChannelSnapshotDto {
96
+ name: ChanName;
97
+ nameLower: string;
98
+ modes: ChannelState['modes'];
99
+ banMasks: string[];
100
+ topic?: ChannelTopic;
101
+ members: RosterEntry[];
102
+ pendingInvites: string[];
103
+ createdAt: number;
104
+ }
105
+
106
+ /**
107
+ * ChannelDO instance.
108
+ *
109
+ * The class is exported as the binding target in `wrangler.toml`; the
110
+ * Workers runtime instantiates it with `(ctx, env)` per DO id.
111
+ */
112
+ export class ChannelDO extends DurableObject<Env> {
113
+ /** Cached authoritative channel state; reloaded from storage on miss. */
114
+ private cached: ChannelState | undefined;
115
+
116
+ // -------------------------------------------------------------------------
117
+ // RPC — roster / modes / topic mutations
118
+ // -------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Applies a {@link ChannelDelta} atomically: memberships, modeChanges,
122
+ * banMaskChanges, and topic. Idempotent for no-op deltas (empty
123
+ * membership lists, missing members, etc.).
124
+ *
125
+ * This is the single entry point ConnectionDO uses (via CfRuntime)
126
+ * to mutate channel state in response to JOIN / PART / KICK / MODE /
127
+ * TOPIC reducers. The reducer builds the delta; this method applies
128
+ * it against the authoritative state and persists the result.
129
+ */
130
+ async applyChannelDelta(delta: ChannelDelta): Promise<void> {
131
+ const state = await this.loadState();
132
+ const next = applyChannelDelta(state, delta);
133
+ await this.persistState(next);
134
+ }
135
+
136
+ // -------------------------------------------------------------------------
137
+ // RPC — fanout
138
+ // -------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Fans `lines` out to every member of the channel (optionally
142
+ * skipping `except`). For each member the DO calls
143
+ * `ConnectionDO.deliver(lines)` via service-binding RPC; members
144
+ * whose `deliver` returns `{ delivered: 0 }` are pruned from the
145
+ * roster (lazy gone-connection sweep).
146
+ *
147
+ * The fan-out runs in parallel via `Promise.all` so a 1k-member
148
+ * channel completes within a single event-loop tick budget. Member
149
+ * order in the resulting prune set is non-deterministic but the
150
+ * prune delta is order-independent (removes are idempotent).
151
+ *
152
+ * @param lines Raw IRC line texts to deliver.
153
+ * @param except Optional ConnId to skip (e.g. the sender of a PRIVMSG).
154
+ */
155
+ async broadcast(lines: string[], except?: ConnId): Promise<void> {
156
+ if (lines.length === 0) return;
157
+ const state = await this.loadState();
158
+ if (state.members.size === 0) return;
159
+
160
+ const payload = lines.map((text) => ({ text }));
161
+ const pruned = await this.fanoutAndCollect(state, payload, except);
162
+ if (pruned.length > 0) {
163
+ const next = applyChannelDelta(state, {
164
+ memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
165
+ });
166
+ await this.persistState(next);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Explicit gone-connection sweep. Walks every roster entry and prunes
172
+ * those whose ConnectionDO reports `delivered: 0` (no live WebSocket).
173
+ * Returns the number of members pruned.
174
+ *
175
+ * Useful for periodic alarm-driven cleanup; the `broadcast` path
176
+ * performs the same pruning lazily on every fan-out.
177
+ */
178
+ async sweep(): Promise<number> {
179
+ const state = await this.loadState();
180
+ if (state.members.size === 0) return 0;
181
+ // Probe each member with an empty payload (deliver returns the
182
+ // OPEN-socket count without sending bytes).
183
+ const pruned = await this.fanoutAndCollect(state, [], undefined);
184
+ if (pruned.length > 0) {
185
+ const next = applyChannelDelta(state, {
186
+ memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
187
+ });
188
+ await this.persistState(next);
189
+ }
190
+ return pruned.length;
191
+ }
192
+
193
+ // -------------------------------------------------------------------------
194
+ // RPC — reads
195
+ // -------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Returns a read-only snapshot of the channel state as a
199
+ * JSON-friendly DTO ({@link ChannelSnapshotDto}). Suitable for
200
+ * NAMES, WHO, and PRIVMSG fanout routing decisions. The DTO uses
201
+ * arrays instead of `Map`/`Set` so it survives DO RPC in both
202
+ * production and the test pool.
203
+ */
204
+ async getChannelSnapshot(): Promise<ChannelSnapshotDto> {
205
+ const state = await this.loadState();
206
+ return toDto(state);
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Internal helpers
211
+ // -------------------------------------------------------------------------
212
+
213
+ /** Loads the authoritative state from storage, caching on first read. */
214
+ private async loadState(): Promise<ChannelState> {
215
+ if (this.cached !== undefined) return this.cached;
216
+ const record = await this.ctx.storage.get<PersistedChannelState>(STATE_STORAGE_KEY);
217
+ if (record === undefined) {
218
+ const key = this.ctx.id.name ?? this.ctx.id.toString();
219
+ this.cached = createChannel({
220
+ name: key,
221
+ nameLower: key,
222
+ createdAt: Date.now(),
223
+ });
224
+ } else {
225
+ this.cached = deserializeChannel(record);
226
+ }
227
+ return this.cached;
228
+ }
229
+
230
+ /** Persists the authoritative state and refreshes the cache. */
231
+ private async persistState(state: ChannelState): Promise<void> {
232
+ this.cached = state;
233
+ await this.ctx.storage.put(STATE_STORAGE_KEY, serializeChannel(state));
234
+ }
235
+
236
+ /**
237
+ * Iterates roster entries, calling `deliver` on each member's
238
+ * ConnectionDO **in parallel** via `Promise.all`. Returns the list
239
+ * of ConnIds whose deliver reported `delivered: 0` (i.e. gone
240
+ * connections). The caller is responsible for applying the prune as
241
+ * a delta so the persisted state stays consistent with the cache.
242
+ *
243
+ * Parallel fan-out keeps a 1k-member broadcast within a single
244
+ * event-loop tick budget. Per-member deliver is independent (no
245
+ * shared mutable state), so parallelization is safe; the resulting
246
+ * prune set order is non-deterministic but the prune delta is
247
+ * order-independent (removes are idempotent).
248
+ *
249
+ * Defensive note: thrown RPCs are treated as "delivered: 0" — we do
250
+ * not want a transient failure to wedge the roster, but the safest
251
+ * interpretation of a thrown cross-DO call is "the member is gone".
252
+ */
253
+ private async fanoutAndCollect(
254
+ state: ChannelState,
255
+ payload: Array<{ text: string }>,
256
+ except: ConnId | undefined,
257
+ ): Promise<ConnId[]> {
258
+ const targets: ConnId[] = [];
259
+ for (const connId of state.members.keys()) {
260
+ if (except !== undefined && connId === except) continue;
261
+ targets.push(connId);
262
+ }
263
+ const results = await Promise.all(
264
+ targets.map(async (connId) => ({
265
+ connId,
266
+ delivered: await this.deliverToMember(connId, payload),
267
+ })),
268
+ );
269
+ return results.filter((r) => r.delivered === 0).map((r) => r.connId);
270
+ }
271
+
272
+ /**
273
+ * Calls `deliver` on the member's ConnectionDO. Returns the
274
+ * `delivered` count reported by the ConnectionDO, or `0` if the RPC
275
+ * throws (defensive: a totally missing DO instance still reports 0,
276
+ * which the caller interprets as "prune me").
277
+ */
278
+ private async deliverToMember(connId: ConnId, lines: Array<{ text: string }>): Promise<number> {
279
+ try {
280
+ // `connId` is the hex form of the existing ConnectionDO id; use
281
+ // `idFromString` so the same DO instance is addressed (hashing it
282
+ // via `idFromName` would point at a different, empty DO).
283
+ const id = this.env.CONNECTION_DO.idFromString(connId);
284
+ const stub = this.env.CONNECTION_DO.get(id);
285
+ const result = await (stub as unknown as ConnectionDeliveryRpc).deliver(lines);
286
+ return result.delivered;
287
+ } catch {
288
+ return 0;
289
+ }
290
+ }
291
+
292
+ // -------------------------------------------------------------------------
293
+ // Test hooks (only invoked from `runInDurableObject` in tests)
294
+ // -------------------------------------------------------------------------
295
+
296
+ /** Drops the in-memory state cache, simulating wake-from-eviction. */
297
+ public __resetCache(): void {
298
+ this.cached = undefined;
299
+ }
300
+
301
+ /**
302
+ * Returns the live cached state (without forcing a storage read).
303
+ * Exposed for tests that need to inspect Map/Set fields directly
304
+ * without going through the JSON-friendly DTO.
305
+ */
306
+ public async __peekState(): Promise<ChannelState | undefined> {
307
+ return this.cached;
308
+ }
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // (De)serialization — Set/Map ↔ array round-trip for state.storage.
313
+ // ---------------------------------------------------------------------------
314
+
315
+ function serializeChannel(chan: ChannelState): PersistedChannelState {
316
+ const out: PersistedChannelState = {
317
+ name: chan.name,
318
+ nameLower: chan.nameLower,
319
+ modes: { ...chan.modes },
320
+ banMasks: [...chan.banMasks],
321
+ members: [...chan.members.values()].map((e) => ({
322
+ conn: e.conn,
323
+ nick: e.nick,
324
+ op: e.op,
325
+ voice: e.voice,
326
+ })),
327
+ pendingInvites: [...chan.pendingInvites],
328
+ createdAt: chan.createdAt,
329
+ };
330
+ if (chan.topic !== undefined) out.topic = chan.topic;
331
+ return out;
332
+ }
333
+
334
+ function deserializeChannel(record: PersistedChannelState): ChannelState {
335
+ const chan: ChannelState = {
336
+ name: record.name,
337
+ nameLower: record.nameLower,
338
+ modes: { ...record.modes },
339
+ banMasks: new Set<string>(record.banMasks),
340
+ members: new Map<ConnId, RosterEntry>(
341
+ record.members.map((e) => [
342
+ e.conn,
343
+ { conn: e.conn, nick: e.nick, op: e.op ?? false, voice: e.voice ?? false },
344
+ ]),
345
+ ),
346
+ pendingInvites: new Set<string>(record.pendingInvites),
347
+ createdAt: record.createdAt,
348
+ };
349
+ if (record.topic !== undefined) chan.topic = record.topic;
350
+ return chan;
351
+ }
352
+
353
+ /**
354
+ * Builds a JSON-friendly {@link ChannelSnapshotDto} from a live
355
+ * {@link ChannelState}. Uses {@link toSnapshot} for the immutable
356
+ * view, then flattens the `Map`/`Set` fields to arrays.
357
+ */
358
+ function toDto(chan: ChannelState): ChannelSnapshotDto {
359
+ // Defensively rebuild arrays; in some test-pool code paths the cached
360
+ // state's Set/Map fields may be missing if the channel was created
361
+ // via an unusual path (e.g. direct storage put without going through
362
+ // createChannel).
363
+ const dto: ChannelSnapshotDto = {
364
+ name: chan.name,
365
+ nameLower: chan.nameLower,
366
+ modes: { ...chan.modes },
367
+ banMasks: chan.banMasks ? [...chan.banMasks] : [],
368
+ members: chan.members ? [...chan.members.values()].map((e) => ({ ...e })) : [],
369
+ pendingInvites: chan.pendingInvites ? [...chan.pendingInvites] : [],
370
+ createdAt: chan.createdAt,
371
+ };
372
+ if (chan.topic !== undefined) dto.topic = chan.topic;
373
+ return dto;
374
+ }