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,428 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ DEFAULT_MULTILINE_MAX_BYTES,
4
+ MULTILINE_CAP,
5
+ type MultilineEntry,
6
+ buildMultilineFanout,
7
+ finalizeMultilineBatch,
8
+ joinMultiline,
9
+ multilineByteLength,
10
+ multilineCapValue,
11
+ recordMultilineMessage,
12
+ } from '../../src/commands/multiline';
13
+ import { Effect } from '../../src/effects';
14
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
15
+ import {
16
+ EmptyMotdProvider,
17
+ FakeClock,
18
+ InMemoryMessageStore,
19
+ SequentialIdFactory,
20
+ } from '../../src/ports';
21
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
22
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
23
+
24
+ const serverConfig: ServerConfig = {
25
+ serverName: 'irc.example.com',
26
+ networkName: 'ExampleNet',
27
+ maxChannelsPerUser: 30,
28
+ maxTargetsPerCommand: 10,
29
+ maxListEntries: 50,
30
+ nickLen: 30,
31
+ channelLen: 50,
32
+ topicLen: 390,
33
+ quitMessage: 'Client Quit',
34
+ };
35
+
36
+ function makeCtx(conn: ConnectionState): Ctx {
37
+ return buildCtx({
38
+ serverConfig,
39
+ clock: new FakeClock(1_000),
40
+ ids: new SequentialIdFactory(),
41
+ motd: EmptyMotdProvider,
42
+ connection: conn,
43
+ });
44
+ }
45
+
46
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
47
+ const s = createConnection({ id, connectedSince: 0 });
48
+ s.nick = nick;
49
+ s.user = nick;
50
+ s.host = 'example.com';
51
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
52
+ s.registration = 'registered';
53
+ return s;
54
+ }
55
+
56
+ const L = (text: string): RawLine => ({ text });
57
+
58
+ function entries(...texts: string[]): MultilineEntry[] {
59
+ return texts.map((t) => ({ text: t }));
60
+ }
61
+
62
+ // ============================================================================
63
+ // finalizeMultilineBatch — legacy fanout (joined single PRIVMSG)
64
+ // ============================================================================
65
+
66
+ describe('finalizeMultilineBatch — legacy fanout', () => {
67
+ it('delivers a 2-line batch as a single joined PRIVMSG to legacy peers', () => {
68
+ const conn = makeConn();
69
+ const ctx = makeCtx(conn);
70
+
71
+ const out = finalizeMultilineBatch(
72
+ '#foo',
73
+ 'PRIVMSG',
74
+ entries('line one', 'line two'),
75
+ DEFAULT_MULTILINE_MAX_BYTES,
76
+ ctx,
77
+ );
78
+
79
+ expect(out.ok).toBe(true);
80
+ if (!out.ok) return;
81
+ // The broadcast to legacy peers carries the joined PRIVMSG with the
82
+ // +draft/multiline client tag, split so multiline-capable peers receive
83
+ // the batched form (capLines) instead.
84
+ const broadcast = out.effects.find(
85
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
86
+ );
87
+ expect(broadcast).toBeDefined();
88
+ expect(broadcast?.chan).toBe('#foo');
89
+ expect(broadcast?.except).toBe('c1');
90
+ expect(broadcast?.cap).toBe(MULTILINE_CAP);
91
+ expect(broadcast?.lines).toEqual<RawLine[]>([
92
+ L('@+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two'),
93
+ ]);
94
+ });
95
+
96
+ it('falls back to "?" as the sender prefix when the connection has no nick', () => {
97
+ // The actor never routes BATCH for an unregistered connection, but the
98
+ // pure reducer defends the hostmask invariant regardless.
99
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
100
+ const ctx = makeCtx(conn);
101
+
102
+ const out = finalizeMultilineBatch(
103
+ '#foo',
104
+ 'PRIVMSG',
105
+ entries('hi'),
106
+ DEFAULT_MULTILINE_MAX_BYTES,
107
+ ctx,
108
+ );
109
+
110
+ expect(out.ok).toBe(true);
111
+ if (!out.ok) return;
112
+ const broadcast = out.effects.find(
113
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
114
+ );
115
+ expect(broadcast?.lines[0]?.text).toBe('@+draft/multiline :? PRIVMSG #foo :hi');
116
+ });
117
+ });
118
+
119
+ // ============================================================================
120
+ // finalizeMultilineBatch — cap-peer batched replay
121
+ // ============================================================================
122
+
123
+ describe('finalizeMultilineBatch — multiline-capable peers', () => {
124
+ it('replays the batch as BATCH +id draft/multiline <target> to cap-enabled peers', () => {
125
+ const conn = makeConn();
126
+ const ctx = makeCtx(conn);
127
+
128
+ const out = finalizeMultilineBatch(
129
+ '#foo',
130
+ 'PRIVMSG',
131
+ entries('line one', 'line two'),
132
+ DEFAULT_MULTILINE_MAX_BYTES,
133
+ ctx,
134
+ );
135
+
136
+ expect(out.ok).toBe(true);
137
+ if (!out.ok) return;
138
+ const broadcast = out.effects.find(
139
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
140
+ );
141
+ // capLines carry the BATCH framing with one inner PRIVMSG per original line.
142
+ expect(broadcast?.capLines).toEqual<RawLine[]>([
143
+ L('BATCH +batch-0 draft/multiline #foo'),
144
+ L(':alice!alice@example.com PRIVMSG #foo :line one'),
145
+ L(':alice!alice@example.com PRIVMSG #foo :line two'),
146
+ L('BATCH -batch-0'),
147
+ ]);
148
+ });
149
+
150
+ it('preserves the inner-line order in the replayed batch', () => {
151
+ const conn = makeConn();
152
+ const ctx = makeCtx(conn);
153
+
154
+ const out = finalizeMultilineBatch(
155
+ '#foo',
156
+ 'PRIVMSG',
157
+ entries('a', 'b', 'c', 'd'),
158
+ DEFAULT_MULTILINE_MAX_BYTES,
159
+ ctx,
160
+ );
161
+
162
+ expect(out.ok).toBe(true);
163
+ if (!out.ok) return;
164
+ const broadcast = out.effects.find(
165
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
166
+ );
167
+ const innerTexts = broadcast?.capLines
168
+ ?.filter((line) => line.text.startsWith(':'))
169
+ .map((line) => line.text);
170
+ expect(innerTexts).toEqual([
171
+ ':alice!alice@example.com PRIVMSG #foo :a',
172
+ ':alice!alice@example.com PRIVMSG #foo :b',
173
+ ':alice!alice@example.com PRIVMSG #foo :c',
174
+ ':alice!alice@example.com PRIVMSG #foo :d',
175
+ ]);
176
+ });
177
+
178
+ it('relays NOTICE inner lines as NOTICE (not PRIVMSG) in the batched replay', () => {
179
+ const conn = makeConn();
180
+ const ctx = makeCtx(conn);
181
+
182
+ const out = finalizeMultilineBatch(
183
+ '#foo',
184
+ 'NOTICE',
185
+ entries('notice one', 'notice two'),
186
+ DEFAULT_MULTILINE_MAX_BYTES,
187
+ ctx,
188
+ );
189
+
190
+ expect(out.ok).toBe(true);
191
+ if (!out.ok) return;
192
+ const broadcast = out.effects.find(
193
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
194
+ );
195
+ expect(broadcast?.lines[0]?.text).toBe(
196
+ '@+draft/multiline :alice!alice@example.com NOTICE #foo :notice one\nnotice two',
197
+ );
198
+ expect(broadcast?.capLines?.[1]?.text).toBe(':alice!alice@example.com NOTICE #foo :notice one');
199
+ });
200
+ });
201
+
202
+ // ============================================================================
203
+ // finalizeMultilineBatch — byte-budget enforcement (FAIL)
204
+ // ============================================================================
205
+
206
+ describe('finalizeMultilineBatch — byte budget', () => {
207
+ it('rejects an over-budget batch with FAIL and relays nothing', () => {
208
+ const conn = makeConn();
209
+ const ctx = makeCtx(conn);
210
+ // Two 5-char lines => joined bytes = 5 + 1 + 5 = 11. Cap at 10 => over.
211
+ const es = entries('hello', 'world');
212
+
213
+ const out = finalizeMultilineBatch('#foo', 'PRIVMSG', es, 10, ctx);
214
+
215
+ expect(out.ok).toBe(false);
216
+ expect(out.effects).toEqual<EffectType[]>([
217
+ Effect.send('c1', [
218
+ L(
219
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
220
+ ),
221
+ ]),
222
+ ]);
223
+ // No broadcast effect on rejection.
224
+ expect(out.effects.some((e) => e.tag === 'Broadcast')).toBe(false);
225
+ });
226
+
227
+ it('accepts a batch whose joined size exactly equals the budget', () => {
228
+ const conn = makeConn();
229
+ const ctx = makeCtx(conn);
230
+ // "hello\nworld" => 5 + 1 + 5 = 11 bytes. Cap at 11 => boundary OK.
231
+ const es = entries('hello', 'world');
232
+
233
+ const out = finalizeMultilineBatch('#foo', 'PRIVMSG', es, 11, ctx);
234
+
235
+ expect(out.ok).toBe(true);
236
+ });
237
+
238
+ it('accounts for multi-byte UTF-8 characters when measuring the budget', () => {
239
+ const conn = makeConn();
240
+ const ctx = makeCtx(conn);
241
+ // 'é' is 2 UTF-8 bytes; 'éé' = 4 bytes. Two such lines => 4 + 1 + 4 = 9.
242
+ const es = entries('éé', 'éé');
243
+
244
+ // Cap at 8 => the 9-byte joined content is over budget.
245
+ const over = finalizeMultilineBatch('#foo', 'PRIVMSG', es, 8, ctx);
246
+ expect(over.ok).toBe(false);
247
+
248
+ // Cap at 9 => exactly at budget, accepted.
249
+ const exact = finalizeMultilineBatch('#foo', 'PRIVMSG', es, 9, ctx);
250
+ expect(exact.ok).toBe(true);
251
+ });
252
+
253
+ it('returns success with no effects for an empty batch', () => {
254
+ const conn = makeConn();
255
+ const ctx = makeCtx(conn);
256
+
257
+ const out = finalizeMultilineBatch('#foo', 'PRIVMSG', [], DEFAULT_MULTILINE_MAX_BYTES, ctx);
258
+
259
+ expect(out.ok).toBe(true);
260
+ expect(out.effects).toEqual<EffectType[]>([]);
261
+ });
262
+ });
263
+
264
+ // ============================================================================
265
+ // joinMultiline — concatenation helper
266
+ // ============================================================================
267
+
268
+ describe('joinMultiline', () => {
269
+ it('joins entry texts with a newline separator', () => {
270
+ expect(joinMultiline(entries('a', 'b', 'c'))).toBe('a\nb\nc');
271
+ });
272
+
273
+ it('returns the single text unchanged when there is one entry', () => {
274
+ expect(joinMultiline(entries('only'))).toBe('only');
275
+ });
276
+
277
+ it('returns the empty string for an empty entry list', () => {
278
+ expect(joinMultiline([])).toBe('');
279
+ });
280
+ });
281
+
282
+ // ============================================================================
283
+ // multilineByteLength — byte-budget accounting helper
284
+ // ============================================================================
285
+
286
+ describe('multilineByteLength', () => {
287
+ it('sums entry lengths plus one byte per newline separator', () => {
288
+ // "hello" + "\n" + "world" => 5 + 1 + 5 = 11
289
+ expect(multilineByteLength(entries('hello', 'world'))).toBe(11);
290
+ });
291
+
292
+ it('returns the single entry byte length with no separator for one entry', () => {
293
+ expect(multilineByteLength(entries('hello'))).toBe(5);
294
+ });
295
+
296
+ it('returns 0 for an empty entry list', () => {
297
+ expect(multilineByteLength([])).toBe(0);
298
+ });
299
+
300
+ it('counts multi-byte UTF-8 sequences by bytes, not code units', () => {
301
+ // '€' is 3 UTF-8 bytes; two '€' entries => 3 + 1 + 3 = 7
302
+ expect(multilineByteLength(entries('€', '€'))).toBe(7);
303
+ });
304
+ });
305
+
306
+ // ============================================================================
307
+ // multilineCapValue — cap value builder
308
+ // ============================================================================
309
+
310
+ describe('multilineCapValue', () => {
311
+ it('returns the byte budget as a string', () => {
312
+ expect(multilineCapValue(4096)).toBe('4096');
313
+ });
314
+
315
+ it('reflects an overridden budget', () => {
316
+ expect(multilineCapValue(8192)).toBe('8192');
317
+ });
318
+ });
319
+
320
+ // ============================================================================
321
+ // buildMultilineFanout — pure wire-line builder (shared by channel + nick paths)
322
+ // ============================================================================
323
+
324
+ describe('buildMultilineFanout', () => {
325
+ it('builds the tagged joined line with the +draft/multiline client tag', () => {
326
+ const f = buildMultilineFanout(
327
+ '#foo',
328
+ 'PRIVMSG',
329
+ entries('a', 'b'),
330
+ 'alice!alice@example.com',
331
+ 'b1',
332
+ );
333
+ expect(f.taggedJoined).toEqual<RawLine>(
334
+ L('@+draft/multiline :alice!alice@example.com PRIVMSG #foo :a\nb'),
335
+ );
336
+ });
337
+
338
+ it('builds the bare joined line with no tag prefix', () => {
339
+ const f = buildMultilineFanout(
340
+ '#foo',
341
+ 'PRIVMSG',
342
+ entries('a', 'b'),
343
+ 'alice!alice@example.com',
344
+ 'b1',
345
+ );
346
+ expect(f.bareJoined).toEqual<RawLine>(L(':alice!alice@example.com PRIVMSG #foo :a\nb'));
347
+ });
348
+
349
+ it('builds the batched replay as a BATCH frame with one inner line per entry', () => {
350
+ const f = buildMultilineFanout(
351
+ 'bob',
352
+ 'PRIVMSG',
353
+ entries('one', 'two'),
354
+ 'alice!alice@example.com',
355
+ 'bx9',
356
+ );
357
+ expect(f.batchedReplay).toEqual<RawLine[]>([
358
+ L('BATCH +bx9 draft/multiline bob'),
359
+ L(':alice!alice@example.com PRIVMSG bob :one'),
360
+ L(':alice!alice@example.com PRIVMSG bob :two'),
361
+ L('BATCH -bx9'),
362
+ ]);
363
+ });
364
+
365
+ it('exposes the joined text for chathistory recording', () => {
366
+ const f = buildMultilineFanout('#foo', 'NOTICE', entries('x', 'y'), 'a!a@a', 'b1');
367
+ expect(f.joined).toBe('x\ny');
368
+ });
369
+
370
+ it('preserves the command (NOTICE) across all wire variants', () => {
371
+ const f = buildMultilineFanout('#foo', 'NOTICE', entries('hi'), 'a!a@a', 'b1');
372
+ expect(f.taggedJoined.text).toContain(' NOTICE #foo :hi');
373
+ expect(f.bareJoined.text).toContain(' NOTICE #foo :hi');
374
+ expect(f.batchedReplay[1]?.text).toContain(' NOTICE #foo :hi');
375
+ });
376
+ });
377
+
378
+ // ============================================================================
379
+ // recordMultilineMessage — chathistory recording helper
380
+ // ============================================================================
381
+
382
+ describe('recordMultilineMessage', () => {
383
+ function makeCtxWithStore(conn: ConnectionState, store: InMemoryMessageStore): Ctx {
384
+ return buildCtx({
385
+ serverConfig,
386
+ clock: new FakeClock(5_000),
387
+ ids: new SequentialIdFactory(),
388
+ motd: EmptyMotdProvider,
389
+ connection: conn,
390
+ messages: store,
391
+ });
392
+ }
393
+
394
+ it('records one entry carrying the joined body, msgid, and +draft/multiline tag', () => {
395
+ const conn = makeConn();
396
+ const store = new InMemoryMessageStore();
397
+ const ctx = makeCtxWithStore(conn, store);
398
+
399
+ recordMultilineMessage(ctx, '#Foo', 'PRIVMSG', 'one\ntwo', 'mid-xyz');
400
+
401
+ const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
402
+ expect(recorded).toHaveLength(1);
403
+ expect(recorded[0]?.msgid).toBe('mid-xyz');
404
+ expect(recorded[0]?.text).toBe('one\ntwo');
405
+ expect(recorded[0]?.command).toBe('PRIVMSG');
406
+ expect(recorded[0]?.chan).toBe('#foo');
407
+ expect(recorded[0]?.nick).toBe('alice');
408
+ expect(recorded[0]?.clientTags).toEqual(['+draft/multiline']);
409
+ });
410
+
411
+ it('is a no-op when no MessageStore is bound (ctx.messages undefined)', () => {
412
+ const conn = makeConn();
413
+ const ctx = makeCtx(conn);
414
+
415
+ expect(() => recordMultilineMessage(ctx, '#foo', 'PRIVMSG', 'hi', 'm1')).not.toThrow();
416
+ });
417
+
418
+ it('records with "?" as the nick when the connection has no nick', () => {
419
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
420
+ const store = new InMemoryMessageStore();
421
+ const ctx = makeCtxWithStore(conn, store);
422
+
423
+ recordMultilineMessage(ctx, '#foo', 'PRIVMSG', 'hi', 'm1');
424
+
425
+ const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
426
+ expect(recorded[0]?.nick).toBe('?');
427
+ });
428
+ });
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ PRE_AWAY_CAP,
4
+ clearPersistedAway,
5
+ nowAwayLine,
6
+ persistAway,
7
+ replayPersistedAway,
8
+ } from '../../src/commands/pre-away';
9
+ import {
10
+ EmptyMotdProvider,
11
+ FakeClock,
12
+ InMemoryAwayStore,
13
+ SequentialIdFactory,
14
+ } from '../../src/ports';
15
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
16
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
17
+
18
+ const serverConfig: ServerConfig = {
19
+ serverName: 'irc.example.com',
20
+ networkName: 'ExampleNet',
21
+ maxChannelsPerUser: 30,
22
+ maxTargetsPerCommand: 10,
23
+ maxListEntries: 50,
24
+ nickLen: 30,
25
+ channelLen: 50,
26
+ topicLen: 390,
27
+ quitMessage: 'Client Quit',
28
+ };
29
+
30
+ function makeConn(id = 'c1', account?: string): ConnectionState {
31
+ const s = createConnection({ id, connectedSince: 0 });
32
+ s.nick = 'alice';
33
+ s.user = 'alice';
34
+ s.host = 'example.com';
35
+ s.registration = 'registered';
36
+ if (account !== undefined) s.account = account;
37
+ return s;
38
+ }
39
+
40
+ function makeCtx(conn: ConnectionState, away?: InMemoryAwayStore): Ctx {
41
+ return buildCtx({
42
+ serverConfig,
43
+ clock: new FakeClock(1_000),
44
+ ids: new SequentialIdFactory(),
45
+ motd: EmptyMotdProvider,
46
+ connection: conn,
47
+ ...(away !== undefined ? { away } : {}),
48
+ });
49
+ }
50
+
51
+ // ============================================================================
52
+ // constants
53
+ // ============================================================================
54
+
55
+ describe('pre-away constants', () => {
56
+ it('exposes the draft/pre-away cap name', () => {
57
+ expect(PRE_AWAY_CAP).toBe('draft/pre-away');
58
+ });
59
+ });
60
+
61
+ // ============================================================================
62
+ // nowAwayLine
63
+ // ============================================================================
64
+
65
+ describe('nowAwayLine', () => {
66
+ it('builds the 306 RPL_NOWAWAY line addressed to the connection nick', () => {
67
+ const conn = makeConn('c1');
68
+ const line = nowAwayLine(conn, 'irc.example.com');
69
+ expect(line.text).toBe(':irc.example.com 306 alice :You have been marked as being away');
70
+ });
71
+
72
+ it('zero-pads the numeric to three digits', () => {
73
+ const conn = makeConn('c1');
74
+ const line = nowAwayLine(conn, 'irc.example.com');
75
+ // 306 is already 3 digits, so no padding needed; this just guards the
76
+ // padStart call against regression for the eventual "no-away" reply.
77
+ expect(line.text).toContain(' 306 ');
78
+ });
79
+
80
+ it('falls back to * when the connection has no nick', () => {
81
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
82
+ const line = nowAwayLine(conn, 'irc.example.com');
83
+ expect(line.text).toBe(':irc.example.com 306 * :You have been marked as being away');
84
+ });
85
+ });
86
+
87
+ // ============================================================================
88
+ // persistAway
89
+ // ============================================================================
90
+
91
+ describe('persistAway', () => {
92
+ it('writes the reason to the store keyed by account', () => {
93
+ const store = new InMemoryAwayStore();
94
+ const ctx = makeCtx(makeConn('c1', 'alice'), store);
95
+
96
+ persistAway(ctx, 'brb');
97
+
98
+ expect(store.get('alice')).toBe('brb');
99
+ });
100
+
101
+ it('also sets the connection in-memory away reason', () => {
102
+ const store = new InMemoryAwayStore();
103
+ const conn = makeConn('c1', 'alice');
104
+ const ctx = makeCtx(conn, store);
105
+
106
+ persistAway(ctx, 'brb');
107
+
108
+ expect(conn.away).toBe('brb');
109
+ });
110
+
111
+ it('overwrites a previously stored reason for the same account', () => {
112
+ const store = new InMemoryAwayStore();
113
+ const ctx = makeCtx(makeConn('c1', 'alice'), store);
114
+
115
+ persistAway(ctx, 'brb');
116
+ persistAway(ctx, 'lunch');
117
+
118
+ expect(store.get('alice')).toBe('lunch');
119
+ });
120
+
121
+ it('still sets the in-memory reason when no AwayStore is bound', () => {
122
+ const conn = makeConn('c1', 'alice');
123
+ const ctx = makeCtx(conn); // no store
124
+
125
+ persistAway(ctx, 'brb');
126
+
127
+ expect(conn.away).toBe('brb');
128
+ });
129
+
130
+ it('is a no-op on the store when the connection has no account', () => {
131
+ const store = new InMemoryAwayStore();
132
+ const conn = makeConn('c1'); // no account
133
+ const ctx = makeCtx(conn, store);
134
+
135
+ persistAway(ctx, 'brb');
136
+
137
+ expect(store.get('alice')).toBeUndefined();
138
+ // In-memory reason still set so the connection's own session tracks it.
139
+ expect(conn.away).toBe('brb');
140
+ });
141
+ });
142
+
143
+ // ============================================================================
144
+ // clearPersistedAway
145
+ // ============================================================================
146
+
147
+ describe('clearPersistedAway', () => {
148
+ it('removes the stored reason for the account', () => {
149
+ const store = new InMemoryAwayStore();
150
+ store.set('alice', 'brb');
151
+ const ctx = makeCtx(makeConn('c1', 'alice'), store);
152
+
153
+ clearPersistedAway(ctx);
154
+
155
+ expect(store.get('alice')).toBeUndefined();
156
+ });
157
+
158
+ it('also clears the connection in-memory away reason', () => {
159
+ const store = new InMemoryAwayStore();
160
+ store.set('alice', 'brb');
161
+ const conn = makeConn('c1', 'alice');
162
+ conn.away = 'brb';
163
+ const ctx = makeCtx(conn, store);
164
+
165
+ clearPersistedAway(ctx);
166
+
167
+ expect(conn.away).toBeUndefined();
168
+ });
169
+
170
+ it('is a no-op on the store when no AwayStore is bound but still clears in-memory', () => {
171
+ const conn = makeConn('c1', 'alice');
172
+ conn.away = 'brb';
173
+ const ctx = makeCtx(conn); // no store
174
+
175
+ clearPersistedAway(ctx);
176
+
177
+ expect(conn.away).toBeUndefined();
178
+ });
179
+
180
+ it('is a no-op on the store when the connection has no account', () => {
181
+ const store = new InMemoryAwayStore();
182
+ store.set('alice', 'brb');
183
+ const conn = makeConn('c1'); // no account
184
+ conn.away = 'brb';
185
+ const ctx = makeCtx(conn, store);
186
+
187
+ clearPersistedAway(ctx);
188
+
189
+ // Store entry survives because the connection is unidentified.
190
+ expect(store.get('alice')).toBe('brb');
191
+ // In-memory is still cleared so the session's view is consistent.
192
+ expect(conn.away).toBeUndefined();
193
+ });
194
+ });
195
+
196
+ // ============================================================================
197
+ // replayPersistedAway
198
+ // ============================================================================
199
+
200
+ describe('replayPersistedAway', () => {
201
+ it('sets the connection away reason from the persisted value', () => {
202
+ const store = new InMemoryAwayStore();
203
+ store.set('alice', 'brb');
204
+ const conn = makeConn();
205
+
206
+ replayPersistedAway(conn, store, 'alice');
207
+
208
+ expect(conn.away).toBe('brb');
209
+ });
210
+
211
+ it('leaves the connection away reason untouched when no entry exists', () => {
212
+ const store = new InMemoryAwayStore();
213
+ const conn = makeConn();
214
+
215
+ replayPersistedAway(conn, store, 'alice');
216
+
217
+ expect(conn.away).toBeUndefined();
218
+ });
219
+
220
+ it('does not overwrite an away reason the session has already set', () => {
221
+ const store = new InMemoryAwayStore();
222
+ store.set('alice', 'stored');
223
+ const conn = makeConn();
224
+ conn.away = 'session';
225
+
226
+ replayPersistedAway(conn, store, 'alice');
227
+
228
+ // The session's own value wins — the user may have re-set AWAY this session.
229
+ expect(conn.away).toBe('session');
230
+ });
231
+
232
+ it('looks up the account case-insensitively', () => {
233
+ const store = new InMemoryAwayStore();
234
+ store.set('Alice', 'brb');
235
+ const conn = makeConn();
236
+
237
+ replayPersistedAway(conn, store, 'alice');
238
+
239
+ expect(conn.away).toBe('brb');
240
+ });
241
+ });