serverless-ircd 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Pure reducer for IRCv3 `draft/multiline` (multi-line `BATCH`).
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/multiline
5
+ *
6
+ * A cap-enabled client opens a multi-line batch with
7
+ * `BATCH +id draft/multiline :<target>`
8
+ * followed by N `PRIVMSG`/`NOTICE` lines addressed to the same target, then
9
+ * closes it with `BATCH -id`. The server concatenates the per-line trailing
10
+ * parameters with `\n`, enforces the advertised byte budget, and fans the
11
+ * result out:
12
+ *
13
+ * - to peers that also negotiated `draft/multiline`: a replayed
14
+ * `BATCH +newid draft/multiline :<target>` containing the original
15
+ * per-line `PRIVMSG`/`NOTICE` messages;
16
+ * - to everyone else: a single joined `PRIVMSG`/`NOTICE` carrying the
17
+ * `+draft/multiline` client tag so message-tags-aware (but not
18
+ * multiline-aware) clients can recognise the joined form.
19
+ *
20
+ * This module owns the pure finalization step (concatenation, byte-budget
21
+ * enforcement, fanout construction). The stateful accumulation of the inner
22
+ * lines — buffering `PRIVMSG`/`NOTICE` between the `BATCH +` and `BATCH -`
23
+ * markers — lives in the actor layer, which calls
24
+ * {@link finalizeMultilineBatch} once the batch closes.
25
+ */
26
+
27
+ import {
28
+ DEFAULT_MULTILINE_MAX_BYTES,
29
+ MULTILINE_CAP_NAME,
30
+ multilineCapValue,
31
+ } from '../caps/capabilities.js';
32
+ import { Effect } from '../effects.js';
33
+ import type { Effect as EffectType, RawLine } from '../effects.js';
34
+ import { hostmaskOf } from '../state/connection.js';
35
+ import type { Ctx } from '../types.js';
36
+
37
+ /**
38
+ * Wire name of the IRCv3 `draft/multiline` capability. Aliased from
39
+ * {@link MULTILINE_CAP_NAME} (the canonical home in the cap data table) for
40
+ * ergonomic use inside the reducer.
41
+ */
42
+ export const MULTILINE_CAP = MULTILINE_CAP_NAME;
43
+
44
+ export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
45
+
46
+ /**
47
+ * One accumulated inner line of a multi-line batch. The actor collects these
48
+ * as the client streams `PRIVMSG`/`NOTICE` lines between the batch markers,
49
+ * then hands the list to {@link finalizeMultilineBatch}. Only the trailing
50
+ * parameter (message text) is stored per line: the spec requires every inner
51
+ * line of a batch to share both the command and the target, so those live on
52
+ * the batch as a whole and are passed to the reducer separately.
53
+ */
54
+ export interface MultilineEntry {
55
+ /** The trailing parameter (message text) of the inner line. */
56
+ readonly text: string;
57
+ }
58
+
59
+ /** Result of {@link finalizeMultilineBatch}: either accepted or rejected. */
60
+ export type FinalizeMultilineResult =
61
+ | { readonly ok: true; readonly effects: EffectType[] }
62
+ | { readonly ok: false; readonly effects: EffectType[] };
63
+
64
+ /**
65
+ * Concatenates the per-line texts of a multi-line batch with `\n`, per the
66
+ * IRCv3 spec. The joined value is what legacy (non-multiline) peers receive
67
+ * as the trailing parameter of the single relayed PRIVMSG/NOTICE.
68
+ */
69
+ export function joinMultiline(entries: readonly MultilineEntry[]): string {
70
+ return entries.map((e) => e.text).join('\n');
71
+ }
72
+
73
+ /**
74
+ * Returns the byte length of the joined multi-line content as the byte budget
75
+ * sees it: the sum of every entry's UTF-8 byte length plus one byte per `\n`
76
+ * separator. This is the quantity compared against `draft/multiline=<max>`.
77
+ */
78
+ export function multilineByteLength(entries: readonly MultilineEntry[]): number {
79
+ if (entries.length === 0) return 0;
80
+ let total = 0;
81
+ for (const entry of entries) {
82
+ total += utf8ByteLength(entry.text);
83
+ }
84
+ // `(n - 1)` separators join `n` entries.
85
+ total += entries.length - 1;
86
+ return total;
87
+ }
88
+
89
+ /** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
90
+ const TEXT_ENCODER = new TextEncoder();
91
+
92
+ /** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
93
+ function utf8ByteLength(s: string): number {
94
+ return TEXT_ENCODER.encode(s).length;
95
+ }
96
+
97
+ /** Resolves the sender's hostmask, falling back to nick then `?`. */
98
+ function senderHostmask(conn: Ctx['connection']): string {
99
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
100
+ }
101
+
102
+ /**
103
+ * Pre-built wire lines for a finalized multi-line batch, independent of the
104
+ * delivery mechanism (channel `Broadcast` vs. nick-target `Send`). The caller
105
+ * picks the appropriate variant per recipient based on the recipient's caps:
106
+ *
107
+ * - `draft/multiline` + `batch` → `batchedReplay` (replayed BATCH frame).
108
+ * - `message-tags` only → `taggedJoined` (joined PRIVMSG with
109
+ * `+draft/multiline` client tag).
110
+ * - neither → `bareJoined` (joined PRIVMSG, no tag).
111
+ */
112
+ export interface MultilineFanout {
113
+ /** Replayed `BATCH +id draft/multiline <target>` … `BATCH -id`. */
114
+ readonly batchedReplay: RawLine[];
115
+ /** Joined PRIVMSG/NOTICE carrying the `+draft/multiline` client tag. */
116
+ readonly taggedJoined: RawLine;
117
+ /** Joined PRIVMSG/NOTICE with no tag prefix (for legacy peers). */
118
+ readonly bareJoined: RawLine;
119
+ /** The `\n`-joined trailing parameter (for chathistory recording). */
120
+ readonly joined: string;
121
+ }
122
+
123
+ /**
124
+ * Builds the wire-line variants for a finalized multi-line batch. Pure: no
125
+ * context, no effects — just string construction. Shared by the channel
126
+ * broadcast path ({@link finalizeMultilineBatch}) and the actor-side nick
127
+ * target path so both emit identical wire forms.
128
+ *
129
+ * @param target The batch target (channel name or nick).
130
+ * @param command The inner-line command (`PRIVMSG` or `NOTICE`).
131
+ * @param entries The accumulated inner lines in arrival order.
132
+ * @param hostmask The sender's `nick!user@host` (or fallback).
133
+ * @param batchId A fresh batch reference id for the replayed BATCH frame.
134
+ */
135
+ export function buildMultilineFanout(
136
+ target: string,
137
+ command: 'PRIVMSG' | 'NOTICE',
138
+ entries: readonly MultilineEntry[],
139
+ hostmask: string,
140
+ batchId: string,
141
+ ): MultilineFanout {
142
+ const joined = joinMultiline(entries);
143
+
144
+ const taggedJoined: RawLine = {
145
+ text: `@+draft/multiline :${hostmask} ${command} ${target} :${joined}`,
146
+ };
147
+ const bareJoined: RawLine = {
148
+ text: `:${hostmask} ${command} ${target} :${joined}`,
149
+ };
150
+
151
+ const inner: RawLine[] = entries.map(
152
+ (e) => ({ text: `:${hostmask} ${command} ${target} :${e.text}` }) as RawLine,
153
+ );
154
+ const batchedReplay: RawLine[] = [
155
+ { text: `BATCH +${batchId} draft/multiline ${target}` },
156
+ ...inner,
157
+ { text: `BATCH -${batchId}` },
158
+ ];
159
+
160
+ return { batchedReplay, taggedJoined, bareJoined, joined };
161
+ }
162
+
163
+ /**
164
+ * Finalizes a closed multi-line batch: enforces the byte budget and builds
165
+ * the fanout effects for both multiline-capable and legacy peers.
166
+ *
167
+ * On success the joined body is also recorded into the bound
168
+ * {@link MessageStore} (if any) as a single entry carrying the
169
+ * `+draft/multiline` client tag, so `CHATHISTORY` playback returns exactly
170
+ * one entry per batch.
171
+ *
172
+ * @param target The batch's relay target (channel name). The actor
173
+ * validates that every inner line addressed this same target
174
+ * before calling.
175
+ * @param command The inner-line command (`PRIVMSG` or `NOTICE`); the actor
176
+ * guarantees every inner line used the same one.
177
+ * @param entries The accumulated inner lines in arrival order.
178
+ * @param maxBytes The advertised byte budget (`draft/multiline=<n>`).
179
+ * @param ctx Per-invocation context.
180
+ * @returns `{ ok: true, effects }` on success; `{ ok: false, effects }`
181
+ * when the joined content exceeds `maxBytes` (the effects
182
+ * carry the `FAIL` rejection back to the sender).
183
+ */
184
+ export function finalizeMultilineBatch(
185
+ target: string,
186
+ command: 'PRIVMSG' | 'NOTICE',
187
+ entries: readonly MultilineEntry[],
188
+ maxBytes: number,
189
+ ctx: Ctx,
190
+ ): FinalizeMultilineResult {
191
+ if (entries.length === 0) {
192
+ return { ok: true, effects: [] };
193
+ }
194
+
195
+ const bytes = multilineByteLength(entries);
196
+ if (bytes > maxBytes) {
197
+ return {
198
+ ok: false,
199
+ effects: [
200
+ Effect.send(ctx.connId, [
201
+ {
202
+ text: `:${ctx.serverName} FAIL BATCH MULTILINE_MAX_BYTES ${target} :Multi-line batch exceeds the ${maxBytes} byte limit`,
203
+ },
204
+ ]),
205
+ ],
206
+ };
207
+ }
208
+
209
+ const hostmask = senderHostmask(ctx.connection);
210
+ const batchId = ctx.ids.batchId();
211
+ const fanout = buildMultilineFanout(target, command, entries, hostmask, batchId);
212
+ const msgid = ctx.ids.nonce();
213
+ recordMultilineMessage(ctx, target, command, fanout.joined, msgid);
214
+
215
+ const effects: EffectType[] = [
216
+ Effect.broadcast(
217
+ target,
218
+ [fanout.taggedJoined],
219
+ ctx.connId,
220
+ MULTILINE_CAP,
221
+ fanout.batchedReplay,
222
+ ),
223
+ ];
224
+
225
+ return { ok: true, effects };
226
+ }
227
+
228
+ /**
229
+ * Records one finalized multi-line batch into the bound {@link MessageStore}
230
+ * (when present) as a single chathistory entry. The joined body is stored
231
+ * verbatim, the assigned `msgid` is the chathistory pivot, and the
232
+ * `+draft/multiline` client tag is attached so the chathistory replay can
233
+ * mark the line as originating from a multi-line batch. Shared by the
234
+ * channel broadcast path ({@link finalizeMultilineBatch}) and the actor-side
235
+ * nick-target path.
236
+ */
237
+ export function recordMultilineMessage(
238
+ ctx: Ctx,
239
+ target: string,
240
+ command: 'PRIVMSG' | 'NOTICE',
241
+ joined: string,
242
+ msgid: string,
243
+ ): void {
244
+ if (ctx.messages === undefined) return;
245
+ ctx.messages.record({
246
+ msgid,
247
+ time: ctx.clock.now(),
248
+ chan: target.toLowerCase(),
249
+ command,
250
+ nick: (ctx.connection.nick as string) ?? '?',
251
+ user: ctx.connection.user as string,
252
+ host: ctx.connection.host as string,
253
+ text: joined,
254
+ clientTags: ['+draft/multiline'],
255
+ });
256
+ }
@@ -14,7 +14,7 @@
14
14
  * `@` (private `+p`). Private wins over secret when both are set, matching
