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,181 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect } from '../src/effects';
3
+ import type { Effect as EffectType, RawLine } from '../src/effects';
4
+ import {
5
+ LABELED_RESPONSE_CAP_NAME,
6
+ LABEL_TAG,
7
+ applyLabelToEffects,
8
+ extractLabel,
9
+ wrapLabeledResponse,
10
+ } from '../src/protocol/labeled-response';
11
+
12
+ const L = (text: string): RawLine => ({ text });
13
+
14
+ // ============================================================================
15
+ // extractLabel — IRCv3 labeled-response client-tag reader
16
+ // ============================================================================
17
+
18
+ describe('extractLabel — +label client tag', () => {
19
+ it('returns the value when the +label tag is present', () => {
20
+ expect(extractLabel({ '+label': 'foo' })).toBe('foo');
21
+ });
22
+
23
+ it('returns undefined when the +label tag is absent', () => {
24
+ expect(extractLabel({})).toBeUndefined();
25
+ });
26
+
27
+ it('returns undefined when the +label tag is present but empty', () => {
28
+ expect(extractLabel({ '+label': '' })).toBeUndefined();
29
+ });
30
+
31
+ it('ignores unrelated tags', () => {
32
+ expect(extractLabel({ '+typing': 'active', server_time: 'x' })).toBeUndefined();
33
+ });
34
+
35
+ it('exposes the wire tag name as the +label constant', () => {
36
+ expect(LABEL_TAG).toBe('+label');
37
+ });
38
+ });
39
+
40
+ // ============================================================================
41
+ // wrapLabeledResponse — the always-emit BATCH framing helper
42
+ // ============================================================================
43
+
44
+ describe('wrapLabeledResponse — batch framing', () => {
45
+ it('wraps a non-empty line list with BATCH +label labeled-response / -label', () => {
46
+ const out = wrapLabeledResponse('foo', [L(':srv PONG :tok'), L(':srv 001 alice :Welcome')]);
47
+ expect(out.map((l) => l.text)).toEqual([
48
+ 'BATCH +foo labeled-response',
49
+ ':srv PONG :tok',
50
+ ':srv 001 alice :Welcome',
51
+ 'BATCH -foo',
52
+ ]);
53
+ });
54
+
55
+ it('emits an empty batch (start + end only) when there are no inner lines', () => {
56
+ const out = wrapLabeledResponse('foo', []);
57
+ expect(out.map((l) => l.text)).toEqual(['BATCH +foo labeled-response', 'BATCH -foo']);
58
+ });
59
+
60
+ it('uses the labeled-response batch type token', () => {
61
+ const out = wrapLabeledResponse('x', [L('inner')]);
62
+ expect(out[0]?.text).toBe('BATCH +x labeled-response');
63
+ });
64
+
65
+ it('uses the supplied label verbatim in both markers', () => {
66
+ const out = wrapLabeledResponse('my-label-7', [L('inner')]);
67
+ expect(out[0]?.text).toBe('BATCH +my-label-7 labeled-response');
68
+ expect(out[out.length - 1]?.text).toBe('BATCH -my-label-7');
69
+ });
70
+
71
+ it('preserves the order of the inner lines', () => {
72
+ const out = wrapLabeledResponse('x', [L('one'), L('two'), L('three')]);
73
+ expect(out.map((l) => l.text)).toEqual([
74
+ 'BATCH +x labeled-response',
75
+ 'one',
76
+ 'two',
77
+ 'three',
78
+ 'BATCH -x',
79
+ ]);
80
+ });
81
+
82
+ it('exposes the wire cap name as the labeled-response constant', () => {
83
+ expect(LABELED_RESPONSE_CAP_NAME).toBe('labeled-response');
84
+ });
85
+ });
86
+
87
+ // ============================================================================
88
+ // applyLabelToEffects — pure Effect[] transform
89
+ // ============================================================================
90
+
91
+ describe('applyLabelToEffects — effect transform', () => {
92
+ it('returns the effects unchanged when no label is set', () => {
93
+ const effects: EffectType[] = [Effect.send('c1', [L('PONG :tok')])];
94
+ expect(applyLabelToEffects(effects, { label: undefined, connId: 'c1' })).toEqual(effects);
95
+ });
96
+
97
+ it('wraps a single Send-to-self effect in a labeled batch', () => {
98
+ const effects: EffectType[] = [Effect.send('c1', [L(':srv PONG :tok')])];
99
+
100
+ const out = applyLabelToEffects(effects, { label: 'foo', connId: 'c1' });
101
+
102
+ expect(out).toEqual<EffectType[]>([
103
+ Effect.send('c1', [L('BATCH +foo labeled-response'), L(':srv PONG :tok'), L('BATCH -foo')]),
104
+ ]);
105
+ });
106
+
107
+ it('accumulates multiple Send-to-self effects into a single labeled batch in order', () => {
108
+ const effects: EffectType[] = [
109
+ Effect.send('c1', [L('echo-a')]),
110
+ Effect.send('c1', [L('echo-b'), L('echo-c')]),
111
+ ];
112
+
113
+ const out = applyLabelToEffects(effects, { label: 'm', connId: 'c1' });
114
+
115
+ expect(out).toHaveLength(1);
116
+ expect(out[0]?.tag).toBe('Send');
117
+ const lines = (out[0] as Extract<EffectType, { tag: 'Send' }>).lines;
118
+ expect(lines.map((l) => l.text)).toEqual([
119
+ 'BATCH +m labeled-response',
120
+ 'echo-a',
121
+ 'echo-b',
122
+ 'echo-c',
123
+ 'BATCH -m',
124
+ ]);
125
+ });
126
+
127
+ it('leaves a Send to a different connection untouched', () => {
128
+ const effects: EffectType[] = [Effect.send('c2', [L('to-other')])];
129
+
130
+ const out = applyLabelToEffects(effects, { label: 'foo', connId: 'c1' });
131
+
132
+ // No Send-to-self → an empty labeled batch is appended at the end, and the
133
+ // Send to c2 stays in place.
134
+ expect(out).toEqual<EffectType[]>([
135
+ Effect.send('c2', [L('to-other')]),
136
+ Effect.send('c1', [L('BATCH +foo labeled-response'), L('BATCH -foo')]),
137
+ ]);
138
+ });
139
+
140
+ it('emits an empty labeled batch when no Send-to-self effect exists', () => {
141
+ const effects: EffectType[] = [Effect.broadcast('#foo', [L(':a PRIVMSG #foo :hi')], 'c1')];
142
+
143
+ const out = applyLabelToEffects(effects, { label: 'foo', connId: 'c1' });
144
+
145
+ expect(out).toEqual<EffectType[]>([
146
+ Effect.broadcast('#foo', [L(':a PRIVMSG #foo :hi')], 'c1'),
147
+ Effect.send('c1', [L('BATCH +foo labeled-response'), L('BATCH -foo')]),
148
+ ]);
149
+ });
150
+
151
+ it('places the batch at the position of the first Send-to-self, preserving surrounding effects', () => {
152
+ const effects: EffectType[] = [
153
+ Effect.broadcast('#a', [L('to-a-peers')], 'c1'),
154
+ Effect.send('c1', [L('echo-a')]),
155
+ Effect.broadcast('#b', [L('to-b-peers')], 'c1'),
156
+ Effect.send('c1', [L('echo-b')]),
157
+ ];
158
+
159
+ const out = applyLabelToEffects(effects, { label: 'm', connId: 'c1' });
160
+
161
+ expect(out).toEqual<EffectType[]>([
162
+ Effect.broadcast('#a', [L('to-a-peers')], 'c1'),
163
+ Effect.send('c1', [L('BATCH +m labeled-response'), L('echo-a'), L('echo-b'), L('BATCH -m')]),
164
+ Effect.broadcast('#b', [L('to-b-peers')], 'c1'),
165
+ ]);
166
+ });
167
+
168
+ it('does not wrap effects addressed to self via non-Send tags (e.g. SendToNick from self)', () => {
169
+ const sendToNick = Effect.sendToNick('bob', 'c1', [L(':a PRIVMSG bob :hi')]);
170
+ const effects: EffectType[] = [sendToNick];
171
+
172
+ const out = applyLabelToEffects(effects, { label: 'foo', connId: 'c1' });
173
+
174
+ // SendToNick is not a Send-to-self; the requester sees no reply, so an
175
+ // empty labeled batch is appended and the SendToNick stays unchanged.
176
+ expect(out).toEqual<EffectType[]>([
177
+ sendToNick,
178
+ Effect.send('c1', [L('BATCH +foo labeled-response'), L('BATCH -foo')]),
179
+ ]);
180
+ });
181
+ });
@@ -78,6 +78,120 @@ describe('filterClientTags — cap gating', () => {
78
78
  });
