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
@@ -27,34 +27,57 @@
27
27
 
28
28
  import {
29
29
  type AccountStore,
30
+ type AwayStore,
30
31
  type ChanName,
31
32
  type ChannelState,
32
33
  type Clock,
33
34
  type ConnSnapshot,
34
35
  type ConnectionState,
36
+ DEFAULT_MONITOR_LIMIT,
37
+ DEFAULT_MULTILINE_MAX_BYTES,
35
38
  Effect,
36
39
  type Effect as EffectType,
37
40
  EmptyMotdProvider,
38
41
  type IdFactory,
39
42
  type IrcMessage,
43
+ LABELED_RESPONSE_CAP_NAME,
40
44
  type Logger,
41
45
  type MessageStore,
46
+ type MonitorStore,
42
47
  type MotdProvider,
43
48
  type MtlsIdentityProvider,
49
+ type MultilineEntry,
44
50
  type NickHistoryStore,
45
51
  NoopLoggerInstance,
46
52
  Numerics,
47
53
  type RawLine,
54
+ type ReadMarkerStore,
48
55
  type ServerConfig,
56
+ type ServerStats,
57
+ applyLabelToEffects,
49
58
  buildCtx,
59
+ buildMultilineFanout,
50
60
  chathistoryReducer,
61
+ extractLabel,
62
+ finalizeMultilineBatch,
51
63
  handleJoinZero,
64
+ hostmaskOf,
52
65
  isChannelTarget,
53
66
  isonReducer,
67
+ killReducer,
54
68
  listReducer,
69
+ lusersReducer,
70
+ monitorReducer,
71
+ multilineByteLength,
55
72
  parse,
73
+ recordMultilineMessage,
74
+ rehashLine,
75
+ rehashReducer,
76
+ statsReducer,
56
77
  toSnapshot,
78
+ traceReducer,
57
79
  userhostReducer,
80
+ wallopsReducer,
58
81
  whoReducer,
59
82
  whoisReducer,
60
83
  whowasReducer,
@@ -100,6 +123,13 @@ export interface ConnectionActorOptions {
100
123
  runtime: IrcRuntime;
101
124
  channels: ActorChannelAccess;
102
125
  serverConfig: ServerConfig;
126
+ /**
127
+ * Short label identifying the adapter's bound config source (e.g.
128
+ * `"KV"`, `"Secrets Manager"`, `"config file"`). Surfaced in the
129
+ * `382 RPL_REHASHING` reply's trailing text so the oper can see where the
130
+ * reload was attempted from. Defaults to `"server config"` when omitted.
131
+ */
132
+ readonly configSource?: string;
103
133
  clock: Clock;
104
134
  ids: IdFactory;
105
135
  motd?: MotdProvider;
@@ -138,6 +168,53 @@ export interface ConnectionActorOptions {
138
168
  * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
139
169
  */
140
170
  readonly history?: NickHistoryStore;
171
+ /**
172
+ * Read-marker persistence source for IRCv3 `draft/read-marker` (absent
173
+ * when the deployment does not persist read markers). When bound,
174
+ * `ctx.readMarkers` is defined so a `+draft/read-marker=<msgid>` TAGMSG
175
+ * from an identified, cap-enabled client updates the per-`(account,
176
+ * channel)` marker, and SASL identify seeds the connection's
177
+ * `lastReadMarkers` from the stored values. Omitted (→ `ctx.readMarkers`
178
+ * undefined) preserves the session-only marker behaviour: the connection
179
+ * still tracks its own read position via chathistory playback, but the
180
+ * marker is not shared across connections or restored on reconnect.
181
+ */
182
+ readonly readMarkers?: ReadMarkerStore;
183
+ /**
184
+ * Away-status persistence source for IRCv3 `draft/pre-away` (absent
185
+ * when the deployment does not persist away reasons). When bound,
186
+ * `ctx.away` is defined so a set/clear of `AWAY` from an identified
187
+ * connection mirrors the change into the per-`account` record, and
188
+ * SASL identify replays the stored reason onto the connection (emitting
189
+ * `306 RPL_NOWAWAY`) so a reconnect restores the user's away state.
190
+ * Omitted (→ `ctx.away` undefined) preserves the session-only
191
+ * behaviour: the connection still tracks its own `AWAY` but the reason
192
+ * is not shared across connections or restored on reconnect.
193
+ */
194
+ readonly away?: AwayStore;
195
+ /**
196
+ * Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
197
+ * when MONITOR push notifications are disabled). When bound, the actor
198
+ * syncs each connection's watchlist into the store and the store's
199
+ * online/offline hooks fan `730`/`731` lines out to subscribed watchers.
200
+ * Omitted (→ no push notifications) preserves the existing behaviour
201
+ * for callers that have not opted in: `MONITOR +nick` still works
202
+ * synchronously (the reducer resolves online/offline status against
203
+ * the store's `onlineNicks` snapshot) but no async push fires when a
204
+ * watched nick later connects or quits.
205
+ */
206
+ readonly monitor?: MonitorStore;
207
+ /**
208
+ * Network-statistics source for the `LUSERS` and `STATS` commands
209
+ * (absent when no stats backend is wired). When bound, the actor
210
+ * awaits `stats.getStats()` once per LUSERS/STATS invocation and hands
211
+ * the resulting snapshot to the pure reducers — same pattern as
212
+ * `listChannels` → `listReducer`. Omitted (→ a zero-count fallback)
213
+ * preserves graceful behaviour for deployments/tests that have not
214
+ * opted in: `LUSERS` reports zero users/channels, `STATS u` reports
215
+ * zero uptime.
216
+ */
217
+ readonly stats?: ServerStats;
141
218
  /**
142
219
  * Structured-logger port. When omitted the actor runs
143
220
  * silently — observability is purely opt-in. When supplied, every
@@ -160,13 +237,24 @@ export interface ConnectionActorOptions {
160
237
  * pipeline is shared unchanged across both transports.
161
238
  */
162
239
  readonly transport?: Transport;
240
+ /**
241
+ * Transport-level TLS fact (PLAN §5 / docs/PlanExtensions.md "user mode
242
+ * `S`"). Set to `true` when the connection arrived over TLS — a `wss://`
243
+ * WebSocket or an `irc+tls://` raw TCP connection. The actor stamps it
244
+ * onto `state.secure` at construction; the registration reducer then
245
+ * surfaces it as read-only user mode `S` (visible in `221`/`WHOIS`,
246
+ * rejected by the `MODE` parser with `501`). Defaults to `false` so
247
+ * plain-transport tests and deployments keep the pre-TLS-mode behaviour.
248
+ */
249
+ readonly secure?: boolean;
163
250
  }
164
251
 
165
252
  export class ConnectionActor {
166
253
  private readonly state: ConnectionState;
167
254
  private readonly runtime: IrcRuntime;
168
255
  private readonly channels: ActorChannelAccess;
169
- private readonly serverConfig: ServerConfig;
256
+ private serverConfig: ServerConfig;
257
+ private readonly configSource: string;
170
258
  private readonly clock: Clock;
171
259
  private readonly ids: IdFactory;
172
260
  private readonly motd: MotdProvider;
@@ -174,6 +262,10 @@ export class ConnectionActor {
174
262
  private readonly accounts: AccountStore | undefined;
175
263
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
176
264
  private readonly history: NickHistoryStore | undefined;
265
+ private readonly readMarkers: ReadMarkerStore | undefined;
266
+ private readonly away: AwayStore | undefined;
267
+ private readonly monitor: MonitorStore | undefined;
268
+ private readonly stats: ServerStats | undefined;
177
269
  private readonly reducers: RoutedReducers;
178
270
  /**
179
271
  * Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
@@ -194,12 +286,29 @@ export class ConnectionActor {
194
286
  * that need a non-undefined id in test assertions.
195
287
  */
196
288
  private readonly pinnedTraceId: string | undefined;
289
+ /**
290
+ * In-progress IRCv3 `draft/multiline` batch, if any. Set when the client
291
+ * sends `BATCH +id draft/multiline :<target>` (and the connection
292
+ * negotiated the cap), populated by each intervening `PRIVMSG`/`NOTICE`,
293
+ * and cleared when the closing `BATCH -id` is finalized. While set, the
294
+ * actor diverts `PRIVMSG`/`NOTICE` into the accumulator instead of
295
+ * running the normal channel/user reducers.
296
+ */
297
+ private openMultilineBatch:
298
+ | {
299
+ readonly id: string;
300
+ readonly target: string;
301
+ command: 'PRIVMSG' | 'NOTICE';
302
+ readonly entries: MultilineEntry[];
303
+ }
304
+ | undefined;
197
305
 
198
306
  constructor(opts: ConnectionActorOptions) {
199
307
  this.state = opts.state;
200
308
  this.runtime = opts.runtime;
201
309
  this.channels = opts.channels;
202
310
  this.serverConfig = opts.serverConfig;
311
+ this.configSource = opts.configSource ?? 'server config';
203
312
  this.clock = opts.clock;
204
313
  this.ids = opts.ids;
205
314
  this.motd = opts.motd ?? EmptyMotdProvider;
@@ -207,6 +316,10 @@ export class ConnectionActor {
207
316
  this.accounts = opts.accounts;
208
317
  this.mtlsIdentity = opts.mtlsIdentity;
209
318
  this.history = opts.history;
319
+ this.readMarkers = opts.readMarkers;
320
+ this.away = opts.away;
321
+ this.monitor = opts.monitor;
322
+ this.stats = opts.stats;
210
323
  this.logger = opts.logger ?? NoopLoggerInstance;
211
324
  this.pinnedTraceId = opts.traceId;
212
325
  // Bind the connection id once so every record the actor emits carries
@@ -221,6 +334,15 @@ export class ConnectionActor {
221
334
  // regardless of which authority owns the command's state.
222
335
  this.reducers = buildRoutedReducers(opts.serverConfig);
223
336
  this.transport = opts.transport ?? new WsTextFrameTransport();
337
+ // Surface the transport's TLS fact on the connection state so the
338
+ // registration reducer can stamp read-only user mode `S` from it.
339
+ // Only stamped when the caller explicitly asserts the fact; adapters
340
+ // that reconstruct the actor from persisted state (where `secure` was
341
+ // already written at construction time) omit the option so the
342
+ // deserialized value survives untouched.
343
+ if (opts.secure !== undefined) {
344
+ this.state.secure = opts.secure;
345
+ }
224
346
  }
225
347
 
226
348
  /**
@@ -285,12 +407,75 @@ export class ConnectionActor {
285
407
  // the channel authority (e.g. CF ChannelDO). Done before `route` so the
286
408
  // synchronous reducers observe up-to-date rosters / modes / topics.
287
409
  await this.prefetchChannelState(msg);
288
- const effects = await this.route(msg);
410
+ const label = extractLabel(msg.tags);
411
+ const effects = await this.route(msg, label);
412
+ const labeled = this.applyLabel(effects, label);
413
+ await dispatch(labeled, this.runtime, this.dispatchOpts(log, traceId));
414
+ // After dispatch completes the bound MonitorStore (if any) may have
415
+ // enqueued `730`/`731` pushes for ReserveNick/ChangeNick/ReleaseNick
416
+ // transitions; turn each into one `Send` effect and dispatch them in a
417
+ // follow-up pass. Kept separate from the original effects array so the
418
+ // reducer's outputs continue to dedupe against themselves, not against
419
+ // fan-out sends.
420
+ await this.drainMonitorPushes(log, traceId);
421
+ }
422
+
423
+ /**
424
+ * Drains pending {@link MonitorPush}es from the bound {@link MonitorStore}
425
+ * and dispatches one `Send` effect per push. The watcher's nick is
426
+ * resolved via the live {@link ConnectionState} so the wire line carries
427
+ * the canonical spelling (`*` placeholder when the watcher is no longer
428
+ * registered — the runtime drops the send in that case anyway).
429
+ */
430
+ private async drainMonitorPushes(log: Logger, traceId: string): Promise<void> {
431
+ if (this.monitor === undefined) return;
432
+ const pushes = this.monitor.drainPushes();
433
+ if (pushes.length === 0) return;
434
+ const effects: EffectType[] = [];
435
+ for (const push of pushes) {
436
+ const watcher = await this.runtime.getConnection(push.connId);
437
+ const watcherNick = watcher?.nick ?? '*';
438
+ const code = push.status === 'online' ? Numerics.RPL_MONONLINE : Numerics.RPL_MONOFFLINE;
439
+ const codeStr = code.toString().padStart(3, '0');
440
+ effects.push(
441
+ Effect.send(push.connId, [
442
+ { text: `:${this.serverConfig.serverName} ${codeStr} ${watcherNick} :${push.display}` },
443
+ ]),
444
+ );
445
+ }
446
+ // `effects` has one entry per push; pushes was non-empty (early
447
+ // return above), so the second dispatch always has work to do.
289
448
  await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
290
449
  }
291
450
 
292
451
  private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
293
- return { logger: log, traceId, connectionId: this.state.id };
452
+ return {
453
+ logger: log,
454
+ traceId,
455
+ connectionId: this.state.id,
456
+ ...(this.monitor !== undefined ? { monitor: this.monitor } : {}),
457
+ };
458
+ }
459
+
460
+ /**
461
+ * IRCv3 `labeled-response` wrapping.
462
+ *
463
+ * When the inbound message carried a `+label` client tag AND the requester
464
+ * negotiated both `batch` and `labeled-response`, every `Send`-to-self the
465
+ * reducer emitted is gathered into one `BATCH +label labeled-response …
466
+ * -label` frame (and an empty batch is emitted when there was no reply).
467
+ * Otherwise the effect list is returned unchanged.
468
+ *
469
+ * The label is read back from {@link Ctx.label} (plumbed through `route`)
470
+ * so the correlation is attributable to the same per-invocation context the
471
+ * reducers observed.
472
+ */
473
+ private applyLabel(effects: EffectType[], label: string | undefined): EffectType[] {
474
+ if (label === undefined) return effects;
475
+ if (!this.state.caps.has('batch') || !this.state.caps.has(LABELED_RESPONSE_CAP_NAME)) {
476
+ return effects;
477
+ }
478
+ return applyLabelToEffects(effects, { label, connId: this.state.id });
294
479
  }
295
480
 
296
481
  /**
@@ -306,7 +491,7 @@ export class ConnectionActor {
306
491
  await Promise.all(targets.map((name) => refresh.call(this.channels, name)));
307
492
  }
308
493
 
309
- private async route(msg: IrcMessage): Promise<EffectType[]> {
494
+ private async route(msg: IrcMessage, label: string | undefined): Promise<EffectType[]> {
310
495
  const ctx = buildCtx({
311
496
  serverConfig: this.serverConfig,
312
497
  clock: this.clock,
@@ -317,8 +502,21 @@ export class ConnectionActor {
317
502
  ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
318
503
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
319
504
  ...(this.history !== undefined ? { history: this.history } : {}),
505
+ ...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
506
+ ...(this.away !== undefined ? { away: this.away } : {}),
507
+ ...(label !== undefined ? { label } : {}),
320
508
  });
321
509
 
510
+ // While a `draft/multiline` batch is open, intervening PRIVMSG/NOTICE
511
+ // lines are accumulated into the batch instead of being delivered
512
+ // immediately. The closing BATCH -id finalizes and fans the batch out.
513
+ if (
514
+ this.openMultilineBatch !== undefined &&
515
+ (msg.command === 'PRIVMSG' || msg.command === 'NOTICE')
516
+ ) {
517
+ return this.accumulateMultilineLine(msg);
518
+ }
519
+
322
520
  switch (msg.command) {
323
521
  // -------- Connection-authority commands --------
324
522
  case 'PING':
@@ -337,12 +535,18 @@ export class ConnectionActor {
337
535
  return this.reducers.motd(this.state, msg, ctx).effects;
338
536
  case 'OPER':
339
537
  return this.reducers.oper(this.state, msg, ctx).effects;
538
+ case 'REHASH':
539
+ return this.routeRehash(msg, ctx);
340
540
  case 'CAP':
341
541
  return this.reducers.cap(this.state, msg, ctx).effects;
342
542
  case 'AUTHENTICATE':
343
543
  return this.reducers.authenticate(this.state, msg, ctx).effects;
544
+ case 'BATCH':
545
+ return this.routeBatch(msg, ctx);
344
546
  case 'AWAY':
345
547
  return this.reducers.away(this.state, msg, ctx).effects;
548
+ case 'SETNAME':
549
+ return this.reducers.setname(this.state, msg, ctx).effects;
346
550
 
347
551
  // -------- Server-info query commands --------
348
552
  case 'VERSION':
@@ -357,6 +561,8 @@ export class ConnectionActor {
357
561
  return this.routeUserhost(msg, ctx);
358
562
  case 'ISON':
359
563
  return this.routeIson(msg, ctx);
564
+ case 'MONITOR':
565
+ return this.routeMonitor(msg, ctx);
360
566
 
361
567
  // -------- Channel-authority commands --------
362
568
  case 'JOIN':
@@ -382,6 +588,16 @@ export class ConnectionActor {
382
588
  return this.routeWhois(msg, ctx);
383
589
  case 'WHOWAS':
384
590
  return this.routeWhowas(msg, ctx);
591
+ case 'KILL':
592
+ return this.routeKill(msg, ctx);
593
+ case 'TRACE':
594
+ return this.routeTrace(msg, ctx);
595
+ case 'WALLOPS':
596
+ return this.routeWallops(msg, ctx);
597
+ case 'LUSERS':
598
+ return this.routeLusers(msg, ctx);
599
+ case 'STATS':
600
+ return this.routeStats(msg, ctx);
385
601
  case 'CHATHISTORY':
386
602
  return this.routeChathistory(msg, ctx);
387
603
 
@@ -418,14 +634,17 @@ export class ConnectionActor {
418
634
  default:
419
635
  // Remaining standard IRC verbs not yet implemented. They fall through
420
636
  // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
421
- // - Deferred (serverless-incompatible / S2S / needs new infra):
422
- // LUSERS (needs cross-adapter getStats), KILL (oper disconnect),
423
- // REHASH (no runtime config reload), CONNECT/SQUIT (S2S, PLAN
424
- // non-goal), TRACE (complex routing info), STATS (stats backend),
425
- // WALLOPS (oper broadcast mechanism), SETNAME (CHGHOST plumbing),
426
- // LINKS (S2S server list).
427
- // - Obsolete (RFC 2812, never widely implemented): SERVICE, SUMMON,
428
- // USERS.
637
+ // - Deferred (serverless-incompatible / needs new infra):
638
+ // (none LUSERS/STATS are now implemented).
639
+ // - Deliberately dropped (S2S PLAN non-goal):
640
+ // CONNECT, SQUIT, LINKS. There is no server mesh to connect,
641
+ // split, or list; these stay 421. Their reserved numerics
642
+ // (RPL_LINKS/RPL_ENDOFLINKS/ERR_CANTKILLSERVER) were removed.
643
+ // - Deliberately dropped (RFC 2812 obsolete, never widely
644
+ // implemented): SERVICE, SUMMON, USERS. These
645
+ // stay 421; their reserved numerics
646
+ // (RPL_YOURESERVICE/ERR_NOSUCHSERVICE/ERR_SUMMONDISABLED/
647
+ // ERR_USERSDISABLED) were removed.
429
648
  return [this.unknownCommandEffect(msg.command)];
430
649
  }
431
650
  }
@@ -438,6 +657,40 @@ export class ConnectionActor {
438
657
  return listReducer(snapshots, msg, ctx).effects;
439
658
  }
440
659
 
660
+ /**
661
+ * Routes `REHASH` (oper-gated config reload). The pure reducer enforces the
662
+ * oper gate and emits `382 RPL_REHASHING`; when the gate passes the actor
663
+ * re-invokes the runtime's bound config loader and swaps its live
664
+ * `serverConfig` reference so subsequent commands observe the refreshed
665
+ * value (rotated oper creds, new MOTD source, etc.).
666
+ *
667
+ * On reload failure the previous config stays in effect and a graceful
668
+ * error-suffixed `382` follows — the oper is NOT disconnected (per the
669
+ * serverless reload contract: a bad store read must never sever a live
670
+ * connection).
671
+ */
672
+ private async routeRehash(
673
+ msg: IrcMessage,
674
+ ctx: ReturnType<typeof buildCtx>,
675
+ ): Promise<EffectType[]> {
676
+ const result = rehashReducer(this.state, msg, ctx, this.configSource);
677
+ if (!result.shouldReload) {
678
+ return result.effects;
679
+ }
680
+ const effects = [...result.effects];
681
+ try {
682
+ this.serverConfig = await this.runtime.reloadConfig();
683
+ } catch (err) {
684
+ const reason = err instanceof Error ? err.message : String(err);
685
+ this.logger.warn('rehash.reload-failed', { reason });
686
+ const nick = this.state.nick ?? '*';
687
+ effects.push(
688
+ Effect.send(this.state.id, [rehashLine(ctx.serverName, nick, this.configSource, reason)]),
689
+ );
690
+ }
691
+ return effects;
692
+ }
693
+
441
694
  private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
442
695
  const target = msg.params[0] ?? '';
443
696
  if (target.length === 0) {
@@ -474,6 +727,78 @@ export class ConnectionActor {
474
727
  return whoisReducer(target, channels, msg, ctx).effects;
475
728
  }
476
729
 
730
+ /**
731
+ * Routes `KILL <nick> <comment>` to the kill reducer. The actor resolves
732
+ * the target via the nick registry + a live connection fetch (mirroring
733
+ * {@link routeWhois}); no channel is created as a side effect. The
734
+ * reducer performs the oper gate and, on success, emits a
735
+ * {@link DisconnectEffect} for the target — the runtime's `disconnect`
736
+ * closes the target transport and fans QUIT out to shared-channel peers
737
+ * (peer fanout is the runtime's responsibility, not the reducer's).
738
+ */
739
+ private async routeKill(
740
+ msg: IrcMessage,
741
+ ctx: ReturnType<typeof buildCtx>,
742
+ ): Promise<EffectType[]> {
743
+ const targetNick = msg.params[0] ?? '';
744
+ let target: ConnectionState | undefined;
745
+ if (targetNick.length > 0) {
746
+ const connId = await this.runtime.lookupNick(targetNick);
747
+ if (connId !== null) {
748
+ target = (await this.runtime.getConnection(connId)) ?? undefined;
749
+ }
750
+ }
751
+ return killReducer(target, msg, ctx).effects;
752
+ }
753
+
754
+ /**
755
+ * Routes `TRACE [<target>]` to the trace reducer. Single-server
756
+ * deployment: when `<target>` is absent or equals the local server name
757
+ * the reducer emits only the `262 RPL_ENDOFTRACE` terminator; when
758
+ * `<target>` resolves to a nick the reducer emits `205`/`204` per-conn
759
+ * detail (oper requester only) plus `262`; any other `<target>` is
760
+ * treated as a non-local server and yields `402 ERR_NOSUCHSERVER`.
761
+ *
762
+ * The actor resolves the target via `lookupNick` + `getConnection`
763
+ * (mirroring {@link routeWhois}). The local-server check is
764
+ * case-insensitive against `serverConfig.serverName`. Multi-hop S2S
765
+ * tracing is a PLAN non-goal.
766
+ */
767
+ private async routeTrace(
768
+ msg: IrcMessage,
769
+ ctx: ReturnType<typeof buildCtx>,
770
+ ): Promise<EffectType[]> {
771
+ const rawTarget = msg.params[0] ?? '';
772
+ if (rawTarget.length === 0) {
773
+ return traceReducer(undefined, msg, ctx).effects;
774
+ }
775
+ // Local server name (case-insensitive) → collapse to the no-target path.
776
+ if (rawTarget.toLowerCase() === ctx.serverName.toLowerCase()) {
777
+ return traceReducer(undefined, msg, ctx).effects;
778
+ }
779
+ // Try to resolve as a nick.
780
+ const connId = await this.runtime.lookupNick(rawTarget);
781
+ if (connId !== null) {
782
+ const target = (await this.runtime.getConnection(connId)) ?? undefined;
783
+ return traceReducer(target, msg, ctx).effects;
784
+ }
785
+ // Unresolved: treat as a remote server → 402 ERR_NOSUCHSERVER.
786
+ return traceReducer(undefined, msg, ctx, rawTarget).effects;
787
+ }
788
+
789
+ /**
790
+ * Routes `WALLOPS :<message>` to the wallops reducer. The pure reducer
791
+ * enforces the oper gate (`481`) and the non-empty-message check
792
+ * (`412`); on success it emits a single {@link BroadcastWallopsEffect}
793
+ * that the dispatch layer interprets via the runtime's
794
+ * `broadcastWallops` method — a global fan-out to every `+w`
795
+ * connection (not channel-scoped). The originator is always excluded
796
+ * (skip-self, matching NOTICE/PRIVMSG broadcast semantics).
797
+ */
798
+ private routeWallops(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
799
+ return wallopsReducer(this.state, msg, ctx).effects;
800
+ }
801
+
477
802
  /**
478
803
  * Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
479
804
  * command is query-only: it reads the bound nick-history store
@@ -488,6 +813,70 @@ export class ConnectionActor {
488
813
  return whowasReducer(this.history, msg, ctx).effects;
489
814
  }
490
815
 
816
+ /**
817
+ * Routes `LUSERS [<mask> [<server>]]` to the lusers reducer. Awaits the
818
+ * bound {@link ServerStats} backend (or falls back to a zero-count
819
+ * snapshot when no backend is wired) and hands the result to the pure
820
+ * reducer, which emits the `251`–`255`/`265`/`266` numerics.
821
+ *
822
+ * Single-server deployment: a `<server>` argument that does not match
823
+ * the local server name (case-insensitive) is treated as a remote
824
+ * server and yields `402 ERR_NOSUCHSERVER`. S2S aggregation is a PLAN
825
+ * non-goal.
826
+ */
827
+ private async routeLusers(
828
+ msg: IrcMessage,
829
+ ctx: ReturnType<typeof buildCtx>,
830
+ ): Promise<EffectType[]> {
831
+ const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
832
+ const snapshot = await this.fetchStats();
833
+ return lusersReducer(snapshot, msg, ctx, serverTarget).effects;
834
+ }
835
+
836
+ /**
837
+ * Routes `STATS <query> [<server>]` to the stats reducer. Awaits the
838
+ * bound {@link ServerStats} backend (or zero-count fallback) and hands
839
+ * the result to the pure reducer. The reducer handles the implemented
840
+ * query letters (`u`, `l`), the unknown-letter charybdis-style empty
841
+ * body, and the `219` terminator; non-local `<server>` arguments
842
+ * produce `402 ERR_NOSUCHSERVER`.
843
+ */
844
+ private async routeStats(
845
+ msg: IrcMessage,
846
+ ctx: ReturnType<typeof buildCtx>,
847
+ ): Promise<EffectType[]> {
848
+ const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
849
+ const snapshot = await this.fetchStats();
850
+ return statsReducer(snapshot, msg, ctx, serverTarget).effects;
851
+ }
852
+
853
+ /**
854
+ * Fetches the current {@link ServerStatsSnapshot} from the bound backend.
855
+ * When no backend is wired, returns a zero-count snapshot so LUSERS/STATS
856
+ * still emit a well-formed reply rather than a 421/empty stream — the
857
+ * uptime anchor defaults to the actor's wall clock so `STATS u` reports
858
+ * zero uptime instead of a negative duration.
859
+ */
860
+ private async fetchStats(): Promise<import('@serverless-ircd/irc-core').ServerStatsSnapshot> {
861
+ if (this.stats !== undefined) {
862
+ return this.stats.getStats();
863
+ }
864
+ const now = this.clock.now();
865
+ return {
866
+ users: 0,
867
+ invisible: 0,
868
+ opers: 0,
869
+ unknownConnections: 0,
870
+ channels: 0,
871
+ servers: 1,
872
+ localConns: 0,
873
+ globalConns: 0,
874
+ maxLocalConns: 0,
875
+ maxGlobalConns: 0,
876
+ uptimeStartedAt: now,
877
+ };
878
+ }
879
+
491
880
  /**
492
881
  * Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
493
882
  * requested nick via the nick registry + connection lookup, passing the
@@ -500,10 +889,8 @@ export class ConnectionActor {
500
889
  ctx: ReturnType<typeof buildCtx>,
501
890
  ): Promise<EffectType[]> {
502
891
  const cap = ctx.serverConfig.maxTargetsPerCommand;
503
- const nicks = msg.params.slice(0, cap);
504
892
  const resolved = new Map<string, ConnSnapshot | null>();
505
- for (const nick of nicks) {
506
- if (nick.length === 0) continue;
893
+ for (const nick of msg.params.slice(0, cap)) {
507
894
  const connId = await this.runtime.lookupNick(nick);
508
895
  if (connId === null) {
509
896
  resolved.set(nick, null);
@@ -526,13 +913,14 @@ export class ConnectionActor {
526
913
  ctx: ReturnType<typeof buildCtx>,
527
914
  ): Promise<EffectType[]> {
528
915
  const cap = ctx.serverConfig.maxTargetsPerCommand;
529
- const nicks = msg.params.slice(0, cap);
530
916
  const online = new Map<string, string>();
531
- for (const nick of nicks) {
532
- if (nick.length === 0) continue;
917
+ for (const nick of msg.params.slice(0, cap)) {
533
918
  const connId = await this.runtime.lookupNick(nick);
534
919
  if (connId === null) continue;
535
920
  const conn = await this.runtime.getConnection(connId);
921
+ // `conn` can be null in the race where lookupNick resolved but the
922
+ // connection has since torn down (the nick map lags the connection
923
+ // map). Skip those — they are no longer online.
536
924
  if (conn?.nick !== undefined) {
537
925
  online.set(conn.nick.toLowerCase(), conn.nick);
538
926
  }
@@ -540,6 +928,74 @@ export class ConnectionActor {
540
928
  return isonReducer(online, msg, ctx).effects;
541
929
  }
542
930
 
931
+ /**
932
+ * Routes `MONITOR <sub> [<nicks>]` to the monitor reducer. Resolves the
933
+ * currently-online nicks (folded → display) via the nick registry so the
934
+ * reducer can emit `730`/`731` synchronously. The reducer also updates
935
+ * `state.monitorList`; the actor then forwards adds/removes to the bound
936
+ * {@link MonitorStore} (when present) so future online/offline
937
+ * transitions push `730`/`731` to the watcher.
938
+ */
939
+ private async routeMonitor(
940
+ msg: IrcMessage,
941
+ ctx: ReturnType<typeof buildCtx>,
942
+ ): Promise<EffectType[]> {
943
+ // Resolve every nick the reducer might consult — the union of the
944
+ // existing watchlist and any newly-requested targets — so the reducer
945
+ // can emit `730`/`731` synchronously without an O(N) lookupNick call
946
+ // per nick inside its own loop. Mirrors the routeIson pattern.
947
+ const requested =
948
+ msg.params[1] !== undefined
949
+ ? msg.params[1]
950
+ .split(',')
951
+ .map((s) => s.trim())
952
+ .filter((s) => s.length > 0)
953
+ : [];
954
+ const candidates = new Set<string>(ctx.connection.monitorList ?? []);
955
+ for (const n of requested) candidates.add(n);
956
+ const online = await this.resolveOnlineMap(candidates);
957
+
958
+ const before = new Set(ctx.connection.monitorList ?? []);
959
+ const effects = monitorReducer(online, DEFAULT_MONITOR_LIMIT, msg, ctx).effects;
960
+ // Sync the watchlist delta (if any) into the bound MonitorStore so
961
+ // later ReserveNick / ReleaseNick dispatches can fire push effects.
962
+ if (this.monitor !== undefined) {
963
+ const after = ctx.connection.monitorList ?? new Set<string>();
964
+ this.monitor.sync(ctx.connId, before, after);
965
+ // Remove the connection's subscription entirely when its watchlist
966
+ // becomes empty — keeps the reverse index tidy.
967
+ if (after.size === 0) {
968
+ this.monitor.unsubscribe(ctx.connId);
969
+ }
970
+ }
971
+ return effects;
972
+ }
973
+
974
+ /**
975
+ * Resolves each candidate nick via the runtime's nick registry and
976
+ * returns a folded-keyed → display-spelling map of the online subset.
977
+ * Mirrors {@link routeIson}'s per-nick lookup loop.
978
+ */
979
+ private async resolveOnlineMap(candidates: Iterable<string>): Promise<Map<string, string>> {
980
+ const online = new Map<string, string>();
981
+ for (const nick of candidates) {
982
+ const connId = await this.runtime.lookupNick(nick);
983
+ if (connId === null) continue;
984
+ const conn = await this.runtime.getConnection(connId);
985
+ // `conn` can be null in the race where lookupNick resolved but the
986
+ // connection has since torn down (the nick map lags the connection
987
+ // map). When the connection is alive its nick is defined (the
988
+ // registry only resolves nicks that have been reserved, which sets
989
+ // `nick` on the state), so the optional chain is purely defensive
990
+ // against that race.
991
+ const display = conn?.nick;
992
+ if (display !== undefined) {
993
+ online.set(display.toLowerCase(), display);
994
+ }
995
+ }
996
+ return online;
997
+ }
998
+
543
999
  /**
544
1000
  * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
545
1001
  * reducer. The command is query-only and must NOT create a channel as a
@@ -553,6 +1009,213 @@ export class ConnectionActor {
553
1009
  * Cap-gating, param validation, and batch framing all live in the pure
554
1010
  * reducer — this method only resolves the channel and delegates.
555
1011
  */
1012
+ /**
1013
+ * Routes an inbound `BATCH +id …` / `BATCH -id` frame. The only
1014
+ * client-initiated batch type this server honours is `draft/multiline`
1015
+ * (and only when the connection negotiated both `batch` and
1016
+ * `draft/multiline`); every other form falls through to
1017
+ * {@link unknownCommandEffect} (`421`), preserving the pre-batch behaviour.
1018
+ *
1019
+ * On `+id draft/multiline :<target>` the actor opens the accumulator
1020
+ * ({@link openMultilineBatch}); intervening PRIVMSG/NOTICE frames populate
1021
+ * it ({@link accumulateMultilineLine}); `BATCH -id` finalizes the batch
1022
+ * via the pure {@link finalizeMultilineBatch} reducer (channel target) or
1023
+ * the actor-side per-recipient path (nick target) and returns its fanout
1024
+ * effects.
1025
+ */
1026
+ private async routeBatch(
1027
+ msg: IrcMessage,
1028
+ ctx: ReturnType<typeof buildCtx>,
1029
+ ): Promise<EffectType[]> {
1030
+ const marker = msg.params[0] ?? '';
1031
+ // `+<id>` opens a batch; `-<id>` closes one. Anything else is malformed.
1032
+ if (marker.startsWith('+')) {
1033
+ return this.beginMultilineBatch(marker.slice(1), msg, ctx);
1034
+ }
1035
+ if (marker.startsWith('-')) {
1036
+ return this.closeMultilineBatch(marker.slice(1), ctx);
1037
+ }
1038
+ return [this.unknownCommandEffect(msg.command)];
1039
+ }
1040
+
1041
+ /**
1042
+ * Opens a `draft/multiline` accumulator. Requires the connection to have
1043
+ * negotiated both `batch` and `draft/multiline`; otherwise the open is
1044
+ * rejected as an unknown command (the client cannot legally send the
1045
+ * frame without the caps).
1046
+ */
1047
+ private beginMultilineBatch(
1048
+ id: string,
1049
+ msg: IrcMessage,
1050
+ _ctx: ReturnType<typeof buildCtx>,
1051
+ ): EffectType[] {
1052
+ // Cap gate: the client must have negotiated both caps to use the frame.
1053
+ if (!this.state.caps.has('batch') || !this.state.caps.has('draft/multiline')) {
1054
+ return [this.unknownCommandEffect(msg.command)];
1055
+ }
1056
+ // Only the `draft/multiline` batch type is supported client→server.
1057
+ const type = msg.params[1];
1058
+ const target = msg.params[2];
1059
+ if (type !== 'draft/multiline' || target === undefined || target.length === 0) {
1060
+ return [this.unknownCommandEffect(msg.command)];
1061
+ }
1062
+ // Reject re-open while another batch is mid-flight: the IRCv3 batch spec
1063
+ // forbids reusing a ref id while one is open. Surface as 421 so the
1064
+ // client does not silently lose the first batch's lines.
1065
+ if (this.openMultilineBatch !== undefined) {
1066
+ return [this.unknownCommandEffect(msg.command)];
1067
+ }
1068
+ this.openMultilineBatch = { id, target, command: 'PRIVMSG', entries: [] };
1069
+ // The open marker itself produces no server→client reply.
1070
+ return [];
1071
+ }
1072
+
1073
+ /**
1074
+ * Accumulates one intervening PRIVMSG/NOTICE line into the open
1075
+ * `draft/multiline` batch. The inner line's target must match the batch
1076
+ * target and (after the first line) its command must match too; a
1077
+ * mismatch aborts the batch with a `FAIL` and clears the accumulator.
1078
+ */
1079
+ private accumulateMultilineLine(msg: IrcMessage): EffectType[] {
1080
+ const open = this.openMultilineBatch;
1081
+ if (open === undefined) return [];
1082
+ const target = msg.params[0];
1083
+ const text = msg.params[1] ?? '';
1084
+ // Target mismatch: the spec requires every inner line to address the
1085
+ // batch's target. Abort the batch.
1086
+ if (target !== open.target) {
1087
+ this.openMultilineBatch = undefined;
1088
+ return [
1089
+ Effect.send(this.state.id, [
1090
+ {
1091
+ text: `:${this.serverConfig.serverName} FAIL BATCH MULTILINE_INVALID_TARGET ${open.target} :Inner line target does not match the batch target`,
1092
+ },
1093
+ ]),
1094
+ ];
1095
+ }
1096
+ const command = msg.command as 'PRIVMSG' | 'NOTICE';
1097
+ // The first inner line pins the command; subsequent lines must match.
1098
+ if (open.entries.length === 0) {
1099
+ open.command = command;
1100
+ } else if (command !== open.command) {
1101
+ this.openMultilineBatch = undefined;
1102
+ return [
1103
+ Effect.send(this.state.id, [
1104
+ {
1105
+ text: `:${this.serverConfig.serverName} FAIL BATCH MULTILINE_MIXED_COMMANDS ${open.target} :Multi-line batch mixes PRIVMSG and NOTICE`,
1106
+ },
1107
+ ]),
1108
+ ];
1109
+ }
1110
+ open.entries.push({ text });
1111
+ return [];
1112
+ }
1113
+
1114
+ /**
1115
+ * Finalizes an open `draft/multiline` batch on `BATCH -id`. Channel targets
1116
+ * delegate to the pure {@link finalizeMultilineBatch} reducer (cap-split
1117
+ * Broadcast). Nick targets use the actor-side per-recipient path
1118
+ * ({@link closeMultilineBatchToNick}) because the recipient is a single
1119
+ * connection whose caps must be resolved before delivery. An unknown /
1120
+ * mismatched id, or a close with no batch open, falls through to `421`.
1121
+ */
1122
+ private async closeMultilineBatch(
1123
+ id: string,
1124
+ ctx: ReturnType<typeof buildCtx>,
1125
+ ): Promise<EffectType[]> {
1126
+ const open = this.openMultilineBatch;
1127
+ if (open === undefined || open.id !== id) {
1128
+ return [this.unknownCommandEffect('BATCH')];
1129
+ }
1130
+ this.openMultilineBatch = undefined;
1131
+ const maxBytes = this.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
1132
+ if (isChannelTarget(open.target)) {
1133
+ const result = finalizeMultilineBatch(open.target, open.command, open.entries, maxBytes, ctx);
1134
+ return result.effects;
1135
+ }
1136
+ return this.closeMultilineBatchToNick(open, maxBytes, ctx);
1137
+ }
1138
+
1139
+ /**
1140
+ * Finalizes a nick-target multi-line batch. Resolves the single recipient
1141
+ * via the nick registry, inspects their caps, and delivers the appropriate
1142
+ * wire form: the batched replay to `draft/multiline`+`batch` peers, the
1143
+ * tagged joined PRIVMSG to `message-tags` peers, or the bare joined
1144
+ * PRIVMSG to legacy peers. PRIVMSG surfaces `401 ERR_NOSUCHNICK` when the
1145
+ * recipient is offline; NOTICE is silently dropped (RFC: NOTICE MUST NOT
1146
+ * generate numerics).
1147
+ */
1148
+ private async closeMultilineBatchToNick(
1149
+ open: {
1150
+ readonly id: string;
1151
+ readonly target: string;
1152
+ readonly command: 'PRIVMSG' | 'NOTICE';
1153
+ readonly entries: readonly MultilineEntry[];
1154
+ },
1155
+ maxBytes: number,
1156
+ ctx: ReturnType<typeof buildCtx>,
1157
+ ): Promise<EffectType[]> {
1158
+ if (open.entries.length === 0) {
1159
+ return [];
1160
+ }
1161
+
1162
+ const bytes = multilineByteLength(open.entries);
1163
+ if (bytes > maxBytes) {
1164
+ return [
1165
+ Effect.send(ctx.connId, [
1166
+ {
1167
+ text: `:${ctx.serverName} FAIL BATCH MULTILINE_MAX_BYTES ${open.target} :Multi-line batch exceeds the ${maxBytes} byte limit`,
1168
+ },
1169
+ ]),
1170
+ ];
1171
+ }
1172
+
1173
+ const recipientConnId = await this.runtime.lookupNick(open.target);
1174
+ if (recipientConnId === null) {
1175
+ return this.nickNotFoundEffects(open, ctx);
1176
+ }
1177
+ const recipient = await this.runtime.getConnection(recipientConnId);
1178
+ if (recipient === null) {
1179
+ return this.nickNotFoundEffects(open, ctx);
1180
+ }
1181
+
1182
+ const hostmask = hostmaskOf(this.state) ?? '?';
1183
+ const batchId = ctx.ids.batchId();
1184
+ const fanout = buildMultilineFanout(open.target, open.command, open.entries, hostmask, batchId);
1185
+ const msgid = ctx.ids.nonce();
1186
+ recordMultilineMessage(ctx, open.target, open.command, fanout.joined, msgid);
1187
+
1188
+ let lines: RawLine[];
1189
+ if (recipient.caps.has('draft/multiline') && recipient.caps.has('batch')) {
1190
+ lines = [...fanout.batchedReplay];
1191
+ } else if (recipient.caps.has('message-tags')) {
1192
+ lines = [fanout.taggedJoined];
1193
+ } else {
1194
+ lines = [fanout.bareJoined];
1195
+ }
1196
+
1197
+ return [Effect.send(recipientConnId, lines)];
1198
+ }
1199
+
1200
+ /**
1201
+ * Builds the `401 ERR_NOSUCHNICK` effect for an offline nick-target batch
1202
+ * close. PRIVMSG surfaces the error; NOTICE returns nothing (the RFC
1203
+ * forbids numerics in response to NOTICE).
1204
+ */
1205
+ private nickNotFoundEffects(
1206
+ open: { readonly target: string; readonly command: 'PRIVMSG' | 'NOTICE' },
1207
+ ctx: ReturnType<typeof buildCtx>,
1208
+ ): EffectType[] {
1209
+ if (open.command === 'NOTICE') return [];
1210
+ const nick = ctx.connection.nick ?? '*';
1211
+ const code = Numerics.ERR_NOSUCHNICK.toString().padStart(3, '0');
1212
+ return [
1213
+ Effect.send(ctx.connId, [
1214
+ { text: `:${ctx.serverName} ${code} ${nick} ${open.target} :No such nick/channel` },
1215
+ ]),
1216
+ ];
1217
+ }
1218
+
556
1219
  private async routeChathistory(
557
1220
  msg: IrcMessage,
558
1221
  ctx: ReturnType<typeof buildCtx>,
@@ -699,6 +1362,20 @@ function splitCsv(param: string): string[] {
699
1362
  return param.split(',').filter((p) => p.length > 0);
700
1363
  }
701
1364
 
1365
+ /**
1366
+ * Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
1367
+ * future single-server-aware query verb). Returns `undefined` when the
1368
+ * argument is absent OR names the local server (case-insensitive) — the
1369
+ * caller then proceeds with the local-server code path. Returns the raw
1370
+ * argument verbatim when it names a non-local server, which the reducer
1371
+ * surfaces as `402 ERR_NOSUCHSERVER`.
1372
+ */
1373
+ function serverTargetOf(rawServer: string, localServerName: string): string | undefined {
1374
+ if (rawServer.length === 0) return undefined;
1375
+ if (rawServer.toLowerCase() === localServerName.toLowerCase()) return undefined;
1376
+ return rawServer;
1377
+ }
1378
+
702
1379
  /**
703
1380
  * Extracts the channel names a reducer will read for the supplied command.
704
1381
  * Used by `prefetchChannelState` to refresh only the channels the upcoming
@@ -708,8 +1385,11 @@ function splitCsv(param: string): string[] {
708
1385
  * Commands not listed here either have no channel target (PING, NICK, …)
709
1386
  * or read channel membership from `ctx.connection.joinedChannels` directly
710
1387
  * (QUIT), so they don't need a prefetch.
1388
+ *
1389
+ * Exported so the per-command dispatch table can be unit-tested without
1390
+ * standing up a full {@link ConnectionActor} + runtime.
711
1391
  */
712
- function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
1392
+ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
713
1393
  const cmd = msg.command;
714
1394
  switch (cmd) {
715
1395
  case 'JOIN':