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,8 +1,8 @@
1
1
  /**
2
- * Parametrized IRC scenarios against the Cloudflare runtime — 036.
2
+ * Parametrized IRC scenarios against the Cloudflare runtime.
3
3
  *
4
4
  * Reuses the scenario runner from `@serverless-ircd/irc-test-support`
5
- * (032) and registers the CF harness factory built on top of the
5
+ * and registers the CF harness factory built on top of the
6
6
  * real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
7
7
  * scenario spawns one or more real WebSockets and drives the full
8
8
  * bytes → actor → CfRuntime pipeline inside `workerd`.
@@ -15,13 +15,13 @@
15
15
  * against the in-memory runtime; CF-specific failures stem from the
16
16
  * ConnectionDO's `PassthroughChannelAccess` not yet fetching
17
17
  * authoritative roster state from ChannelDO before running channel
18
- * reducers. That wiring lands in a follow-up to keep this ticket
18
+ * reducers. That wiring lands in a follow-up to keep this work
19
19
  * focused on the CfRuntime class itself):
20
20
  * - Multi-client channel reads (NAMES of peers, KICK roster refresh)
21
21
  * see only the local connection until the actor's channel access
22
22
  * is upgraded to read from ChannelDO.
23
23
  * - ChannelDO.getChannelSnapshot has an unrelated bug in the parallel
24
- * ChannelDO ticket (035) that returns a malformed snapshot;
24
+ * ChannelDO work that returns a malformed snapshot;
25
25
  * that bug is not introduced here.
26
26
  */
27
27
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CfRuntime — 036.
2
+ * CfRuntime.
3
3
  *
4
4
  * Verifies that every {@link IrcRuntime} method on {@link CfRuntime} issues
5
5
  * the correct DO `stub()` call:
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Two envs are used:
17
17
  * - `env` — production bindings (recording stubs for the
18
- * 033 unit suite; swapped to `_REAL` for
18
+ * ConnectionDO unit suite; swapped to `_REAL` for
19
19
  * the CfRuntime uniqueness + end-to-end tests).
20
20
  * - `envWithRealDOs()` — a shim that re-exposes the real RegistryDO +
21
21
  * ChannelDO under the production binding names,
@@ -616,3 +616,308 @@ describe('CfRuntime — IrcRuntime structural conformance', () => {
616
616
  expect(handlers.sent).toEqual([':server 001 alice :Welcome']);
617
617
  });
618
618
  });