79
79
  });
80
80
 
81
+ // ============================================================================
82
+ // filterClientTags — draft/typing client-tag whitelist
83
+ //
84
+ // IRCv3 `draft/typing` carves out an exception to the message-tags cap
85
+ // requirement: a peer that announced `draft/typing` (but NOT message-tags)
86
+ // still receives the `+draft/typing` client tag on TAGMSG/PRIVMSG lines.
87
+ // Other client tags (`+foo`, `+typing` without the `draft/` prefix, …)
88
+ // remain stripped for non-message-tags recipients.
89
+ // ============================================================================
90
+
91
+ describe('filterClientTags — draft/typing whitelist', () => {
92
+ it('preserves +draft/typing for a draft/typing-only recipient', () => {
93
+ const line = L('@+draft/typing=active :alice TAGMSG #foo');
94
+ const out = filterClientTags(line, new Set<string>(['draft/typing']));
95
+ expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');
96
+ });
97
+
98
+ it('preserves a valueless +draft/typing tag for a draft/typing-only recipient', () => {
99
+ const line = L('@+draft/typing :alice TAGMSG #foo');
100
+ const out = filterClientTags(line, new Set<string>(['draft/typing']));
101
+ expect(out.text).toBe('@+draft/typing :alice TAGMSG #foo');
102
+ });
103
+
104
+ it('strips a non-typing client tag even when draft/typing is negotiated', () => {
105
+ const line = L('@+typing=1;+draft/typing=active :alice TAGMSG #foo');
106
+ const out = filterClientTags(line, new Set<string>(['draft/typing']));
107
+ expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');
108
+ });
109
+
110
+ it('keeps +draft/typing alongside a server tag for a draft/typing-only recipient', () => {
111
+ const line = L('@msgid=abc;+draft/typing=active :alice TAGMSG #foo');
112
+ const out = filterClientTags(line, new Set<string>(['draft/typing']));
113
+ expect(out.text).toBe('@msgid=abc;+draft/typing=active :alice TAGMSG #foo');
114
+ });
115
+
116
+ it('drops every client tag (including +draft/typing) for a cap-less recipient', () => {
117
+ const line = L('@+draft/typing=active;+foo=bar :alice TAGMSG #foo');
118
+ const out = filterClientTags(line, new Set<string>());
119
+ expect(out.text).toBe(':alice TAGMSG #foo');
120
+ });
121
+
122
+ it('preserves all client tags for a recipient with both message-tags and draft/typing', () => {
123
+ const line = L('@+draft/typing=active;+foo=bar :alice TAGMSG #foo');
124
+ const out = filterClientTags(line, new Set<string>(['message-tags', 'draft/typing']));
125
+ expect(out.text).toBe('@+draft/typing=active;+foo=bar :alice TAGMSG #foo');
126
+ });
127
+
128
+ it('drops the leading @ when only a non-typing client tag was present', () => {
129
+ const line = L('@+foo=bar;+draft/typing=active :alice TAGMSG #foo');
130
+ const out = filterClientTags(line, new Set<string>(['draft/typing']));
131
+ expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');
132
+ });
133
+ });
134
+
135
+ // ============================================================================
136
+ // filterClientTags — draft/read-marker client-tag whitelist
137
+ //
138
+ // IRCv3 `draft/read-marker` carves out the same exception `draft/typing`
139
+ // does: a peer that announced `draft/read-marker` (but NOT message-tags)
140
+ // still receives the `+draft/read-marker` client tag on TAGMSG lines so the
141
+ // read-marker update reaches the user's other connections. Other client
142
+ // tags remain stripped for non-message-tags recipients.
143
+ // ============================================================================
144
+
145
+ describe('filterClientTags — draft/read-marker whitelist', () => {
146
+ it('preserves +draft/read-marker for a draft/read-marker-only recipient', () => {
147
+ const line = L('@+draft/read-marker=m42 :alice TAGMSG #foo');
148
+ const out = filterClientTags(line, new Set<string>(['draft/read-marker']));
149
+ expect(out.text).toBe('@+draft/read-marker=m42 :alice TAGMSG #foo');
150
+ });
151
+
152
+ it('preserves a valueless +draft/read-marker tag for a draft/read-marker-only recipient', () => {
153
+ const line = L('@+draft/read-marker :alice TAGMSG #foo');
154
+ const out = filterClientTags(line, new Set<string>(['draft/read-marker']));
155
+ expect(out.text).toBe('@+draft/read-marker :alice TAGMSG #foo');
156
+ });
157
+
158
+ it('strips a non-read-marker client tag even when draft/read-marker is negotiated', () => {
159
+ const line = L('@+foo=bar;+draft/read-marker=m1 :alice TAGMSG #foo');
160
+ const out = filterClientTags(line, new Set<string>(['draft/read-marker']));
161
+ expect(out.text).toBe('@+draft/read-marker=m1 :alice TAGMSG #foo');
162
+ });
163
+
164
+ it('keeps both +draft/typing and +draft/read-marker for a recipient with both draft caps', () => {
165
+ const line = L('@+draft/typing=active;+draft/read-marker=m1 :alice TAGMSG #foo');
166
+ const out = filterClientTags(line, new Set<string>(['draft/typing', 'draft/read-marker']));
167
+ expect(out.text).toBe('@+draft/typing=active;+draft/read-marker=m1 :alice TAGMSG #foo');
168
+ });
169
+
170
+ it('keeps +draft/read-marker alongside a server tag for a draft/read-marker-only recipient', () => {
171
+ const line = L('@msgid=abc;+draft/read-marker=m1 :alice TAGMSG #foo');
172
+ const out = filterClientTags(line, new Set<string>(['draft/read-marker']));
173
+ expect(out.text).toBe('@msgid=abc;+draft/read-marker=m1 :alice TAGMSG #foo');
174
+ });
175
+
176
+ it('drops every client tag (including +draft/read-marker) for a cap-less recipient', () => {
177
+ const line = L('@+draft/read-marker=m1;+foo=bar :alice TAGMSG #foo');
178
+ const out = filterClientTags(line, new Set<string>());
179
+ expect(out.text).toBe(':alice TAGMSG #foo');
180
+ });
181
+
182
+ it('drops +draft/read-marker for a draft/typing-only recipient', () => {
183
+ const line = L('@+draft/typing=active;+draft/read-marker=m1 :alice TAGMSG #foo');
184
+ const out = filterClientTags(line, new Set<string>(['draft/typing']));
185
+ expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');
186
+ });
187
+
188
+ it('preserves all client tags for a recipient with message-tags', () => {
189
+ const line = L('@+draft/read-marker=m1;+foo=bar :alice TAGMSG #foo');
190
+ const out = filterClientTags(line, new Set<string>(['message-tags', 'draft/read-marker']));
191
+ expect(out.text).toBe('@+draft/read-marker=m1;+foo=bar :alice TAGMSG #foo');
192
+ });
193
+ });
194
+
81
195
  // ============================================================================
