serverless-ircd 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 034 — `RegistryDO` with sharding plan.
2
+ * `RegistryDO` with sharding plan.
3
3
  *
4
- * TDD outline (from tickets.md):
4
+ * TDD outline:
5
5
  * "Red on concurrent reserve from two stub ConnectionDOs; green."
6
6
  *
7
7
  * Tests run inside real `workerd` via miniflare so DO storage
@@ -12,7 +12,7 @@
12
12
  * The binding `REGISTRY_DO_REAL` (declared in `wrangler.test.toml`)
13
13
  * points at the real {@link RegistryDO} class. Tests address a
14
14
  * specific shard by name (the same `registryKeyForNick` result the
15
- * CfRuntime will compute in 036), so a single DO instance
15
+ * CfRuntime will compute), so a single DO instance
16
16
  * stands in for one shard of the production fleet.
17
17
  */
18
18
 
@@ -34,7 +34,7 @@ declare global {
34
34
 
35
35
  /**
36
36
  * Returns a stub for the registry shard that owns `nick` under the
37
- * default shard count. Mirrors what CfRuntime will do in 036.
37
+ * default shard count. Mirrors what CfRuntime will do.
38
38
  */
39
39
  function shardForNick(nick: string, shards = DEFAULT_REGISTRY_SHARDS): DurableObjectStub {
40
40
  const key = registryKeyForNick(nick, shards);
@@ -53,6 +53,7 @@ interface RegistryRpc {
53
53
  changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
54
54
  releaseNick(nick: string): Promise<void>;
55
55
  lookupNick(nick: string): Promise<string | null>;
56
+ listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
56
57
  }
57
58
 
58
59
  function rpc(stub: DurableObjectStub): RegistryRpc {
@@ -333,3 +334,106 @@ describe('RegistryDO — shard isolation', () => {
333
334
  expect(await rpc(i).lookupNick('in-shard-31')).toBeNull();
334
335
  });
335
336
  });
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // changeNick — direct contract tests (sharding is a CfRuntime concern; the
340
+ // DO method itself operates on whatever keys it is given, so we address a
341
+ // single DO instance directly to exercise every branch without depending on
342
+ // the FNV-1a hash landing two nicks in the same shard).
343
+ // ---------------------------------------------------------------------------
344
+
345
+ describe('RegistryDO — changeNick branch coverage (direct on one shard)', () => {
346
+ // Pin every test to a single, stable shard instance. The DO method is
347
+ // agnostic to which shard it lives on; what matters is the storage
348
+ // state we put in place before the call.
349
+ const shard = () => shardByIndex(1);
350
+
351
+ it('returns false when the new nick is held by a different conn', async () => {
352
+ const s = shard();
353
+ // Seed storage so both `taken` and `current` are owned by different
354
+ // conns on this same DO.
355
+ expect(await rpc(s).reserveNick('taken-new', 'conn-owner')).toEqual({ ok: true });
356
+ expect(await rpc(s).reserveNick('current-old', 'conn-changer')).toEqual({ ok: true });
357
+
358
+ const ok = await rpc(s).changeNick('conn-changer', 'current-old', 'taken-new');
359
+ expect(ok).toBe(false);
360
+ // State untouched: owners stay as they were.
361
+ expect(await rpc(s).lookupNick('taken-new')).toBe('conn-owner');
362
+ expect(await rpc(s).lookupNick('current-old')).toBe('conn-changer');
363
+ });
364
+
365
+ it('clears the old nick when it still points at the caller', async () => {
366
+ const s = shard();
367
+ expect(await rpc(s).reserveNick('cn-clear-old', 'conn-self')).toEqual({ ok: true });
368
+
369
+ const ok = await rpc(s).changeNick('conn-self', 'cn-clear-old', 'cn-clear-new');
370
+ expect(ok).toBe(true);
371
+ expect(await rpc(s).lookupNick('cn-clear-old')).toBeNull();
372
+ expect(await rpc(s).lookupNick('cn-clear-new')).toBe('conn-self');
373
+ });
374
+
375
+ it('leaves a stale old nick alone when it no longer points at the caller', async () => {
376
+ // Crash-recovery / race defence: another conn grabbed `old` between
377
+ // our reserve and our changeNick. The conditional delete must not
378
+ // clobber that third party.
379
+ const s = shard();
380
+ // `old` is currently held by `conn-other`, not by `conn-caller`.
381
+ expect(await rpc(s).reserveNick('cn-stale-old', 'conn-other')).toEqual({ ok: true });
382
+
383
+ const ok = await rpc(s).changeNick('conn-caller', 'cn-stale-old', 'cn-stale-new');
384
+ expect(ok).toBe(true);
385
+ // New nick reserved for the caller...
386
+ expect(await rpc(s).lookupNick('cn-stale-new')).toBe('conn-caller');
387
+ // ...but `old` is untouched because it never pointed at the caller.
388
+ expect(await rpc(s).lookupNick('cn-stale-old')).toBe('conn-other');
389
+ });
390
+ });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // listEntries — backs the global WALLOPS fan-out (CfRuntime.broadcastWallops
394
+ // walks every shard via this method).
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe('RegistryDO — listEntries', () => {
398
+ it('returns an empty array for a fresh shard', async () => {
399
+ expect(await rpc(shardByIndex(2)).listEntries()).toEqual([]);
400
+ });
401
+
402
+ it('enumerates every (nickLower, connId) pair in this shard', async () => {
403
+ const s = shardByIndex(3);
404
+ await rpc(s).reserveNick('Alice', 'conn-a');
405
+ await rpc(s).reserveNick('Bob', 'conn-b');
406
+ await rpc(s).reserveNick('Carol', 'conn-c');
407
+
408
+ const entries = await rpc(s).listEntries();
409
+ // Keys are lowercased in storage; the strip recovers the lowercased
410
+ // nick (matches what CfRuntime expects when deduping conn ids).
411
+ const sorted = [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
412
+ expect(sorted).toEqual([
413
+ ['alice', 'conn-a'],
414
+ ['bob', 'conn-b'],
415
+ ['carol', 'conn-c'],
416
+ ]);
417
+ });
418
+
419
+ it('reflects releases: a freed nick drops out of the listing', async () => {
420
+ const s = shardByIndex(4);
421
+ await rpc(s).reserveNick('keep', 'conn-k');
422
+ await rpc(s).reserveNick('drop', 'conn-d');
423
+ await rpc(s).releaseNick('drop');
424
+
425
+ const entries = await rpc(s).listEntries();
426
+ expect(entries).toEqual([['keep', 'conn-k']]);
427
+ });
428
+
429
+ it('strips the storage prefix so callers see a bare nickLower', async () => {
430
+ const s = shardByIndex(5);
431
+ await rpc(s).reserveNick('prefixed', 'conn-p');
432
+ const entries = await rpc(s).listEntries();
433
+ const nickLower = entries[0]?.[0];
434
+ // The internal key is `nick:prefixed`; the public API must not leak
435
+ // that implementation detail.
436
+ expect(nickLower).toBe('prefixed');
437
+ expect(nickLower?.startsWith('nick:')).toBe(false);
438
+ });
439
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `serialize` / `deserialize` / `migrate`.
3
+ *
4
+ * These helpers bridge {@link ConnectionState} (which carries `Set` and
5
+ * `Map` fields that degrade under `JSON.stringify`) and the JSON-safe
6
+ * {@link PersistedConnectionState} shape stored in DO transactional KV.
7
+ *
8
+ * Coverage focus: every optional-field branch in both directions, the
9
+ * `secure` / `userModes.tls` backfill paths in `migrate`, and the
10
+ * rejection paths (non-object record, too-new version).
11
+ */
12
+
13
+ import { type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
14
+ import { describe, expect, it } from 'vitest';
15
+ import { PERSISTED_STATE_VERSION, deserialize, migrate, serialize } from '../src/serialize';
16
+
17
+ /** Builds a fully-populated connection state for round-trip assertions. */
18
+ function fullState(): ConnectionState {
19
+ const s = createConnection({ id: 'conn-1', connectedSince: 100 });
20
+ s.registration = 'registered';
21
+ s.capNegotiating = false;
22
+ s.secure = true;
23
+ s.nick = 'Alice';
24
+ s.user = 'alice';
25
+ s.host = 'example.com';
26
+ s.realname = 'Alice Example';
27
+ s.passAttempt = 'hunter2';
28
+ s.account = 'alice-account';
29
+ s.away = 'brb';
30
+ s.caps = new Set(['server-time', 'message-tags']);
31
+ s.joinedChannels = new Set(['#one', '#two']);
32
+ s.userModes = {
33
+ invisible: true,
34
+ oper: false,
35
+ wallops: true,
36
+ serverNotices: false,
37
+ tls: true,
38
+ };
39
+ s.lastSeen = 200;
40
+ return s;
41
+ }
42
+
43
+ describe('serialize — round trip', () => {
44
+ it('survives serialize → deserialize for a fully-populated state', () => {
45
+ const original = fullState();
46
+ const round = deserialize(serialize(original));
47
+ // Sets are rebuilt with the same members.
48
+ expect(round.caps).toEqual(new Set(['server-time', 'message-tags']));
49
+ expect(round.joinedChannels).toEqual(new Set(['#one', '#two']));
50
+ // Optional fields preserved.
51
+ expect(round.nick).toBe('Alice');
52
+ expect(round.user).toBe('alice');
53
+ expect(round.host).toBe('example.com');
54
+ expect(round.realname).toBe('Alice Example');
55
+ expect(round.passAttempt).toBe('hunter2');
56
+ expect(round.account).toBe('alice-account');
57
+ expect(round.away).toBe('brb');
58
+ // userModes cloned (mutating the round trip must not leak back).
59
+ expect(round.userModes).toEqual(original.userModes);
60
+ expect(round.userModes).not.toBe(original.userModes);
61
+ expect(round.secure).toBe(true);
62
+ });
63
+
64
+ it('is JSON-stable: serialize → JSON.stringify → JSON.parse → deserialize', () => {
65
+ // This is the actual production path through DO storage: the runtime
66
+ // structured-clones the value, but the persisted shape must also be
67
+ // JSON-tolerant for older storage rows and snapshots written to R2.
68
+ const original = fullState();
69
+ const json = JSON.stringify(serialize(original));
70
+ const round = deserialize(JSON.parse(json));
71
+ expect(round.caps).toEqual(original.caps);
72
+ expect(round.nick).toBe(original.nick);
73
+ expect(round.away).toBe(original.away);
74
+ });
75
+
76
+ it('stamps the current persisted schema version', () => {
77
+ expect(serialize(fullState()).version).toBe(PERSISTED_STATE_VERSION);
78
+ });
79
+ });
80
+
81
+ describe('serialize — optional-field branches', () => {
82
+ it('omits host when undefined', () => {
83
+ const s = createConnection({ id: 'c', connectedSince: 0 });
84
+ s.nick = 'n';
85
+ expect(serialize(s).host).toBeUndefined();
86
+ });
87
+
88
+ it('omits passAttempt when undefined', () => {
89
+ const s = createConnection({ id: 'c', connectedSince: 0 });
90
+ expect(serialize(s).passAttempt).toBeUndefined();
91
+ });
92
+
93
+ it('omits away when undefined', () => {
94
+ const s = createConnection({ id: 'c', connectedSince: 0 });
95
+ expect(serialize(s).away).toBeUndefined();
96
+ });
97
+
98
+ it('clones caps and joinedChannels arrays away from the live Set', () => {
99
+ const s = createConnection({ id: 'c', connectedSince: 0 });
100
+ s.caps = new Set(['a']);
101
+ s.joinedChannels = new Set(['#x']);
102
+ const out = serialize(s);
103
+ expect(out.caps).toEqual(['a']);
104
+ expect(out.joinedChannels).toEqual(['#x']);
105
+ });
106
+ });
107
+
108
+ describe('deserialize — optional-field branches', () => {
109
+ it('populates host when the persisted record carries it', () => {
110
+ const s = createConnection({ id: 'c', connectedSince: 0 });
111
+ s.host = 'host.example';
112
+ expect(deserialize(serialize(s)).host).toBe('host.example');
113
+ });
114
+
115
+ it('populates passAttempt when the persisted record carries it', () => {
116
+ const s = createConnection({ id: 'c', connectedSince: 0 });
117
+ s.passAttempt = 'pw';
118
+ expect(deserialize(serialize(s)).passAttempt).toBe('pw');
119
+ });
120
+
121
+ it('populates account when the persisted record carries it', () => {
122
+ const s = createConnection({ id: 'c', connectedSince: 0 });
123
+ s.account = 'acc';
124
+ expect(deserialize(serialize(s)).account).toBe('acc');
125
+ });
126
+
127
+ it('populates away when the persisted record carries it', () => {
128
+ const s = createConnection({ id: 'c', connectedSince: 0 });
129
+ s.away = 'afk';
130
+ expect(deserialize(serialize(s)).away).toBe('afk');
131
+ });
132
+
133
+ it('leaves optional fields absent on a minimal pre-registration state', () => {
134
+ const s = createConnection({ id: 'c', connectedSince: 0 });
135
+ const round = deserialize(serialize(s));
136
+ expect(round.nick).toBeUndefined();
137
+ expect(round.host).toBeUndefined();
138
+ expect(round.away).toBeUndefined();
139
+ expect(round.account).toBeUndefined();
140
+ expect(round.passAttempt).toBeUndefined();
141
+ });
142
+ });
143
+
144
+ describe('migrate — backfills additive fields', () => {
145
+ it('defaults `secure` to false when the field is absent', () => {
146
+ // Pre-feature record: no `secure` key at all.
147
+ const legacy = {
148
+ version: 1,
149
+ id: 'c',
150
+ registration: 'registered' as const,
151
+ capNegotiating: false,
152
+ caps: [],
153
+ joinedChannels: [],
154
+ userModes: {
155
+ invisible: false,
156
+ oper: false,
157
+ wallops: false,
158
+ serverNotices: false,
159
+ },
160
+ lastSeen: 0,
161
+ connectedSince: 0,
162
+ };
163
+ const migrated = migrate(legacy);
164
+ expect(migrated.secure).toBe(false);
165
+ // The mutation landed on the original object reference (defensive
166
+ // backfill writes through to the record).
167
+ expect((legacy as Record<string, unknown>).secure).toBe(false);
168
+ });
169
+
170
+ it('defaults `userModes.tls` to false when the field is absent', () => {
171
+ const legacy = {
172
+ version: 1,
173
+ id: 'c',
174
+ registration: 'registered' as const,
175
+ capNegotiating: false,
176
+ caps: [],
177
+ joinedChannels: [],
178
+ userModes: {
179
+ invisible: false,
180
+ oper: false,
181
+ wallops: false,
182
+ serverNotices: false,
183
+ },
184
+ lastSeen: 0,
185
+ connectedSince: 0,
186
+ };
187
+ const migrated = migrate(legacy);
188
+ expect(migrated.userModes.tls).toBe(false);
189
+ });
190
+
191
+ it('leaves an explicit `secure: true` untouched', () => {
192
+ const rec = {
193
+ version: 1,
194
+ id: 'c',
195
+ registration: 'registered' as const,
196
+ capNegotiating: false,
197
+ secure: true,
198
+ caps: [],
199
+ joinedChannels: [],
200
+ userModes: {
201
+ invisible: false,
202
+ oper: false,
203
+ wallops: false,
204
+ serverNotices: false,
205
+ tls: true,
206
+ },
207
+ lastSeen: 0,
208
+ connectedSince: 0,
209
+ };
210
+ expect(migrate(rec).secure).toBe(true);
211
+ expect(migrate(rec).userModes.tls).toBe(true);
212
+ });
213
+
214
+ it('tolerates a missing `userModes` object without throwing', () => {
215
+ // A record corrupted in storage (or hand-built by an older code
216
+ // path) might not carry userModes at all. Migrate must not crash.
217
+ const rec = {
218
+ version: 1,
219
+ id: 'c',
220
+ registration: 'registered' as const,
221
+ capNegotiating: false,
222
+ caps: [],
223
+ joinedChannels: [],
224
+ lastSeen: 0,
225
+ connectedSince: 0,
226
+ };
227
+ expect(() => migrate(rec)).not.toThrow();
228
+ });
229
+ });
230
+
231
+ describe('migrate — rejection paths', () => {
232
+ it('rejects a non-object record', () => {
233
+ for (const bad of [null, 'string', 42, true, undefined]) {
234
+ expect(() => migrate(bad)).toThrow(/not an object/u);
235
+ }
236
+ });
237
+
238
+ it('rejects an array record (arrays are not plain objects)', () => {
239
+ expect(() => migrate([])).toThrow(/not an object/u);
240
+ });
241
+
242
+ it('rejects a record persisted with a newer schema version', () => {
243
+ const future = {
244
+ version: PERSISTED_STATE_VERSION + 1,
245
+ id: 'c',
246
+ registration: 'registered' as const,
247
+ capNegotiating: false,
248
+ caps: [],
249
+ joinedChannels: [],
250
+ userModes: {
251
+ invisible: false,
252
+ oper: false,
253
+ wallops: false,
254
+ serverNotices: false,
255
+ tls: false,
256
+ },
257
+ lastSeen: 0,
258
+ connectedSince: 0,
259
+ };
260
+ expect(() => migrate(future)).toThrow(/newer than/u);
261
+ });
262
+
263
+ it('treats a missing version as the legacy default (0)', () => {
264
+ // No `version` field — old shape. Migrate must accept it rather
265
+ // than fail the boot path; the backfill covers the additive keys.
266
+ const legacy = {
267
+ id: 'c',
268
+ registration: 'registered' as const,
269
+ capNegotiating: false,
270
+ caps: [],
271
+ joinedChannels: [],
272
+ userModes: {
273
+ invisible: false,
274
+ oper: false,
275
+ wallops: false,
276
+ serverNotices: false,
277
+ },
278
+ lastSeen: 0,
279
+ connectedSince: 0,
280
+ };
281
+ expect(() => migrate(legacy)).not.toThrow();
282
+ });
283
+ });
284
+
285
+ describe('deserialize — migrate integration', () => {
286
+ it('backfills `secure` and `userModes.tls` on a legacy record, then materializes', () => {
287
+ const legacy = {
288
+ id: 'c',
289
+ registration: 'registered' as const,
290
+ capNegotiating: false,
291
+ caps: ['server-time'],
292
+ joinedChannels: ['#x'],
293
+ userModes: {
294
+ invisible: false,
295
+ oper: false,
296
+ wallops: true,
297
+ serverNotices: false,
298
+ },
299
+ lastSeen: 5,
300
+ connectedSince: 1,
301
+ };
302
+ const state = deserialize(legacy);
303
+ expect(state.secure).toBe(false);
304
+ expect(state.userModes.tls).toBe(false);
305
+ expect(state.userModes.wallops).toBe(true);
306
+ expect(state.caps).toEqual(new Set(['server-time']));
307
+ });
308
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 034 — Sharding function for `RegistryDO`.
2
+ * Sharding function for `RegistryDO`.
3
3
  *
4
4
  * PLAN §6.1 / §4 — the registry is sharded by `hash(nick) % N` so no
5
5
  * single Durable Object becomes a hot spot for nick registrations. Each
@@ -0,0 +1,120 @@
1
+ import { type ConnId, type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { CfStats } from '../src/stats.js';
4
+
5
+ /**
6
+ * Minimal stub satisfying the structural interface CfStats needs (just
7
+ * `listChannels` + `getChannelConnections`). Keeps the unit test free of
8
+ * the workerd/miniflare harness the full CfRuntime integration suite uses.
9
+ */
10
+ interface StatsRuntimeShape {
11
+ listChannels(): Promise<{ nameLower: string }[]>;
12
+ getChannelConnections(name: string): Promise<ReadonlyMap<ConnId, ConnectionState>>;
13
+ }
14
+
15
+ class StubRuntime implements StatsRuntimeShape {
16
+ constructor(
17
+ private readonly channels: ReadonlyMap<string, ReadonlyMap<ConnId, ConnectionState>>,
18
+ ) {}
19
+ async listChannels(): Promise<{ nameLower: string }[]> {
20
+ return Array.from(this.channels.keys(), (nameLower) => ({ nameLower }));
21
+ }
22
+ async getChannelConnections(name: string): Promise<ReadonlyMap<ConnId, ConnectionState>> {
23
+ return this.channels.get(name) ?? new Map();
24
+ }
25
+ }
26
+
27
+ function makeConn(
28
+ id: ConnId,
29
+ opts: { oper?: boolean; invisible?: boolean; registered?: boolean } = {},
30
+ ): ConnectionState {
31
+ const s = createConnection({ id, connectedSince: 0 });
32
+ s.nick = id;
33
+ s.registration = opts.registered === false ? 'pre-registration' : 'registered';
34
+ if (opts.oper !== undefined) s.userModes.oper = opts.oper;
35
+ if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
36
+ return s;
37
+ }
38
+
39
+ describe('CfStats', () => {
40
+ it('returns zero counts for an empty deployment', async () => {
41
+ const rt = new StubRuntime(new Map());
42
+ const stats = new CfStats(rt, 1_000);
43
+ const snap = await stats.getStats();
44
+ expect(snap.users).toBe(0);
45
+ expect(snap.channels).toBe(0);
46
+ expect(snap.servers).toBe(1);
47
+ expect(snap.localConns).toBe(0);
48
+ });
49
+
50
+ it('counts channels and their members (cross-channel dedup)', async () => {
51
+ const alice = makeConn('c1');
52
+ const bob = makeConn('c2', { invisible: true });
53
+ const carol = makeConn('c3', { oper: true });
54
+ // Alice and Bob share #a; Carol is alone in #b. Alice is also in #b
55
+ // (dup across channels) — must be counted once.
56
+ const channels = new Map<string, ReadonlyMap<ConnId, ConnectionState>>([
57
+ [
58
+ '#a',
59
+ new Map<ConnId, ConnectionState>([
60
+ ['c1', alice],
61
+ ['c2', bob],
62
+ ]),
63
+ ],
64
+ [
65
+ '#b',
66
+ new Map<ConnId, ConnectionState>([
67
+ ['c1', alice],
68
+ ['c3', carol],
69
+ ]),
70
+ ],
71
+ ]);
72
+ const rt = new StubRuntime(channels);
73
+
74
+ const stats = new CfStats(rt, 1_000);
75
+ const snap = await stats.getStats();
76
+
77
+ expect(snap.channels).toBe(2);
78
+ // 3 unique connections (alice, bob, carol).
79
+ expect(snap.localConns).toBe(3);
80
+ // 3 registered, 1 invisible → 2 visible.
81
+ expect(snap.users).toBe(2);
82
+ expect(snap.invisible).toBe(1);
83
+ expect(snap.opers).toBe(1);
84
+ });
85
+
86
+ it('counts a pre-registration member as an unknown connection', async () => {
87
+ const a = makeConn('c1');
88
+ const b = makeConn('c2', { registered: false });
89
+ const channels = new Map<string, ReadonlyMap<ConnId, ConnectionState>>([
90
+ [
91
+ '#a',
92
+ new Map<ConnId, ConnectionState>([
93
+ ['c1', a],
94
+ ['c2', b],
95
+ ]),
96
+ ],
97
+ ]);
98
+ const rt = new StubRuntime(channels);
99
+
100
+ const stats = new CfStats(rt, 1_000);
101
+ const snap = await stats.getStats();
102
+ expect(snap.users).toBe(1);
103
+ expect(snap.unknownConnections).toBe(1);
104
+ expect(snap.localConns).toBe(2);
105
+ });
106
+
107
+ it('echoes the supplied uptimeStartedAt', async () => {
108
+ const rt = new StubRuntime(new Map());
109
+ const stats = new CfStats(rt, 9_999);
110
+ const snap = await stats.getStats();
111
+ expect(snap.uptimeStartedAt).toBe(9_999);
112
+ });
113
+
114
+ it('returns servers=1 (single-server deployment)', async () => {
115
+ const rt = new StubRuntime(new Map());
116
+ const stats = new CfStats(rt, 0);
117
+ const snap = await stats.getStats();
118
+ expect(snap.servers).toBe(1);
119
+ });
120
+ });
@@ -7,15 +7,15 @@
7
7
  * and assert cross-DO interactions.
8
8
  *
9
9
  * Bindings:
10
- * - `CONNECTION_DO` → `ConnectionDO` (real, 033).
10
+ * - `CONNECTION_DO` → `ConnectionDO` (real).
11
11
  * - `REGISTRY_DO` → `RecordingRegistryDO` stub used by the
12
- * ConnectionDO tests (033). The stub records every RPC call
12
+ * ConnectionDO tests. The stub records every RPC call
13
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).
14
+ * - `REGISTRY_DO_REAL` → `RegistryDO` (real). Used by
15
+ * the RegistryDO unit tests. The CfRuntime swaps `REGISTRY_DO`
16
+ * over to this class once it lands.
17
+ * - `CHANNEL_DO` → `RecordingChannelDO` stub (real impl in
18
+ * `ChannelDO`).
19
19
  */
20
20
 
21
21
  import { ChannelDO } from '../../src/channel-do';
@@ -1,7 +1,7 @@
1
1
  /**
2
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,
3
+ * {@link ConnectionDO}. The real ChannelDO ships in `channel-do.ts`;
4
+ * we only need to verify that ConnectionDO fan-outs (broadcast,
5
5
  * applyChannelDelta) reach it.
6
6
  *
7
7
  * Each instance is keyed by lowercased channel name (the convention the
@@ -3,7 +3,7 @@
3
3
  * {@link ConnectionDO} into its `state.storage`. Tests inspect the call
4
4
  * log via `runInDurableObject` after exercising the connection.
5
5
  *
6
- * PLAN §2.1 — the real RegistryDO ships in 034. For 033 we
6
+ * PLAN §2.1 — the real RegistryDO ships separately. We
7
7
  * only need enough surface area to assert that ConnectionDO closes
8
8
  * release the nick and fan out QUIT correctly. The stub records the
9
9
  * requested operations verbatim; it does not enforce uniqueness.
@@ -12,7 +12,7 @@
12
12
  import { DurableObject } from 'cloudflare:workers';
13
13
 
14
14
  export interface RecordedRegistryCall {
15
- method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick';
15
+ method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick' | 'listEntries';
16
16
  args: unknown[];
17
17
  }
18
18
 
@@ -21,6 +21,7 @@ interface RegistryRpc {
21
21
  changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
22
22
  releaseNick(nick: string): Promise<void>;
23
23
  lookupNick(nick: string): Promise<string | null>;
24
+ listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
24
25
  }
25
26
 
26
27
  const LOG_KEY = 'registry-call-log';
@@ -54,4 +55,9 @@ export class RecordingRegistryDO extends DurableObject implements RegistryRpc {
54
55
  await this.append({ method: 'lookupNick', args: [nick] });
55
56
  return null;
56
57
  }
58
+
59
+ async listEntries(): Promise<Array<[nickLower: string, connId: string]>> {
60
+ await this.append({ method: 'listEntries', args: [] });
61
+ return [];
62
+ }
57
63
  }
@@ -5,7 +5,7 @@ export default defineConfig({
5
5
  test: {
6
6
  include: ['tests/**/*.test.ts'],
7
7
  coverage: {
8
- provider: 'v8',
8
+ provider: 'istanbul',
9
9
  all: false,
10
10
  include: ['src/**/*.ts'],
11
11
  exclude: ['src/**/index.ts'],
@@ -8,11 +8,18 @@ compatibility_flags = ["nodejs_compat"]
8
8
  # integration-test harness so scenario 18 (MOTD) asserts against the same
9
9
  # body text in every runtime factory.
10
10
  [vars]
11
+ SERVER_NAME = "irc.example.com"
12
+ NETWORK_NAME = "ServerlessIRCd"
11
13
  MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
12
14
  # SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
13
15
  # delimited `username:password` pairs; the connection-do parses these into
14
16
  # an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
15
17
  SASL_ACCOUNTS = "cf-sasl:s3cret"
18
+ # IRC operator credentials consulted by the OPER reducer. The DO must
19
+ # thread these from env into the reducer-facing ServerConfig so
20
+ # `OPER <user> <pass>` returns 381 rather than 491 ERR_NOOPERHOST.
21
+ OPER_USER = "cf-oper"
22
+ OPER_PASSWORD = "oper-secret"
16
23
 
17
24
  # D1 database backing persistent SASL accounts. The D1 account-store suite
18
25
  # creates the `accounts` table in a beforeEach and exercises the real
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",