serverless-ircd 0.5.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 (167) 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 +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  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 +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  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/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -35,6 +35,7 @@
35
35
  "@cloudflare/workers-types": "^5.20260714.1",
36
36
  "@serverless-ircd/in-memory-runtime": "workspace:*",
37
37
  "@serverless-ircd/irc-test-support": "workspace:*",
38
+ "@vitest/coverage-istanbul": "^4.1.0",
38
39
  "@vitest/coverage-v8": "^4.1.0",
39
40
  "rimraf": "^6.0.0",
40
41
  "typescript": "^5.6.0",
@@ -466,6 +466,9 @@ export class ConnectionDO extends DurableObject<Env> {
466
466
  // deployment-wide uptime, tracked as future work.
467
467
  stats: new CfStats(runtime, WORKER_STARTUP_AT),
468
468
  transport: makeWsFrameTransport(mode),
469
+ // Cloudflare terminates TLS at the edge before the WebSocket reaches
470
+ // the DO, so every WS connection is secure → user mode `S`.
471
+ secure: true,
469
472
  ...(accounts !== undefined ? { accounts } : {}),
470
473
  ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
471
474
  logger,
@@ -34,6 +34,13 @@ export interface PersistedConnectionState {
34
34
  id: string;
35
35
  registration: RegistrationState;
36
36
  capNegotiating: boolean;
37
+ /**
38
+ * Transport TLS fact (user mode `S`). Optional on the persisted shape so
39
+ * records written before the field shipped (schema is otherwise
40
+ * unchanged) deserialize with a `false` default via the coalescing in
41
+ * {@link deserialize}. Always written by {@link serialize}.
42
+ */
43
+ secure?: boolean;
37
44
  caps: string[];
38
45
  joinedChannels: ChanName[];
39
46
  userModes: UserModes;
@@ -61,6 +68,7 @@ export function serialize(state: ConnectionState): PersistedConnectionState {
61
68
  id: state.id,
62
69
  registration: state.registration,
63
70
  capNegotiating: state.capNegotiating,
71
+ secure: state.secure,
64
72
  caps: [...state.caps],
65
73
  joinedChannels: [...state.joinedChannels],
66
74
  userModes: { ...state.userModes },
@@ -88,6 +96,9 @@ export function deserialize(record: unknown): ConnectionState {
88
96
  id: migrated.id,
89
97
  registration: migrated.registration,
90
98
  capNegotiating: migrated.capNegotiating,
99
+ // `secure` is absent on records persisted before user mode `S` shipped;
100
+ // `migrate` backfills it to false so the cast below is truthful.
101
+ secure: migrated.secure ?? false,
91
102
  caps: new Set<string>(migrated.caps),
92
103
  joinedChannels: new Set<ChanName>(migrated.joinedChannels),
93
104
  userModes: { ...migrated.userModes },
@@ -119,8 +130,19 @@ export function migrate(record: unknown): PersistedConnectionState {
119
130
  if (!isPlainObject(record)) {
120
131
  throw new Error('persisted connection state is not an object');
121
132
  }
133
+ // Backfill additive fields introduced by user mode `S` (TLS connected):
134
+ // `secure` and `userModes.tls`. Both default to false (plain transport),
135
+ // preserving the pre-feature behaviour for records persisted before the
136
+ // mode shipped. The schema version is unchanged because the additions are
137
+ // purely additive with safe defaults — no row transformation is needed
138
+ // beyond coalescing the missing keys.
139
+ if (record.secure === undefined) record.secure = false;
140
+ if (isPlainObject(record.userModes)) {
141
+ const um = record.userModes as Record<string, unknown>;
142
+ if (um.tls === undefined) um.tls = false;
143
+ }
122
144
  const version = typeof record.version === 'number' ? record.version : 0;
123
- // No migrations yet — v1 is the initial schema. Future work adds steps:
145
+ // No versioned migrations yet — v1 is the initial schema. Future work:
124
146
  // if (version < 2) record = migrate_v1_v2(record);
125
147
  // if (version < 3) record = migrate_v2_v3(record);
126
148
  if (version > PERSISTED_STATE_VERSION) {
@@ -131,9 +153,8 @@ export function migrate(record: unknown): PersistedConnectionState {
131
153
  }
132
154
  // The cast below is safe: we accept any JSON shape the storage layer
133
155
  // returns; production code wrote it via `serialize`, tests wrote it
134
- // through the same path. If a future field is missing, the
135
- // `exactOptionalPropertyTypes` discipline in `serialize` keeps
136
- // undefined keys out of the persisted record entirely.
156
+ // through the same path. Backfilling above keeps the additive
157
+ // `secure` / `userModes.tls` keys present even on pre-feature records.
137
158
  return record as unknown as PersistedConnectionState;
138
159
  }
139
160
 
@@ -652,3 +652,272 @@ describe('CfRuntime — reloadConfig (REHASH)', () => {
652
652
  await expect(rt.reloadConfig()).rejects.toThrow(/networkName/u);
653
653
  });
654
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
+ });
@@ -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
+ });
@@ -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
+ });
@@ -224,3 +224,51 @@ describe('resolveAccountStore precedence', () => {
224
224
  ).toBe(true);
225
225
  });
226
226
  });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // resolveAccountStore seed parsing — malformed entries are skipped so a
230
+ // trailing newline or partial edit does not break boot.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe('resolveAccountStore — SASL_ACCOUNTS seed edge cases', () => {
234
+ it('skips blank lines within the seed', async () => {
235
+ const store = await resolveAccountStore(undefined, 'alice:secret\n\nbob:pw\n \n');
236
+ expect(store).toBeDefined();
237
+ expect(
238
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
239
+ ).toBe(true);
240
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'pw' }).ok).toBe(
241
+ true,
242
+ );
243
+ });
244
+
245
+ it('skips lines with no colon separator', async () => {
246
+ const store = await resolveAccountStore(undefined, 'alice:secret\nnocolon\nbob:pw');
247
+ // Only the two well-formed entries are loaded.
248
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'nocolon', password: '' }).ok).toBe(
249
+ false,
250
+ );
251
+ expect(
252
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
253
+ ).toBe(true);
254
+ });
255
+
256
+ it('skips lines where the colon is at position 0', async () => {
257
+ const store = await resolveAccountStore(undefined, ':secret\nalice:valid');
258
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'valid' }).ok).toBe(
259
+ true,
260
+ );
261
+ });
262
+
263
+ it('skips lines with an empty password', async () => {
264
+ const store = await resolveAccountStore(undefined, 'alice:\nbob:valid');
265
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'valid' }).ok).toBe(
266
+ true,
267
+ );
268
+ });
269
+
270
+ it('returns undefined when every line is malformed', async () => {
271
+ const store = await resolveAccountStore(undefined, '\n \n:no-user\nuser:');
272
+ expect(store).toBeUndefined();
273
+ });
274
+ });