82
196
  // filterClientTags + applyServerTime composition (dispatch-layer ordering)
83
197
  // ============================================================================
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InMemoryMonitorStore, type MonitorPush } from '../src/ports';
3
+
4
+ describe('InMemoryMonitorStore — sync', () => {
5
+ it('is a no-op when before and after are both empty', () => {
6
+ const store = new InMemoryMonitorStore();
7
+ store.sync('c1', new Set(), new Set());
8
+ // No public surface to assert directly; subsequent drainPushes shows
9
+ // no subscriptions were recorded (an onNickOnline push would land
10
+ // if any were tracked).
11
+ store.onNickOnline('bob');
12
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
13
+ });
14
+
15
+ it('is a no-op when before equals after (no delta)', () => {
16
+ const store = new InMemoryMonitorStore();
17
+ store.sync('c1', new Set(['bob']), new Set(['bob']));
18
+ store.onNickOnline('bob');
19
+ // Already-monitored → no push because sync recorded nothing.
20
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
21
+ });
22
+
23
+ it('records added nicks in the reverse index', () => {
24
+ const store = new InMemoryMonitorStore();
25
+ store.sync('c1', new Set(), new Set(['bob', 'carol']));
26
+ store.onNickOnline('bob');
27
+ store.onNickOnline('carol');
28
+
29
+ const pushes = store.drainPushes();
30
+ expect(pushes).toContainEqual({ connId: 'c1', display: 'bob', status: 'online' });
31
+ expect(pushes).toContainEqual({ connId: 'c1', display: 'carol', status: 'online' });
32
+ });
33
+
34
+ it('removes dropped nicks from the reverse index', () => {
35
+ const store = new InMemoryMonitorStore();
36
+ store.sync('c1', new Set(), new Set(['bob', 'carol']));
37
+ store.sync('c1', new Set(['bob', 'carol']), new Set(['bob']));
38
+ store.onNickOnline('carol');
39
+ // carol was dropped → no push.
40
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
41
+ });
42
+
43
+ it('cleans up the watcher entry when its watchlist becomes empty', () => {
44
+ const store = new InMemoryMonitorStore();
45
+ store.sync('c1', new Set(), new Set(['bob']));
46
+ store.sync('c1', new Set(['bob']), new Set());
47
+ store.onNickOnline('bob');
48
+ // c1 is fully unsubscribed → no push.
49
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
50
+ });
51
+
52
+ it('records two watchers of the same nick independently', () => {
53
+ const store = new InMemoryMonitorStore();
54
+ store.sync('c1', new Set(), new Set(['bob']));
55
+ store.sync('c2', new Set(), new Set(['bob']));
56
+ store.onNickOnline('bob');
57
+
58
+ const pushes = store.drainPushes();
59
+ expect(pushes).toContainEqual({ connId: 'c1', display: 'bob', status: 'online' });
60
+ expect(pushes).toContainEqual({ connId: 'c2', display: 'bob', status: 'online' });
61
+ });
62
+
63
+ it('drops a watcher without disturbing the others', () => {
64
+ const store = new InMemoryMonitorStore();
65
+ store.sync('c1', new Set(), new Set(['bob']));
66
+ store.sync('c2', new Set(), new Set(['bob']));
67
+ store.sync('c1', new Set(['bob']), new Set());
68
+ store.onNickOnline('bob');
69
+
70
+ const pushes = store.drainPushes();
71
+ expect(pushes).toEqual<MonitorPush[]>([{ connId: 'c2', display: 'bob', status: 'online' }]);
72
+ });
73
+ });
74
+
75
+ describe('InMemoryMonitorStore — unsubscribe', () => {
76
+ it('is a no-op when the connection had no subscriptions', () => {
77
+ const store = new InMemoryMonitorStore();
78
+ store.unsubscribe('ghost');
79
+ store.onNickOnline('bob');
80
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
81
+ });
82
+
83
+ it('removes every subscription for a connection', () => {
84
+ const store = new InMemoryMonitorStore();
85
+ store.sync('c1', new Set(), new Set(['bob', 'carol']));
86
+ store.unsubscribe('c1');
87
+ store.onNickOnline('bob');
88
+ store.onNickOnline('carol');
89
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
90
+ });
91
+
92
+ it('leaves the reverse entry intact when another watcher remains', () => {
93
+ const store = new InMemoryMonitorStore();
94
+ store.sync('c1', new Set(), new Set(['bob']));
95
+ store.sync('c2', new Set(), new Set(['bob']));
96
+ store.unsubscribe('c1');
97
+ store.onNickOnline('bob');
98
+
99
+ // c2 still subscribed → push goes to c2 only.
100
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([
101
+ { connId: 'c2', display: 'bob', status: 'online' },
102
+ ]);
103
+ });
104
+ });
105
+
106
+ describe('InMemoryMonitorStore — onNickOnline / onNickOffline', () => {
107
+ it('onNickOnline enqueues a push for every watcher of the nick', () => {
108
+ const store = new InMemoryMonitorStore();
109
+ store.sync('c1', new Set(), new Set(['bob']));
110
+ store.sync('c2', new Set(), new Set(['bob']));
111
+ store.onNickOnline('Bob');
112
+
113
+ const pushes = store.drainPushes();
114
+ expect(pushes).toHaveLength(2);
115
+ expect(pushes.every((p) => p.display === 'Bob' && p.status === 'online')).toBe(true);
116
+ });
117
+
118
+ it('onNickOnline is idempotent (second call enqueues nothing)', () => {
119
+ const store = new InMemoryMonitorStore();
120
+ store.sync('c1', new Set(), new Set(['bob']));
121
+ store.onNickOnline('bob');
122
+ store.onNickOnline('bob');
123
+
124
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([
125
+ { connId: 'c1', display: 'bob', status: 'online' },
126
+ ]);
127
+ });
128
+
129
+ it('onNickOnline without any watchers records the online state but no push', () => {
130
+ const store = new InMemoryMonitorStore();
131
+ store.onNickOnline('bob');
132
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
133
+ expect(store.onlineNicks()).toEqual(['bob']);
134
+ });
135
+
136
+ it('onNickOffline enqueues a push only when the nick was previously online', () => {
137
+ const store = new InMemoryMonitorStore();
138
+ store.sync('c1', new Set(), new Set(['bob']));
139
+ // bob was never announced online → offline push is a no-op.
140
+ store.onNickOffline('bob');
141
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
142
+
143
+ // After online, offline produces a push.
144
+ store.onNickOnline('bob');
145
+ store.drainPushes();
146
+ store.onNickOffline('bob');
147
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([
148
+ { connId: 'c1', display: 'bob', status: 'offline' },
149
+ ]);
150
+ });
151
+
152
+ it('onNickOffline is idempotent (second call enqueues nothing)', () => {
153
+ const store = new InMemoryMonitorStore();
154
+ store.sync('c1', new Set(), new Set(['bob']));
155
+ store.onNickOnline('bob');
156
+ store.drainPushes();
157
+ store.onNickOffline('bob');
158
+ store.onNickOffline('bob');
159
+
160
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([
161
+ { connId: 'c1', display: 'bob', status: 'offline' },
162
+ ]);
163
+ });
164
+
165
+ it('onNickOffline without any watchers clears the online state', () => {
166
+ const store = new InMemoryMonitorStore();
167
+ store.onNickOnline('bob');
168
+ store.onNickOffline('bob');
169
+ expect(store.onlineNicks()).toEqual([]);
170
+ });
171
+ });
172
+
173
+ describe('InMemoryMonitorStore — onlineNicks', () => {
174
+ it('returns the display spellings of every online nick', () => {
175
+ const store = new InMemoryMonitorStore();
176
+ store.onNickOnline('Bob');
177
+ store.onNickOnline('Carol');
178
+ expect(store.onlineNicks().sort()).toEqual(['Bob', 'Carol']);
179
+ });
180
+
181
+ it('returns an empty array when no nick is online', () => {
182
+ const store = new InMemoryMonitorStore();
183
+ expect(store.onlineNicks()).toEqual([]);
184
+ });
185
+ });
186
+
187
+ describe('InMemoryMonitorStore — drainPushes', () => {
188
+ it('returns the pending pushes and empties the queue', () => {
189
+ const store = new InMemoryMonitorStore();
190
+ store.sync('c1', new Set(), new Set(['bob']));
191
+ store.onNickOnline('bob');
192
+ expect(store.drainPushes()).toHaveLength(1);
193
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
194
+ });
195
+
196
+ it('returns an empty array when nothing is pending', () => {
197
+ const store = new InMemoryMonitorStore();
198
+ expect(store.drainPushes()).toEqual<MonitorPush[]>([]);
199
+ });
200
+ });
@@ -31,6 +31,68 @@ describe('Numerics registry', () => {
31
31
  expect(Numerics.RPL_ENDOFWHOWAS).toBe(369);
32
32
  });
