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
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { caseFold } from './case-fold.js';
10
+ import type { ChannelState } from './state/channel.js';
11
+ import type { ConnId, ConnectionState } from './state/connection.js';
10
12
 
11
13
  /** Read-only wall clock, epoch milliseconds. */
12
14
  export interface Clock {
@@ -338,6 +340,13 @@ export type StoredCommand = 'PRIVMSG' | 'NOTICE' | 'TAGMSG';
338
340
  * is reconstructed by the reducer from the authoritative {@link ChannelState}
339
341
  * when building replay lines. `time` is epoch milliseconds from the injected
340
342
  * {@link Clock}; `msgid` is a fresh nonce from {@link IdFactory}.
343
+ *
344
+ * `clientTags` carries optional IRCv3 message-tags client-only entries
345
+ * (keys prefixed with `+`) that the recording reducer wants preserved on
346
+ * replay — e.g. `+draft/multiline` so a chathistory client can recognise
347
+ * that a stored PRIVMSG originated from a multi-line batch. Omitted (or
348
+ * empty) for normal PRIVMSG/NOTICE/TAGMSG. The chathistory replay weaves
349
+ * these into the `@time=…;msgid=…` tag section verbatim.
341
350
  */
342
351
  export interface StoredMessage {
343
352
  msgid: string;
@@ -348,6 +357,7 @@ export interface StoredMessage {
348
357
  user?: string;
349
358
  host?: string;
350
359
  text: string;
360
+ readonly clientTags?: readonly string[];
351
361
  }
352
362
 
353
363
  /** Subcommands of `CHATHISTORY` that select a slice of the backlog. */
@@ -568,6 +578,180 @@ function tail<T>(arr: readonly T[], n: number): T[] {
568
578
  return arr.slice(start);
569
579
  }
570
580
 
581
+ // ============================================================================
582
+ // Read-marker persistence (IRCv3 draft/read-marker)
583
+ // ============================================================================
584
+
585
+ /**
586
+ * One persisted last-read marker, keyed per `(account, channel)`.
587
+ *
588
+ * `channel` is the lowercased channel name (the storage key); `msgid` is the
589
+ * msgid of the most recent message the account has read in that channel.
590
+ * Returned by {@link ReadMarkerStore.forAccount} when seeding a fresh
591
+ * connection at identify time.
592
+ */
593
+ export interface ReadMarkerEntry {
594
+ /** Canonical account name (as recorded by SASL). */
595
+ account: string;
596
+ /** Lowercased channel name (rfc1459-folded storage key). */
597
+ channel: string;
598
+ /** Msgid of the most recent read message in this channel. */
599
+ msgid: string;
600
+ }
601
+
602
+ /**
603
+ * Persistence port for IRCv3 `draft/read-marker`.
604
+ *
605
+ * The chathistory command keeps a per-`(connection, channel)`
606
+ * last-read marker in {@link ConnectionState.lastReadMarkers}; this port
607
+ * promotes that marker to a per-`(account, channel)` record so it survives
608
+ * reconnects and is shared across an account's connections.
609
+ *
610
+ * Mirrors the {@link MessageStore} / {@link AccountStore} pattern: the port
611
+ * is **synchronous** (reducers must stay pure and free of IO) and injected
612
+ * via {@link Ctx}. Adapters that need async storage (D1, DynamoDB) pre-load
613
+ * the relevant slice into a synchronously-readable store; the in-memory
614
+ * reference implementation ({@link InMemoryReadMarkerStore}) ships here.
615
+ *
616
+ * Account and channel keys are both case-folded (rfc1459) so lookups are
617
+ * case-insensitive, matching the rest of the server's comparison semantics.
618
+ */
619
+ export interface ReadMarkerStore {
620
+ /**
621
+ * Returns the stored last-read msgid for `(account, channel)`, or
622
+ * `undefined` when no marker has been recorded.
623
+ */
624
+ get(account: string, channel: string): string | undefined;
625
+ /**
626
+ * Records (or replaces) the last-read msgid for `(account, channel)`.
627
+ * Callers MUST only move the marker forward (towards newer messages);
628
+ * the store itself does not order msgids, it stores the latest value set.
629
+ */
630
+ set(account: string, channel: string, msgid: string): void;
631
+ /**
632
+ * Enumerates every recorded marker for `account`. Used at identify time to
633
+ * seed a fresh connection's {@link ConnectionState.lastReadMarkers} so a
634
+ * reconnect restores the user's read position across all their channels.
635
+ */
636
+ forAccount(account: string): ReadonlyArray<ReadMarkerEntry>;
637
+ }
638
+
639
+ /**
640
+ * Reference in-memory {@link ReadMarkerStore} backed by a
641
+ * `foldedAccount → (foldedChannel → msgid)` nested map.
642
+ *
643
+ * Used by the local CLI, integration tests, and any deployment that has not
644
+ * bound a distributed backend. Cloud adapters replace this with a D1 /
645
+ * DynamoDB-backed implementation against the same surface.
646
+ *
647
+ * Both keys are folded via {@link caseFold} (`rfc1459`) so lookups are
648
+ * case-insensitive; the returned {@link ReadMarkerEntry.channel} carries the
649
+ * folded spelling and {@link ReadMarkerEntry.account} the canonical name
650
+ * passed to {@link ReadMarkerStore.forAccount}.
651
+ */
652
+ export class InMemoryReadMarkerStore implements ReadMarkerStore {
653
+ private readonly byAccount = new Map<string, Map<string, string>>();
654
+
655
+ get(account: string, channel: string): string | undefined {
656
+ return this.byAccount.get(folded(account))?.get(folded(channel));
657
+ }
658
+
659
+ set(account: string, channel: string, msgid: string): void {
660
+ const acct = folded(account);
661
+ let chanMap = this.byAccount.get(acct);
662
+ if (chanMap === undefined) {
663
+ chanMap = new Map<string, string>();
664
+ this.byAccount.set(acct, chanMap);
665
+ }
666
+ chanMap.set(folded(channel), msgid);
667
+ }
668
+
669
+ forAccount(account: string): ReadonlyArray<ReadMarkerEntry> {
670
+ const chanMap = this.byAccount.get(folded(account));
671
+ if (chanMap === undefined) return [];
672
+ const out: ReadMarkerEntry[] = [];
673
+ for (const [channel, msgid] of chanMap) {
674
+ out.push({ account, channel, msgid });
675
+ }
676
+ return out;
677
+ }
678
+ }
679
+
680
+ /** rfc1459 case-fold used for both account and channel keys. */
681
+ function folded(value: string): string {
682
+ return caseFold('rfc1459', value);
683
+ }
684
+
685
+ // ============================================================================
686
+ // Away-status persistence (IRCv3 draft/pre-away)
687
+ // ============================================================================
688
+
689
+ /**
690
+ * Persistence port for IRCv3 `draft/pre-away`.
691
+ *
692
+ * The base `AWAY` reducer keeps the away reason in
693
+ * {@link ConnectionState.away} for the lifetime of a single connection; this
694
+ * port promotes that reason to a per-`account` record so it survives
695
+ * reconnects and is shared across an account's connections.
696
+ *
697
+ * Mirrors the {@link ReadMarkerStore} pattern: the port is
698
+ * **synchronous** (reducers must stay pure and free of IO) and injected
699
+ * via {@link Ctx}. Adapters that need async storage (D1, DynamoDB) pre-load
700
+ * the relevant slice into a synchronously-readable store; the in-memory
701
+ * reference implementation ({@link InMemoryAwayStore}) ships here.
702
+ *
703
+ * The account key is case-folded (rfc1459) so lookups are
704
+ * case-insensitive, matching the rest of the server's comparison
705
+ * semantics. The unified services `UserStore` should fold this store in
706
+ * when it lands.
707
+ */
708
+ export interface AwayStore {
709
+ /**
710
+ * Returns the stored away reason for `account`, or `undefined` when no
711
+ * reason has been recorded.
712
+ */
713
+ get(account: string): string | undefined;
714
+ /**
715
+ * Records (or replaces) the away reason for `account`. An empty string
716
+ * is permitted at the store layer (callers avoid it via
717
+ * {@link clearAway}).
718
+ */
719
+ set(account: string, reason: string): void;
720
+ /**
721
+ * Removes the stored away reason for `account`. Used when the user
722
+ * unsets `AWAY` so the next reconnect does not auto-mark them away.
723
+ */
724
+ clear(account: string): void;
725
+ }
726
+
727
+ /**
728
+ * Reference in-memory {@link AwayStore} backed by a
729
+ * `foldedAccount → reason` map.
730
+ *
731
+ * Used by the local CLI, integration tests, and any deployment that has
732
+ * not bound a distributed backend. Cloud adapters replace this with a D1
733
+ * / DynamoDB-backed implementation against the same surface.
734
+ *
735
+ * The account key is folded via {@link caseFold} (`rfc1459`) so lookups
736
+ * are case-insensitive, matching the IRC nick-comparison semantics
737
+ * advertised via `005 RPL_ISUPPORT CASEMAPPING=rfc1459`.
738
+ */
739
+ export class InMemoryAwayStore implements AwayStore {
740
+ private readonly byAccount = new Map<string, string>();
741
+
742
+ get(account: string): string | undefined {
743
+ return this.byAccount.get(folded(account));
744
+ }
745
+
746
+ set(account: string, reason: string): void {
747
+ this.byAccount.set(folded(account), reason);
748
+ }
749
+
750
+ clear(account: string): void {
751
+ this.byAccount.delete(folded(account));
752
+ }
753
+ }
754
+
571
755
  // ============================================================================
572
756
  // Nick history (WHOWAS)
573
757
  // ============================================================================
@@ -670,6 +854,390 @@ export class InMemoryNickHistoryStore implements NickHistoryStore {
670
854
  }
671
855
  }
672
856
 
857
+ // ============================================================================
858
+ // MONITOR subscription registry (IRCv3 MONITOR)
859
+ // ============================================================================
860
+
861
+ /**
862
+ * One async push notification the {@link MonitorStore} requests from the
863
+ * actor layer. The actor turns each push into one or more `Send` effects
864
+ * addressed to the watcher's connection.
865
+ *
866
+ * - `online` — the nick came online; the watcher should receive a
867
+ * `730 RPL_MONONLINE` line naming `display`.
868
+ * - `offline` — the nick went offline; the watcher should receive a
869
+ * `731 RPL_MONOFFLINE` line naming `display`.
870
+ */
871
+ export interface MonitorPush {
872
+ /** Watcher's connection id. */
873
+ connId: string;
874
+ /** Display spelling of the nick that changed state. */
875
+ display: string;
876
+ /** New state. */
877
+ status: 'online' | 'offline';
878
+ }
879
+
880
+ /**
881
+ * Subscription registry for the IRCv3 `MONITOR` command.
882
+ *
883
+ * The watchlist itself lives on {@link ConnectionState.monitorList} (so it
884
+ * persists across actor re-bindings); this port owns the **reverse index**
885
+ * (nick → set of watcher connIds) and the **online set** the actor consults
886
+ * when synchronously resolving `MONITOR +` / `MONITOR S`.
887
+ *
888
+ * Mirrors the {@link MessageStore} / {@link AccountStore} pattern: the port
889
+ * is **synchronous** (reducers must stay pure and free of IO). The actor
890
+ * layer drains pending pushes after each command via {@link drainPushes};
891
+ * adapters that need to fan notifications out across processes (CF
892
+ * ConnectionDO sharding, AWS WebSocket fanout) replace
893
+ * {@link InMemoryMonitorStore} with their own implementation against the
894
+ * same surface.
895
+ *
896
+ * Lifecycle:
897
+ * - On `MONITOR +`, the actor calls {@link sync} with the before/after
898
+ * watchlist snapshots; the store records each newly-added nick →
899
+ * watcher mapping.
900
+ * - On `MONITOR -` / `C`, {@link sync} drops the removed mappings.
901
+ * - On `NICK` (reserve/change) the actor calls {@link onNickOnline} for
902
+ * each newly-reserved nick; the store appends a {@link MonitorPush}
903
+ * for every watcher of that nick.
904
+ * - On `QUIT` (or any release) the actor calls {@link onNickOffline};
905
+ * same flow with a `731` push.
906
+ * - On connection teardown, the actor calls {@link unsubscribe} so the
907
+ * reverse index does not leak.
908
+ */
909
+ export interface MonitorStore {
910
+ /**
911
+ * Reconciles the reverse index for `connId` against a watchlist delta.
912
+ * `before` is the watchlist at the start of the command (folded nicks);
913
+ * `after` is the watchlist after the reducer ran. The store adds
914
+ * mappings for entries in `after` not in `before`, and removes mappings
915
+ * for entries in `before` not in `after`.
916
+ */
917
+ sync(connId: string, before: ReadonlySet<string>, after: ReadonlySet<string>): void;
918
+ /**
919
+ * Removes every subscription for `connId`. Safe to call when the
920
+ * connection had no subscriptions. Idempotent.
921
+ */
922
+ unsubscribe(connId: string): void;
923
+ /**
924
+ * Records `nick` as online and enqueues a `730 RPL_MONONLINE` push to
925
+ * every watcher currently subscribed to that nick. `display` is the
926
+ * registered spelling the wire line should carry.
927
+ *
928
+ * Idempotent: a no-op when `nick` was already online (the second call
929
+ * enqueues nothing — prevents double-pushes on a `ReserveNick` for an
930
+ * already-reserved nick the runtime allowed through as a same-conn
931
+ * re-reserve).
932
+ */
933
+ onNickOnline(display: string): void;
934
+ /**
935
+ * Records `nick` as offline and enqueues a `731 RPL_MONOFFLINE` push to
936
+ * every watcher currently subscribed to that nick. Idempotent in the
937
+ * same way {@link onNickOnline} is.
938
+ */
939
+ onNickOffline(display: string): void;
940
+ /**
941
+ * Returns every display spelling currently marked online, in insertion
942
+ * order. Used by the actor's synchronous `MONITOR +` / `MONITOR S`
943
+ * resolution so the reducer can emit `730`/`731` lines without an
944
+ * O(N) `lookupNick` per requested nick.
945
+ */
946
+ onlineNicks(): string[];
947
+ /**
948
+ * Drains the queue of pending {@link MonitorPush}es the store has
949
+ * enqueued since the last drain. The actor calls this once per
950
+ * dispatched batch (after `NICK`/`QUIT` reducers release/reserve
951
+ * nicks) and turns each push into a `Send` effect for the watcher.
952
+ */
953
+ drainPushes(): MonitorPush[];
954
+ }
955
+
956
+ /**
957
+ * Reference in-memory {@link MonitorStore} used by the local CLI, the
958
+ * integration tests, and any deployment that has not bound a distributed
959
+ * backend.
960
+ *
961
+ * Backed by three plain `Map`s:
962
+ * - `watchers` connId → set of folded watched nicks.
963
+ * - `reverse` folded nick → set of watcher connIds.
964
+ * - `online` folded nick → display spelling (insertion-ordered).
965
+ *
966
+ * Plus a `pending` array of {@link MonitorPush} entries drained by the
967
+ * actor after each dispatched batch.
968
+ */
969
+ export class InMemoryMonitorStore implements MonitorStore {
970
+ private readonly watchers = new Map<string, Set<string>>();
971
+ private readonly reverse = new Map<string, Set<string>>();
972
+ private readonly online = new Map<string, string>();
973
+ private readonly pending: MonitorPush[] = [];
974
+
975
+ sync(connId: string, before: ReadonlySet<string>, after: ReadonlySet<string>): void {
976
+ // Compute the delta without mutating `before`/`after` (callers pass
977
+ // references to their own state).
978
+ const added: string[] = [];
979
+ const removed: string[] = [];
980
+ for (const n of after) if (!before.has(n)) added.push(n);
981
+ for (const n of before) if (!after.has(n)) removed.push(n);
982
+
983
+ if (added.length === 0 && removed.length === 0) return;
984
+
985
+ // Look up (or allocate) the watcher's per-connection set; cleared from
986
+ // `watchers` when it ends up empty so unsubscribed connections do not
987
+ // leak in the forward map.
988
+ let wSet = this.watchers.get(connId);
989
+ if (wSet === undefined) {
990
+ wSet = new Set<string>();
991
+ this.watchers.set(connId, wSet);
992
+ }
993
+
994
+ for (const folded of added) {
995
+ wSet.add(folded);
996
+ let watchers = this.reverse.get(folded);
997
+ if (watchers === undefined) {
998
+ watchers = new Set<string>();
999
+ this.reverse.set(folded, watchers);
1000
+ }
1001
+ watchers.add(connId);
1002
+ }
1003
+ for (const folded of removed) {
1004
+ wSet.delete(folded);
1005
+ // The forward (`watchers`) and reverse indexes are kept in lockstep
1006
+ // by sync, so any folded nick present in `before` has a
1007
+ // corresponding reverse entry. The non-null assertion encodes that
1008
+ // invariant; the alternative `if (watchers === undefined) return`
1009
+ // would introduce an unreachable branch that violates the project's
1010
+ // 100% branch-coverage threshold.
1011
+ // biome-ignore lint/style/noNonNullAssertion: invariant — see above.
1012
+ const watchers = this.reverse.get(folded)!;
1013
+ watchers.delete(connId);
1014
+ if (watchers.size === 0) this.reverse.delete(folded);
1015
+ }
1016
+ if (wSet.size === 0) this.watchers.delete(connId);
1017
+ }
1018
+
1019
+ unsubscribe(connId: string): void {
1020
+ const wSet = this.watchers.get(connId);
1021
+ if (wSet === undefined) return;
1022
+ for (const folded of wSet) {
1023
+ // Same invariant as in `sync`: the forward/reverse indexes are kept
1024
+ // in lockstep, so every folded nick in `wSet` has a reverse entry.
1025
+ // biome-ignore lint/style/noNonNullAssertion: invariant — see above.
1026
+ const watchers = this.reverse.get(folded)!;
1027
+ watchers.delete(connId);
1028
+ if (watchers.size === 0) this.reverse.delete(folded);
1029
+ }
1030
+ this.watchers.delete(connId);
1031
+ }
1032
+
1033
+ onNickOnline(display: string): void {
1034
+ const folded = display.toLowerCase();
1035
+ if (this.online.has(folded)) return;
1036
+ this.online.set(folded, display);
1037
+ const watchers = this.reverse.get(folded);
1038
+ if (watchers === undefined) return;
1039
+ for (const connId of watchers) {
1040
+ this.pending.push({ connId, display, status: 'online' });
1041
+ }
1042
+ }
1043
+
1044
+ onNickOffline(display: string): void {
1045
+ const folded = display.toLowerCase();
1046
+ if (!this.online.has(folded)) return;
1047
+ this.online.delete(folded);
1048
+ const watchers = this.reverse.get(folded);
1049
+ if (watchers === undefined) return;
1050
+ for (const connId of watchers) {
1051
+ this.pending.push({ connId, display, status: 'offline' });
1052
+ }
1053
+ }
1054
+
1055
+ onlineNicks(): string[] {
1056
+ return Array.from(this.online.values());
1057
+ }
1058
+
1059
+ drainPushes(): MonitorPush[] {
1060
+ return this.pending.splice(0);
1061
+ }
1062
+ }
1063
+
1064
+ // ============================================================================
1065
+ // Network statistics (LUSERS / STATS)
1066
+ // ============================================================================
1067
+
1068
+ /**
1069
+ * Snapshot of network-wide counts returned by a {@link ServerStats} backend.
1070
+ *
1071
+ * The shape mirrors the fields the `LUSERS` and `STATS` reducers need to
1072
+ * format their numerics:
1073
+ * - `users` / `invisible`: the split registered-user counts that drive
1074
+ * `251 RPL_LUSERCLIENT` ("There are X users and Y invisible on Z
1075
+ * servers"). `users` is the *visible* (non-`+i`) registered count;
1076
+ * `invisible` is the `+i` registered count. Total registered users
1077
+ * is `users + invisible`.
1078
+ * - `opers`: count of IRC operators; emitted as `252 RPL_LUSEROP` when
1079
+ * non-zero. An oper counts here regardless of `+i` (an `+o` oper who
1080
+ * is also `+i` still bumps `opers`, `invisible`, and `users` is
1081
+ * computed as visible = total-registered − invisible).
1082
+ * - `unknownConnections`: pre-registration connection count; emitted as
1083
+ * `253 RPL_LUSERUNKNOWN` when non-zero.
1084
+ * - `channels`: live channel count; emitted as `254 RPL_LUSERCHANNELS`
1085
+ * when non-zero.
1086
+ * - `servers`: server count seen by `251`/`255`. Always 1 for the
1087
+ * single-server serverless deployment; S2S aggregation is a PLAN
1088
+ * non-goal.
1089
+ * - `localConns` / `globalConns`: total connection counts (registered
1090
+ * + pre-registration) for the optional `265 RPL_LOCALUSERS` /
1091
+ * `266 RPL_GLOBALUSERS` numerics. Equal for a single-server network.
1092
+ * - `maxLocalConns` / `maxGlobalConns`: high-water marks accompanying
1093
+ * `265`/`266`. When the backend has no recorded max it reports the
1094
+ * current count (so the wire numeric never claims a max below live).
1095
+ * - `uptimeStartedAt`: epoch-ms the server (or stats backend) started
1096
+ * counting from. Drives the `STATS u` uptime line.
1097
+ */
1098
+ export interface ServerStatsSnapshot {
1099
+ users: number;
1100
+ invisible: number;
1101
+ opers: number;
1102
+ unknownConnections: number;
1103
+ channels: number;
1104
+ servers: number;
1105
+ localConns: number;
1106
+ globalConns: number;
1107
+ maxLocalConns: number;
1108
+ maxGlobalConns: number;
1109
+ uptimeStartedAt: number;
1110
+ }
1111
+
1112
+ /**
1113
+ * Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
1114
+ *
1115
+ * Unlike the synchronous {@link MotdProvider} / {@link AccountStore} ports
1116
+ * (which reducers consume directly via {@link Ctx}), this port is
1117
+ * **asynchronous** (`Promise`-returning) because the aggregation crosses
1118
+ * every connection and channel in the deployment — it cannot be pre-loaded
1119
+ * into a synchronous view the way MOTD or a credential table can. The
1120
+ * actor layer awaits {@link getStats} once per `LUSERS` / `STATS`
1121
+ * invocation, then passes the resulting {@link ServerStatsSnapshot} to the
1122
+ * pure reducers (`lusersReducer`, `statsReducer`). This mirrors the
1123
+ * `listChannels` → `listReducer` pattern.
1124
+ *
1125
+ * Each adapter implements this against its authoritative store:
1126
+ * - in-memory runtime: derive from its `Map`s (reference impl
1127
+ * {@link InMemoryStats}).
1128
+ * - CF: aggregate via `listChannels` / connection-enumeration RPCs.
1129
+ * - AWS: `Scan` / `Count` queries against `Connections` / `ChannelMeta`.
1130
+ */
1131
+ export interface ServerStats {
1132
+ getStats(): Promise<ServerStatsSnapshot>;
1133
+ }
1134
+
1135
+ /**
1136
+ * Aggregates a snapshot from already-collected connection state and a
1137
+ * channel count.
1138
+ *
1139
+ * Pure: backends that gather their data through async I/O (CF RPC fan-out,
1140
+ * AWS DynamoDB `Scan`) call this once they have the raw inputs. The
1141
+ * {@link InMemoryStats} reference backend delegates here so the
1142
+ * classification logic (oper / invisible / unknown) lives in exactly one
1143
+ * place and is unit-tested independently of any adapter's I/O.
1144
+ *
1145
+ * Counting rules (RFC 2812 §4.6.2 + modern practice):
1146
+ * - `users` is the **visible** registered count (total registered minus
1147
+ * `+i`); the first number in "There are X users and Y invisible".
1148
+ * - `invisible` is the `+i` registered count; the second number.
1149
+ * - An oper who is also `+i` increments `opers`, `invisible`, and
1150
+ * reduces `users` (because they are invisible) — matching charybdis.
1151
+ * - `localConns` is every tracked connection (registered + unknown).
1152
+ */
1153
+ export function computeStatsSnapshot(
1154
+ connections: Iterable<ConnectionState>,
1155
+ channelCount: number,
1156
+ uptimeStartedAt: number,
1157
+ opts?: { maxLocalConns?: number; maxGlobalConns?: number; servers?: number },
1158
+ ): ServerStatsSnapshot {
1159
+ let registered = 0;
1160
+ let invisible = 0;
1161
+ let opers = 0;
1162
+ let unknown = 0;
1163
+ let total = 0;
1164
+ for (const conn of connections) {
1165
+ total++;
1166
+ if (conn.registration !== 'registered') {
1167
+ unknown++;
1168
+ continue;
1169
+ }
1170
+ registered++;
1171
+ if (conn.userModes.invisible) invisible++;
1172
+ if (conn.userModes.oper) opers++;
1173
+ }
1174
+ return {
1175
+ users: registered - invisible,
1176
+ invisible,
1177
+ opers,
1178
+ unknownConnections: unknown,
1179
+ channels: channelCount,
1180
+ servers: opts?.servers ?? 1,
1181
+ localConns: total,
1182
+ globalConns: total,
1183
+ // The wire numeric must never claim a max below the current live count;
1184
+ // when the backend's recorded max is stale (or never seeded) the live
1185
+ // count is the lower bound.
1186
+ maxLocalConns: Math.max(opts?.maxLocalConns ?? 0, total),
1187
+ maxGlobalConns: Math.max(opts?.maxGlobalConns ?? 0, total),
1188
+ uptimeStartedAt,
1189
+ };
1190
+ }
1191
+
1192
+ /**
1193
+ * Reference in-memory {@link ServerStats} backed by the live `Map`s the
1194
+ * {@link InMemoryRuntime} (and tests) already maintain.
1195
+ *
1196
+ * The constructor takes *references* to the runtime's mutable maps; later
1197
+ * mutations are visible on the next {@link getStats} call without
1198
+ * re-binding. Used by the local CLI, by integration tests, and as the
1199
+ * contract spec the CF / AWS backends mirror.
1200
+ *
1201
+ * `uptimeStartedAt` is supplied at construction (typically the runtime's
1202
+ * boot time). `maxLocalConns` / `maxGlobalConns` are optional high-water
1203
+ * marks; when omitted the live count is reported as the max so `265`/`266`
1204
+ * never regress below the current connection total.
1205
+ */
1206
+ export class InMemoryStats implements ServerStats {
1207
+ private readonly connections: ReadonlyMap<ConnId, ConnectionState>;
1208
+ private readonly channels: ReadonlyMap<string, ChannelState>;
1209
+ private readonly uptimeStartedAt: number;
1210
+ private readonly maxLocalConns: number | undefined;
1211
+ private readonly maxGlobalConns: number | undefined;
1212
+
1213
+ constructor(opts: {
1214
+ connections: ReadonlyMap<ConnId, ConnectionState>;
1215
+ channels: ReadonlyMap<string, ChannelState>;
1216
+ uptimeStartedAt: number;
1217
+ clock?: Clock;
1218
+ maxLocalConns?: number;
1219
+ maxGlobalConns?: number;
1220
+ }) {
1221
+ this.connections = opts.connections;
1222
+ this.channels = opts.channels;
1223
+ this.uptimeStartedAt = opts.uptimeStartedAt;
1224
+ this.maxLocalConns = opts.maxLocalConns;
1225
+ this.maxGlobalConns = opts.maxGlobalConns;
1226
+ }
1227
+
1228
+ async getStats(): Promise<ServerStatsSnapshot> {
1229
+ return computeStatsSnapshot(
1230
+ this.connections.values(),
1231
+ this.channels.size,
1232
+ this.uptimeStartedAt,
1233
+ {
1234
+ ...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
1235
+ ...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
1236
+ },
1237
+ );
1238
+ }
1239
+ }
1240
+
673
1241
  // ============================================================================
674
1242
  // Observability — structured logger
675
1243
  // ============================================================================
@@ -9,6 +9,14 @@ export {
9
9
  formatServerTime,
10
10
  } from './outbound.js';
11
11
  export { wrapBatch, type BatchFrame } from './batch.js';
12
+ export {
13
+ LABELED_RESPONSE_CAP_NAME,
14
+ LABEL_TAG,
15
+ applyLabelToEffects,
16
+ type LabelApplyOptions,
17
+ extractLabel,
18
+ wrapLabeledResponse,
19
+ } from './labeled-response.js';
12
20
  export { decodeBase64, encodeBase64 } from './base64.js';
13
21
  export {
14
22
  formatNumeric,
@@ -17,3 +25,9 @@ export {
17
25
  numericToName,
18
26
  Numerics,
19
27
  } from './numerics.js';
28
+ export {
29
+ type ReplyKind,
30
+ type StandardReplyMapping,
31
+ numericToStandardReply,
32
+ toStandardReply,
33
+ } from './standard-replies.js';