15
15
  * the JOIN reducer's NAMES reply and RFC 2812 §3.2.4.
16
16
  *
17
- * Visibility rules (PLAN §3 + ticket spec):
17
+ * Visibility rules (PLAN §3):
18
18
  * - `+s` or `+p` set: requester must be a member to receive the NAMES list.
19
19
  * Otherwise `442 ERR_NOTONCHANNEL`.
20
20
  * - Otherwise: any connection may list.
@@ -44,12 +44,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
44
44
  }
45
45
 
46
46
  /** Formats a numeric error/reply line addressed to the connection's nick. */
47
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
47
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
48
48
  const nick = ctx.connection.nick ?? '*';
49
49
  const codeStr = code.toString().padStart(3, '0');
50
- const parts = [`:${ctx.serverName}`, codeStr, nick];
51
- if (middle !== undefined) parts.push(middle);
52
- parts.push(`:${trailing}`);
50
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
53
51
  return { text: parts.join(' ') };
54
52
  }
55
53
 
@@ -33,12 +33,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
33
33
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
34
34
  * trailing `:`-prefixed text.
35
35
  */
36
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
36
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
37
37
  const nick = ctx.connection.nick ?? '*';
38
38
  const codeStr = code.toString().padStart(3, '0');
39
- const parts = [`:${ctx.serverName}`, codeStr, nick];
40
- if (middle !== undefined) parts.push(middle);
41
- parts.push(`:${trailing}`);
39
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
42
40
  return { text: parts.join(' ') };