619
+
620
+ describe('CfRuntime — reloadConfig (REHASH)', () => {
621
+ it('re-reads the bound Workers env and reparses the server config', async () => {
622
+ const realEnv = envWithRealDOs();
623
+ const rt = new CfRuntime(realEnv, 'conn-rehash', recordingHandlers());
624
+
625
+ const cfg = await rt.reloadConfig();
626
+
627
+ expect(cfg.serverName).toBe('irc.example.com');
628
+ expect(cfg.networkName).toBe('ExampleNet');
629
+ });
630
+
631
+ it('reflects a rotated env value on the next reload (no caching)', async () => {
632
+ const rotatingEnv = {
633
+ ...envWithRealDOs(),
634
+ OPER_USER: 'admin',
635
+ OPER_PASSWORD: 'old',
636
+ };
637
+ const rt = new CfRuntime(rotatingEnv, 'conn-rehash-2', recordingHandlers());
638
+
639
+ const first = await rt.reloadConfig();
640
+ expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
641
+
642
+ // Rotate the env (simulate a re-deployed secret) and reload again.
643
+ rotatingEnv.OPER_PASSWORD = 'rotated';
644
+ const second = await rt.reloadConfig();
645
+ expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
646
+ });
647
+
648
+ it('propagates a schema-validation failure so the actor applies the graceful 382', async () => {
649
+ const badEnv: Env = { ...envWithRealDOs(), NETWORK_NAME: '' };
650
+ const rt = new CfRuntime(badEnv, 'conn-rehash-bad', recordingHandlers());
651
+
652
+ await expect(rt.reloadConfig()).rejects.toThrow(/networkName/u);
653
+ });
654
+ });
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // Tests — broadcastWallops (global +w fan-out across every registry shard)
658
+ // ---------------------------------------------------------------------------
659
+
660
+ /**
661
+ * Opens a ConnectionDO, registers it with the given nick, and optionally
662
+ * flips user mode `w` on via a direct state mutation (avoids driving the
663
+ * full MODE handshake through the actor). Returns the live WebSocket and
664
+ * the connection's canonical hex id.
665
+ */
666
+ async function openWallopsCapableConn(
667
+ name: string,
668
+ opts: { wallops: boolean },
669
+ ): Promise<{ ws: WebSocket; hexId: string; received: string[] }> {
670
+ const ws = await openConnection(name);
671
+ await new Promise<void>((r) => setTimeout(r, 20));
672
+ ws.send(`NICK ${name}\r\n`);
673
+ ws.send(`USER ${name} 0 * :${name}\r\n`);
674
+ // Drain the registration numerics so they don't interfere with the
675
+ // assertions on wallops delivery below.
676
+ await new Promise<void>((r) => setTimeout(r, 100));
677
+
678
+ const hexId = await getConnectionHexId(name);
679
+ const received = drainSocket(ws);
680
+
681
+ if (opts.wallops) {
682
+ // Flip the +w mode directly on the live cached state. The DO is the
683
+ // authority for this state; broadcastWallops reads it back via
684
+ // getConnState() so what we mutate here is exactly what the fan-out
685
+ // will see.
686
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromString(hexId));
687
+ await runInDurableObject(stub, async (instance: unknown) => {
688
+ const doi = instance as {
689
+ __peekState?: () => Promise<
690
+ import('@serverless-ircd/irc-core').ConnectionState | undefined
691
+ >;
692
+ __pokeState?: (s: import('@serverless-ircd/irc-core').ConnectionState) => void;
693
+ };
694
+ const state = await doi.__peekState?.();
695
+ if (state !== undefined) state.userModes.wallops = true;
696
+ });
697
+ }
698
+
699
+ // Reserve the nick in the REAL registry under its hex id so
700
+ // broadcastWallops' shard walk finds it.
701
+ await reserveNickInRealRegistry(name, hexId);
702
+
703
+ return { ws, hexId, received };
704
+ }
705
+
706
+ describe('CfRuntime — broadcastWallops', () => {
707
+ it('delivers to every +w connection enumerated across all registry shards', async () => {
708
+ const realEnv = envWithRealDOs();
709
+
710
+ const a = await openWallopsCapableConn('wallops-a', { wallops: true });
711
+ const b = await openWallopsCapableConn('wallops-b', { wallops: true });
712
+ // A connection without +w must not receive the wallops.
713
+ const plain = await openWallopsCapableConn('wallops-plain', { wallops: false });
714
+
715
+ const rt = new CfRuntime(realEnv, 'conn-wallops-sender', recordingHandlers());
716
+ await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :hi fleet' }]);
717
+
718
+ await new Promise<void>((resolve) => {
719
+ const deadline = Date.now() + 1500;
720
+ const tick = (): void => {
721
+ const aGot = a.received.some((l) => l.includes('hi fleet'));
722
+ const bGot = b.received.some((l) => l.includes('hi fleet'));
723
+ if (aGot && bGot && Date.now() > deadline - 1000) {
724
+ resolve();
725
+ return;
726
+ }
727
+ if (Date.now() > deadline) {
728
+ resolve();
729
+ return;
730
+ }
731
+ setTimeout(tick, 20);
732
+ };
733
+ tick();
734
+ });
735
+
736
+ expect(a.received.some((l) => l.includes('hi fleet'))).toBe(true);
737
+ expect(b.received.some((l) => l.includes('hi fleet'))).toBe(true);
738
+ expect(plain.received.some((l) => l.includes('hi fleet'))).toBe(false);
739
+
740
+ a.ws.close();
741
+ b.ws.close();
742
+ plain.ws.close();
743
+ });
744
+
745
+ it('skips the excepted originator', async () => {
746
+ const realEnv = envWithRealDOs();
747
+ const sender = await openWallopsCapableConn('wallops-except-sender', { wallops: true });
748
+ const other = await openWallopsCapableConn('wallops-except-other', { wallops: true });
749
+
750
+ const rt = new CfRuntime(realEnv, sender.hexId, recordingHandlers());
751
+ await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :except me' }], sender.hexId);
752
+
753
+ await new Promise<void>((resolve) => {
754
+ const deadline = Date.now() + 1000;
755
+ const tick = (): void => {
756
+ if (other.received.some((l) => l.includes('except me'))) {
757
+ resolve();
758
+ return;
759
+ }
760
+ if (Date.now() > deadline) {
761
+ resolve();
762
+ return;
763
+ }
764
+ setTimeout(tick, 20);
765
+ };
766
+ tick();
767
+ });
768
+
769
+ expect(other.received.some((l) => l.includes('except me'))).toBe(true);
770
+ expect(sender.received.some((l) => l.includes('except me'))).toBe(false);
771
+
772
+ sender.ws.close();
773
+ other.ws.close();
774
+ });
775
+
776
+ it('is a no-op when the registry has no entries', async () => {
777
+ const realEnv = envWithRealDOs();
778
+ const handlers = recordingHandlers();
779
+ const rt = new CfRuntime(realEnv, 'conn-wallops-empty', handlers);
780
+ // Fresh storage (afterEach reset); no nick reservations anywhere.
781
+ await expect(
782
+ rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :nobody' }]),
783
+ ).resolves.toBeUndefined();
784
+ expect(handlers.sent).toEqual([]);
785
+ });
786
+
787
+ it('skips a connection whose persisted state is null without throwing', async () => {
788
+ // Reserve a nick pointing at a hex id whose ConnectionDO has no
789
+ // live state. `newUniqueId()` gives us a valid 64-hex DO id that
790
+ // is guaranteed not to collide with any connection we open here.
791
+ const realEnv = envWithRealDOs();
792
+ const ghostHex = env.CONNECTION_DO.newUniqueId().toString();
793
+ await reserveNickInRealRegistry('wallops-ghost', ghostHex);
794
+
795
+ const other = await openWallopsCapableConn('wallops-alongside-ghost', { wallops: true });
796
+
797
+ const rt = new CfRuntime(realEnv, 'conn-wallops-ghost-caller', recordingHandlers());
798
+ // Must not reject even though one enumerated conn reports null state.
799
+ await expect(
800
+ rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :ghost friendly' }]),
801
+ ).resolves.toBeUndefined();
802
+
803
+ await new Promise<void>((resolve) => {
804
+ const deadline = Date.now() + 1000;
805
+ const tick = (): void => {
806
+ if (other.received.some((l) => l.includes('ghost friendly'))) {
807
+ resolve();
808
+ return;
809
+ }
810
+ if (Date.now() > deadline) {
811
+ resolve();
812
+ return;
813
+ }
814
+ setTimeout(tick, 20);
815
+ };
816
+ tick();
817
+ });
818
+ expect(other.received.some((l) => l.includes('ghost friendly'))).toBe(true);
819
+
820
+ other.ws.close();
821
+ });
822
+ });
823
+
824
+ // ---------------------------------------------------------------------------
825
+ // Tests — changeNick shard routing + failure paths
826
+ // ---------------------------------------------------------------------------
827
+
828
+ describe('CfRuntime — changeNick shard routing', () => {
829
+ it('uses the single-shard RegistryDO.changeNick when old and new hash to the same shard', async () => {
830
+ // Pin registryShards to 1 so every nick routes to shard 0 — the
831
+ // same-shard branch in CfRuntime.changeNick is taken.
832
+ const realEnv = envWithRealDOs();
833
+ const rt = new CfRuntime(realEnv, 'conn-sameshard', recordingHandlers(), {
834
+ registryShards: 1,
835
+ });
836
+ expect(await rt.reserveNick('sn-old', 'conn-sameshard')).toEqual({ ok: true });
837
+
838
+ const ok = await rt.changeNick('conn-sameshard', 'sn-old', 'sn-new');
839
+ expect(ok).toBe(true);
840
+
841
+ // Look up under the same shard count the runtime used (1).
842
+ const key = registryKeyForNick('sn-new', 1);
843
+ const newOwner = (
844
+ (await env.REGISTRY_DO_REAL.get(
845
+ env.REGISTRY_DO_REAL.idFromName(key),
846
+ )) as unknown as RegistryRpc
847
+ ).lookupNick('sn-new');
848
+ expect(await newOwner).toBe('conn-sameshard');
849
+ const oldKey = registryKeyForNick('sn-old', 1);
850
+ const oldOwner = (
851
+ (await env.REGISTRY_DO_REAL.get(
852
+ env.REGISTRY_DO_REAL.idFromName(oldKey),
853
+ )) as unknown as RegistryRpc
854
+ ).lookupNick('sn-old');
855
+ expect(await oldOwner).toBeNull();
856
+ });
857
+
858
+ it('two-phase path returns false when the new nick is already taken cross-shard', async () => {
859
+ // Default shard count (32). Reserve `wn-taken` by another conn first,
860
+ // then attempt to changeNick to it from a different conn.
861
+ const realEnv = envWithRealDOs();
862
+ const rt = new CfRuntime(realEnv, 'conn-newowner', recordingHandlers());
863
+ expect(await rt.reserveNick('wn-current', 'conn-newowner')).toEqual({ ok: true });
864
+ expect(await rt.reserveNick('wn-taken', 'conn-someoneelse')).toEqual({ ok: true });
865
+
866
+ const ok = await rt.changeNick('conn-newowner', 'wn-current', 'wn-taken');
867
+ expect(ok).toBe(false);
868
+ // Old nick untouched because the reserve-new phase failed.
869
+ expect(await registryForNick(realEnv, 'wn-current').lookupNick('wn-current')).toBe(
870
+ 'conn-newowner',
871
+ );
872
+ expect(await registryForNick(realEnv, 'wn-taken').lookupNick('wn-taken')).toBe(
873
+ 'conn-someoneelse',
874
+ );
875
+ });
876
+ });
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // Tests — getConnection / getChannelConnections edge cases
880
+ // ---------------------------------------------------------------------------
881
+
882
+ describe('CfRuntime — getConnection and getChannelConnections branches', () => {
883
+ it('getConnection(this.connId) returns the live snapshot via handlers', async () => {
884
+ const stubEnv = envWithChannelStub();
885
+ const state = createConnection({ id: 'conn-getc-self', connectedSince: 0 });
886
+ state.nick = 'self';
887
+ const handlers: CfConnectionHandlers = {
888
+ send: (): void => {},
889
+ disconnect: (): void => {},
890
+ snapshot: () => state,
891
+ };
892
+ const rt = new CfRuntime(stubEnv, 'conn-getc-self', handlers);
893
+ const conn = await rt.getConnection('conn-getc-self');
894
+ expect(conn).not.toBeNull();
895
+ expect(conn?.nick).toBe('self');
896
+ });
897
+
898
+ it('getConnection(this.connId) returns null when handlers.snapshot is undefined', async () => {
899
+ const stubEnv = envWithChannelStub();
900
+ const rt = new CfRuntime(stubEnv, 'conn-getc-empty', recordingHandlers());
901
+ expect(await rt.getConnection('conn-getc-empty')).toBeNull();
902
+ });
903
+
904
+ it('getChannelConnections returns every member that reports a persisted state', async () => {
905
+ // A channel roster with one real member; the ConnectionDO is the
906
+ // authority for each member's state. getChannelConnections fans out
907
+ // one getConnState RPC per member and collects the results.
908
+ const realEnv = envWithRealDOs();
909
+ const rt = new CfRuntime(realEnv, 'conn-gcc-caller', recordingHandlers());
910
+
911
+ const member = await openWallopsCapableConn('gcc-real', { wallops: false });
912
+
913
+ await rt.applyChannelDelta('#gcc', {
914
+ memberships: [{ type: 'add' as const, conn: member.hexId, nick: 'gcc-real' }],
915
+ });
916
+
917
+ const conns = await rt.getChannelConnections('#gcc');
918
+ expect(conns.size).toBe(1);
919
+ expect(conns.get(member.hexId)?.nick).toBe('gcc-real');
920
+
921
+ member.ws.close();
922
+ });
923
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ChannelDO — roster + modes + fanout via ConnectionDO stubs.
3
3
  *
4
- * TDD outline (from tickets.md):
4
+ * TDD outline:
5
5
  * "Red on 'two members of a channel both receive PRIVMSG'; green;
6
6
  * then gone-connection sweep."
7
7
  *
@@ -452,7 +452,7 @@ describe('ChannelDO — instance keying', () => {
452
452
  });
453
453
 
454
454
  // ---------------------------------------------------------------------------
455
- // Scale smoke test — exercises the 1k-member ceiling from the ticket.
455
+ // Scale smoke test — exercises the 1k-member ceiling.
456
456
  // ---------------------------------------------------------------------------
457
457
 
458
458
  describe('ChannelDO — scale', () => {
@@ -478,3 +478,120 @@ describe('ChannelDO — scale', () => {
478
478
  expect(snapAfter?.members.length).toBe(0);
479
479
  }, 15_000);
480
480
  });
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Edge cases — empty-lines broadcast, cache reload, test hooks
484
+ // ---------------------------------------------------------------------------
485
+
486
+ describe('ChannelDO — broadcast + cache edge cases', () => {
487
+ it('broadcast is a no-op for an empty lines array', async () => {
488
+ const chan = '#empty-bcast';
489
+ await channel(chan.toLowerCase()).applyChannelDelta({
490
+ memberships: [{ type: 'add' as const, conn: 'ghost', nick: 'g' }],
491
+ });
492
+ // Must not reject and must not prune members (no fan-out attempted).
493
+ await expect(channel(chan.toLowerCase()).broadcast([])).resolves.toBeUndefined();
494
+ const snap = await channel(chan.toLowerCase()).getChannelSnapshot();
495
+ expect(snap?.members.length).toBe(1);
496
+ });
497
+
498
+ it('reloads persisted state from storage after __resetCache (deserializeChannel)', async () => {
499
+ // Mutate the channel, drop the in-memory cache, then read again.
500
+ // The read must go through deserializeChannel to rebuild Map/Set
501
+ // fields from the JSON-friendly persisted shape.
502
+ const chan = '#cache-reload';
503
+ const lower = chan.toLowerCase();
504
+ await channel(lower).applyChannelDelta({
505
+ memberships: [{ type: 'add' as const, conn: 'c-1', nick: 'alice' }],
506
+ topic: { text: 'hello topic', setter: 'alice', setAt: 100 },
507
+ });
508
+
509
+ // Drop the cache so the next read forces a storage reload.
510
+ await runInDurableObject(channelStub(lower), (instance: unknown) => {
511
+ const doi = instance as { __resetCache?: () => void };
512
+ doi.__resetCache?.();
513
+ });
514
+
515
+ const snap = await channel(lower).getChannelSnapshot();
516
+ expect(snap?.members.length).toBe(1);
517
+ expect(snap?.members[0]?.nick).toBe('alice');
518
+ expect(snap?.topic?.text).toBe('hello topic');
519
+ });
520
+
521
+ it('__peekState returns the live cached state before any reload', async () => {
522
+ const lower = '#peek-state';
523
+ await channel(lower).applyChannelDelta({
524
+ memberships: [{ type: 'add' as const, conn: 'c-peek', nick: 'peeker' }],
525
+ });
526
+ const cached = await runInDurableObject(channelStub(lower), async (instance: unknown) => {
527
+ const doi = instance as { __peekState?: () => Promise<unknown> };
528
+ return doi.__peekState?.();
529
+ });
530
+ expect(cached).toBeDefined();
531
+ // The cached state's members Map carries the entry we just added.
532
+ const members = (cached as { members: Map<string, unknown> }).members;
533
+ expect(members.get('c-peek')).toBeDefined();
534
+ });
535
+
536
+ it('__resetCache clears the in-memory cache', async () => {
537
+ const lower = '#reset-cache';
538
+ await channel(lower).applyChannelDelta({
539
+ memberships: [{ type: 'add' as const, conn: 'c-reset', nick: 'resetter' }],
540
+ });
541
+
542
+ await runInDurableObject(channelStub(lower), (instance: unknown) => {
543
+ const doi = instance as { __resetCache?: () => void };
544
+ doi.__resetCache?.();
545
+ });
546
+
547
+ const cached = await runInDurableObject(channelStub(lower), async (instance: unknown) => {
548
+ const doi = instance as { __peekState?: () => Promise<unknown> };
549
+ return doi.__peekState?.();
550
+ });
551
+ expect(cached).toBeUndefined();
552
+ });
553
+
554
+ it('broadcast with all-live members does not prune the roster', async () => {
555
+ // When every member has an OPEN WebSocket, deliver returns > 0 for
556
+ // each and the `pruned.length > 0` else branch is taken (no prune
557
+ // delta applied).
558
+ const chan = '#no-prune';
559
+ const alice = await connectAndRegister('conn-noprune-alice', 'alice');
560
+ const bob = await connectAndRegister('conn-noprune-bob', 'bob');
561
+
562
+ await channel(chan.toLowerCase()).applyChannelDelta({
563
+ memberships: [
564
+ { type: 'add', conn: alice.id, nick: 'alice' },
565
+ { type: 'add', conn: bob.id, nick: 'bob' },
566
+ ],
567
+ });
568
+
569
+ await channel(chan.toLowerCase()).broadcast(['PRIVMSG #no-prune :hello']);
570
+ await alice.client.waitForMessage((l) => l.startsWith('PRIVMSG #no-prune'));
571
+ await bob.client.waitForMessage((l) => l.startsWith('PRIVMSG #no-prune'));
572
+
573
+ // Both members survive — no prune occurred.
574
+ const snap = await channel(chan.toLowerCase()).getChannelSnapshot();
575
+ expect(snap?.members.length).toBe(2);
576
+
577
+ await alice.client.close();
578
+ await bob.client.close();
579
+ });
580
+
581
+ it('sweep on a channel with all-live members prunes nobody', async () => {
582
+ const chan = '#sweep-clean';
583
+ const alice = await connectAndRegister('conn-sweep-uniq-001', 'sweepop');
584
+
585
+ await channel(chan.toLowerCase()).applyChannelDelta({
586
+ memberships: [{ type: 'add', conn: alice.id, nick: 'alice' }],
587
+ });
588
+
589
+ const pruned = await channel(chan.toLowerCase()).sweep();
590
+ expect(pruned).toBe(0);
591
+
592
+ const snap = await channel(chan.toLowerCase()).getChannelSnapshot();
593
+ expect(snap?.members.length).toBe(1);
594
+
595
+ await alice.client.close();
596
+ });
597
+ });
@@ -169,3 +169,52 @@ describe('loadServerConfigFromCfEnv — failure modes', () => {
169
169
  }
170
170
  });
171
171
  });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // SASL_ACCOUNTS env parsing — malformed entries are skipped, valid ones kept.
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe('loadServerConfigFromCfEnv — SASL_ACCOUNTS parsing', () => {
178
+ it('skips blank lines within the seed', () => {
179
+ const cfg = loadServerConfigFromCfEnv({
180
+ ...baseEnv(),
181
+ SASL_ACCOUNTS: 'alice:secret\n\nbob:password\n \n',
182
+ });
183
+ expect(cfg.saslAccounts).toEqual([
184
+ { username: 'alice', password: 'secret' },
185
+ { username: 'bob', password: 'password' },
186
+ ]);
187
+ });
188
+
189
+ it('skips lines with no colon separator', () => {
190
+ const cfg = loadServerConfigFromCfEnv({
191
+ ...baseEnv(),
192
+ SASL_ACCOUNTS: 'alice:secret\nnocolonhere\nbob:password',
193
+ });
194
+ expect(cfg.saslAccounts).toHaveLength(2);
195
+ });
196
+
197
+ it('skips lines where the colon is at position 0 (empty username)', () => {
198
+ const cfg = loadServerConfigFromCfEnv({
199
+ ...baseEnv(),
200
+ SASL_ACCOUNTS: ':secret\nalice:valid',
201
+ });
202
+ expect(cfg.saslAccounts).toEqual([{ username: 'alice', password: 'valid' }]);
203
+ });
204
+
205
+ it('skips lines with an empty password after the colon', () => {
206
+ const cfg = loadServerConfigFromCfEnv({
207
+ ...baseEnv(),
208
+ SASL_ACCOUNTS: 'alice:\nbob:valid',
209
+ });
210
+ expect(cfg.saslAccounts).toEqual([{ username: 'bob', password: 'valid' }]);
211
+ });
212
+
213
+ it('passes CREATED_AT verbatim when it overflows Number precision', () => {
214
+ // A digit string so large that Number() produces Infinity — the
215
+ // parser must fall back to the raw string rather than emit Infinity.
216
+ const huge = '9'.repeat(400);
217
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: huge });
218
+ expect(cfg.createdAt).toBe(huge);
219
+ });
220
+ });
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 085 — CF `webSocketMessage` channel-registration: wire ChannelDO
2
+ * CF `webSocketMessage` channel-registration: wire ChannelDO
3
3
  * registration OR remove the dead loop.
