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,174 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type ClientResult,
4
+ PHASE_NAMES,
5
+ type PhaseName,
6
+ percentile,
7
+ summarizePhase,
8
+ summarizeResults,
9
+ } from '../src/metrics';
10
+
11
+ /**
12
+ * The metrics module is the statistical core of the load tester. It is the
13
+ * piece that turns raw per-client latency samples into the p50/p95/p99
14
+ * summary the report renders, so its math must be pinned independently of
15
+ * any real socket behaviour.
16
+ */
17
+ describe('percentile (nearest-rank)', () => {
18
+ it('returns the upper-median for p50 of an even-sized sample set', () => {
19
+ // nearest-rank: index = ceil(p/100 * n) - 1
20
+ // n=4, p=50 -> ceil(2)-1 = 1 -> sorted[1] = 20
21
+ expect(percentile([10, 20, 30, 40], 50)).toBe(20);
22
+ });
23
+
24
+ it('returns the exact middle for p50 of an odd-sized sample set', () => {
25
+ expect(percentile([10, 20, 30], 50)).toBe(20);
26
+ });
27
+
28
+ it('returns the max for p100', () => {
29
+ expect(percentile([5, 9, 3, 12, 7], 100)).toBe(12);
30
+ });
31
+
32
+ it('returns the min for p0', () => {
33
+ expect(percentile([5, 9, 3, 12, 7], 0)).toBe(3);
34
+ });
35
+
36
+ it('clamps the rank index to the last element for p99 on a small set', () => {
37
+ // n=3, p=99 -> ceil(2.97)-1 = 3-1 = 2 -> sorted[2] = 90
38
+ expect(percentile([10, 50, 90], 99)).toBe(90);
39
+ });
40
+
41
+ it('throws for a percentile below 0 (programming error)', () => {
42
+ expect(() => percentile([10, 20, 30], -5)).toThrow(/percentile/i);
43
+ });
44
+
45
+ it('works on an unsorted input (sorts internally)', () => {
46
+ expect(percentile([100, 1, 50, 25, 75], 50)).toBe(50);
47
+ expect(percentile([100, 1, 50, 25, 75], 100)).toBe(100);
48
+ });
49
+
50
+ it('returns 0 for an empty sample set', () => {
51
+ expect(percentile([], 50)).toBe(0);
52
+ });
53
+
54
+ it('returns the sole sample for a single-element set at any percentile', () => {
55
+ expect(percentile([42], 50)).toBe(42);
56
+ expect(percentile([42], 99)).toBe(42);
57
+ });
58
+
59
+ it('throws for a percentile above 100 (programming error)', () => {
60
+ expect(() => percentile([1, 2, 3], 101)).toThrow(/percentile/i);
61
+ });
62
+ });
63
+
64
+ describe('summarizePhase', () => {
65
+ it('reports count and the percentile triple for a populated phase', () => {
66
+ const s = summarizePhase([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]);
67
+ expect(s.count).toBe(10);
68
+ expect(s.min).toBe(10);
69
+ expect(s.max).toBe(100);
70
+ expect(s.p50).toBe(50);
71
+ expect(s.p95).toBe(100);
72
+ expect(s.p99).toBe(100);
73
+ });
74
+
75
+ it('returns a zeroed summary for an empty sample set', () => {
76
+ const s = summarizePhase([]);
77
+ expect(s).toEqual({ count: 0, min: 0, max: 0, p50: 0, p95: 0, p99: 0 });
78
+ });
79
+
80
+ it('handles a single sample', () => {
81
+ expect(summarizePhase([77])).toEqual({
82
+ count: 1,
83
+ min: 77,
84
+ max: 77,
85
+ p50: 77,
86
+ p95: 77,
87
+ p99: 77,
88
+ });
89
+ });
90
+ });
91
+
92
+ describe('summarizeResults', () => {
93
+ const emptySamples = (): Record<PhaseName, number[]> => ({
94
+ connect: [],
95
+ register: [],
96
+ join: [],
97
+ privmsg: [],
98
+ });
99
+
100
+ it('computes drop rate as failed / total', () => {
101
+ const results: ClientResult[] = [
102
+ { id: 0, ok: true, samples: emptySamples() },
103
+ { id: 1, ok: true, samples: emptySamples() },
104
+ {
105
+ id: 2,
106
+ ok: false,
107
+ error: 'connect timeout',
108
+ failedPhase: 'connect',
109
+ samples: emptySamples(),
110
+ },
111
+ ];
112
+ const summary = summarizeResults(results);
113
+ expect(summary.totalClients).toBe(3);
114
+ expect(summary.succeeded).toBe(2);
115
+ expect(summary.failed).toBe(1);
116
+ expect(summary.dropRate).toBeCloseTo(1 / 3, 5);
117
+ });
118
+
119
+ it('flatten-aggregates samples per phase across all clients (including privmsg repeats)', () => {
120
+ const results: ClientResult[] = [
121
+ {
122
+ id: 0,
123
+ ok: true,
124
+ samples: {
125
+ connect: [12],
126
+ register: [40],
127
+ join: [8],
128
+ privmsg: [3, 4],
129
+ },
130
+ },
131
+ {
132
+ id: 1,
133
+ ok: true,
134
+ samples: {
135
+ connect: [20],
136
+ register: [60],
137
+ join: [10],
138
+ privmsg: [5],
139
+ },
140
+ },
141
+ ];
142
+ const summary = summarizeResults(results);
143
+ expect(summary.phases.connect).toEqual(summarizePhase([12, 20]));
144
+ expect(summary.phases.privmsg).toEqual(summarizePhase([3, 4, 5]));
145
+ expect(summary.phases.privmsg.count).toBe(3);
146
+ });
147
+
148
+ it('only counts samples up to the failed phase (drop attribution)', () => {
149
+ // A client that failed at `register` contributes a connect sample but
150
+ // no register/join/privmsg sample.
151
+ const results: ClientResult[] = [
152
+ {
153
+ id: 0,
154
+ ok: false,
155
+ error: 'register timeout',
156
+ failedPhase: 'register',
157
+ samples: { connect: [12], register: [], join: [], privmsg: [] },
158
+ },
159
+ ];
160
+ const summary = summarizeResults(results);
161
+ expect(summary.phases.connect.count).toBe(1);
162
+ expect(summary.phases.register.count).toBe(0);
163
+ expect(summary.phases.join.count).toBe(0);
164
+ });
165
+
166
+ it('returns a zeroed summary for no clients', () => {
167
+ const summary = summarizeResults([]);
168
+ expect(summary.totalClients).toBe(0);
169
+ expect(summary.dropRate).toBe(0);
170
+ for (const phase of PHASE_NAMES) {
171
+ expect(summary.phases[phase].count).toBe(0);
172
+ }
173
+ });
174
+ });
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { type LoadTestConfig, resolveConfig } from '../src/config';
3
+ import {
4
+ type ClientResult,
5
+ type LoadTestSummary,
6
+ PHASE_NAMES,
7
+ summarizeResults,
8
+ } from '../src/metrics';
9
+ import { type LoadTestRun, formatMs, formatPercent, generateReport } from '../src/report';
10
+
11
+ /**
12
+ * The report module renders the run's aggregate into a markdown string.
13
+ * Tests pin the structural pieces a human scans for (platform, target,
14
+ * drop rate, the per-phase latency table) without coupling to exact
15
+ * whitespace — the layout can evolve without churn.
16
+ */
17
+
18
+ function baseConfig(overrides: Partial<LoadTestConfig> = {}): LoadTestConfig {
19
+ return resolveConfig({
20
+ target: 'wss://irc.staging.example.com/',
21
+ platform: 'cf-staging',
22
+ connections: 5,
23
+ channel: '#loadtest',
24
+ ...overrides,
25
+ });
26
+ }
27
+
28
+ function emptySummary(): LoadTestSummary {
29
+ return summarizeResults([]);
30
+ }
31
+
32
+ function makeRun(
33
+ summary: LoadTestSummary = emptySummary(),
34
+ config: LoadTestConfig = baseConfig(),
35
+ durationMs = 1000,
36
+ ): LoadTestRun {
37
+ const startedAt = new Date('2026-08-03T14:00:00.000Z');
38
+ return {
39
+ config,
40
+ summary,
41
+ startedAt,
42
+ finishedAt: new Date(startedAt.getTime() + durationMs),
43
+ durationMs,
44
+ };
45
+ }
46
+
47
+ function okClient(
48
+ id: number,
49
+ latencies: Partial<Record<(typeof PHASE_NAMES)[number], number[]>>,
50
+ ): ClientResult {
51
+ const samples: ClientResult['samples'] = { connect: [], register: [], join: [], privmsg: [] };
52
+ for (const [phase, vals] of Object.entries(latencies) as [keyof typeof samples, number[]][]) {
53
+ samples[phase] = vals;
54
+ }
55
+ return { id, ok: true, samples };
56
+ }
57
+
58
+ describe('formatMs', () => {
59
+ it('formats sub-second durations in ms', () => {
60
+ expect(formatMs(12)).toBe('12.00ms');
61
+ });
62
+
63
+ it('formats seconds with two decimals', () => {
64
+ expect(formatMs(42_300)).toBe('42.30s');
65
+ });
66
+
67
+ it('formats minutes with seconds', () => {
68
+ expect(formatMs(90_000)).toBe('1m30.00s');
69
+ });
70
+
71
+ it('formats zero as 0.00ms', () => {
72
+ expect(formatMs(0)).toBe('0.00ms');
73
+ });
74
+ });
75
+
76
+ describe('formatPercent', () => {
77
+ it('formats a fraction as a percentage with two decimals', () => {
78
+ expect(formatPercent(0.0002)).toBe('0.02%');
79
+ });
80
+
81
+ it('formats 1 as 100.00%', () => {
82
+ expect(formatPercent(1)).toBe('100.00%');
83
+ });
84
+
85
+ it('formats 0 as 0.00%', () => {
86
+ expect(formatPercent(0)).toBe('0.00%');
87
+ });
88
+ });
89
+
90
+ describe('generateReport', () => {
91
+ it('includes the platform label and target URL in the header', () => {
92
+ const md = generateReport(makeRun());
93
+ expect(md).toContain('cf-staging');
94
+ expect(md).toContain('wss://irc.staging.example.com/');
95
+ });
96
+
97
+ it('includes the ISO date and the human-readable duration', () => {
98
+ const md = generateReport(makeRun(emptySummary(), baseConfig(), 42_300));
99
+ expect(md).toContain('2026-08-03T14:00:00.000Z');
100
+ expect(md).toContain('42.30s');
101
+ });
102
+
103
+ it('reports the configured connection count, channel, and messages/client', () => {
104
+ const cfg = baseConfig({ connections: 10_000, channel: '#perf', messagesPerClient: 3 });
105
+ const md = generateReport(makeRun(emptySummary(), cfg));
106
+ expect(md).toContain('10000');
107
+ expect(md).toContain('#perf');
108
+ expect(md).toContain('3');
109
+ });
110
+
111
+ it('renders the drop rate from the summary', () => {
112
+ const results: ClientResult[] = [
113
+ okClient(0, { connect: [10] }),
114
+ okClient(1, { connect: [12] }),
115
+ {
116
+ id: 2,
117
+ ok: false,
118
+ error: 'timeout',
119
+ failedPhase: 'connect',
120
+ samples: { connect: [], register: [], join: [], privmsg: [] },
121
+ },
122
+ ];
123
+ const run = makeRun(summarizeResults(results));
124
+ const md = generateReport(run);
125
+ expect(md).toContain('Attempted');
126
+ expect(md).toContain('3');
127
+ expect(md).toContain('2'); // succeeded
128
+ expect(md).toContain('1'); // failed
129
+ expect(md).toContain('33.33%'); // drop rate
130
+ });
131
+
132
+ it('renders a latency table row per phase with p50/p95/p99 columns', () => {
133
+ const results: ClientResult[] = [
134
+ okClient(0, { connect: [12, 20], register: [40], join: [8], privmsg: [3, 4] }),
135
+ okClient(1, { connect: [30], register: [60], join: [10], privmsg: [5] }),
136
+ ];
137
+ const md = generateReport(makeRun(summarizeResults(results)));
138
+ // Column headers present.
139
+ expect(md).toContain('p50');
140
+ expect(md).toContain('p95');
141
+ expect(md).toContain('p99');
142
+ // Each phase name appears as a row.
143
+ for (const phase of PHASE_NAMES) {
144
+ expect(md).toContain(`| ${phase} `);
145
+ }
146
+ // Sample counts reach the table.
147
+ expect(md).toContain('| connect |'); // row marker
148
+ });
149
+
150
+ it('renders a zeroed latency table when no clients succeeded', () => {
151
+ const md = generateReport(makeRun(emptySummary()));
152
+ for (const phase of PHASE_NAMES) {
153
+ expect(md).toContain(`| ${phase} `);
154
+ }
155
+ });
156
+
157
+ it('documents that cost modeling is platform-specific (operator-filled)', () => {
158
+ const md = generateReport(makeRun());
159
+ expect(md.toLowerCase()).toContain('cost');
160
+ });
161
+ });
@@ -0,0 +1,67 @@
1
+ import { type LocalServer, startLocalServer } from '@serverless-ircd/local-cli/dist/server.js';
2
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
3
+ import { resolveConfig } from '../src/config';
4
+ import { runLoadTest } from '../src/harness';
5
+ import { generateReport } from '../src/report';
6
+
7
+ /**
8
+ * Smoke acceptance: "load test script completes without crashing."
9
+ *
10
+ * This is the end-to-end smoke: the real `IrcLoadClient` factory, a real
11
+ * in-process IRC server (`startLocalServer`, the project's e2e fixture
12
+ * target), and a small fanout of real WebSocket connections through the
13
+ * full lifecycle. Sizes are kept tiny so the suite stays fast; the same
14
+ * machinery scales to 10k by changing `--connections` on the CLI.
15
+ */
16
+ describe('load-test smoke (real IRC server)', () => {
17
+ let irc: LocalServer;
18
+ let target: string;
19
+
20
+ beforeAll(async () => {
21
+ irc = await startLocalServer({
22
+ port: 0,
23
+ hostname: '127.0.0.1',
24
+ serverName: 'irc.load-test.local',
25
+ networkName: 'LoadTestNet',
26
+ });
27
+ target = `ws://127.0.0.1:${irc.port}/`;
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await irc.close();
32
+ });
33
+
34
+ it('completes a small fanout with no crashes and sane metrics', async () => {
35
+ const channel = `#smoke-${Math.random().toString(36).slice(2, 8)}`;
36
+ const config = resolveConfig({
37
+ target,
38
+ connections: 6,
39
+ connectConcurrency: 3,
40
+ rampMs: 0,
41
+ channel,
42
+ nickPrefix: 'smoke',
43
+ messagesPerClient: 2,
44
+ messageIntervalMs: 0,
45
+ platform: 'local-cli-smoke',
46
+ connectTimeoutMs: 3000,
47
+ registerTimeoutMs: 3000,
48
+ });
49
+
50
+ const run = await runLoadTest(config);
51
+
52
+ // Every client should succeed against the healthy in-process server.
53
+ expect(run.summary.totalClients).toBe(6);
54
+ expect(run.summary.succeeded).toBe(6);
55
+ expect(run.summary.failed).toBe(0);
56
+ expect(run.summary.dropRate).toBe(0);
57
+ // Each phase that was reached has samples.
58
+ expect(run.summary.phases.connect.count).toBe(6);
59
+ expect(run.summary.phases.register.count).toBe(6);
60
+ expect(run.summary.phases.join.count).toBe(6);
61
+ expect(run.summary.phases.privmsg.count).toBe(6 * 2);
62
+ // The report renders without throwing and mentions the platform.
63
+ const md = generateReport(run);
64
+ expect(md).toContain('local-cli-smoke');
65
+ expect(md).toContain('Latency');
66
+ }, 20_000);
67
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"]
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"],
6
+ "composite": false
7
+ },
8
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
9
+ "exclude": ["dist"]
10
+ }
@@ -0,0 +1,29 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ // The harness smoke test spins up a real in-process WS IRC server
8
+ // (via @serverless-ircd/local-cli) and ramps a small fanout of real
9
+ // WebSocket connections against it; give it headroom while staying
10
+ // fast enough for watch mode.
11
+ testTimeout: 15_000,
12
+ hookTimeout: 15_000,
13
+ coverage: {
14
+ provider: 'v8',
15
+ all: false,
16
+ include: ['src/**/*.ts'],
17
+ // `main.ts` is CLI glue (argv parsing, signal handlers); exercised
18
+ // manually by `pnpm start`. Mirrors the apps/local-cli policy.
19
+ exclude: ['src/main.ts'],
20
+ reporter: ['text', 'html', 'json-summary'],
21
+ thresholds: {
22
+ lines: 90,
23
+ functions: 90,
24
+ branches: 80,
25
+ statements: 90,
26
+ },
27
+ },
28
+ },
29
+ });
@@ -1,4 +1,9 @@
1
1
  {
2
+ "name": "@serverless-ircd/tools",
2
3
  "private": true,
3
- "type": "module"
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@serverless-ircd/aws-adapter": "workspace:*",
7
+ "@serverless-ircd/cf-adapter": "workspace:*"
8
+ }
4
9
  }
