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,204 @@
1
+ /**
2
+ * Pure reducer for the IRC `LUSERS` command (RFC 2812 §4.6.2).
3
+ *
4
+ * PLAN §2.1 location-of-authority: LUSERS is a read-only diagnostic that
5
+ * reports network-wide counts. The reducer takes an already-aggregated
6
+ * {@link ServerStatsSnapshot} (the actor layer awaits a {@link ServerStats}
7
+ * backend before calling here — same pattern as `listChannels` →
8
+ * `listReducer`). The reducer never mutates anything but the requester's
9
+ * `lastSeen`.
10
+ *
11
+ * Wire format (RFC 2812 §5.1):
12
+ * - `LUSERS [<mask> [<server>]]`
13
+ * - `251 RPL_LUSERCLIENT`:
14
+ * `:<server> 251 <nick> :There are <users> users and <invisible>
15
+ * invisible on <servers> servers`
16
+ * - `252 RPL_LUSEROP`:
17
+ * `:<server> 252 <nick> <count> :operator(s) online` (only when non-zero)
18
+ * - `253 RPL_LUSERUNKNOWN`:
19
+ * `:<server> 253 <nick> <count> :unknown connection(s)` (only when
20
+ * non-zero)
21
+ * - `254 RPL_LUSERCHANNELS`:
22
+ * `:<server> 254 <nick> <count> :channel(s) formed` (only when non-zero)
23
+ * - `255 RPL_LUSERME`:
24
+ * `:<server> 255 <nick> :I have <localConns> clients and <servers>
25
+ * servers`
26
+ * - `265 RPL_LOCALUSERS`:
27
+ * `:<server> 265 <nick> <cur> <max> :Current local users <cur>, max
28
+ * <max>`
29
+ * - `266 RPL_GLOBALUSERS`:
30
+ * `:<server> 266 <nick> <cur> <max> :Current global users <cur>, max
31
+ * <max>`
32
+ * - `402 ERR_NOSUCHSERVER`:
33
+ * `:<server> 402 <nick> <server> :No such server`
34
+ *
35
+ * Non-zero omission: 252/253/254 are suppressed when their count is zero
36
+ * (per RFC 2812). 251, 255, 265, and 266 are always emitted — modern
37
+ * clients rely on 265/266 for the user-list count and charybdis emits
38
+ * them unconditionally.
39
+ *
40
+ * Single-server deployment: `servers` is always 1 (PLAN §1 — S2S linking
41
+ * is a non-goal). `<mask>` is accepted for RFC compliance but not applied
42
+ * (the serverless deployment has one server, so a wildcard match is
43
+ * trivially true).
44
+ */
45
+
46
+ import { Effect } from '../effects.js';
47
+ import type { Effect as EffectType, RawLine } from '../effects.js';
48
+ import type { ServerStatsSnapshot } from '../ports.js';
49
+ import { Numerics } from '../protocol/numerics.js';
50
+ import type { Ctx } from '../types.js';
51
+
52
+ /** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
53
+ const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
54
+
55
+ /** Human-readable trailing templates per numeric. */
56
+ const LUSERCLIENT_TEXT = (users: number, invisible: number, servers: number): string =>
57
+ `There are ${users} users and ${invisible} invisible on ${servers} servers`;
58
+ const LUSEROP_TEXT = 'operator(s) online';
59
+ const LUSERUNKNOWN_TEXT = 'unknown connection(s)';
60
+ const LUSERCHANNELS_TEXT = 'channel(s) formed';
61
+ const LUSERME_TEXT = (localConns: number, servers: number): string =>
62
+ `I have ${localConns} clients and ${servers} servers`;
63
+ const LOCALUSERS_TEXT = (cur: number, max: number): string =>
64
+ `Current local users ${cur}, max ${max}`;
65
+ const GLOBALUSERS_TEXT = (cur: number, max: number): string =>
66
+ `Current global users ${cur}, max ${max}`;
67
+
68
+ /**
69
+ * Builds a `:<server> <code> <nick> :<trailing>` line. The middle field is
70
+ * empty for the summary numerics; the count-formatting helpers below carry
71
+ * a `<count>` middle for 252/253/254.
72
+ */
73
+ function trailingLine(ctx: Ctx, code: number, trailing: string): RawLine {
74
+ const nick = ctx.connection.nick ?? '*';
75
+ const codeStr = code.toString().padStart(3, '0');
76
+ return {
77
+ text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}`,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Builds a `:<server> <code> <nick> <count> :<trailing>` line for the
83
+ * count-carrying numerics (252/253/254).
84
+ */
85
+ function countLine(ctx: Ctx, code: number, count: number, trailing: string): RawLine {
86
+ const nick = ctx.connection.nick ?? '*';
87
+ const codeStr = code.toString().padStart(3, '0');
88
+ return {
89
+ text: `:${ctx.serverName} ${codeStr} ${nick} ${count} :${trailing}`,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Builds a `:<server> <code> <nick> <cur> <max> :<trailing>` line for
95
+ * 265/266.
96
+ */
97
+ function usageLine(ctx: Ctx, code: number, cur: number, max: number, trailing: string): RawLine {
98
+ const nick = ctx.connection.nick ?? '*';
99
+ const codeStr = code.toString().padStart(3, '0');
100
+ return {
101
+ text: `:${ctx.serverName} ${codeStr} ${nick} ${cur} ${max} :${trailing}`,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Handles `LUSERS [<mask> [<server>]]`.
107
+ *
108
+ * @param snapshot The aggregated network statistics (counts).
109
+ * @param _msg The parsed `LUSERS` message. The optional `<mask>`
110
+ * is accepted for RFC compliance but not applied — single-server
111
+ * deployments collapse any wildcard match to the local server.
112
+ * @param ctx The per-invocation context.
113
+ * @param serverTarget When set, the actor determined `<server>` names a
114
+ * non-local server; the reducer emits `402 ERR_NOSUCHSERVER` against this
115
+ * name and nothing else.
116
+ */
117
+ export function lusersReducer(
118
+ snapshot: ServerStatsSnapshot,
119
+ _msg: { command: string; params: readonly string[] },
120
+ ctx: Ctx,
121
+ serverTarget?: string,
122
+ ): { effects: EffectType[] } {
123
+ ctx.connection.lastSeen = ctx.clock.now();
124
+
125
+ const effects: EffectType[] = [];
126
+
127
+ if (serverTarget !== undefined) {
128
+ const nick = ctx.connection.nick ?? '*';
129
+ const code = Numerics.ERR_NOSUCHSERVER.toString().padStart(3, '0');
130
+ effects.push(
131
+ Effect.send(ctx.connId, [
132
+ {
133
+ text: `:${ctx.serverName} ${code} ${nick} ${serverTarget} :${NOSUCHSERVER_TRAILING_TEXT}`,
134
+ },
135
+ ]),
136
+ );
137
+ return { effects };
138
+ }
139
+
140
+ // 251 always.
141
+ effects.push(
142
+ Effect.send(ctx.connId, [
143
+ trailingLine(
144
+ ctx,
145
+ Numerics.RPL_LUSERCLIENT,
146
+ LUSERCLIENT_TEXT(snapshot.users, snapshot.invisible, snapshot.servers),
147
+ ),
148
+ ]),
149
+ );
150
+
151
+ // 252/253/254 only when non-zero (RFC: omit zero-count summary lines).
152
+ if (snapshot.opers > 0) {
153
+ effects.push(
154
+ Effect.send(ctx.connId, [countLine(ctx, Numerics.RPL_LUSEROP, snapshot.opers, LUSEROP_TEXT)]),
155
+ );
156
+ }
157
+ if (snapshot.unknownConnections > 0) {
158
+ effects.push(
159
+ Effect.send(ctx.connId, [
160
+ countLine(ctx, Numerics.RPL_LUSERUNKNOWN, snapshot.unknownConnections, LUSERUNKNOWN_TEXT),
161
+ ]),
162
+ );
163
+ }
164
+ if (snapshot.channels > 0) {
165
+ effects.push(
166
+ Effect.send(ctx.connId, [
167
+ countLine(ctx, Numerics.RPL_LUSERCHANNELS, snapshot.channels, LUSERCHANNELS_TEXT),
168
+ ]),
169
+ );
170
+ }
171
+
172
+ // 255 always.
173
+ effects.push(
174
+ Effect.send(ctx.connId, [
175
+ trailingLine(ctx, Numerics.RPL_LUSERME, LUSERME_TEXT(snapshot.localConns, snapshot.servers)),
176
+ ]),
177
+ );
178
+
179
+ // 265/266 always (modern clients depend on them).
180
+ effects.push(
181
+ Effect.send(ctx.connId, [
182
+ usageLine(
183
+ ctx,
184
+ Numerics.RPL_LOCALUSERS,
185
+ snapshot.localConns,
186
+ snapshot.maxLocalConns,
187
+ LOCALUSERS_TEXT(snapshot.localConns, snapshot.maxLocalConns),
188
+ ),
189
+ ]),
190
+ );
191
+ effects.push(
192
+ Effect.send(ctx.connId, [
193
+ usageLine(
194
+ ctx,
195
+ Numerics.RPL_GLOBALUSERS,
196
+ snapshot.globalConns,
197
+ snapshot.maxGlobalConns,
198
+ GLOBALUSERS_TEXT(snapshot.globalConns, snapshot.maxGlobalConns),
199
+ ),
200
+ ]),
201
+ );
202
+
203
+ return { effects };
204
+ }
@@ -191,14 +191,10 @@ export const channelModeReducer: Reducer<ChannelState> = (state, msg, ctx) => {
191
191
  // authority BEFORE the broadcast so distributed runtimes (CF
192
192
  // ChannelDO) have persisted the change before any peer's next
193
193
  // prefetch observes it. In the in-memory runtime the actor's
194
- // `state` IS authoritative so this is redundant.
195
- if (
196
- (delta.modeChanges !== undefined && delta.modeChanges.length > 0) ||
197
- (delta.banMaskChanges !== undefined && delta.banMaskChanges.length > 0) ||
198
- (delta.memberships !== undefined && delta.memberships.length > 0)
199
- ) {
200
- effects.push(Effect.applyChannelDelta(state.name, delta));
201
- }
194
+ // `state` IS authoritative so this is redundant. Every non-null
195
+ // `applyChannelOp` result is paired with at least one delta push,
196
+ // so `delta` is guaranteed non-empty here.
197
+ effects.push(Effect.applyChannelDelta(state.name, delta));
202
198
  const hostmask = callerHostmask(ctx.connection);
203
199
  effects.push(Effect.broadcast(state.name, [buildModeBroadcast(hostmask, state.name, applied)]));
204
200
  }
@@ -532,7 +528,7 @@ const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userMode
532
528
  };
533
529
 
534
530
  /** User-mode letters in canonical 221 output order. */
535
- const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o'] as const;
531
+ const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'S'] as const;
536
532
 
537
533
  /**
538
534
  * Handles `MODE <nick> [<modestring>]`.
@@ -644,6 +640,13 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
644
640
  function buildUserModeString(state: ConnectionState): string {
645
641
  let modeStr = '+';
646
642
  for (const letter of USER_MODE_LETTER_ORDER) {
643
+ // `S` (TLS connected) is read-only: it is not in USER_MODE_LETTERS
644
+ // (the write-side map the parser consults) so `MODE nick +S` rejects
645
+ // with 501. Surface it on the read side directly from its field.
646
+ if (letter === 'S') {
647
+ if (state.userModes.tls) modeStr += 'S';
648
+ continue;
649
+ }
647
650
  const field = USER_MODE_LETTERS[letter];
648
651
  if (field !== undefined && state.userModes[field]) modeStr += letter;
649
652
  }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `MONITOR` command.
3
+ *
4
+ * IRCv3 MONITOR spec <https://ircv3.net/specs/extensions/monitor-3.2.html>:
5
+ *
6
+ * - `MONITOR + <nick>{,<nick>}` — add targets to the watchlist.
7
+ * - `MONITOR - <nick>{,<nick>}` — remove targets from the watchlist.
8
+ * - `MONITOR C` — clear the watchlist.
9
+ * - `MONITOR L` — list the watchlist (`732` per line +
10
+ * `733` terminator).
11
+ * - `MONITOR S` — status: `730 RPL_MONONLINE` for the
12
+ * online subset, `731 RPL_MONOFFLINE`
13
+ * for the rest.
14
+ *
15
+ * Numerics:
16
+ * - `730 RPL_MONONLINE` `:<server> 730 <nick> :<online>{,<online>}`
17
+ * - `731 RPL_MONOFFLINE` `:<server> 731 <nick> :<offline>{,<offline>}`
18
+ * - `732 RPL_MONLIST` `:<server> 732 <nick> :<entry>`
19
+ * - `733 RPL_ENDOFMONLIST` `:<server> 733 <nick> :End of MONITOR list`
20
+ * - `734 ERR_MONLISTFULL` `:<server> 734 <nick> <limit> :<rejected>{,<rejected>}`
21
+ *
22
+ * The watchlist lives on {@link ConnectionState.monitorList} (folded nicks,
23
+ * case-insensitive membership). The reducer resolves each requested nick
24
+ * against the supplied `online` map (folded-keyed → display spelling); the
25
+ * actor builds that map from the nick registry before invoking the reducer,
26
+ * mirroring the {@link isonReducer} pattern.
27
+ *
28
+ * Asynchronous `730`/`731` pushes on online/offline transitions are emitted
29
+ * by the actor layer (it watches `ReserveNick` / `ReleaseNick` effects
30
+ * against the watchlists); the pure reducer only handles the request/reply
31
+ * subcommands above.
32
+ */
33
+
34
+ import { Effect } from '../effects.js';
35
+ import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { Numerics } from '../protocol/numerics.js';
37
+ import type { ConnectionState } from '../state/connection.js';
38
+ import type { Ctx, ReducerResult } from '../types.js';
39
+
40
+ /** Wire name of the IRCv3 MONITOR capability. */
41
+ export const MONITOR_CAP = 'monitor';
42
+
43
+ /** Default monitor cap (max watchlist size) advertised via `MONITOR=<n>`. */
44
+ export const DEFAULT_MONITOR_LIMIT = 30;
45
+
46
+ /**
47
+ * Handles the IRCv3 `MONITOR` subcommands.
48
+ *
49
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
50
+ * the requested nicks asynchronously before formatting — mirrors
51
+ * {@link isonReducer} / {@link whoisReducer}.
52
+ *
53
+ * @param online Folded-keyed → display-spelling map of currently
54
+ * online nicks (built by the actor from the nick
55
+ * registry).
56
+ * @param maxMonitor Server-wide cap on the per-connection watchlist size
57
+ * (advertised via `MONITOR=<n>` in `005`).
58
+ * @param msg Parsed `MONITOR <sub> [<args>]` message.
59
+ * @param ctx Per-invocation context.
60
+ */
61
+ export function monitorReducer(
62
+ online: ReadonlyMap<string, string>,
63
+ maxMonitor: number,
64
+ msg: { params: readonly string[] },
65
+ ctx: Ctx,
66
+ ): ReducerResult<ConnectionState> {
67
+ ctx.connection.lastSeen = ctx.clock.now();
68
+
69
+ const state = ctx.connection;
70
+ const nick = state.nick ?? '*';
71
+ const serverName = ctx.serverName;
72
+
73
+ const sub = (msg.params[0] ?? '').toUpperCase();
74
+
75
+ // No subcommand at all → 461 NEEDMOREPARAMS (the parser hands us an
76
+ // empty string when the sender wrote bare `MONITOR`). A non-empty but
77
+ // unknown subcommand (e.g. `MONITOR Z`) is also 461 with a different
78
+ // trailing string per the IRCv3 MONITOR dispatch table.
79
+ if (sub.length === 0) {
80
+ return {
81
+ state,
82
+ effects: [
83
+ Effect.send(ctx.connId, [
84
+ numericLine(
85
+ serverName,
86
+ Numerics.ERR_NEEDMOREPARAMS,
87
+ nick,
88
+ 'MONITOR',
89
+ 'Not enough parameters',
90
+ ),
91
+ ]),
92
+ ],
93
+ };
94
+ }
95
+
96
+ switch (sub) {
97
+ case '+':
98
+ return handleAdd(state, online, maxMonitor, msg.params[1] ?? '', nick, serverName);
99
+ case '-':
100
+ return handleRemove(state, msg.params[1] ?? '');
101
+ case 'C':
102
+ return handleClear(state);
103
+ case 'L':
104
+ return handleList(state, nick, serverName);
105
+ case 'S':
106
+ return handleStatus(state, online, nick, serverName);
107
+ default:
108
+ return {
109
+ state,
110
+ effects: [
111
+ Effect.send(ctx.connId, [
112
+ numericLine(
113
+ serverName,
114
+ Numerics.ERR_NEEDMOREPARAMS,
115
+ nick,
116
+ 'MONITOR',
117
+ 'Unknown command',
118
+ ),
119
+ ]),
120
+ ],
121
+ };
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Subcommand handlers
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function handleAdd(
130
+ state: ConnectionState,
131
+ online: ReadonlyMap<string, string>,
132
+ maxMonitor: number,
133
+ targetList: string,
134
+ nick: string,
135
+ serverName: string,
136
+ ): ReducerResult<ConnectionState> {
137
+ if (targetList.length === 0) {
138
+ return {
139
+ state,
140
+ effects: [
141
+ Effect.send(state.id, [
142
+ numericLine(
143
+ serverName,
144
+ Numerics.ERR_NEEDMOREPARAMS,
145
+ nick,
146
+ 'MONITOR',
147
+ 'Not enough parameters',
148
+ ),
149
+ ]),
150
+ ],
151
+ };
152
+ }
153
+
154
+ const watchlist = ensureWatchlist(state);
155
+
156
+ const added: { folded: string; display: string }[] = [];
157
+ const rejected: string[] = [];
158
+ for (const requested of splitNicks(targetList)) {
159
+ const folded = requested.toLowerCase();
160
+ if (watchlist.has(folded)) continue; // already monitored; no output
161
+ if (watchlist.size >= maxMonitor) {
162
+ rejected.push(requested);
163
+ continue;
164
+ }
165
+ watchlist.add(folded);
166
+ // For the display spelling prefer the online map (canonical) and fall
167
+ // back to the requester spelling when the nick is offline.
168
+ const display = online.get(folded) ?? requested;
169
+ added.push({ folded, display });
170
+ }
171
+
172
+ const effects: EffectType[] = [];
173
+
174
+ const onlineDisplays = added.filter((a) => online.has(a.folded)).map((a) => a.display);
175
+ const offlineDisplays = added.filter((a) => !online.has(a.folded)).map((a) => a.display);
176
+
177
+ if (onlineDisplays.length > 0) {
178
+ effects.push(
179
+ Effect.send(state.id, [
180
+ monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
181
+ ]),
182
+ );
183
+ }
184
+ if (offlineDisplays.length > 0) {
185
+ effects.push(
186
+ Effect.send(state.id, [
187
+ monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
188
+ ]),
189
+ );
190
+ }
191
+ if (rejected.length > 0) {
192
+ effects.push(
193
+ Effect.send(state.id, [monListFullLine(serverName, nick, maxMonitor, rejected.join(','))]),
194
+ );
195
+ }
196
+
197
+ return { state, effects };
198
+ }
199
+
200
+ function handleRemove(state: ConnectionState, targetList: string): ReducerResult<ConnectionState> {
201
+ if (state.monitorList === undefined) return { state, effects: [] };
202
+ if (targetList.length === 0) return { state, effects: [] };
203
+ for (const requested of splitNicks(targetList)) {
204
+ state.monitorList.delete(requested.toLowerCase());
205
+ }
206
+ return { state, effects: [] };
207
+ }
208
+
209
+ function handleClear(state: ConnectionState): ReducerResult<ConnectionState> {
210
+ // Allocate the set on first clear so callers observe a stable post-state
211
+ // (size 0) regardless of whether the watchlist previously existed.
212
+ if (state.monitorList === undefined) {
213
+ state.monitorList = new Set<string>();
214
+ } else {
215
+ state.monitorList.clear();
216
+ }
217
+ return { state, effects: [] };
218
+ }
219
+
220
+ function handleList(
221
+ state: ConnectionState,
222
+ nick: string,
223
+ serverName: string,
224
+ ): ReducerResult<ConnectionState> {
225
+ const effects: EffectType[] = [];
226
+ if (state.monitorList !== undefined) {
227
+ for (const entry of state.monitorList) {
228
+ effects.push(Effect.send(state.id, [monLine(Numerics.RPL_MONLIST, serverName, nick, entry)]));
229
+ }
230
+ }
231
+ effects.push(
232
+ Effect.send(state.id, [
233
+ monLine(Numerics.RPL_ENDOFMONLIST, serverName, nick, 'End of MONITOR list'),
234
+ ]),
235
+ );
236
+ return { state, effects };
237
+ }
238
+
239
+ function handleStatus(
240
+ state: ConnectionState,
241
+ online: ReadonlyMap<string, string>,
242
+ nick: string,
243
+ serverName: string,
244
+ ): ReducerResult<ConnectionState> {
245
+ if (state.monitorList === undefined || state.monitorList.size === 0) {
246
+ return { state, effects: [] };
247
+ }
248
+ const onlineDisplays: string[] = [];
249
+ const offlineDisplays: string[] = [];
250
+ for (const folded of state.monitorList) {
251
+ const display = online.get(folded);
252
+ if (display !== undefined) {
253
+ onlineDisplays.push(display);
254
+ } else {
255
+ offlineDisplays.push(folded);
256
+ }
257
+ }
258
+ const effects: EffectType[] = [];
259
+ if (onlineDisplays.length > 0) {
260
+ effects.push(
261
+ Effect.send(state.id, [
262
+ monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
263
+ ]),
264
+ );
265
+ }
266
+ if (offlineDisplays.length > 0) {
267
+ effects.push(
268
+ Effect.send(state.id, [
269
+ monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
270
+ ]),
271
+ );
272
+ }
273
+ return { state, effects };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Helpers
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Returns the connection's watchlist, lazily allocating it on first use.
282
+ * Existing persisted state migrates without a schema bump (the field stays
283
+ * optional; reducers that pre-date MONITOR never touch it).
284
+ */
285
+ function ensureWatchlist(state: ConnectionState): Set<string> {
286
+ if (state.monitorList === undefined) {
287
+ state.monitorList = new Set<string>();
288
+ }
289
+ return state.monitorList;
290
+ }
291
+
292
+ /** Splits a comma-separated nick list, dropping empty entries. */
293
+ function splitNicks(csv: string): string[] {
294
+ return csv
295
+ .split(',')
296
+ .map((s) => s.trim())
297
+ .filter((s) => s.length > 0);
298
+ }
299
+
300
+ /** `:<server> <code> <nick> :<trailing>` — used by 730/731/732/733. */
301
+ function monLine(code: number, serverName: string, nick: string, trailing: string): RawLine {
302
+ const codeStr = code.toString().padStart(3, '0');
303
+ return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
304
+ }
305
+
306
+ /** `:<server> 734 <nick> <limit> :<rejected>` (ERR_MONLISTFULL). */
307
+ function monListFullLine(
308
+ serverName: string,
309
+ nick: string,
310
+ limit: number,
311
+ rejected: string,
312
+ ): RawLine {
313
+ const codeStr = Numerics.ERR_MONLISTFULL.toString().padStart(3, '0');
314
+ return { text: `:${serverName} ${codeStr} ${nick} ${limit} :${rejected}` };
315
+ }
316
+
317
+ /** `:<server> <code> <nick> <cmd> :<trailing>` — used by 461-style errors. */
318
+ function numericLine(
319
+ serverName: string,
320
+ code: number,
321
+ nick: string,
322
+ cmd: string,
323
+ trailing: string,
324
+ ): RawLine {
325
+ const codeStr = code.toString().padStart(3, '0');
326
+ return { text: `:${serverName} ${codeStr} ${nick} ${cmd} :${trailing}` };
327
+ }