4
4
  *
5
- * Spike conclusion (documented in the ticket): the dead loop that
5
+ * Spike conclusion: the dead loop that
6
6
  * iterated `state.joinedChannels` to "register" the ConnectionDO with
7
7
  * each ChannelDO is redundant. Channel membership — and therefore
8
8
  * broadcast fan-out registration — is driven solely by the
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 087 — CF outbound batching: implement OR drop the reserved optimization.
2
+ * CF outbound batching: implement OR drop the reserved optimization.
3
3
  *
4
- * Spike conclusion (documented in the ticket): the speculative per-microtask
4
+ * Spike conclusion: the speculative per-microtask
5
5
  * outbound drain that was reserved as a commented-out field in
6
6
  * `ConnectionDO` is dropped. Per-call batching is already implemented —
7
7
  * `ConnectionDO.deliver()` joins every line of a ChannelDO broadcast into
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Pure exported helpers from `connection-do.ts`.
3
+ *
4
+ * These functions are independent of Durable Object state, so they are
5
+ * unit-tested directly without the workerd/miniflare DO harness.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { parseSaslAccountsEnv, wsFrameModeFromTags } from '../src/connection-do';
10
+
11
+ describe('parseSaslAccountsEnv', () => {
12
+ it('returns an empty array for undefined input', () => {
13
+ expect(parseSaslAccountsEnv(undefined)).toEqual([]);
14
+ });
15
+
16
+ it('returns an empty array for an empty string', () => {
17
+ expect(parseSaslAccountsEnv('')).toEqual([]);
18
+ });
19
+
20
+ it('parses a single well-formed entry', () => {
21
+ expect(parseSaslAccountsEnv('alice:secret')).toEqual([
22
+ { username: 'alice', password: 'secret' },
23
+ ]);
24
+ });
25
+
26
+ it('parses multiple newline-delimited entries', () => {
27
+ expect(parseSaslAccountsEnv('alice:secret\nbob:pw')).toEqual([
28
+ { username: 'alice', password: 'secret' },
29
+ { username: 'bob', password: 'pw' },
30
+ ]);
31
+ });
32
+
33
+ it('skips blank lines', () => {
34
+ expect(parseSaslAccountsEnv('alice:secret\n\nbob:pw\n \n')).toEqual([
35
+ { username: 'alice', password: 'secret' },
36
+ { username: 'bob', password: 'pw' },
37
+ ]);
38
+ });
39
+
40
+ it('skips lines with no colon separator', () => {
41
+ expect(parseSaslAccountsEnv('alice:secret\nnocolonhere\nbob:pw')).toEqual([
42
+ { username: 'alice', password: 'secret' },
43
+ { username: 'bob', password: 'pw' },
44
+ ]);
45
+ });
46
+
47
+ it('skips lines where the colon is at position 0 (empty username)', () => {
48
+ expect(parseSaslAccountsEnv(':secret\nalice:valid')).toEqual([
49
+ { username: 'alice', password: 'valid' },
50
+ ]);
51
+ });
52
+
53
+ it('skips lines with an empty password after the colon', () => {
54
+ expect(parseSaslAccountsEnv('alice:\nbob:valid')).toEqual([
55
+ { username: 'bob', password: 'valid' },
56
+ ]);
57
+ });
58
+
59
+ it('returns an empty array when every line is malformed', () => {
60
+ expect(parseSaslAccountsEnv('\n \n:no-user\nuser:')).toEqual([]);
61
+ });
62
+ });
63
+
64
+ describe('wsFrameModeFromTags', () => {
65
+ it("returns 'legacy' when no tags are present", () => {
66
+ expect(wsFrameModeFromTags([])).toBe('legacy');
67
+ });
68
+
69
+ it("returns 'legacy' when no recognized tag matches", () => {
70
+ // Unknown tags (e.g. from an older deployment's tag scheme) fall
71
+ // back to the legacy-tolerant mode.
72
+ expect(wsFrameModeFromTags(['unknown:tag', 'another'])).toBe('legacy');
73
+ });
74
+
75
+ it("returns 'spec-text' when the spec-text tag is present", () => {
76
+ expect(wsFrameModeFromTags(['ws:spec-text'])).toBe('spec-text');
77
+ });
78
+
79
+ it("returns 'spec-binary' when the spec-binary tag is present", () => {
80
+ expect(wsFrameModeFromTags(['ws:spec-binary'])).toBe('spec-binary');
81
+ });
82
+
83
+ it("returns 'legacy' when the legacy tag is present", () => {
84
+ expect(wsFrameModeFromTags(['ws:legacy'])).toBe('legacy');
85
+ });
86
+
87
+ it('picks the first recognized tag when multiple are present', () => {
88
+ // The scan is order-sensitive; the first matching tag wins.
89
+ expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
90
+ });
91
+ });