33
33
 
34
+ it('maps RPL_TRACELINK to 200', () => {
35
+ expect(Numerics.RPL_TRACELINK).toBe(200);
36
+ });
37
+
38
+ it('maps RPL_TRACEOPERATOR to 204', () => {
39
+ expect(Numerics.RPL_TRACEOPERATOR).toBe(204);
40
+ });
41
+
42
+ it('maps RPL_TRACEUSER to 205', () => {
43
+ expect(Numerics.RPL_TRACEUSER).toBe(205);
44
+ });
45
+
46
+ it('maps RPL_TRACESERVER to 206', () => {
47
+ expect(Numerics.RPL_TRACESERVER).toBe(206);
48
+ });
49
+
50
+ it('maps RPL_ENDOFTRACE to 262', () => {
51
+ expect(Numerics.RPL_ENDOFTRACE).toBe(262);
52
+ });
53
+
54
+ // -- LUSERS (RFC 2812 §4.6.2) ----------------------------------------------
55
+ it('maps RPL_LUSERCLIENT to 251', () => {
56
+ expect(Numerics.RPL_LUSERCLIENT).toBe(251);
57
+ });
58
+
59
+ it('maps RPL_LUSEROP to 252', () => {
60
+ expect(Numerics.RPL_LUSEROP).toBe(252);
61
+ });
62
+
63
+ it('maps RPL_LUSERUNKNOWN to 253', () => {
64
+ expect(Numerics.RPL_LUSERUNKNOWN).toBe(253);
65
+ });
66
+
67
+ it('maps RPL_LUSERCHANNELS to 254', () => {
68
+ expect(Numerics.RPL_LUSERCHANNELS).toBe(254);
69
+ });
70
+
71
+ it('maps RPL_LUSERME to 255', () => {
72
+ expect(Numerics.RPL_LUSERME).toBe(255);
73
+ });
74
+
75
+ it('maps RPL_LOCALUSERS to 265', () => {
76
+ expect(Numerics.RPL_LOCALUSERS).toBe(265);
77
+ });
78
+
79
+ it('maps RPL_GLOBALUSERS to 266', () => {
80
+ expect(Numerics.RPL_GLOBALUSERS).toBe(266);
81
+ });
82
+
83
+ // -- STATS (RFC 2812 §4.6.3) -----------------------------------------------
84
+ it('maps RPL_STATSLINKINFO to 211', () => {
85
+ expect(Numerics.RPL_STATSLINKINFO).toBe(211);
86
+ });
87
+
88
+ it('maps RPL_ENDOFSTATS to 219', () => {
89
+ expect(Numerics.RPL_ENDOFSTATS).toBe(219);
90
+ });
91
+
92
+ it('maps RPL_STATSUPTIME to 242', () => {
93
+ expect(Numerics.RPL_STATSUPTIME).toBe(242);
94
+ });
95
+
34
96
  it('maps ERR_WASNOSUCHNICK to 406', () => {
35
97
  expect(Numerics.ERR_WASNOSUCHNICK).toBe(406);
36
98
  });
