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
@@ -0,0 +1,411 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { monitorReducer } from '../../src/commands/monitor';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const baseServerConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ /** Monitor cap ceiling used in tests; mirrors the production default. */
22
+ const MAX_MONITOR = 30;
23
+
24
+ function makeCtx(state: ConnectionState): Ctx {
25
+ return buildCtx({
26
+ serverConfig: baseServerConfig,
27
+ clock: new FakeClock(5_000),
28
+ ids: new SequentialIdFactory(),
29
+ motd: EmptyMotdProvider,
30
+ connection: state,
31
+ });
32
+ }
33
+
34
+ function makeState(): ConnectionState {
35
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
36
+ s.nick = 'alice';
37
+ s.user = 'alice';
38
+ s.host = 'example.com';
39
+ s.realname = 'Alice';
40
+ s.registration = 'registered';
41
+ return s;
42
+ }
43
+
44
+ const L = (text: string): RawLine => ({ text });
45
+
46
+ const monitor = (sub: string, target?: string) =>
47
+ ({
48
+ command: 'MONITOR',
49
+ params: target === undefined ? [sub] : [sub, target],
50
+ tags: {},
51
+ }) as const;
52
+
53
+ /** Builds an online map from display nicks (auto-folds each key). */
54
+ const onlineMap = (...nicks: string[]): ReadonlyMap<string, string> =>
55
+ new Map(nicks.map((n) => [n.toLowerCase(), n]));
56
+
57
+ describe('monitorReducer — MONITOR + (add)', () => {
58
+ it('emits 731 RPL_MONOFFLINE for an offline nick', () => {
59
+ const state = makeState();
60
+ const ctx = makeCtx(state);
61
+
62
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);
63
+
64
+ expect(out.effects).toEqual<EffectType[]>([
65
+ Effect.send('c1', [L(':irc.example.com 731 alice :bob')]),
66
+ ]);
67
+ expect(state.monitorList?.has('bob')).toBe(true);
68
+ });
69
+
70
+ it('emits 730 RPL_MONONLINE for an online nick', () => {
71
+ const state = makeState();
72
+ const ctx = makeCtx(state);
73
+
74
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);
75
+
76
+ expect(out.effects).toEqual<EffectType[]>([
77
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob')]),
78
+ ]);
79
+ expect(state.monitorList?.has('bob')).toBe(true);
80
+ });
81
+
82
+ it('handles a comma-separated add list, splitting online/offline into 730/731', () => {
83
+ const state = makeState();
84
+ const ctx = makeCtx(state);
85
+
86
+ const out = monitorReducer(
87
+ onlineMap('bob', 'carol'),
88
+ MAX_MONITOR,
89
+ monitor('+', 'bob,carol,dave'),
90
+ ctx,
91
+ );
92
+
93
+ // Two separate effects: 730 with online nicks, then 731 with offline.
94
+ expect(out.effects).toEqual<EffectType[]>([
95
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),
96
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave')]),
97
+ ]);
98
+ expect(Array.from(state.monitorList ?? []).sort()).toEqual(['bob', 'carol', 'dave']);
99
+ });
100
+
101
+ it('omits the 730 line when no added nick is online', () => {
102
+ const state = makeState();
103
+ const ctx = makeCtx(state);
104
+
105
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'dave,eve'), ctx);
106
+
107
+ expect(out.effects).toEqual<EffectType[]>([
108
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave,eve')]),
109
+ ]);
110
+ });
111
+
112
+ it('omits the 731 line when every added nick is online', () => {
113
+ const state = makeState();
114
+ const ctx = makeCtx(state);
115
+
116
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);
117
+
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob')]),
120
+ ]);
121
+ });
122
+
123
+ it('skips nicks already in the watchlist without re-emitting their status', () => {
124
+ const state = makeState();
125
+ state.monitorList = new Set(['bob']);
126
+ const ctx = makeCtx(state);
127
+
128
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);
129
+
130
+ // Already-monitored nick produces no output (per ircv3 MONITOR spec).
131
+ expect(out.effects).toEqual<EffectType[]>([]);
132
+ });
133
+
134
+ it('reports the registered display spelling, not the requester spelling', () => {
135
+ const state = makeState();
136
+ const ctx = makeCtx(state);
137
+
138
+ const out = monitorReducer(onlineMap('Bob'), MAX_MONITOR, monitor('+', 'BOB'), ctx);
139
+
140
+ expect(out.effects).toEqual<EffectType[]>([
141
+ Effect.send('c1', [L(':irc.example.com 730 alice :Bob')]),
142
+ ]);
143
+ // Stored in the folded form for case-insensitive membership tests.
144
+ expect(state.monitorList?.has('bob')).toBe(true);
145
+ });
146
+
147
+ it('emits 734 ERR_MONLISTFULL when adding would exceed the cap, naming the rejected nicks', () => {
148
+ const state = makeState();
149
+ // Pre-fill the list to the cap.
150
+ state.monitorList = new Set(Array.from({ length: MAX_MONITOR }, (_, i) => `nick${i}`));
151
+ const ctx = makeCtx(state);
152
+
153
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob,carol'), ctx);
154
+
155
+ expect(out.effects).toEqual<EffectType[]>([
156
+ Effect.send('c1', [L(`:irc.example.com 734 alice ${MAX_MONITOR} :bob,carol`)]),
157
+ ]);
158
+ // Cap unchanged.
159
+ expect(state.monitorList?.size).toBe(MAX_MONITOR);
160
+ });
161
+
162
+ it('partially admits an oversized add list up to the cap and 734s the rest', () => {
163
+ const state = makeState();
164
+ state.monitorList = new Set(Array.from({ length: MAX_MONITOR - 1 }, (_, i) => `nick${i}`));
165
+ const ctx = makeCtx(state);
166
+
167
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob,carol'), ctx);
168
+
169
+ // First nick admitted (offline) → 731; second rejected → 734.
170
+ expect(out.effects).toEqual<EffectType[]>([
171
+ Effect.send('c1', [L(':irc.example.com 731 alice :bob')]),
172
+ Effect.send('c1', [L(`:irc.example.com 734 alice ${MAX_MONITOR} :carol`)]),
173
+ ]);
174
+ expect(state.monitorList?.has('bob')).toBe(true);
175
+ expect(state.monitorList?.has('carol')).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe('monitorReducer — MONITOR - (remove)', () => {
180
+ it('removes nicks from the watchlist silently (no numeric output)', () => {
181
+ const state = makeState();
182
+ state.monitorList = new Set(['bob', 'carol']);
183
+ const ctx = makeCtx(state);
184
+
185
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('-', 'bob'), ctx);
186
+
187
+ expect(out.effects).toEqual<EffectType[]>([]);
188
+ expect(state.monitorList?.has('bob')).toBe(false);
189
+ expect(state.monitorList?.has('carol')).toBe(true);
190
+ });
191
+
192
+ it('removes multiple comma-separated nicks', () => {
193
+ const state = makeState();
194
+ state.monitorList = new Set(['bob', 'carol', 'dave']);
195
+ const ctx = makeCtx(state);
196
+
197
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'bob,dave'), ctx);
198
+
199
+ expect(out.effects).toEqual<EffectType[]>([]);
200
+ expect(state.monitorList?.has('bob')).toBe(false);
201
+ expect(state.monitorList?.has('carol')).toBe(true);
202
+ expect(state.monitorList?.has('dave')).toBe(false);
203
+ });
204
+
205
+ it('is a no-op for a nick that was not being monitored', () => {
206
+ const state = makeState();
207
+ state.monitorList = new Set(['bob']);
208
+ const ctx = makeCtx(state);
209
+
210
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'ghost'), ctx);
211
+
212
+ expect(out.effects).toEqual<EffectType[]>([]);
213
+ expect(state.monitorList?.has('bob')).toBe(true);
214
+ });
215
+
216
+ it('is a no-op when no watchlist has been allocated yet', () => {
217
+ const state = makeState();
218
+ const ctx = makeCtx(state);
219
+
220
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'bob'), ctx);
221
+
222
+ expect(out.effects).toEqual<EffectType[]>([]);
223
+ expect(state.monitorList).toBeUndefined();
224
+ });
225
+
226
+ it('is a no-op when the target list is empty (`MONITOR -` with no args)', () => {
227
+ const state = makeState();
228
+ state.monitorList = new Set(['bob']);
229
+ const ctx = makeCtx(state);
230
+
231
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-'), ctx);
232
+
233
+ expect(out.effects).toEqual<EffectType[]>([]);
234
+ expect(state.monitorList?.has('bob')).toBe(true);
235
+ });
236
+ });
237
+
238
+ describe('monitorReducer — MONITOR C (clear)', () => {
239
+ it('empties the entire watchlist silently', () => {
240
+ const state = makeState();
241
+ state.monitorList = new Set(['bob', 'carol', 'dave']);
242
+ const ctx = makeCtx(state);
243
+
244
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('C'), ctx);
245
+
246
+ expect(out.effects).toEqual<EffectType[]>([]);
247
+ expect(state.monitorList?.size).toBe(0);
248
+ });
249
+
250
+ it('is a no-op when the watchlist is already empty', () => {
251
+ const state = makeState();
252
+ const ctx = makeCtx(state);
253
+
254
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('C'), ctx);
255
+
256
+ expect(out.effects).toEqual<EffectType[]>([]);
257
+ expect(state.monitorList?.size).toBe(0);
258
+ });
259
+ });
260
+
261
+ describe('monitorReducer — MONITOR L (list)', () => {
262
+ it('emits 732 RPL_MONLIST per line then 733 RPL_ENDOFMONLIST', () => {
263
+ const state = makeState();
264
+ state.monitorList = new Set(['bob', 'carol']);
265
+ const ctx = makeCtx(state);
266
+
267
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);
268
+
269
+ // Nicks are listed in their stored display form (which is folded, so
270
+ // lowercase here). Order is the Set's insertion order.
271
+ const sends = out.effects.map((e) => (e as { lines: RawLine[] }).lines[0]?.text);
272
+ expect(sends).toContain(':irc.example.com 732 alice :bob');
273
+ expect(sends).toContain(':irc.example.com 732 alice :carol');
274
+ expect(sends[sends.length - 1]).toBe(':irc.example.com 733 alice :End of MONITOR list');
275
+ });
276
+
277
+ it('emits only 733 when the watchlist is empty', () => {
278
+ const state = makeState();
279
+ const ctx = makeCtx(state);
280
+
281
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);
282
+
283
+ expect(out.effects).toEqual<EffectType[]>([
284
+ Effect.send('c1', [L(':irc.example.com 733 alice :End of MONITOR list')]),
285
+ ]);
286
+ });
287
+ });
288
+
289
+ describe('monitorReducer — MONITOR S (status)', () => {
290
+ it('emits 730 for online monitored nicks and 731 for offline ones', () => {
291
+ const state = makeState();
292
+ state.monitorList = new Set(['bob', 'carol', 'dave']);
293
+ const ctx = makeCtx(state);
294
+
295
+ const out = monitorReducer(onlineMap('bob', 'carol'), MAX_MONITOR, monitor('S'), ctx);
296
+
297
+ expect(out.effects).toEqual<EffectType[]>([
298
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),
299
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave')]),
300
+ ]);
301
+ });
302
+
303
+ it('emits only 730 when every monitored nick is online', () => {
304
+ const state = makeState();
305
+ state.monitorList = new Set(['bob', 'carol']);
306
+ const ctx = makeCtx(state);
307
+
308
+ const out = monitorReducer(onlineMap('bob', 'carol'), MAX_MONITOR, monitor('S'), ctx);
309
+
310
+ expect(out.effects).toEqual<EffectType[]>([
311
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),
312
+ ]);
313
+ });
314
+
315
+ it('emits only 731 when every monitored nick is offline', () => {
316
+ const state = makeState();
317
+ state.monitorList = new Set(['dave', 'eve']);
318
+ const ctx = makeCtx(state);
319
+
320
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('S'), ctx);
321
+
322
+ expect(out.effects).toEqual<EffectType[]>([
323
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave,eve')]),
324
+ ]);
325
+ });
326
+
327
+ it('emits nothing when the watchlist is empty', () => {
328
+ const state = makeState();
329
+ const ctx = makeCtx(state);
330
+
331
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('S'), ctx);
332
+
333
+ expect(out.effects).toEqual<EffectType[]>([]);
334
+ });
335
+ });
336
+
337
+ describe('monitorReducer — validation & state', () => {
338
+ it('uses * as nick placeholder for unregistered connections', () => {
339
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
340
+ const ctx = makeCtx(state);
341
+
342
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);
343
+
344
+ expect(out.effects).toEqual<EffectType[]>([
345
+ Effect.send('c1', [L(':irc.example.com 731 * :bob')]),
346
+ ]);
347
+ });
348
+
349
+ it('updates lastSeen to ctx.clock.now()', () => {
350
+ const state = makeState();
351
+ const ctx = makeCtx(state);
352
+
353
+ monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);
354
+
355
+ expect(state.lastSeen).toBe(5_000);
356
+ });
357
+
358
+ it('returns the same state reference', () => {
359
+ const state = makeState();
360
+ const ctx = makeCtx(state);
361
+
362
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);
363
+
364
+ expect(out.state).toBe(state);
365
+ });
366
+
367
+ it('rejects an unknown subcommand with 461 ERR_NEEDMOREPARAMS', () => {
368
+ const state = makeState();
369
+ const ctx = makeCtx(state);
370
+
371
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('Z'), ctx);
372
+
373
+ expect(out.effects).toEqual<EffectType[]>([
374
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Unknown command')]),
375
+ ]);
376
+ });
377
+
378
+ it('rejects MONITOR + with no nick argument via 461 ERR_NEEDMOREPARAMS', () => {
379
+ const state = makeState();
380
+ const ctx = makeCtx(state);
381
+
382
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+'), ctx);
383
+
384
+ expect(out.effects).toEqual<EffectType[]>([
385
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),
386
+ ]);
387
+ });
388
+
389
+ it('treats MONITOR (no sub) as 461 ERR_NEEDMOREPARAMS', () => {
390
+ const state = makeState();
391
+ const ctx = makeCtx(state);
392
+
393
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor(''), ctx);
394
+
395
+ expect(out.effects).toEqual<EffectType[]>([
396
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),
397
+ ]);
398
+ });
399
+
400
+ it('treats MONITOR with no params array at all as 461 ERR_NEEDMOREPARAMS', () => {
401
+ const state = makeState();
402
+ const ctx = makeCtx(state);
403
+
404
+ // Mirrors the parser shape for a bare `MONITOR` with no subcommand.
405
+ const out = monitorReducer(new Map(), MAX_MONITOR, { params: [] }, ctx);
406
+
407
+ expect(out.effects).toEqual<EffectType[]>([
408
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),
409
+ ]);
410
+ });
411
+ });