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,335 @@
1
+ /**
2
+ * 034 — `RegistryDO` with sharding plan.
3
+ *
4
+ * TDD outline (from tickets.md):
5
+ * "Red on concurrent reserve from two stub ConnectionDOs; green."
6
+ *
7
+ * Tests run inside real `workerd` via miniflare so DO storage
8
+ * transactions behave as in production. Each test gets its own
9
+ * isolated storage namespace (`isolatedStorage: true` in
10
+ * `vitest.config.ts`).
11
+ *
12
+ * The binding `REGISTRY_DO_REAL` (declared in `wrangler.test.toml`)
13
+ * points at the real {@link RegistryDO} class. Tests address a
14
+ * specific shard by name (the same `registryKeyForNick` result the
15
+ * CfRuntime will compute in 036), so a single DO instance
16
+ * stands in for one shard of the production fleet.
17
+ */
18
+
19
+ import { env, runInDurableObject } from 'cloudflare:test';
20
+ import { describe, expect, it } from 'vitest';
21
+ import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from '../src/sharding';
22
+
23
+ declare global {
24
+ namespace Cloudflare {
25
+ interface Env {
26
+ REGISTRY_DO_REAL: DurableObjectNamespace;
27
+ }
28
+ }
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Returns a stub for the registry shard that owns `nick` under the
37
+ * default shard count. Mirrors what CfRuntime will do in 036.
38
+ */
39
+ function shardForNick(nick: string, shards = DEFAULT_REGISTRY_SHARDS): DurableObjectStub {
40
+ const key = registryKeyForNick(nick, shards);
41
+ return env.REGISTRY_DO_REAL.get(env.REGISTRY_DO_REAL.idFromName(key));
42
+ }
43
+
44
+ /** Convenience: addresses a shard directly by its numeric index. */
45
+ function shardByIndex(index: number, shards = DEFAULT_REGISTRY_SHARDS): DurableObjectStub {
46
+ return env.REGISTRY_DO_REAL.get(
47
+ env.REGISTRY_DO_REAL.idFromName(registryKeyForNick(`__shard${index}`, shards)),
48
+ );
49
+ }
50
+
51
+ interface RegistryRpc {
52
+ reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
53
+ changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
54
+ releaseNick(nick: string): Promise<void>;
55
+ lookupNick(nick: string): Promise<string | null>;
56
+ }
57
+
58
+ function rpc(stub: DurableObjectStub): RegistryRpc {
59
+ return stub as unknown as RegistryRpc;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // reserveNick
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe('RegistryDO — reserveNick', () => {
67
+ it('succeeds for a fresh nick', async () => {
68
+ const stub = shardForNick('alice');
69
+ const result = await rpc(stub).reserveNick('alice', 'conn-1');
70
+ expect(result).toEqual({ ok: true });
71
+ });
72
+
73
+ it('stores the owner so a subsequent lookup resolves the conn', async () => {
74
+ const stub = shardForNick('bob');
75
+ expect(await rpc(stub).reserveNick('bob', 'conn-2')).toEqual({ ok: true });
76
+ expect(await rpc(stub).lookupNick('bob')).toBe('conn-2');
77
+ });
78
+
79
+ it('rejects a second reservation by a different conn', async () => {
80
+ const stub = shardForNick('carol');
81
+ expect(await rpc(stub).reserveNick('carol', 'conn-3')).toEqual({ ok: true });
82
+ expect(await rpc(stub).reserveNick('carol', 'conn-4')).toEqual({ ok: false });
83
+ });
84
+
85
+ it('is idempotent for re-reserve by the SAME conn', async () => {
86
+ // The same client re-sending NICK during registration must not
87
+ // fail its own reservation.
88
+ const stub = shardForNick('dave');
89
+ expect(await rpc(stub).reserveNick('dave', 'conn-5')).toEqual({ ok: true });
90
+ expect(await rpc(stub).reserveNick('dave', 'conn-5')).toEqual({ ok: true });
91
+ });
92
+
93
+ it('is case-insensitive — Alice and alice collide', async () => {
94
+ // RFC 1459: nicks are case-insensitive. Both forms MUST map to the
95
+ // same registry entry. The sharding function already case-folds;
96
+ // the DO's storage key must too (defence in depth).
97
+ const stub = shardForNick('eve');
98
+ expect(await rpc(stub).reserveNick('Eve', 'conn-6')).toEqual({ ok: true });
99
+ expect(await rpc(stub).reserveNick('eve', 'conn-7')).toEqual({ ok: false });
100
+ expect(await rpc(stub).lookupNick('EVE')).toBe('conn-6');
101
+ });
102
+
103
+ it('isolates nicks across different shards', async () => {
104
+ // Pick two nicks guaranteed to live in different shards.
105
+ const a = 'a';
106
+ let b = 'b';
107
+ while (shardNick(a) === shardNick(b)) b += 'z';
108
+
109
+ expect(await rpc(shardForNick(a)).reserveNick(a, 'conn-a')).toEqual({ ok: true });
110
+ // `b` is a different nick in a different shard; reserving it must
111
+ // succeed regardless of what `a` did.
112
+ expect(await rpc(shardForNick(b)).reserveNick(b, 'conn-b')).toEqual({ ok: true });
113
+ expect(await rpc(shardForNick(a)).lookupNick(a)).toBe('conn-a');
114
+ expect(await rpc(shardForNick(b)).lookupNick(b)).toBe('conn-b');
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // The killer property: concurrent reservations from two ConnectionDOs.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe('RegistryDO — concurrent reservations for the same nick', () => {
123
+ it('exactly one of two concurrent reservations wins', async () => {
124
+ // Two ConnectionDOs race on the same nick. Both calls hit the same
125
+ // shard (that's the whole point of sharding by nick). The DO's
126
+ // storage transaction serializes them; exactly one returns ok:true.
127
+ const stub = shardForNick('race');
128
+ const [r1, r2] = await Promise.all([
129
+ rpc(stub).reserveNick('race', 'conn-x'),
130
+ rpc(stub).reserveNick('race', 'conn-y'),
131
+ ]);
132
+ const results = [r1, r2];
133
+ const winners = results.filter((r) => r.ok === true);
134
+ const losers = results.filter((r) => r.ok === false);
135
+ expect(winners.length).toBe(1);
136
+ expect(losers.length).toBe(1);
137
+ });
138
+
139
+ it('exactly one of three concurrent reservations wins', async () => {
140
+ const stub = shardForNick('triple');
141
+ const [r1, r2, r3] = await Promise.all([
142
+ rpc(stub).reserveNick('triple', 'c1'),
143
+ rpc(stub).reserveNick('triple', 'c2'),
144
+ rpc(stub).reserveNick('triple', 'c3'),
145
+ ]);
146
+ const results = [r1, r2, r3];
147
+ expect(results.filter((r) => r.ok === true).length).toBe(1);
148
+ expect(results.filter((r) => r.ok === false).length).toBe(2);
149
+ });
150
+
151
+ it('after the winner releases, a later reservation succeeds', async () => {
152
+ const stub = shardForNick('recycle');
153
+ expect(await rpc(stub).reserveNick('recycle', 'first')).toEqual({ ok: true });
154
+ expect(await rpc(stub).reserveNick('recycle', 'second')).toEqual({ ok: false });
155
+ await rpc(stub).releaseNick('recycle');
156
+ expect(await rpc(stub).reserveNick('recycle', 'second')).toEqual({ ok: true });
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // changeNick
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe('RegistryDO — changeNick (same shard)', () => {
165
+ it('atomically swaps old → new when both nicks are in this shard', async () => {
166
+ // Find two nicks that hash to the same shard under the default count.
167
+ const old = 'm0';
168
+ let next = `${old}-z`;
169
+ let guard = 0;
170
+ while (shardNick(old) !== shardNick(next) && guard < 1000) {
171
+ next += 'q';
172
+ guard++;
173
+ }
174
+ // Skip the test gracefully if we couldn't find a same-shard pair
175
+ // (extremely unlikely with 32 shards, but keeps the test honest).
176
+ if (shardNick(old) !== shardNick(next)) return;
177
+
178
+ const stub = shardForNick(old);
179
+ expect(await rpc(stub).reserveNick(old, 'conn-cn-1')).toEqual({ ok: true });
180
+
181
+ const ok = await rpc(stub).changeNick('conn-cn-1', old, next);
182
+ expect(ok).toBe(true);
183
+
184
+ // Old nick is gone; new nick points at the same conn.
185
+ expect(await rpc(stub).lookupNick(old)).toBeNull();
186
+ expect(await rpc(stub).lookupNick(next)).toBe('conn-cn-1');
187
+ });
188
+
189
+ it('refuses the change when the new nick is taken by another conn', async () => {
190
+ // Reserve both nicks by different conns, then attempt changeNick.
191
+ const a = 'cn-a';
192
+ let b = 'cn-b';
193
+ let guard = 0;
194
+ while (shardNick(a) !== shardNick(b) && guard < 1000) {
195
+ b += 'z';
196
+ guard++;
197
+ }
198
+ if (shardNick(a) !== shardNick(b)) return;
199
+
200
+ const stub = shardForNick(a);
201
+ expect(await rpc(stub).reserveNick(a, 'conn-1')).toEqual({ ok: true });
202
+ expect(await rpc(stub).reserveNick(b, 'conn-2')).toEqual({ ok: true });
203
+
204
+ const ok = await rpc(stub).changeNick('conn-1', a, b);
205
+ expect(ok).toBe(false);
206
+ // State unchanged.
207
+ expect(await rpc(stub).lookupNick(a)).toBe('conn-1');
208
+ expect(await rpc(stub).lookupNick(b)).toBe('conn-2');
209
+ });
210
+
211
+ it('allows changeNick to a free nick even if old was never reserved', async () => {
212
+ // Defensive: if old isn't held, changeNick should still reserve new
213
+ // (mirrors the in-memory-runtime semantics). Useful for crash recovery.
214
+ const a = 'cn-recover-a';
215
+ let b = 'cn-recover-b';
216
+ let guard = 0;
217
+ while (shardNick(a) !== shardNick(b) && guard < 1000) {
218
+ b += 'z';
219
+ guard++;
220
+ }
221
+ if (shardNick(a) !== shardNick(b)) return;
222
+
223
+ const stub = shardForNick(a);
224
+ const ok = await rpc(stub).changeNick('conn-1', a, b);
225
+ expect(ok).toBe(true);
226
+ expect(await rpc(stub).lookupNick(b)).toBe('conn-1');
227
+ });
228
+
229
+ it('clears the old nick only when it still points at the caller', async () => {
230
+ // If another conn stole `old` between reserve and changeNick, the
231
+ // changeNick caller must not clobber that mapping.
232
+ const a = 'cn-stale-a';
233
+ let b = 'cn-stale-b';
234
+ let guard = 0;
235
+ while (shardNick(a) !== shardNick(b) && guard < 1000) {
236
+ b += 'z';
237
+ guard++;
238
+ }
239
+ if (shardNick(a) !== shardNick(b)) return;
240
+
241
+ const stub = shardForNick(a);
242
+ expect(await rpc(stub).reserveNick(a, 'conn-1')).toEqual({ ok: true });
243
+ // `conn-2` takes over `a` (e.g. after conn-1 was deemed gone).
244
+ expect(await rpc(stub).reserveNick(a, 'conn-2')).toEqual({ ok: false });
245
+ // conn-2 forcibly reserves via changeNick-from-itself path:
246
+ await rpc(stub).releaseNick(a);
247
+ expect(await rpc(stub).reserveNick(a, 'conn-2')).toEqual({ ok: true });
248
+
249
+ const ok = await rpc(stub).changeNick('conn-1', a, b);
250
+ expect(ok).toBe(false);
251
+ // conn-2 still owns `a`.
252
+ expect(await rpc(stub).lookupNick(a)).toBe('conn-2');
253
+ });
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // releaseNick
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('RegistryDO — releaseNick', () => {
261
+ it('clears the mapping so the nick becomes available again', async () => {
262
+ const stub = shardForNick('rel-1');
263
+ await rpc(stub).reserveNick('rel-1', 'conn-r1');
264
+ expect(await rpc(stub).lookupNick('rel-1')).toBe('conn-r1');
265
+
266
+ await rpc(stub).releaseNick('rel-1');
267
+ expect(await rpc(stub).lookupNick('rel-1')).toBeNull();
268
+ expect(await rpc(stub).reserveNick('rel-1', 'conn-r2')).toEqual({ ok: true });
269
+ });
270
+
271
+ it('is idempotent — releasing a missing nick does nothing', async () => {
272
+ const stub = shardForNick('rel-2');
273
+ await expect(rpc(stub).releaseNick('never-existed')).resolves.toBeUndefined();
274
+ await expect(rpc(stub).releaseNick('never-existed')).resolves.toBeUndefined();
275
+ });
276
+
277
+ it('is case-insensitive', async () => {
278
+ const stub = shardForNick('Rel3');
279
+ await rpc(stub).reserveNick('REL3', 'conn-r3');
280
+ await rpc(stub).releaseNick('rel3');
281
+ expect(await rpc(stub).lookupNick('REL3')).toBeNull();
282
+ });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // lookupNick
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe('RegistryDO — lookupNick', () => {
290
+ it('returns null for an unknown nick', async () => {
291
+ const stub = shardForNick('look-unknown');
292
+ expect(await rpc(stub).lookupNick('look-unknown')).toBeNull();
293
+ });
294
+
295
+ it('returns the registered conn id', async () => {
296
+ const stub = shardForNick('look-known');
297
+ await rpc(stub).reserveNick('look-known', 'conn-lk');
298
+ expect(await rpc(stub).lookupNick('look-known')).toBe('conn-lk');
299
+ });
300
+
301
+ it('survives a simulated shard reload (storage-backed)', async () => {
302
+ // Force the DO instance to drop in-memory state by toggling a test
303
+ // hook; the next call must reload from `state.storage`.
304
+ const stub = shardForNick('look-persist');
305
+ await rpc(stub).reserveNick('look-persist', 'conn-lp');
306
+ await runInDurableObject(stub, (instance: unknown) => {
307
+ const doi = instance as { __dropCache?: () => void };
308
+ doi.__dropCache?.();
309
+ });
310
+ expect(await rpc(stub).lookupNick('look-persist')).toBe('conn-lp');
311
+ });
312
+ });
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Storage isolation between shards
316
+ // ---------------------------------------------------------------------------
317
+
318
+ describe('RegistryDO — shard isolation', () => {
319
+ it('two shards maintain independent nick maps', async () => {
320
+ // Address two different shard instances by index; both should be
321
+ // empty initially, then hold independent entries.
322
+ const i = shardByIndex(0);
323
+ const j = shardByIndex(31);
324
+
325
+ // Reserve different nicks in each shard; cross-shard lookups must
326
+ // not find a nick that lives in the other shard.
327
+ await rpc(i).reserveNick('in-shard-0', 'c0');
328
+ await rpc(j).reserveNick('in-shard-31', 'c31');
329
+
330
+ expect(await rpc(i).lookupNick('in-shard-0')).toBe('c0');
331
+ expect(await rpc(j).lookupNick('in-shard-31')).toBe('c31');
332
+ // The DO instance for shard 0 doesn't know about the shard-31 nick.
333
+ expect(await rpc(i).lookupNick('in-shard-31')).toBeNull();
334
+ });
335
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 034 — Sharding function for `RegistryDO`.
3
+ *
4
+ * PLAN §6.1 / §4 — the registry is sharded by `hash(nick) % N` so no
5
+ * single Durable Object becomes a hot spot for nick registrations. Each
6
+ * shard owns a disjoint slice of the nick→connectionId map; two clients
7
+ * racing on the *same* nick both route to the same shard, where DO
8
+ * single-threadedness makes the uniqueness invariant structural.
9
+ *
10
+ * These are pure-function unit tests; no Durable Object involved.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from '../src/sharding';
15
+
16
+ describe('shardNick', () => {
17
+ it('returns a non-negative integer below the shard count', () => {
18
+ for (const nick of ['alice', 'Bob', 'carol123', 'x', 'irc-user']) {
19
+ const s = shardNick(nick, 32);
20
+ expect(Number.isInteger(s)).toBe(true);
21
+ expect(s).toBeGreaterThanOrEqual(0);
22
+ expect(s).toBeLessThan(32);
23
+ }
24
+ });
25
+
26
+ it('honors a custom shard count', () => {
27
+ for (let n = 1; n <= 64; n *= 2) {
28
+ for (const nick of ['a', 'bb', 'ccc']) {
29
+ expect(shardNick(nick, n)).toBeLessThan(n);
30
+ expect(shardNick(nick, n)).toBeGreaterThanOrEqual(0);
31
+ }
32
+ }
33
+ });
34
+
35
+ it('is deterministic — same nick + same N always shard alike', () => {
36
+ const a = shardNick('alice', 32);
37
+ const b = shardNick('alice', 32);
38
+ expect(a).toBe(b);
39
+ });
40
+
41
+ it('is case-insensitive — nick and its lowercased form shard alike', () => {
42
+ // The sharding function MUST case-fold the input: the registry
43
+ // treats `Alice` and `alice` as the same key (RFC 1459 nick
44
+ // case-insensitivity), so they MUST land in the same shard.
45
+ expect(shardNick('Alice', 32)).toBe(shardNick('alice', 32));
46
+ expect(shardNick('ALICE', 32)).toBe(shardNick('alice', 32));
47
+ expect(shardNick('BiRd', 32)).toBe(shardNick('bird', 32));
48
+ });
49
+
50
+ it('uses the default shard count (32) when omitted', () => {
51
+ expect(shardNick('alice')).toBe(shardNick('alice', DEFAULT_REGISTRY_SHARDS));
52
+ });
53
+
54
+ it('throws on non-positive shard count (programming error)', () => {
55
+ expect(() => shardNick('alice', 0)).toThrow();
56
+ expect(() => shardNick('alice', -1)).toThrow();
57
+ });
58
+
59
+ it('distributes a synthetic population across all shards', () => {
60
+ // Generate 10_000 nicks; every shard should get at least one hit.
61
+ // A reasonable hash spreads inputs roughly uniformly; this guards
62
+ // against degenerate hashes (e.g. identity, modulo bias on small N).
63
+ const N = 32;
64
+ const hits = new Set<number>();
65
+ for (let i = 0; i < 10_000; i++) {
66
+ hits.add(shardNick(`user${i}`, N));
67
+ if (hits.size === N) break;
68
+ }
69
+ expect(hits.size).toBe(N);
70
+ });
71
+
72
+ it('is stable across N=1 (single-shard degenerate case)', () => {
73
+ expect(shardNick('alice', 1)).toBe(0);
74
+ expect(shardNick('bob', 1)).toBe(0);
75
+ });
76
+ });
77
+
78
+ describe('registryKeyForNick', () => {
79
+ it('returns a stable string key suitable for idFromName', () => {
80
+ const key = registryKeyForNick('alice', 32);
81
+ expect(typeof key).toBe('string');
82
+ expect(key.length).toBeGreaterThan(0);
83
+ });
84
+
85
+ it('agrees with shardNick — same nick → same shard → same key', () => {
86
+ const N = 32;
87
+ expect(registryKeyForNick('alice', N)).toBe(registryKeyForNick('alice', N));
88
+ expect(registryKeyForNick('Alice', N)).toBe(registryKeyForNick('alice', N));
89
+ });
90
+
91
+ it('nicks in different shards produce different keys', () => {
92
+ // Find two nicks that hash to different shards.
93
+ const a = 'a';
94
+ let b = 'b';
95
+ while (shardNick(a, 32) === shardNick(b, 32)) {
96
+ b = `${b}z`;
97
+ }
98
+ expect(registryKeyForNick(a, 32)).not.toBe(registryKeyForNick(b, 32));
99
+ });
100
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Test worker entry point for the cf-adapter `vitest-pool-workers` suite.
3
+ *
4
+ * Each test file gets its own isolate with `isolatedStorage: true`, so DO
5
+ * state is fresh per test. We export the {@link ConnectionDO} plus
6
+ * collaborators so tests can wire them via `wrangler.test.toml` bindings
7
+ * and assert cross-DO interactions.
8
+ *
9
+ * Bindings:
10
+ * - `CONNECTION_DO` → `ConnectionDO` (real, 033).
11
+ * - `REGISTRY_DO` → `RecordingRegistryDO` stub used by the
12
+ * ConnectionDO tests (033). The stub records every RPC call
13
+ * into a per-instance log exposed via `runInDurableObject`.
14
+ * - `REGISTRY_DO_REAL` → `RegistryDO` (real, 034). Used by
15
+ * the RegistryDO unit tests. 036 will swap `REGISTRY_DO`
16
+ * over to this class once the full CfRuntime lands.
17
+ * - `CHANNEL_DO` → `RecordingChannelDO` stub (real impl lands in
18
+ * 035).
19
+ */
20
+
21
+ import { ChannelDO } from '../../src/channel-do';
22
+ import { ConnectionDO } from '../../src/connection-do';
23
+ import { RegistryDO } from '../../src/registry-do';
24
+ import { RecordingChannelDO } from './stubs/channel-stub';
25
+ import { RecordingRegistryDO } from './stubs/registry-stub';
26
+
27
+ export { ChannelDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
28
+
29
+ export default {
30
+ async fetch(): Promise<Response> {
31
+ return new Response('cf-adapter test worker', { status: 200 });
32
+ },
33
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Test-only {@link DurableObject} that records every cross-DO call from
3
+ * {@link ConnectionDO}. The real ChannelDO ships in 035; for
4
+ * 033 we only need to verify that ConnectionDO fan-outs (broadcast,
5
+ * applyChannelDelta) reach it.
6
+ *
7
+ * Each instance is keyed by lowercased channel name (the convention the
8
+ * real ChannelDO will use). ConnectionDO looks up the right instance via
9
+ * `env.CHANNEL_DO.idFromName(chanLower)`.
10
+ */
11
+
12
+ import { DurableObject } from 'cloudflare:workers';
13
+
14
+ export interface RecordedChannelCall {
15
+ method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot';
16
+ args: unknown[];
17
+ }
18
+
19
+ interface ChannelRpc {
20
+ broadcast(lines: unknown[], except?: string): Promise<void>;
21
+ applyChannelDelta(delta: unknown): Promise<void>;
22
+ getChannelSnapshot(): Promise<unknown>;
23
+ }
24
+
25
+ const LOG_KEY = 'channel-call-log';
26
+
27
+ export class RecordingChannelDO extends DurableObject implements ChannelRpc {
28
+ private async append(call: RecordedChannelCall): Promise<void> {
29
+ const existing = (await this.ctx.storage.get<RecordedChannelCall[]>(LOG_KEY)) ?? [];
30
+ existing.push(call);
31
+ await this.ctx.storage.put(LOG_KEY, existing);
32
+ }
33
+
34
+ async recordedCalls(): Promise<RecordedChannelCall[]> {
35
+ return (await this.ctx.storage.get<RecordedChannelCall[]>(LOG_KEY)) ?? [];
36
+ }
37
+
38
+ async broadcast(lines: unknown[], except?: string): Promise<void> {
39
+ await this.append({ method: 'broadcast', args: [lines, except] });
40
+ }
41
+
42
+ async applyChannelDelta(delta: unknown): Promise<void> {
43
+ await this.append({ method: 'applyChannelDelta', args: [delta] });
44
+ }
45
+
46
+ async getChannelSnapshot(): Promise<unknown> {
47
+ await this.append({ method: 'getChannelSnapshot', args: [] });
48
+ return null;
49
+ }
50
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Test-only {@link DurableObject} that records every cross-DO call from
3
+ * {@link ConnectionDO} into its `state.storage`. Tests inspect the call
4
+ * log via `runInDurableObject` after exercising the connection.
5
+ *
6
+ * PLAN §2.1 — the real RegistryDO ships in 034. For 033 we
7
+ * only need enough surface area to assert that ConnectionDO closes
8
+ * release the nick and fan out QUIT correctly. The stub records the
9
+ * requested operations verbatim; it does not enforce uniqueness.
10
+ */
11
+
12
+ import { DurableObject } from 'cloudflare:workers';
13
+
14
+ export interface RecordedRegistryCall {
15
+ method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick';
16
+ args: unknown[];
17
+ }
18
+
19
+ interface RegistryRpc {
20
+ reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }>;
21
+ changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
22
+ releaseNick(nick: string): Promise<void>;
23
+ lookupNick(nick: string): Promise<string | null>;
24
+ }
25
+
26
+ const LOG_KEY = 'registry-call-log';
27
+
28
+ export class RecordingRegistryDO extends DurableObject implements RegistryRpc {
29
+ private async append(call: RecordedRegistryCall): Promise<void> {
30
+ const existing = (await this.ctx.storage.get<RecordedRegistryCall[]>(LOG_KEY)) ?? [];
31
+ existing.push(call);
32
+ await this.ctx.storage.put(LOG_KEY, existing);
33
+ }
34
+
35
+ async recordedCalls(): Promise<RecordedRegistryCall[]> {
36
+ return (await this.ctx.storage.get<RecordedRegistryCall[]>(LOG_KEY)) ?? [];
37
+ }
38
+
39
+ async reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }> {
40
+ await this.append({ method: 'reserveNick', args: [nick, conn] });
41
+ return { ok: true };
42
+ }
43
+
44
+ async changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean> {
45
+ await this.append({ method: 'changeNick', args: [conn, oldNick, newNick] });
46
+ return true;
47
+ }
48
+
49
+ async releaseNick(nick: string): Promise<void> {
50
+ await this.append({ method: 'releaseNick', args: [nick] });
51
+ }
52
+
53
+ async lookupNick(nick: string): Promise<string | null> {
54
+ await this.append({ method: 'lookupNick', args: [nick] });
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["@cloudflare/workers-types"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"],
12
+ "references": [
13
+ { "path": "../irc-core/tsconfig.build.json" },
14
+ { "path": "../irc-server/tsconfig.build.json" }
15
+ ]
16
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["@cloudflare/workers-types"]
6
+ },
7
+ "include": [
8
+ "src/**/*.ts",
9
+ "tests/**/*.ts",
10
+ "node_modules/@cloudflare/vitest-pool-workers/types/cloudflare-test.d.ts"
11
+ ],
12
+ "exclude": ["dist"],
13
+ "references": [
14
+ { "path": "../irc-core/tsconfig.build.json" },
15
+ { "path": "../irc-server/tsconfig.build.json" },
16
+ { "path": "../in-memory-runtime/tsconfig.build.json" }
17
+ ]
18
+ }
@@ -0,0 +1,32 @@
1
+ import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
2
+ import { defineConfig } from 'vitest/config';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ include: ['tests/**/*.test.ts'],
7
+ coverage: {
8
+ provider: 'v8',
9
+ all: false,
10
+ include: ['src/**/*.ts'],
11
+ exclude: ['src/**/index.ts'],
12
+ reporter: ['text', 'html', 'json-summary'],
13
+ thresholds: {
14
+ lines: 90,
15
+ functions: 90,
16
+ branches: 90,
17
+ statements: 90,
18
+ },
19
+ },
20
+ },
21
+ plugins: [
22
+ cloudflareTest({
23
+ wrangler: {
24
+ configPath: './wrangler.test.toml',
25
+ },
26
+ miniflare: {
27
+ // Ensure DO alarms and hibernation are supported in tests.
28
+ compatibilityFlags: ['nodejs_compat'],
29
+ },
30
+ }),
31
+ ],
32
+ });