@@ -33,7 +33,10 @@
33
33
  import { execFileSync } from 'node:child_process';
34
34
  import { readFileSync } from 'node:fs';
35
35
  import { parseArgs } from 'node:util';
36
- import { CREATE_ACCOUNTS_TABLE_SQL, buildAccountPutStatement } from '@serverless-ircd/cf-adapter';
36
+ import {
37
+ CREATE_ACCOUNTS_TABLE_SQL,
38
+ buildAccountPutStatement,
39
+ } from '@serverless-ircd/cf-adapter/accounts';
37
40
 
38
41
  const { values } = parseArgs({
39
42
  options: {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "private": true,
5
5
  "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
6
  "license": "BSD-3-Clause",
@@ -11,16 +11,25 @@
11
11
  * IRC client ──TCP──▶ forwarder ──WS──▶ ServerlessIRCd endpoint
12
12
  * IRC client ◀─TCP── forwarder ◀─WS── ServerlessIRCd endpoint
13
13
  *
14
+ * IRCv3 WebSocket subprotocol: every dial offers
15
+ * `['binary.ircv3.net', 'text.ircv3.net']` so a spec-capable upstream
16
+ * (e.g. the CF `ConnectionDO`) agrees on one-message-per-frame framing. A
17
+ * server that supports neither simply leaves the subprotocol unset and the
18
+ * bridge keeps working with its tolerant line-split framing.
19
+ *
14
20
  * Framing:
15
21
  * • TCP→WS: the TCP byte stream is reassembled into IRC lines with the
16
22
  * byte-accurate {@link LineScanner} (tolerant of CRLF and bare LF per
17
23
  * PLAN §9). Each complete line is sent as one bare WebSocket text frame
18
24
  * — the canonical "one message per frame" convention the server's
19
- * `ConnectionActor` expects.
25
+ * `ConnectionActor` expects — with any trailing CR-LF stripped by
26
+ * {@link toWsMessagePayload} per the IRCv3 spec ("no trailing CR-LF on
27
+ * the wire").
20
28
  * • WS→TCP: each inbound WS frame is split on line terminators and every
21
29
  * non-empty line is written back to the TCP socket with a trailing
22
- * CRLF, normalizing whatever framing the server used (single message,
23
- * `\r\n`-joined, or bare) into canonical IRC wire format.
30
+ * CRLF via {@link toTcpWire}, normalizing whatever framing the server
31
+ * used (single message, `\r\n`-joined, or bare) into canonical IRC wire
32
+ * format.
24
33
  *
25
34
  * Lifetime: closing either side tears down the whole bridge. The forwarder
26
35
  * itself is transport-agnostic and has no `@serverless-ircd/*` dependency.
@@ -31,6 +40,16 @@ import { type RawData, WebSocket } from 'ws';
31
40
  import { LineScanner } from './line-scanner.js';
32
41
  import { ConsoleLogger, LogLevel, type Logger } from './logger.js';
33
42
 
43
+ /**
44
+ * IRCv3 WebSocket subprotocols the forwarder offers when dialing the upstream
45
+ * IRCd, in preference order. `binary.ircv3.net` is offered first so a
46
+ * spec-capable server agrees on binary framing; `text.ircv3.net` is the
47
+ * fallback. A server that supports neither (e.g. a legacy deployment) leaves
48
+ * the subprotocol unset and the bridge keeps working with its tolerant
49
+ * line-split framing.
50
+ */
51
+ export const FORWARDER_WS_SUBPROTOCOLS = ['binary.ircv3.net', 'text.ircv3.net'] as const;
52
+
34
53
  /** Options accepted by {@link startForwarder}. */
35
54
  export interface StartForwarderOptions {
36
55
  /** TCP port to listen on. Use 0 for an ephemeral port. */
@@ -157,7 +176,7 @@ function bridgeConnection(
157
176
  logger: Logger,
158
177
  connId: number,
159
178
  ): () => void {
160
- const ws = new WebSocket(targetHref);
179
+ const ws = new WebSocket(targetHref, [...FORWARDER_WS_SUBPROTOCOLS]);
161
180
  const outgoing = new LineScanner();
162
181
  const pending: string[] = [];
163
182
  let wsReady = false;
@@ -167,8 +186,8 @@ function bridgeConnection(
167
186
  logger.info('bridge.accepted', { conn: connId, remote });
168
187
 
169
188
  const sendLine = (line: string): void => {
170
- if (wsReady) ws.send(line);
171
- else pending.push(line);
189
+ if (wsReady) ws.send(toWsMessagePayload(line));
190
+ else pending.push(toWsMessagePayload(line));
172
191
  };
173
192
 
174
193
  tcp.on('data', (chunk: Buffer) => {
@@ -191,9 +210,7 @@ function bridgeConnection(
191
210
  ws.on('message', (data: RawData) => {
192
211
  const text = toBuffer(data).toString('utf8');
193
212
  logger.debug('ws.frame', { conn: connId, frame: text });
194
- for (const line of splitFrameLines(text)) {
195
- if (line.length > 0) tcp.write(`${line}\r\n`);
196
- }
213
+ for (const chunk of toTcpWire(text)) tcp.write(chunk);
197
214
  });
198
215
 
199
216
  const teardown = (reason: CloseReason): void => {
@@ -241,6 +258,37 @@ export function splitFrameLines(text: string): string[] {
241
258
  return text.split(/\r?\n/u);
242
259
  }
243
260
 
261
+ /**
262
+ * Translates a TCP IRC line into the WebSocket message payload for the
263
+ * upstream send direction. Strips a single trailing CR-LF (or bare LF) so
264
+ * the WS message carries the bare IRC line, per the IRCv3 WebSocket spec
265
+ * ("no trailing CR-LF on the wire"). Idempotent on already-bare lines
266
+ * (the {@link LineScanner} output), so callers can apply it unconditionally
267
+ * without inspecting the line first. Exported so the CRLF-strip contract is
268
+ * unit-testable in isolation.
269
+ */
270
+ export function toWsMessagePayload(line: string): string {
271
+ if (line.endsWith('\r\n')) return line.slice(0, -2);
272
+ if (line.endsWith('\n')) return line.slice(0, -1);
273
+ return line;
274
+ }
275
+
276
+ /**
277
+ * Translates an inbound WebSocket message payload into the CRLF-terminated
278
+ * TCP chunk(s) to write back to the IRC client. Each non-empty IRC line is
279
+ * returned as its own entry ending in CRLF, ready for `socket.write`. A
280
+ * single trailing CR-LF on the message (defensive against servers that emit
281
+ * one) is collapsed rather than doubled. Exported so the CRLF-re-append
282
+ * contract is unit-testable in isolation.
283
+ */
284
+ export function toTcpWire(text: string): string[] {
285
+ const out: string[] = [];
286
+ for (const line of splitFrameLines(text)) {
287
+ if (line.length > 0) out.push(`${line}\r\n`);
288
+ }
289
+ return out;
290
+ }
291
+
244
292
  /**
245
293
  * Normalizes a `ws` `RawData` payload into a single UTF-8 `Buffer`. Exported
246
294
  * so the `ArrayBuffer` / `Uint8Array` / `Buffer[]` variants — which ws only
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import http from 'node:http';
2
+ import http, { type IncomingHttpHeaders } from 'node:http';
3
3
  import { type Socket, createConnection, createServer as createNetServer } from 'node:net';
4
4
  import type { AddressInfo } from 'node:net';
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -51,6 +51,12 @@ interface Target {
51
51
  readonly frames: string[];
52
52
  readonly connections: WebSocket[];
53
53
  waitForConnection(): Promise<WebSocket>;
54
+ /**
55
+ * Resolves the next inbound upgrade request, in arrival order. Used to
56
+ * assert headers the forwarder sends during the WS handshake (e.g. the
57
+ * offered `Sec-WebSocket-Protocol`).
58
+ */
59
+ waitForUpgradeRequest(): Promise<{ headers: IncomingHttpHeaders }>;
54
60
  close(): Promise<void>;
55
61
  }
56
62
 
@@ -69,8 +75,14 @@ function startDelayedTarget(delayMs: number): Promise<Target> {
69
75
  // arrival order; queue waiters that resolve one-for-one as connections land.
70
76
  const waiters: Array<(ws: WebSocket) => void> = [];
71
77
  let waitForCount = 0;
78
+ // Likewise for upgrade requests, captured before the WS is handed off so
79
+ // headers can be asserted even when the caller never awaits a connection.
80
+ const upgradeReqWaiters: Array<(req: { headers: IncomingHttpHeaders }) => void> = [];
72
81
 
73
82
  server.on('upgrade', (req, socket, head) => {
83
+ const captured = { headers: req.headers };
84
+ const waiter = upgradeReqWaiters.shift();
85
+ if (waiter) waiter(captured);
74
86
  const finish = () => {
75
87
  wss.handleUpgrade(req, socket, head, (ws) => {
76
88
  connections.push(ws);
@@ -97,6 +109,10 @@ function startDelayedTarget(delayMs: number): Promise<Target> {
97
109
  if (connections.length > want) res(connections[want] as WebSocket);
98
110
  else waiters.push(res);
99
111
  }),
112
+ waitForUpgradeRequest: () =>
113
+ new Promise((res) => {
114
+ upgradeReqWaiters.push(res);
115
+ }),
100
116
  close: () =>
101
117
  new Promise<void>((r) => {
102
118
  for (const ws of connections) ws.close();
@@ -260,6 +276,23 @@ describe('startForwarder — lifecycle', () => {
260
276
  openServers.push(fwd);
261
277
  expect(fwd.targetUrl).toBe('wss://example.invalid/');
262
278
  });
279
+
280
+ it('offers binary.ircv3.net and text.ircv3.net in the upstream WS handshake', async () => {
281
+ const target = await startTarget();
282
+ openServers.push(target);
283
+ const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
284
+ openServers.push(fwd);
285
+
286
+ const client = new TcpClient(fwd.listenPort);
287
+ openClients.push(client);
288
+ await client.opened();
289
+
290
+ const req = await target.waitForUpgradeRequest();
291
+ const offered = req.headers['sec-websocket-protocol'];
292
+ expect(typeof offered).toBe('string');
293
+ const list = (offered as string).split(',').map((s) => s.trim());
294
+ expect(list).toEqual(['binary.ircv3.net', 'text.ircv3.net']);
295
+ });
263
296
  });
264
297
 
265
298
  describe('forwarder — TCP→WS framing', () => {