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,542 @@
1
+ /**
2
+ * `ConnectionDO` — Cloudflare Durable Object that owns one IRC
3
+ * connection's socket + authoritative state.
4
+ *
5
+ * PLAN §6.1 — uses {@link DurableObjectState}'s
6
+ * `HibernatingWebSocketHandler` so idle WebSocket connections cost
7
+ * nothing between messages. When a message arrives the DO wakes, replays
8
+ * the persisted {@link ConnectionState} from `state.storage`, runs the
9
+ * frame through {@link ConnectionActor} with a stub {@link CfRuntime},
10
+ * and persists the updated state back.
11
+ *
12
+ * Lifecycle:
13
+ * - `fetch()` WebSocket upgrade; `acceptWebSocket`.
14
+ * - `webSocketMessage()` Frame → actor → dispatch → persist.
15
+ * - `webSocketClose()`/`Error()` QUIT fanout + nick release.
16
+ * - `alarm()` PING sweep; missing PONG disconnects.
17
+ *
18
+ * Cross-DO coordination:
19
+ * - `REGISTRY_DO` (RPC stub today, real in 034).
20
+ * - `CHANNEL_DO` per lowercased channel (stub today, real in 035).
21
+ *
22
+ * The actor's `ActorChannelAccess` is a passthrough that returns
23
+ * throwaway {@link ChannelState} objects — reducers mutate the local
24
+ * view and emit `ApplyChannelDelta` effects that the CfRuntime forwards
25
+ * to the authoritative ChannelDO. Sufficient for connection-authority
26
+ * commands and JOIN/QUIT fan-out; richer channel reads (NAMES of remote
27
+ * state) ship in 036.
28
+ */
29
+
30
+ import { DurableObject } from 'cloudflare:workers';
31
+ import {
32
+ type ChanName,
33
+ type ChannelState,
34
+ type Clock,
35
+ type ConnId,
36
+ type ConnectionState,
37
+ type IdFactory,
38
+ type MotdProvider,
39
+ type RawLine,
40
+ type ServerConfig,
41
+ SystemClock,
42
+ UuidIdFactory,
43
+ applyChannelDelta,
44
+ createChannel,
45
+ createConnection,
46
+ toSnapshot,
47
+ } from '@serverless-ircd/irc-core';
48
+ import { ConnectionActor } from '@serverless-ircd/irc-server';
49
+ import { makeCfRuntime } from './cf-runtime.js';
50
+ import type { CfConnectionHandlers } from './cf-runtime.js';
51
+ import type { Env } from './env.js';
52
+ import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
53
+
54
+ /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
55
+ export const DEFAULT_PING_INTERVAL_MS = 60_000;
56
+ /** Default no-PONG disconnect threshold (ms). */
57
+ export const DEFAULT_PONG_TIMEOUT_MS = 90_000;
58
+
59
+ /** Server config defaults; production deployments override via env. */
60
+ const DEFAULT_SERVER_CONFIG: ServerConfig = {
61
+ serverName: 'irc.example.com',
62
+ networkName: 'ServerlessIRCd',
63
+ maxChannelsPerUser: 30,
64
+ maxTargetsPerCommand: 10,
65
+ maxListEntries: 50,
66
+ nickLen: 30,
67
+ channelLen: 50,
68
+ topicLen: 390,
69
+ quitMessage: 'Client Quit',
70
+ };
71
+
72
+ /**
73
+ * ConnectionDO instance.
74
+ *
75
+ * The class is exported as the binding target in `wrangler.toml`; the
76
+ * Workers runtime instantiates it with `(ctx, env)` per DO id.
77
+ */
78
+ export class ConnectionDO extends DurableObject<Env> {
79
+ /** Cached connection state; reloaded from storage after hibernation. */
80
+ private cached: ConnectionState | undefined;
81
+ /**
82
+ * Per-instance channel-state cache. Reused across every WebSocket frame
83
+ * the connection handles (within a single hibernation cycle) so that
84
+ * reducers see the roster mutations they performed on prior frames —
85
+ * e.g. JOIN in frame 1, then PART/TOPIC/MODE in later frames all
86
+ * share the same ChannelState. Authoritative state still lives in the
87
+ * ChannelDO; this cache only mirrors the local connection's view.
88
+ *
89
+ * Created lazily because `clock.now()` advances and the first frame
90
+ * should seed `createdAt` with its receive time.
91
+ */
92
+ private channelAccess: PassthroughChannelAccess | undefined;
93
+ private readonly clock: Clock = SystemClock;
94
+ private readonly ids: IdFactory = new UuidIdFactory();
95
+ private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
96
+ private readonly pongTimeoutMs: number = DEFAULT_PONG_TIMEOUT_MS;
97
+ /** Outstanding outbound lines, drained when the WS sends complete. */
98
+ // (Reserved for a future batching optimization; kept off until 036.)
99
+
100
+ // -------------------------------------------------------------------------
101
+ // WebSocket upgrade
102
+ // -------------------------------------------------------------------------
103
+
104
+ /** Standard WebSocket upgrade handler. */
105
+ override async fetch(request: Request): Promise<Response> {
106
+ if (request.headers.get('Upgrade') !== 'websocket') {
107
+ return new Response('Expected WebSocket', { status: 426 });
108
+ }
109
+ const pair = new WebSocketPair();
110
+ const server = pair[0];
111
+ const client = pair[1];
112
+ // Hibernation: `acceptWebSocket` accepts the server side implicitly.
113
+ // Calling `server.accept()` first throws "already accepted".
114
+ this.ctx.acceptWebSocket(server);
115
+ // Schedule the first PING sweep.
116
+ await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
117
+ return new Response(null, { status: 101, webSocket: client });
118
+ }
119
+
120
+ // -------------------------------------------------------------------------
121
+ // Hibernating WebSocket event handlers
122
+ // -------------------------------------------------------------------------
123
+
124
+ /** Frame → actor → dispatch → persist. */
125
+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
126
+ const text = typeof message === 'string' ? message : new TextDecoder().decode(message);
127
+ const state = await this.loadState();
128
+ const before = new Set(state.joinedChannels);
129
+ const beforeNick = state.nick;
130
+
131
+ const actor = this.buildActor(ws, state);
132
+ try {
133
+ await actor.receiveTextFrame(text);
134
+ } finally {
135
+ // Persist any mutations even if dispatch threw partway through.
136
+ await this.persistState(state);
137
+ }
138
+
139
+ // If registration completed this frame, schedule a PING alarm.
140
+ if (beforeNick === undefined && state.nick !== undefined) {
141
+ await this.ctx.storage.setAlarm(Date.now() + this.pingIntervalMs);
142
+ }
143
+ // Detect newly joined channels so we can register them with the
144
+ // channel DO — necessary for the close handler to fan out QUIT to
145
+ // them. The stub channel DO records the call; the real ChannelDO
146
+ // (035) maintains authoritative roster state.
147
+ for (const chan of state.joinedChannels) {
148
+ if (!before.has(chan)) {
149
+ // joinReducer already emitted an ApplyChannelDelta; the stub
150
+ // ChannelDO records it. No extra work needed here for 033.
151
+ void chan;
152
+ }
153
+ }
154
+ }
155
+
156
+ /** QUIT fanout + nick release. RFC 1459: a missing close is a QUIT. */
157
+ override async webSocketClose(
158
+ ws: WebSocket,
159
+ _code: number,
160
+ _reason: string,
161
+ _wasClean: boolean,
162
+ ): Promise<void> {
163
+ await this.tearDown(ws);
164
+ }
165
+
166
+ /** Same teardown as close. */
167
+ override async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
168
+ await this.tearDown(ws);
169
+ }
170
+
171
+ // -------------------------------------------------------------------------
172
+ // Alarms (PING / idle sweeps)
173
+ // -------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Fires on the scheduled alarm. Sends a PING; if the connection has
177
+ * been idle past the PONG timeout, closes it.
178
+ *
179
+ * PLAN §6.1 — DO alarms are the canonical PING/idle mechanism on CF;
180
+ * equivalent to EventBridge Scheduler on AWS (042).
181
+ */
182
+ override async alarm(): Promise<void> {
183
+ const state = await this.loadState();
184
+ const now = this.clock.now();
185
+ if (state.lastSeen + this.pongTimeoutMs < now) {
186
+ // Idle past the PONG threshold: tear down.
187
+ const sockets = this.ctx.getWebSockets();
188
+ if (sockets.length > 0) {
189
+ await this.tearDown(sockets[0] as WebSocket);
190
+ } else {
191
+ await this.releaseAndPersist(state);
192
+ }
193
+ return;
194
+ }
195
+ // Send PING. PONG replies update lastSeen via the actor.
196
+ const token = this.ids.nonce();
197
+ for (const ws of this.ctx.getWebSockets()) {
198
+ (ws as WebSocket).send(`PING :${token}\r\n`);
199
+ }
200
+ await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
201
+ }
202
+
203
+ // -------------------------------------------------------------------------
204
+ // RPC — cross-DO fan-out (called by ChannelDO)
205
+ // -------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Pushes `lines` to every WebSocket attached to this connection. This
209
+ * is the fan-out target used by {@link ChannelDO}: one broadcast to
210
+ * a channel becomes N `deliver` calls, one per member's
211
+ * ConnectionDO.
212
+ *
213
+ * Returns `{ delivered: number }` — the number of WebSockets the
214
+ * lines were written to. Zero signals "no live connection"; callers
215
+ * (ChannelDO.broadcast / sweep) use that to prune dead roster
216
+ * entries lazily.
217
+ *
218
+ * Hibernation note: `state.getWebSockets()` returns the live set
219
+ * even after the DO has been evicted and woken back up, because the
220
+ * runtime tracks them out-of-process. A connection that closed
221
+ * unilaterally reports an empty list and is pruned on the next
222
+ * broadcast.
223
+ *
224
+ * Empty payload (`lines === []`) is a probe — returns the live
225
+ * socket count without sending anything. Used by ChannelDO.sweep.
226
+ */
227
+ async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
228
+ let delivered = 0;
229
+ let text: string | undefined;
230
+ if (lines.length > 0) {
231
+ text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
232
+ }
233
+ for (const ws of this.ctx.getWebSockets()) {
234
+ const socket = ws as WebSocket;
235
+ if (socket.readyState !== WebSocket.OPEN) continue;
236
+ if (text !== undefined) socket.send(text);
237
+ delivered++;
238
+ }
239
+ return { delivered };
240
+ }
241
+
242
+ // -------------------------------------------------------------------------
243
+ // Internal helpers
244
+ // -------------------------------------------------------------------------
245
+
246
+ /** Loads the connection state from storage, caching for the event. */
247
+ private async loadState(): Promise<ConnectionState> {
248
+ if (this.cached !== undefined) return this.cached;
249
+ const record = await this.ctx.storage.get(STATE_STORAGE_KEY);
250
+ if (record === undefined) {
251
+ this.cached = createConnection({
252
+ id: this.ctx.id.toString(),
253
+ connectedSince: this.clock.now(),
254
+ });
255
+ } else {
256
+ this.cached = deserialize(record);
257
+ }
258
+ return this.cached;
259
+ }
260
+
261
+ /** Persists the connection state to storage. */
262
+ private async persistState(state: ConnectionState): Promise<void> {
263
+ this.cached = state;
264
+ await this.ctx.storage.put(STATE_STORAGE_KEY, serialize(state));
265
+ }
266
+
267
+ /**
268
+ * Constructs an actor wired to this DO's CfRuntime.
269
+ */
270
+ private buildActor(ws: WebSocket, state: ConnectionState): ConnectionActor {
271
+ const serverConfig = this.serverConfig();
272
+ const motd = this.motdProvider();
273
+ const handlers = {
274
+ send: (lines: RawLine[]): void => {
275
+ if (ws.readyState === WebSocket.OPEN) {
276
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
277
+ ws.send(text);
278
+ }
279
+ },
280
+ disconnect: (reason?: string): void => {
281
+ if (reason !== undefined && ws.readyState === WebSocket.OPEN) {
282
+ ws.send(`ERROR :Closing link: (${reason})\r\n`);
283
+ }
284
+ ws.close();
285
+ },
286
+ snapshot: (): ConnectionState | undefined => this.cached,
287
+ };
288
+ // For 033 stub routing we look up the per-connection registry
289
+ // stub by the DO instance's hex id. Production (034) shards the
290
+ // registry by nick instead, at which point `registryKey` is unused.
291
+ const registryKey = this.ctx.id.toString();
292
+ const runtime = makeCfRuntime(this.env, state.id, registryKey, handlers);
293
+ if (this.channelAccess === undefined) {
294
+ this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
295
+ }
296
+ return new ConnectionActor({
297
+ state,
298
+ runtime,
299
+ channels: this.channelAccess,
300
+ serverConfig,
301
+ clock: this.clock,
302
+ ids: this.ids,
303
+ motd,
304
+ });
305
+ }
306
+
307
+ /** Tears down a connection: emit QUIT effects, release nick, close WS. */
308
+ private async tearDown(ws: WebSocket): Promise<void> {
309
+ const state = await this.loadState();
310
+ const actor = this.buildActor(ws, state);
311
+ // Drive QUIT through the actor so the same effect pipeline handles
312
+ // fanout as during normal operation.
313
+ await actor.receiveTextFrame('QUIT\r\n');
314
+ await this.releaseAndPersist(state);
315
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
316
+ ws.close();
317
+ }
318
+ try {
319
+ await this.ctx.storage.deleteAlarm();
320
+ } catch {
321
+ // Already cleared; ignore.
322
+ }
323
+ }
324
+
325
+ /** Releases the nick via the registry and persists the (possibly mutated) state. */
326
+ private async releaseAndPersist(state: ConnectionState): Promise<void> {
327
+ if (state.nick !== undefined) {
328
+ const registryKey = this.ctx.id.toString();
329
+ const stub = this.env.REGISTRY_DO.get(this.env.REGISTRY_DO.idFromName(registryKey));
330
+ await (stub as unknown as { releaseNick: (n: string) => Promise<void> }).releaseNick(
331
+ state.nick,
332
+ );
333
+ }
334
+ await this.persistState(state);
335
+ }
336
+
337
+ private serverConfig(): ServerConfig {
338
+ return {
339
+ ...DEFAULT_SERVER_CONFIG,
340
+ ...(this.env.SERVER_NAME !== undefined ? { serverName: this.env.SERVER_NAME } : {}),
341
+ ...(this.env.NETWORK_NAME !== undefined ? { networkName: this.env.NETWORK_NAME } : {}),
342
+ };
343
+ }
344
+
345
+ private motdProvider(): MotdProvider {
346
+ const raw = this.env.MOTD_LINES;
347
+ if (raw === undefined || raw.length === 0) {
348
+ return { lines: () => DEFAULT_MOTD };
349
+ }
350
+ // MOTD_LINES is a newline-delimited env var.
351
+ return { lines: () => raw.split('\n') };
352
+ }
353
+
354
+ // -------------------------------------------------------------------------
355
+ // Test hooks (only invoked from `runInDurableObject` in tests)
356
+ // -------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Test hook: explicitly invokes the close-teardown path. Useful when
360
+ * the test framework does not propagate the WebSocket close event to
361
+ * hibernated sockets synchronously enough for assertions.
362
+ */
363
+ public async __triggerClose(): Promise<void> {
364
+ const sockets = this.ctx.getWebSockets();
365
+ const ws = sockets[0];
366
+ if (ws === undefined) {
367
+ // No socket attached; run cleanup without it.
368
+ const state = await this.loadState();
369
+ await this.tearDownNoSocket(state);
370
+ return;
371
+ }
372
+ await this.tearDown(ws as WebSocket);
373
+ }
374
+
375
+ /** Like {@link tearDown} but without a live WebSocket reference. */
376
+ private async tearDownNoSocket(state: ConnectionState): Promise<void> {
377
+ // Emit QUIT effects without dispatching transport calls (the socket
378
+ // is already gone). The cross-DO fanout still runs.
379
+ const noOpHandlers: CfConnectionHandlers = {
380
+ send: (): void => {},
381
+ disconnect: (): void => {},
382
+ snapshot: (): ConnectionState | undefined => this.cached,
383
+ };
384
+ const registryKey = this.ctx.id.toString();
385
+ const runtime = makeCfRuntime(this.env, state.id, registryKey, noOpHandlers);
386
+ if (this.channelAccess === undefined) {
387
+ this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
388
+ }
389
+ const actor = new ConnectionActor({
390
+ state,
391
+ runtime,
392
+ channels: this.channelAccess,
393
+ serverConfig: this.serverConfig(),
394
+ clock: this.clock,
395
+ ids: this.ids,
396
+ motd: this.motdProvider(),
397
+ });
398
+ await actor.receiveTextFrame('QUIT\r\n');
399
+ await this.releaseAndPersist(state);
400
+ try {
401
+ await this.ctx.storage.deleteAlarm();
402
+ } catch {
403
+ // Already cleared.
404
+ }
405
+ }
406
+
407
+ /** Drops the in-memory state cache, simulating wake-from-hibernation. */
408
+ public __resetCache(): void {
409
+ this.cached = undefined;
410
+ }
411
+
412
+ /** Returns the live cached state (without forcing a storage read). */
413
+ public async __peekState(): Promise<ConnectionState | undefined> {
414
+ return this.cached;
415
+ }
416
+
417
+ /** Backdates `lastSeen` to test the alarm-driven disconnect path. */
418
+ public async __backdateLastSeen(deltaMs: number): Promise<void> {
419
+ const state = await this.loadState();
420
+ state.lastSeen = this.clock.now() - deltaMs;
421
+ await this.persistState(state);
422
+ }
423
+
424
+ /** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
425
+ public __peekHexId(): string {
426
+ return this.ctx.id.toString();
427
+ }
428
+
429
+ /**
430
+ * Cross-connection RPC: returns a snapshot of this connection's state.
431
+ * Called by another connection's {@link CfRuntime} when a
432
+ * `GetConnectionInfo` effect targets this connection. Loads from storage
433
+ * on demand (safe to call after hibernation).
434
+ */
435
+ public async getConnSnapshot(): Promise<import('@serverless-ircd/irc-core').ConnSnapshot | null> {
436
+ const state = await this.loadState();
437
+ return toSnapshot(state);
438
+ }
439
+ }
440
+
441
+ /** Default MOTD when no `MOTD_LINES` env is supplied. */
442
+ const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
443
+
444
+ /**
445
+ * `ActorChannelAccess` backed by a per-instance cache that is refreshed
446
+ * from the authoritative ChannelDO before each reducer that needs to
447
+ * observe channel state. The cache is seeded lazily by `getOrCreateChannel`
448
+ * (for commands like JOIN that create a brand-new channel) and overwritten
449
+ * by `refreshChannel` (for commands that read existing state: NAMES, MODE,
450
+ * KICK, PART, TOPIC, PRIVMSG fan-out checks, …).
451
+ *
452
+ * The `env` reference lets `refreshChannel` issue a `getChannelSnapshot`
453
+ * RPC to the right ChannelDO instance. The DTO returned across the RPC
454
+ * boundary is rehydrated into a proper {@link ChannelState} so the
455
+ * synchronous reducers can mutate `members` (a Map) and `banMasks` /
456
+ * `pendingInvites` (Sets) in place.
457
+ */
458
+ class PassthroughChannelAccess {
459
+ private readonly cache = new Map<string, ChannelState>();
460
+ constructor(
461
+ private readonly now: number,
462
+ private readonly env: Env,
463
+ ) {}
464
+
465
+ getOrCreateChannel(name: ChanName): ChannelState {
466
+ const key = name.toLowerCase();
467
+ const existing = this.cache.get(key);
468
+ if (existing !== undefined) return existing;
469
+ const created = createChannel({
470
+ name,
471
+ nameLower: key,
472
+ createdAt: this.now,
473
+ });
474
+ this.cache.set(key, created);
475
+ return created;
476
+ }
477
+
478
+ /**
479
+ * Pulls the authoritative snapshot for `name` from the ChannelDO and
480
+ * replaces the local cache entry. No-op if the channel does not exist
481
+ * remotely (the caller will fall through to `getOrCreateChannel` which
482
+ * lazily seeds an empty entry).
483
+ *
484
+ * Safe to call on a channel the connection has not joined: the snapshot
485
+ * is read-only and the local cache entry is only consulted by the
486
+ * reducer running this frame.
487
+ *
488
+ * The caller-supplied `name` is preserved verbatim in the rebuilt state
489
+ * (rather than trusting `dto.name`, which can default to a hex DO id on
490
+ * channels that were lazily initialised by ChannelDO but never received
491
+ * an `applyChannelDelta`).
492
+ */
493
+ async refreshChannel(name: ChanName): Promise<void> {
494
+ const key = name.toLowerCase();
495
+ const id = this.env.CHANNEL_DO.idFromName(key);
496
+ const stub = this.env.CHANNEL_DO.get(id);
497
+ const dto = (await (
498
+ stub as unknown as {
499
+ getChannelSnapshot: () => Promise<unknown>;
500
+ }
501
+ ).getChannelSnapshot()) as {
502
+ modes: ChannelState['modes'];
503
+ banMasks: string[];
504
+ topic?: ChannelState['topic'];
505
+ members: Array<{ conn: ConnId; nick: string; op: boolean; voice: boolean }>;
506
+ pendingInvites: string[];
507
+ createdAt: number;
508
+ } | null;
509
+ if (dto === null) return;
510
+ // Don't replace the cache with an empty snapshot — the channel
511
+ // effectively doesn't exist yet, and the local reducer should lazily
512
+ // seed it via getOrCreateChannel. This also avoids clobbering the
513
+ // local cache with ChannelDO's "name unknown" default state.
514
+ if (dto.members.length === 0) return;
515
+ const members = new Map<ConnId, { conn: ConnId; nick: string; op: boolean; voice: boolean }>();
516
+ for (const entry of dto.members) {
517
+ members.set(entry.conn, {
518
+ conn: entry.conn,
519
+ nick: entry.nick,
520
+ op: entry.op,
521
+ voice: entry.voice,
522
+ });
523
+ }
524
+ const rebuilt: ChannelState = {
525
+ name,
526
+ nameLower: key,
527
+ modes: { ...dto.modes },
528
+ banMasks: new Set(dto.banMasks),
529
+ ...(dto.topic !== undefined ? { topic: dto.topic } : {}),
530
+ members,
531
+ pendingInvites: new Set(dto.pendingInvites),
532
+ createdAt: dto.createdAt,
533
+ };
534
+ this.cache.set(key, rebuilt);
535
+ }
536
+ }
537
+
538
+ // Reference `applyChannelDelta` to ensure the import is used downstream by
539
+ // tests that exercise state migrations; harmless if the symbol is unused
540
+ // in production today. (Removed from the ConnectionDO path because the
541
+ // authoritative delta application lives in the ChannelDO stub today.)
542
+ export { applyChannelDelta, PERSISTED_STATE_VERSION };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Cloudflare Workers environment bindings for the cf-adapter.
3
+ *
4
+ * Each binding corresponds to a Durable Object namespace declared in
5
+ * `wrangler.toml`. The same shape is used in production and tests;
6
+ * tests register stub implementations in `wrangler.test.toml`.
7
+ */
8
+
9
+ /**
10
+ * The production {@link Env} for `apps/cf-worker`. ConnectionDO uses the
11
+ * three DO namespaces below to coordinate with the registry and channel
12
+ * authorities. 036 (CfRuntime) is where the full wiring lives;
13
+ * 033 (ConnectionDO) only needs `CONNECTION_DO` itself plus the
14
+ * two collaborator namespaces.
15
+ *
16
+ * RPC method shapes on {@link RegistryRpc} and {@link ChannelRpc} are
17
+ * implemented by both the production DOs and the test stubs in
18
+ * `tests/worker/stubs/`.
19
+ */
20
+ export interface Env {
21
+ /** Per-connection authority: socket + ConnectionState. */
22
+ CONNECTION_DO: DurableObjectNamespace;
23
+ /**
24
+ * Nick registry authority. RPC: reserveNick / changeNick / releaseNick /
25
+ * lookupNick. Stubbed now (033); real impl in 034.
26
+ *
27
+ * Note: the production DO class is what's brand-typed; here we keep the
28
+ * namespace unparameterized and cast to the RPC surface at the call
29
+ * site. The full type plumbing lands with 034.
30
+ */
31
+ REGISTRY_DO: DurableObjectNamespace;
32
+ /**
33
+ * Channel authority, keyed by lowercased channel name. RPC: broadcast /
34
+ * applyChannelDelta / getChannelSnapshot. Stubbed now; real in 035.
35
+ */
36
+ CHANNEL_DO: DurableObjectNamespace;
37
+ /** Server-level config knobs (name, MOTD lines, limits). */
38
+ SERVER_NAME: string;
39
+ NETWORK_NAME: string;
40
+ MOTD_LINES: string;
41
+ }
42
+
43
+ /**
44
+ * RPC contract ConnectionDO expects from the registry. The real
45
+ * RegistryDO (034) and the test stub both implement this.
46
+ */
47
+ export interface RegistryRpc {
48
+ reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
49
+ changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
50
+ releaseNick(nick: string): Promise<void>;
51
+ lookupNick(nick: string): Promise<string | null>;
52
+ }
53
+
54
+ /**
55
+ * RPC contract ConnectionDO expects from each channel instance. The real
56
+ * ChannelDO (035) and the test stub both implement this.
57
+ *
58
+ * Note: `broadcast` accepts already-formatted raw line texts (no
59
+ * `RawLine` wrapper) because that's what `cf-runtime.broadcast`
60
+ * forwards after unwrapping the pure-core `RawLine[]`. The ChannelDO
61
+ * re-wraps them when calling `ConnectionDO.deliver`.
62
+ */
63
+ export interface ChannelRpc {
64
+ broadcast(lines: unknown[], except?: string): Promise<void>;
65
+ applyChannelDelta(delta: unknown): Promise<void>;
66
+ getChannelSnapshot(): Promise<unknown>;
67
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @serverless-ircd/cf-adapter
3
+ *
4
+ * Cloudflare Workers adapter for ServerlessIRCd. Implements the three
5
+ * Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and a
6
+ * CfRuntime that bridges them behind the platform-agnostic
7
+ * {@link IrcRuntime} port.
8
+ *
9
+ * 033 shipped the {@link ConnectionDO} + a stub CfRuntime.
10
+ * 034 ships the {@link RegistryDO} (real nick registry with
11
+ * sharding). The {@link ChannelDO} (roster + modes + fanout) ships
12
+ * here. The full CfRuntime lands in 036.
13
+ */
14
+
15
+ export {
16
+ ConnectionDO,
17
+ DEFAULT_PING_INTERVAL_MS,
18
+ DEFAULT_PONG_TIMEOUT_MS,
19
+ } from './connection-do.js';
20
+ export { RegistryDO } from './registry-do.js';
21
+ export { ChannelDO } from './channel-do.js';
22
+ export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
23
+ export {
24
+ DEFAULT_REGISTRY_SHARDS,
25
+ registryKeyForNick,
26
+ shardNick,
27
+ } from './sharding.js';
28
+ export { makeCfRuntime } from './cf-runtime.js';
29
+ export type { CfConnectionHandlers } from './cf-runtime.js';
30
+ export type { ChannelRpc, Env, RegistryRpc } from './env.js';
31
+ export {
32
+ PERSISTED_STATE_VERSION,
33
+ STATE_STORAGE_KEY,
34
+ deserialize,
35
+ migrate,
36
+ serialize,
37
+ } from './serialize.js';
38
+ export type { PersistedConnectionState } from './serialize.js';