43
41
  }
44
42
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pure helpers for IRCv3 `draft/pre-away` (persistent away status).
3
+ *
4
+ * Spec (work-in-progress): https://github.com/ircv3/ircv3-specifications/pull/415
5
+ *
6
+ * Flow:
7
+ * - The {@link awayReducer} sets/clears {@link ConnectionState.away} as
8
+ * usual. When an {@link AwayStore} is bound AND the connection is
9
+ * identified (has an `account`), the change is mirrored into the
10
+ * per-`account` record via {@link persistAway} (on set) or
11
+ * {@link clearPersistedAway} (on unset).
12
+ * - On SASL identify, the actor calls {@link replayPersistedAway} so a
13
+ * fresh connection's `away` is restored from the persisted reason and
14
+ * `306 RPL_NOWAWAY` is emitted as if the client had sent
15
+ * `AWAY :reason` themselves.
16
+ *
17
+ * Mirrors the {@link ReadMarkerStore} plumbing from the read-marker cap.
18
+ */
19
+
20
+ import { PRE_AWAY_CAP_NAME } from '../caps/capabilities.js';
21
+ import type { AwayStore } from '../ports.js';
22
+ import { Numerics } from '../protocol/numerics.js';
23
+ import type { ConnectionState } from '../state/connection.js';
24
+ import type { Ctx } from '../types.js';
25
+
26
+ /**
27
+ * Wire name of the IRCv3 `draft/pre-away` capability. Aliased from
28
+ * {@link PRE_AWAY_CAP_NAME} (the canonical home in the cap data table)
29
+ * for ergonomic use inside the reducer.
30
+ */
31
+ export const PRE_AWAY_CAP = PRE_AWAY_CAP_NAME;
32
+
33
+ /**
34
+ * Records the away reason for the connection's account and stamps the
35
+ * in-memory {@link ConnectionState.away} so the current session tracks
36
+ * the new reason.
37
+ *
38
+ * Mirrors {@link persistReadMarker}: the store write is gated on both an
39
+ * {@link AwayStore} being bound AND the connection being identified.
40
+ * Unidentified connections still get the in-memory write so the session's
41
+ * own view (numerics, WHO/WHOIS) stays consistent.
42
+ */
43
+ export function persistAway(ctx: Ctx, reason: string): void {
44
+ ctx.connection.away = reason;
45
+ const store = ctx.away;
46
+ const account = ctx.connection.account;
47
+ if (store !== undefined && account !== undefined) {
48
+ store.set(account, reason);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Clears the persisted away reason for the connection's account and the
54
+ * in-memory {@link ConnectionState.away}.
55
+ *
56
+ * Mirrors {@link persistAway}'s gating: a store write (delete) is gated
57
+ * on both a store being bound AND the connection being identified. The
58
+ * in-memory clear is unconditional so the session's own view stays
59
+ * consistent regardless of identification.
60
+ */
61
+ export function clearPersistedAway(ctx: Ctx): void {
62
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
63
+ delete ctx.connection.away;
64
+ const store = ctx.away;
65
+ const account = ctx.connection.account;
66
+ if (store !== undefined && account !== undefined) {
67
+ store.clear(account);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Restores a fresh connection's {@link ConnectionState.away} from the
73
+ * persisted reason for `account`. Called at SASL identify so a reconnect
74
+ * resumes the user's away state.
75
+ *
76
+ * Existing in-memory `away` is preserved: a session may have already set
77
+ * a different reason, and the session's own value wins. Returns the
78
+ * persisted reason (or `undefined`) so the caller can emit the
79
+ * `306 RPL_NOWAWAY` numeric only when the replay actually applied a value.
80
+ */
81
+ export function replayPersistedAway(
82
+ conn: ConnectionState,
83
+ store: AwayStore,
84
+ account: string,
85
+ ): string | undefined {
86
+ if (conn.away !== undefined) return undefined;
87
+ const reason = store.get(account);
88
+ if (reason === undefined) return undefined;
89
+ conn.away = reason;
90
+ return reason;
91
+ }
92
+
93
+ /**
94
+ * Builds the `306 RPL_NOWAWAY` line emitted after a successful away
95
+ * replay at identify time. Mirrors the line the {@link awayReducer}
96
+ * emits when a client sets `AWAY :reason` themselves, so the
97
+ * `draft/pre-away` replay is wire-identical to a direct AWAY set as far
98
+ * as the identified user is concerned.
99
+ *
100
+ * The companion `away-notify` broadcast (`:<hostmask> AWAY :<reason>` to
101
+ * cap-enabled peers in shared channels) is intentionally NOT emitted
102
+ * here: at SASL identify time the connection has not yet joined any
103
+ * channels, so the broadcast would have no audience. Peers learn the
104
+ * away state through the normal `AWAY` / `WHO` / `WHOIS` queries once
105
+ * the user joins.
106
+ */
107
+ export function nowAwayLine(conn: ConnectionState, serverName: string): { text: string } {
108
+ const nick = conn.nick ?? '*';
109
+ return {
110
+ text: `:${serverName} ${Numerics.RPL_NOWAWAY.toString().padStart(3, '0')} ${nick} :You have been marked as being away`,
111
+ };
112
+ }
@@ -175,18 +175,18 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
175
175
 
176
176
  // All checks passed: persist the message for chathistory playback when a
177
177
  // MessageStore is bound. The msgid/time are generated once here and travel
178
- // on the StoredMessage; the live broadcast line stays bare (per-recipient
179
- // server-time/msgid decoration is the dispatch layer's job). Recording is
180
- // gated on ctx.messages so deployments with chathistory disabled see no
181
- // change whatsoever.
178
+ // on the StoredMessage AND on the live fanout line (IRCv3 msgid). A single
179
+ // nonce is shared between the live tag and the record so the value a cap
180
+ // enabled peer sees live matches the value replayed by chathistory.
182
181
  //
183
182
  // By this point the sender's hostmask has already been computed and used
184
183
  // in the `+b` ban check above, so the connection's nick/user/host are all
185
184
  // defined for any registered PRIVMSG (registration sets all three); the
186
185
  // fallbacks mirror `senderHostmask` purely for the type-checker.
186
+ const msgid = ctx.ids.nonce();
187
187
  if (ctx.messages !== undefined) {
188
188
  ctx.messages.record({
189
- msgid: ctx.ids.nonce(),
189
+ msgid,
190
190
  time: ctx.clock.now(),
191
191
  chan: state.nameLower,
192
192
  command,
@@ -197,14 +197,21 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
197
197
  });
198
198
  }
199
199
 
200
- // All checks passed: broadcast to the channel, excluding the sender.
201
- const line: RawLine = { text: `:${hostmask} ${command} ${state.name} :${text}` };
202
- effects.push(Effect.broadcast(state.name, [line], ctx.connId));
203
-
204
- // IRCv3 echo-message: echo the same line back to the sender, after
205
- // the broadcast effect so clients observe their own message last.
200
+ // All checks passed: broadcast to the channel, excluding the sender. The
201
+ // bare line goes to every member; the `@msgid=<id>` decorated line is sent
202
+ // only to members who negotiated the `msgid` cap (IRCv3 msgid). The
203
+ // dispatch layer's cap-split resolves per-recipient.
204
+ const body = `${command} ${state.name} :${text}`;
205
+ const line: RawLine = { text: `:${hostmask} ${body}` };
206
+ const msgidLine: RawLine = { text: `@msgid=${msgid} :${hostmask} ${body}` };
207
+ effects.push(Effect.broadcast(state.name, [line], ctx.connId, 'msgid', [msgidLine]));
208
+
209
+ // IRCv3 echo-message: echo the line back to the sender, after the
210
+ // broadcast effect so clients observe their own message last. A sender
211
+ // that also negotiated `msgid` sees the decorated echo.
206
212
  if (ctx.connection.caps.has('echo-message')) {
207
- effects.push(Effect.send(ctx.connId, [line]));
213
+ const echoLine = ctx.connection.caps.has('msgid') ? msgidLine : line;
214
+ effects.push(Effect.send(ctx.connId, [echoLine]));
208
215
  }
209
216
 
210
217
  return { state, effects };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pure helpers for IRCv3 `draft/read-marker` (persistent last-read marker).
3
+ *
4
+ * Spec (work-in-progress): https://ircv3.net/specs/extensions/read-marker
5
+ *
6
+ * The serverless-ircd deployment diverges from the work-in-progress draft:
7
+ * instead of the draft's `MARKREAD` command + server-time timestamp, this
8
+ * implementation is **msgid-based** so it composes with the
9
+ * per-`(connection, channel)` last-read marker the chathistory command
10
+ * already maintains in {@link ConnectionState.lastReadMarkers}.
11
+ *
12
+ * Flow:
13
+ * - A cap-enabled client marks a message read by sending a
14
+ * `TAGMSG <target>` carrying `+draft/read-marker=<msgid>`. The
15
+ * {@link tagmsgChannelReducer} detects the tag, calls
16
+ * {@link persistReadMarker} to advance the per-`(account, channel)`
17
+ * record in the bound {@link ReadMarkerStore}, and fans the TAGMSG out
18
+ * (carrying the tag) so the user's other connections learn the new
19
+ * read position.
20
+ * - On SASL identify, the actor calls {@link seedReadMarkers} so a fresh
21
+ * connection's `lastReadMarkers` is restored from the stored values and
22
+ * a reconnect resumes at the user's last read position.
23
+ */
24
+
25
+ import { READ_MARKER_CAP_NAME } from '../caps/capabilities.js';
26
+ import type { ReadMarkerStore } from '../ports.js';
27
+ import type { ConnectionState } from '../state/connection.js';
28
+ import type { Ctx } from '../types.js';
29
+
30
+ /**
31
+ * Wire name of the IRCv3 `draft/read-marker` capability. Aliased from
32
+ * {@link READ_MARKER_CAP_NAME} (the canonical home in the cap data table)
33
+ * for ergonomic use inside the reducer.
34
+ */
35
+ export const READ_MARKER_CAP = READ_MARKER_CAP_NAME;
36
+
37
+ /**
38
+ * The client-tag key that carries a read-marker update. Presence of this key
39
+ * (with a non-empty value) on a `TAGMSG` identifies a read-marker update the
40
+ * server persists and propagates.
41
+ */
42
+ export const READ_MARKER_TAG = '+draft/read-marker';
43
+
44
+ /** Returns true iff `tags` carries the `+draft/read-marker` client tag. */
45
+ export function isReadMarkerTagmsg(tags: Readonly<Record<string, string>>): boolean {
46
+ return READ_MARKER_TAG in tags;
47
+ }
48
+
49
+ /**
50
+ * Returns the msgid carried by the `+draft/read-marker` tag, or `undefined`
51
+ * when the tag is absent or valueless. A valueless tag carries no msgid and
52
+ * therefore no marker to persist.
53
+ */
54
+ export function readMarkerMsgid(tags: Readonly<Record<string, string>>): string | undefined {
55
+ const value = tags[READ_MARKER_TAG];
56
+ if (value === undefined || value === '') return undefined;
57
+ return value;
58
+ }
59
+
60
+ /**
61
+ * Advances the per-`(account, channel)` last-read marker.
62
+ *
63
+ * Always advances the connection's in-memory {@link ConnectionState.lastReadMarkers}
64
+ * so the current session tracks the read position. When a {@link ReadMarkerStore}
65
+ * is bound AND the connection is identified (has an `account`), the marker is
66
+ * also persisted so it survives reconnects and is shared across the account's
67
+ * connections.
68
+ *
69
+ * `chanLower` is the lowercased channel name (the storage key); callers pass
70
+ * the authoritative `ChannelState.nameLower`.
71
+ */
72
+ export function persistReadMarker(ctx: Ctx, chanLower: string, msgid: string): void {
73
+ if (ctx.connection.lastReadMarkers === undefined) {
74
+ ctx.connection.lastReadMarkers = new Map<string, string>();
75
+ }
76
+ ctx.connection.lastReadMarkers.set(chanLower, msgid);
77
+
78
+ const store = ctx.readMarkers;
79
+ const account = ctx.connection.account;
80
+ if (store !== undefined && account !== undefined) {
81
+ store.set(account, chanLower, msgid);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Seeds a fresh connection's `lastReadMarkers` from the persisted values for
87
+ * `account`. Called at SASL identify so a reconnect restores the user's last
88
+ * read position across every channel they have a marker for.
89
+ *
90
+ * Existing in-memory markers are preserved: a session may have already
91
+ * advanced past the stored value, and the in-memory (newer) marker wins.
92
+ */
93
+ export function seedReadMarkers(
94
+ conn: ConnectionState,
95
+ store: ReadMarkerStore,
96
+ account: string,
97
+ ): void {
98
+ const entries = store.forAccount(account);
99
+ if (entries.length === 0) return;
100
+ if (conn.lastReadMarkers === undefined) {
101
+ conn.lastReadMarkers = new Map<string, string>();
102
+ }
103
+ for (const entry of entries) {
104
+ if (!conn.lastReadMarkers.has(entry.channel)) {
105
+ conn.lastReadMarkers.set(entry.channel, entry.msgid);
106
+ }
107
+ }
108
+ }
@@ -117,6 +117,14 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
117
117
  // so the `001` hostmask and every downstream message uses the cloak.
118
118
  applyCloakIfEnabled(state, ctx);
119
119
 
120
+ // Surface the transport's TLS fact as read-only user mode `S` so clients
121
+ // learn the connection is secure via `221` / `WHOIS`. The adapter sets
122
+ // `state.secure` from the platform's transport metadata at construction;
123
+ // the mode is not settable via `MODE` (the parser rejects `+S`/`-S`).
124
+ if (state.secure) {
125
+ state.userModes.tls = true;
126
+ }
127
+
120
128
  state.registration = 'registered';
121
129
  return [Effect.send(ctx.connId, buildWelcomeLines(state, ctx))];
122
130
  }