@@ -77,6 +139,26 @@ describe('numericToName', () => {
77
139
  expect(numericToName.get(1)).toBe('RPL_WELCOME');
78
140
  });
79
141
 
142
+ it('reverses RPL_LUSERCLIENT', () => {
143
+ expect(numericToName.get(251)).toBe('RPL_LUSERCLIENT');
144
+ });
145
+
146
+ it('reverses RPL_GLOBALUSERS', () => {
147
+ expect(numericToName.get(266)).toBe('RPL_GLOBALUSERS');
148
+ });
149
+
150
+ it('reverses RPL_STATSLINKINFO', () => {
151
+ expect(numericToName.get(211)).toBe('RPL_STATSLINKINFO');
152
+ });
153
+
154
+ it('reverses RPL_ENDOFSTATS', () => {
155
+ expect(numericToName.get(219)).toBe('RPL_ENDOFSTATS');
156
+ });
157
+
158
+ it('reverses RPL_STATSUPTIME', () => {
159
+ expect(numericToName.get(242)).toBe('RPL_STATSUPTIME');
160
+ });
161
+
80
162
  it('returns undefined for an unregistered code', () => {
81
163
  expect(numericToName.get(600)).toBeUndefined();
82
164
  });
@@ -107,4 +189,12 @@ describe('formatNumeric', () => {
107
189
  it('zero-pads RPL_ISUPPORT', () => {
108
190
  expect(formatNumeric('RPL_ISUPPORT')).toBe('005');
109
191
  });
192
+
193
+ it('formats RPL_LUSERCLIENT as 251', () => {
194
+ expect(formatNumeric('RPL_LUSERCLIENT')).toBe('251');
195
+ });
196
+
197
+ it('formats RPL_ENDOFSTATS as 219', () => {
198
+ expect(formatNumeric('RPL_ENDOFSTATS')).toBe('219');
199
+ });
110
200
  });
@@ -9,6 +9,7 @@ import {
9
9
  } from '../src/protocol/outbound';
10
10
  import { parse } from '../src/protocol/parser';
11
11
  import { serialize } from '../src/protocol/serializer';
12
+ import { MAX_WS_MESSAGE_BYTES } from '../src/ws-framing';
12
13
 
13
14
  const L = (text: string): RawLine => ({ text });
14
15
 
@@ -192,6 +193,56 @@ describe('enforceLineLimit', () => {
192
193
  });
193
194
  });
194
195
 
196
+ // ============================================================================
197
+ // enforceLineLimit — budget-aware optional WsFrameMode parameter
198
+ // ============================================================================
199
+
200
+ describe('enforceLineLimit — WsFrameMode budget', () => {
201
+ // The optional `mode` parameter selects the byte budget. Spec modes cap at
202
+ // MAX_WS_MESSAGE_BYTES (510) directly — there is no trailing CRLF on a WS
203
+ // message. Legacy mode caps at MAX_LINE_BYTES - 2 (512 - 2 = 510). Both
204
+ // resolve to 510 today, but the API is mode-aware so a future change to
205
+ // either constant needs no call-site edits.
206
+ const SPEC_BUDGET = MAX_WS_MESSAGE_BYTES;
207
+
208
+ it('accepts an optional spec-text mode and caps at MAX_WS_MESSAGE_BYTES', () => {
209
+ const exact: RawLine = L('x'.repeat(SPEC_BUDGET));
210
+ expect(enforceLineLimit(exact, new Set<string>(), 'spec-text')).toBe(exact);
211
+
212
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
213
+ const out = enforceLineLimit(tooLong, new Set<string>(), 'spec-text');
214
+ expect(out.text.length).toBe(SPEC_BUDGET);
215
+ });
216
+
217
+ it('accepts an optional spec-binary mode and caps at MAX_WS_MESSAGE_BYTES', () => {
218
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
219
+ const out = enforceLineLimit(tooLong, new Set<string>(), 'spec-binary');
220
+ expect(out.text.length).toBe(SPEC_BUDGET);
221
+ });
222
+
223
+ it('uses the legacy budget when mode is explicitly "legacy"', () => {
224
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
225
+ const out = enforceLineLimit(tooLong, new Set<string>(), 'legacy');
226
+ expect(out.text.length).toBe(SPEC_BUDGET);
227
+ });
228
+
229
+ it('omitting mode defaults to the legacy budget (backward compatible)', () => {
230
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
231
+ const withDefault = enforceLineLimit(tooLong, new Set<string>());
232
+ const withLegacy = enforceLineLimit(tooLong, new Set<string>(), 'legacy');
233
+ expect(withDefault).toEqual(withLegacy);
234
+ });
235
+
236
+ it('preserves the tag section in spec-text mode just as in legacy mode', () => {
237
+ const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
238
+ const body = `:alice PRIVMSG #foo :${'x'.repeat(500)}`;
239
+ const line: RawLine = L(`${tagPrefix}${body}`);
240
+ const out = enforceLineLimit(line, new Set<string>(['message-tags']), 'spec-text');
241
+ expect(out.text.length).toBe(SPEC_BUDGET);
242
+ expect(out.text.startsWith(tagPrefix)).toBe(true);
243
+ });
244
+ });
245
+
195
246
  // ============================================================================
196
247
  // Round-trip through serialize -> applyServerTime -> parse
197
248
  // ============================================================================