serverless-ircd 0.5.0 → 0.7.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 (394) hide show
  1. package/.github/workflows/ci.yml +40 -3
  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/.gitmodules +3 -0
  6. package/CHANGELOG.md +474 -0
  7. package/README.md +219 -22
  8. package/apps/aws-stack/README.md +33 -0
  9. package/apps/aws-stack/package.json +1 -1
  10. package/apps/aws-stack/src/aws-stack.ts +74 -9
  11. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  12. package/apps/aws-stack/tests/stack.test.ts +2 -2
  13. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  14. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  15. package/apps/aws-stack/vitest.config.ts +3 -0
  16. package/apps/cf-tcp-container/package.json +1 -1
  17. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  18. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  20. package/apps/cf-tcp-container/wrangler.toml +1 -1
  21. package/apps/cf-worker/package.json +2 -1
  22. package/apps/cf-worker/src/origin-allowlist.ts +99 -0
  23. package/apps/cf-worker/src/worker.ts +89 -9
  24. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +18 -0
  25. package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
  26. package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
  27. package/apps/cf-worker/tests/smoke.test.ts +28 -2
  28. package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
  29. package/apps/cf-worker/vitest.config.ts +1 -1
  30. package/apps/cf-worker/wrangler.test.toml +23 -0
  31. package/apps/cf-worker/wrangler.toml +59 -3
  32. package/apps/local-cli/package.json +1 -1
  33. package/apps/local-cli/src/config-loader.ts +6 -6
  34. package/apps/local-cli/src/main.ts +20 -0
  35. package/apps/local-cli/src/server.ts +9 -7
  36. package/apps/local-cli/tests/e2e.test.ts +112 -28
  37. package/apps/local-cli/tests/tcp.test.ts +71 -19
  38. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  39. package/apps/web/landing/index.html +217 -0
  40. package/apps/web/package.json +28 -0
  41. package/apps/web/scripts/build.mjs +125 -0
  42. package/apps/web/src/build-env.ts +43 -0
  43. package/apps/web/src/config-schema.ts +138 -0
  44. package/apps/web/static/config.json +28 -0
  45. package/apps/web/static/config.prod.json +28 -0
  46. package/apps/web/static/config.staging.json +28 -0
  47. package/apps/web/tests/build-env.test.ts +63 -0
  48. package/apps/web/tests/build-smoke.test.ts +103 -0
  49. package/apps/web/tests/config-schema.test.ts +432 -0
  50. package/apps/web/tests/workspace.test.ts +12 -0
  51. package/apps/web/tsconfig.json +10 -0
  52. package/apps/web/upstream/.browserslistrc +3 -0
  53. package/apps/web/upstream/.editorconfig +9 -0
  54. package/apps/web/upstream/.eslintignore +3 -0
  55. package/apps/web/upstream/.eslintrc.js +90 -0
  56. package/apps/web/upstream/.github/FUNDING.yml +12 -0
  57. package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  58. package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  59. package/apps/web/upstream/.github/workflows/push.yml +26 -0
  60. package/apps/web/upstream/.github/workflows/release.yml +33 -0
  61. package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
  62. package/apps/web/upstream/.prettierrc.js +9 -0
  63. package/apps/web/upstream/.stylelintignore +5 -0
  64. package/apps/web/upstream/.stylelintrc.js +21 -0
  65. package/apps/web/upstream/LICENSE +201 -0
  66. package/apps/web/upstream/README.md +76 -0
  67. package/apps/web/upstream/babel.config.js +25 -0
  68. package/apps/web/upstream/crowdin.yml +3 -0
  69. package/apps/web/upstream/index.html +35 -0
  70. package/apps/web/upstream/jest.config.js +7 -0
  71. package/apps/web/upstream/jsdoc.json +22 -0
  72. package/apps/web/upstream/package.json +80 -0
  73. package/apps/web/upstream/scripts/pre-commit +43 -0
  74. package/apps/web/upstream/src/components/App.vue +516 -0
  75. package/apps/web/upstream/src/components/AppSettings.vue +587 -0
  76. package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
  77. package/apps/web/upstream/src/components/Avatar.vue +105 -0
  78. package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
  79. package/apps/web/upstream/src/components/BufferKey.vue +121 -0
  80. package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
  81. package/apps/web/upstream/src/components/Captcha.vue +85 -0
  82. package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
  83. package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
  84. package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
  85. package/apps/web/upstream/src/components/ChannelList.vue +345 -0
  86. package/apps/web/upstream/src/components/Container.vue +401 -0
  87. package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
  88. package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
  89. package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
  90. package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
  91. package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
  92. package/apps/web/upstream/src/components/MessageList.vue +1028 -0
  93. package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
  94. package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
  95. package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
  96. package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
  97. package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
  98. package/apps/web/upstream/src/components/Nicklist.vue +309 -0
  99. package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
  100. package/apps/web/upstream/src/components/NotConnected.vue +271 -0
  101. package/apps/web/upstream/src/components/SelfUser.vue +283 -0
  102. package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
  103. package/apps/web/upstream/src/components/ServerView.vue +126 -0
  104. package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
  105. package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
  106. package/apps/web/upstream/src/components/Sidebar.vue +429 -0
  107. package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
  108. package/apps/web/upstream/src/components/SidebarState.vue +145 -0
  109. package/apps/web/upstream/src/components/StartupError.vue +35 -0
  110. package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
  111. package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
  112. package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
  113. package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
  114. package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
  115. package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
  116. package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
  117. package/apps/web/upstream/src/components/UserBox.vue +728 -0
  118. package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
  119. package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
  120. package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
  121. package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
  122. package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
  123. package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
  124. package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
  125. package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
  126. package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
  127. package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
  128. package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
  129. package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
  130. package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
  131. package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
  132. package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
  133. package/apps/web/upstream/src/helpers/Colours.js +128 -0
  134. package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
  135. package/apps/web/upstream/src/helpers/Md5.js +193 -0
  136. package/apps/web/upstream/src/helpers/Misc.js +401 -0
  137. package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
  138. package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
  139. package/apps/web/upstream/src/libs/AudioManager.js +60 -0
  140. package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
  141. package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
  142. package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
  143. package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
  144. package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
  145. package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
  146. package/apps/web/upstream/src/libs/IPC.js +51 -0
  147. package/apps/web/upstream/src/libs/InputHandler.js +921 -0
  148. package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
  149. package/apps/web/upstream/src/libs/Logger.js +71 -0
  150. package/apps/web/upstream/src/libs/Message.js +169 -0
  151. package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
  152. package/apps/web/upstream/src/libs/MessageParser.js +249 -0
  153. package/apps/web/upstream/src/libs/Notifications.js +78 -0
  154. package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
  155. package/apps/web/upstream/src/libs/ServerSession.js +102 -0
  156. package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
  157. package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
  158. package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
  159. package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
  160. package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
  161. package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
  162. package/apps/web/upstream/src/libs/bufferTools.js +177 -0
  163. package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
  164. package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
  165. package/apps/web/upstream/src/libs/settingTools.js +31 -0
  166. package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
  167. package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
  168. package/apps/web/upstream/src/libs/state/UserState.js +89 -0
  169. package/apps/web/upstream/src/libs/state/common.js +26 -0
  170. package/apps/web/upstream/src/libs/state.js +961 -0
  171. package/apps/web/upstream/src/libs/storage/Local.js +51 -0
  172. package/apps/web/upstream/src/main.js +566 -0
  173. package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
  174. package/apps/web/upstream/src/res/configTemplates.js +373 -0
  175. package/apps/web/upstream/src/res/globalStyle.css +277 -0
  176. package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
  177. package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
  178. package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
  179. package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
  180. package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
  181. package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
  182. package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
  183. package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
  184. package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
  185. package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
  186. package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
  187. package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
  188. package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
  189. package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
  190. package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
  191. package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
  192. package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
  193. package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
  194. package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
  195. package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
  196. package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
  197. package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
  198. package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
  199. package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
  200. package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
  201. package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
  202. package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
  203. package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
  204. package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
  205. package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
  206. package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
  207. package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
  208. package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
  209. package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
  210. package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
  211. package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
  212. package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
  213. package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
  214. package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
  215. package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
  216. package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
  217. package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
  218. package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
  219. package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
  220. package/apps/web/upstream/src/res/localesList.json +27 -0
  221. package/apps/web/upstream/src/res/logo.png +0 -0
  222. package/apps/web/upstream/src/thirdparty/about.html +43 -0
  223. package/apps/web/upstream/src/thirdparty/index.js +5 -0
  224. package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
  225. package/apps/web/upstream/static/config.json +28 -0
  226. package/apps/web/upstream/static/emoticons/smile.png +0 -0
  227. package/apps/web/upstream/static/favicon.png +0 -0
  228. package/apps/web/upstream/static/highlight.mp3 +0 -0
  229. package/apps/web/upstream/static/highlight.ogg +0 -0
  230. package/apps/web/upstream/static/locales/.gitignore +2 -0
  231. package/apps/web/upstream/static/plugins/customise.html.example +10 -0
  232. package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
  233. package/apps/web/upstream/static/themes/common/base.css +1064 -0
  234. package/apps/web/upstream/static/themes/dark/theme.css +196 -0
  235. package/apps/web/upstream/static/themes/default/theme.css +92 -0
  236. package/apps/web/upstream/static/themes/elite/theme.css +248 -0
  237. package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
  238. package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
  239. package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
  240. package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
  241. package/apps/web/upstream/static/themes/sky/theme.css +107 -0
  242. package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
  243. package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
  244. package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
  245. package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
  246. package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
  247. package/apps/web/upstream/vue.config.js +150 -0
  248. package/apps/web/upstream/yarn.lock +9767 -0
  249. package/apps/web/vitest.config.ts +13 -0
  250. package/biome.json +2 -0
  251. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  252. package/docs/ADR-002-location-of-authority.md +82 -0
  253. package/docs/ADR-003-durable-object-sharding.md +110 -0
  254. package/docs/ADR-004-dynamodb-schema.md +96 -0
  255. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  256. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  257. package/docs/ADR-007-deterministic-ports.md +82 -0
  258. package/docs/ADR-008-monorepo-tooling.md +60 -0
  259. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  260. package/docs/ADR-Index.md +15 -0
  261. package/docs/AWS-Adapter-Architecture.md +496 -0
  262. package/docs/AWS-Deployment.md +1275 -0
  263. package/docs/AWS-TCP-Deployment.md +410 -0
  264. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  265. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  266. package/docs/Home.md +16 -0
  267. package/docs/Observability.md +87 -0
  268. package/docs/PlanExtensions.md +812 -0
  269. package/docs/PlanIRCv3Websocket.md +503 -0
  270. package/docs/PlanWebClient.md +451 -0
  271. package/docs/Release-Process.md +443 -0
  272. package/docs/WebClientGuide.md +535 -0
  273. package/package.json +2 -2
  274. package/packages/aws-adapter/package.json +1 -1
  275. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  276. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  277. package/packages/aws-adapter/src/serialize.ts +29 -1
  278. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  279. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  280. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  281. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  282. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  283. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  284. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  285. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  286. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  287. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  288. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  289. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  290. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  291. package/packages/cf-adapter/package.json +2 -1
  292. package/packages/cf-adapter/src/connection-do.ts +3 -0
  293. package/packages/cf-adapter/src/serialize.ts +25 -4
  294. package/packages/cf-adapter/tests/cf-harness.ts +12 -1
  295. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  296. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  297. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  298. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  299. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  300. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  301. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  302. package/packages/cf-adapter/vitest.config.ts +1 -1
  303. package/packages/in-memory-runtime/package.json +1 -1
  304. package/packages/irc-core/package.json +1 -1
  305. package/packages/irc-core/reports/mutation/mutation.html +342 -0
  306. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  307. package/packages/irc-core/src/caps/index.ts +1 -0
  308. package/packages/irc-core/src/caps/sts.ts +84 -0
  309. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  310. package/packages/irc-core/src/commands/away.ts +9 -3
  311. package/packages/irc-core/src/commands/cap.ts +23 -5
  312. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  313. package/packages/irc-core/src/commands/index.ts +34 -0
  314. package/packages/irc-core/src/commands/isupport.ts +53 -0
  315. package/packages/irc-core/src/commands/mode.ts +8 -1
  316. package/packages/irc-core/src/commands/monitor.ts +327 -0
  317. package/packages/irc-core/src/commands/multiline.ts +256 -0
  318. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  319. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  320. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  321. package/packages/irc-core/src/commands/registration.ts +8 -0
  322. package/packages/irc-core/src/commands/sasl.ts +24 -1
  323. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  324. package/packages/irc-core/src/commands/whois.ts +5 -0
  325. package/packages/irc-core/src/config.ts +65 -10
  326. package/packages/irc-core/src/effects.ts +14 -0
  327. package/packages/irc-core/src/ports.ts +389 -0
  328. package/packages/irc-core/src/protocol/index.ts +14 -0
  329. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  330. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  331. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  332. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  333. package/packages/irc-core/src/state/connection.ts +32 -1
  334. package/packages/irc-core/src/types.ts +112 -0
  335. package/packages/irc-core/stryker.commands.conf.json +1 -2
  336. package/packages/irc-core/tests/away-store.test.ts +73 -0
  337. package/packages/irc-core/tests/batch.test.ts +15 -0
  338. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  339. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  340. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  341. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  342. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  343. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  344. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  345. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  346. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  347. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  348. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  349. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  350. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  351. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  352. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  353. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  354. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  355. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  356. package/packages/irc-core/tests/config.test.ts +103 -13
  357. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  358. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  359. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  360. package/packages/irc-core/tests/numerics.test.ts +13 -0
  361. package/packages/irc-core/tests/parser.test.ts +109 -0
  362. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  363. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  364. package/packages/irc-core/tests/types.test.ts +27 -0
  365. package/packages/irc-server/package.json +1 -1
  366. package/packages/irc-server/src/actor.ts +450 -3
  367. package/packages/irc-server/src/dispatch.ts +108 -16
  368. package/packages/irc-server/tests/actor.test.ts +1432 -39
  369. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  370. package/packages/irc-test-support/package.json +1 -1
  371. package/packages/irc-test-support/src/index.ts +6 -0
  372. package/packages/irc-test-support/src/scenarios.ts +24 -2
  373. package/packages/irc-test-support/src/test-config.ts +54 -0
  374. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  375. package/tools/ci-hardening/package.json +1 -1
  376. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  377. package/tools/load-test/package.json +33 -0
  378. package/tools/load-test/src/client.ts +351 -0
  379. package/tools/load-test/src/config.ts +313 -0
  380. package/tools/load-test/src/harness.ts +116 -0
  381. package/tools/load-test/src/main.ts +120 -0
  382. package/tools/load-test/src/metrics.ts +168 -0
  383. package/tools/load-test/src/report.ts +106 -0
  384. package/tools/load-test/tests/client.test.ts +212 -0
  385. package/tools/load-test/tests/config.test.ts +152 -0
  386. package/tools/load-test/tests/framing.test.ts +37 -0
  387. package/tools/load-test/tests/harness.test.ts +165 -0
  388. package/tools/load-test/tests/metrics.test.ts +174 -0
  389. package/tools/load-test/tests/report.test.ts +161 -0
  390. package/tools/load-test/tests/smoke.test.ts +67 -0
  391. package/tools/load-test/tsconfig.build.json +12 -0
  392. package/tools/load-test/tsconfig.test.json +10 -0
  393. package/tools/load-test/vitest.config.ts +29 -0
  394. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Load-test orchestrator (the "client farm") — PLAN §6.
3
+ *
4
+ * {@link runLoadTest} spawns `config.connections` synthetic clients under
5
+ * a concurrency cap (`config.connectConcurrency`) and ramps them over
6
+ * `config.rampMs`, then aggregates their {@link ClientResult}s into the
7
+ * {@link LoadTestRun} the report renders.
8
+ *
9
+ * The default client factory builds real {@link IrcLoadClient}s; tests
10
+ * inject a fake factory so the concurrency cap, ramp pacing, and
11
+ * aggregation math can be pinned without touching real sockets. The
12
+ * end-to-end smoke (real server, real sockets) lives in
13
+ * `tests/smoke.test.ts` and is the ticket's acceptance criterion.
14
+ */
15
+
16
+ import { IrcLoadClient, type IrcLoadClientOptions } from './client.js';
17
+ import type { LoadTestConfig } from './config.js';
18
+ import { type ClientResult, type LoadTestSummary, summarizeResults } from './metrics.js';
19
+ import type { LoadTestRun } from './report.js';
20
+
21
+ /** Minimal client surface the harness drives (so tests can inject fakes). */
22
+ export interface SyntheticClient {
23
+ /** Runs the full lifecycle; never throws — failures become `ok: false`. */
24
+ run(): Promise<ClientResult>;
25
+ }
26
+
27
+ /** Builds a synthetic client for one connection. */
28
+ export type ClientFactory = (opts: IrcLoadClientOptions) => SyntheticClient;
29
+
30
+ /** Optional harness dependencies (all have production defaults). */
31
+ export interface LoadTestDeps {
32
+ /**
33
+ * Overrides the default {@link IrcLoadClient} factory. Tests inject a
34
+ * fake so the orchestration math can be pinned without real sockets.
35
+ */
36
+ clientFactory?: ClientFactory;
37
+ /** Progress callback fired as each client completes. */
38
+ onProgress?: (done: number, total: number) => void;
39
+ /** Injectable sleep (tests can substitute for deterministic ramp pacing). */
40
+ sleep?: (ms: number) => Promise<void>;
41
+ }
42
+
43
+ /** Default client factory: real {@link IrcLoadClient}s. */
44
+ const defaultFactory: ClientFactory = (opts) => new IrcLoadClient(opts);
45
+
46
+ /** Default sleep helper (real timers). */
47
+ function defaultSleep(ms: number): Promise<void> {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ /**
52
+ * Runs the configured load test and returns the aggregate.
53
+ *
54
+ * Concurrency: at most `config.connectConcurrency` clients are in flight
55
+ * at once. Ramp: when `config.rampMs > 0`, each client's launch is
56
+ * staggered by its share of the ramp window
57
+ * (`floor(rampMs / connections * launchIndex)`), smoothing the
58
+ * connection-rate onto the target.
59
+ */
60
+ export async function runLoadTest(
61
+ config: LoadTestConfig,
62
+ deps?: LoadTestDeps,
63
+ ): Promise<LoadTestRun> {
64
+ const factory = deps?.clientFactory ?? defaultFactory;
65
+ const sleep = deps?.sleep ?? defaultSleep;
66
+ const onProgress = deps?.onProgress;
67
+
68
+ const total = config.connections;
69
+ // Pre-allocated so out-of-order completion lands at the right index.
70
+ const results: ClientResult[] = new Array(total);
71
+ let nextId = 0;
72
+ let launched = 0;
73
+ let completed = 0;
74
+
75
+ const startedAt = new Date();
76
+ const startTime = Date.now();
77
+
78
+ // Cap workers at the requested concurrency, but never above the number
79
+ // of connections (so a `connections: 1, concurrency: 100` run starts
80
+ // exactly one worker rather than 100 idle ones).
81
+ const workerCount = Math.max(1, Math.min(config.connectConcurrency, total));
82
+
83
+ const worker = async (): Promise<void> => {
84
+ for (;;) {
85
+ const id = nextId++;
86
+ if (id >= total) return;
87
+
88
+ // Ramp: stagger this launch by its share of the window. `launched`
89
+ // is a monotonic launch counter (distinct from `id`) so the stagger
90
+ // reflects actual launch order, not the worker that picked it up.
91
+ if (config.rampMs > 0 && total > 1) {
92
+ const launchIndex = launched++;
93
+ const stagger = Math.floor((config.rampMs / total) * launchIndex);
94
+ if (stagger > 0) await sleep(stagger);
95
+ }
96
+
97
+ const client = factory({ id, config });
98
+ const result = await client.run();
99
+ results[id] = result;
100
+ completed++;
101
+ if (onProgress !== undefined) onProgress(completed, total);
102
+ }
103
+ };
104
+
105
+ const workers = Array.from({ length: workerCount }, () => worker());
106
+ await Promise.all(workers);
107
+
108
+ const summary: LoadTestSummary = summarizeResults(results);
109
+ return {
110
+ config,
111
+ summary,
112
+ startedAt,
113
+ finishedAt: new Date(),
114
+ durationMs: Date.now() - startTime,
115
+ };
116
+ }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `tools/load-test` entry point — the synthetic WebSocket IRC client farm
4
+ * (PLAN §6).
5
+ *
6
+ * Boots a load-test run against a `ws://` / `wss://` target: opens N
7
+ * connections, registers (NICK/USER), joins a channel, and optionally
8
+ * chats. Captures p50/p95/p99 latency per phase and the drop rate, then
9
+ * prints a markdown report to stdout (and writes it to `--report <path>`
10
+ * when given).
11
+ *
12
+ * Usage:
13
+ * node --import tsx tools/load-test/src/main.ts \
14
+ * --target wss://irc.staging.example.com/ \
15
+ * --connections 10000 --concurrency 200 --ramp-ms 60000 \
16
+ * --channel '#loadtest' --messages 3 --platform cf-staging \
17
+ * --report reports/cf-staging-2026-08-03.md
18
+ *
19
+ * Or, once built:
20
+ * pnpm --filter load-test start -- --target wss://irc.staging.example.com/ ...
21
+ */
22
+
23
+ import { writeFileSync } from 'node:fs';
24
+ import { HELP_TEXT, parseArgs } from './config.js';
25
+ import { runLoadTest } from './harness.js';
26
+ import { generateReport } from './report.js';
27
+
28
+ async function main(): Promise<void> {
29
+ // Handle -h/--help before parseArgs so the help text is printed and the
30
+ // process exits cleanly without parseArgs having to call process.exit
31
+ // (which would make it untestable).
32
+ if (process.argv.slice(2).some((a) => a === '-h' || a === '--help')) {
33
+ process.stdout.write(`${HELP_TEXT}\n`);
34
+ process.exit(0);
35
+ }
36
+
37
+ const config = parseArgs(process.argv.slice(2));
38
+
39
+ process.stderr.write(
40
+ `${JSON.stringify({
41
+ ts: new Date().toISOString(),
42
+ level: 'info',
43
+ msg: 'load-test starting',
44
+ target: config.target,
45
+ platform: config.platform,
46
+ connections: config.connections,
47
+ concurrency: config.connectConcurrency,
48
+ rampMs: config.rampMs,
49
+ channel: config.channel,
50
+ messagesPerClient: config.messagesPerClient,
51
+ echoMessage: config.echoMessage,
52
+ })}\n`,
53
+ );
54
+
55
+ const lastReport = { value: Date.now() };
56
+ const run = await runLoadTest(config, {
57
+ onProgress: (done, total) => {
58
+ // Throttle progress to one line per second so a 10k run doesn't
59
+ // flood stderr while still showing live progress.
60
+ const now = Date.now();
61
+ if (now - lastReport.value >= 1000 || done === total) {
62
+ lastReport.value = now;
63
+ process.stderr.write(
64
+ `${JSON.stringify({
65
+ ts: new Date().toISOString(),
66
+ level: 'info',
67
+ msg: 'load-test progress',
68
+ done,
69
+ total,
70
+ pct: Number(((done / total) * 100).toFixed(1)),
71
+ })}\n`,
72
+ );
73
+ }
74
+ },
75
+ });
76
+
77
+ const report = generateReport(run);
78
+ process.stdout.write(`${report}\n`);
79
+
80
+ if (config.reportPath !== undefined) {
81
+ writeFileSync(config.reportPath, report, 'utf8');
82
+ process.stderr.write(
83
+ `${JSON.stringify({
84
+ ts: new Date().toISOString(),
85
+ level: 'info',
86
+ msg: 'load-test report written',
87
+ path: config.reportPath,
88
+ })}\n`,
89
+ );
90
+ }
91
+
92
+ process.stderr.write(
93
+ `${JSON.stringify({
94
+ ts: new Date().toISOString(),
95
+ level: 'info',
96
+ msg: 'load-test complete',
97
+ durationMs: run.durationMs,
98
+ succeeded: run.summary.succeeded,
99
+ failed: run.summary.failed,
100
+ dropRate: run.summary.dropRate,
101
+ })}\n`,
102
+ );
103
+
104
+ // A non-zero drop rate is a signal, not a hard failure: the run still
105
+ // "completes without crashing" (the ticket's acceptance). Operators
106
+ // inspect the report. Exit 0 unless a catastrophic error threw above.
107
+ process.exit(0);
108
+ }
109
+
110
+ main().catch((err) => {
111
+ process.stderr.write(
112
+ `${JSON.stringify({
113
+ ts: new Date().toISOString(),
114
+ level: 'error',
115
+ msg: 'load-test failed',
116
+ error: err instanceof Error ? err.message : String(err),
117
+ })}\n`,
118
+ );
119
+ process.exit(1);
120
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Latency statistics for the load-test harness.
3
+ *
4
+ * This module is intentionally pure: it turns raw per-client latency
5
+ * samples into the p50/p95/p99 summary the report renders, with no socket
6
+ * or timer dependencies. Keeping it pure means the math can be pinned
7
+ * independently of any real connection behaviour (PLAN §6).
8
+ *
9
+ * Percentile convention: **nearest-rank**. For `n` sorted samples and a
10
+ * percentile `p ∈ [0,100]`, the rank index is `ceil(p/100 * n) - 1`,
11
+ * clamped to `[0, n-1]`. This is the same method `hd`/`ping`/k6 report;
12
+ * it is conservative for small `n` (p95 of 3 samples returns the max
13
+ * rather than interpolating) which suits "worst-case tail" reporting for
14
+ * an IRC control plane.
15
+ */
16
+
17
+ /** The lifecycle phases a synthetic client measures. */
18
+ export type PhaseName = 'connect' | 'register' | 'join' | 'privmsg';
19
+
20
+ /** All phases, in lifecycle order (used for deterministic report rows). */
21
+ export const PHASE_NAMES: readonly PhaseName[] = [
22
+ 'connect',
23
+ 'register',
24
+ 'join',
25
+ 'privmsg',
26
+ ] as const;
27
+
28
+ /**
29
+ * Aggregated latency for one phase across every client that reached it.
30
+ * All fields are milliseconds. A phase a client never reached contributes
31
+ * no samples (so a client that died at `register` does not inflate the
32
+ * `join` bucket).
33
+ */
34
+ export interface PhaseSummary {
35
+ /** Number of samples recorded for this phase. */
36
+ count: number;
37
+ /** Minimum latency observed (ms). */
38
+ min: number;
39
+ /** Maximum latency observed (ms). */
40
+ max: number;
41
+ /** 50th percentile latency (ms). */
42
+ p50: number;
43
+ /** 95th percentile latency (ms). */
44
+ p95: number;
45
+ /** 99th percentile latency (ms). */
46
+ p99: number;
47
+ }
48
+
49
+ /**
50
+ * The result one synthetic client reports back to the harness. The harness
51
+ * flattens an array of these into a {@link LoadTestSummary}.
52
+ */
53
+ export interface ClientResult {
54
+ /** 0-based client index. */
55
+ id: number;
56
+ /** `true` iff the client reached the end of every measured phase. */
57
+ ok: boolean;
58
+ /** Human-readable failure reason when `ok === false`. */
59
+ error?: string;
60
+ /** The phase at which the client failed (drop attribution). */
61
+ failedPhase?: PhaseName;
62
+ /** Latency samples per phase in ms (may be empty for unreached phases). */
63
+ samples: Record<PhaseName, number[]>;
64
+ }
65
+
66
+ /** Top-level aggregate the report renders. */
67
+ export interface LoadTestSummary {
68
+ /** Total clients attempted (connections the harness tried to open). */
69
+ totalClients: number;
70
+ /** Clients that completed every measured phase. */
71
+ succeeded: number;
72
+ /** Clients that failed at some phase. */
73
+ failed: number;
74
+ /** Fraction of clients that failed: `failed / total` (0 when total === 0). */
75
+ dropRate: number;
76
+ /** Per-phase latency summary. Phases no client reached are zeroed. */
77
+ phases: Record<PhaseName, PhaseSummary>;
78
+ }
79
+
80
+ /**
81
+ * Returns the `p`-th percentile of `samples` using the nearest-rank method.
82
+ *
83
+ * - `p` must be in `[0, 100]`; outside that range throws (programming error).
84
+ * - An empty sample set returns `0` (so a phase nobody reached renders as
85
+ * `0ms` rather than `NaN`).
86
+ *
87
+ * The input is not assumed to be sorted; a defensive copy is sorted
88
+ * in place on that copy so callers' arrays are left untouched.
89
+ */
90
+ export function percentile(samples: readonly number[], p: number): number {
91
+ if (p < 0 || p > 100) {
92
+ throw new RangeError(`percentile must be in [0, 100] (got ${p})`);
93
+ }
94
+ if (samples.length === 0) return 0;
95
+ const sorted = [...samples].sort((a, b) => a - b);
96
+ // nearest-rank: rank = ceil(p/100 * n), index = rank - 1, clamped.
97
+ const rank = Math.ceil((p / 100) * sorted.length);
98
+ const index = Math.max(0, Math.min(sorted.length - 1, rank - 1));
99
+ return sorted[index] ?? 0;
100
+ }
101
+
102
+ /**
103
+ * Summarises one phase's sample set into a {@link PhaseSummary}. An empty
104
+ * sample set yields a zeroed summary (count 0, every percentile 0) so the
105
+ * report renders `—`/`0` cleanly instead of `NaN`.
106
+ */
107
+ export function summarizePhase(samples: readonly number[]): PhaseSummary {
108
+ if (samples.length === 0) {
109
+ return { count: 0, min: 0, max: 0, p50: 0, p95: 0, p99: 0 };
110
+ }
111
+ let min = Number.POSITIVE_INFINITY;
112
+ let max = Number.NEGATIVE_INFINITY;
113
+ for (const v of samples) {
114
+ if (v < min) min = v;
115
+ if (v > max) max = v;
116
+ }
117
+ return {
118
+ count: samples.length,
119
+ min,
120
+ max,
121
+ p50: percentile(samples, 50),
122
+ p95: percentile(samples, 95),
123
+ p99: percentile(samples, 99),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Flattens per-client results into a {@link LoadTestSummary}.
129
+ *
130
+ * - `totalClients` / `succeeded` / `failed` come from the `ok` flag.
131
+ * - `dropRate = failed / total` (0 when no clients were attempted).
132
+ * - Each phase aggregates every sample across all clients that recorded
133
+ * one. A client that failed before a phase contributes no sample to
134
+ * that phase (drop attribution: a `register` timeout does not pollute
135
+ * the `join` bucket).
136
+ */
137
+ export function summarizeResults(results: readonly ClientResult[]): LoadTestSummary {
138
+ const totalClients = results.length;
139
+ let succeeded = 0;
140
+ const buckets: Record<PhaseName, number[]> = {
141
+ connect: [],
142
+ register: [],
143
+ join: [],
144
+ privmsg: [],
145
+ };
146
+ for (const r of results) {
147
+ if (r.ok) succeeded++;
148
+ for (const phase of PHASE_NAMES) {
149
+ const samples = r.samples[phase];
150
+ if (samples !== undefined && samples.length > 0) {
151
+ buckets[phase].push(...samples);
152
+ }
153
+ }
154
+ }
155
+ const failed = totalClients - succeeded;
156
+ return {
157
+ totalClients,
158
+ succeeded,
159
+ failed,
160
+ dropRate: totalClients === 0 ? 0 : failed / totalClients,
161
+ phases: {
162
+ connect: summarizePhase(buckets.connect),
163
+ register: summarizePhase(buckets.register),
164
+ join: summarizePhase(buckets.join),
165
+ privmsg: summarizePhase(buckets.privmsg),
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Markdown report rendering for a load-test run (PLAN §6).
3
+ *
4
+ * Pure function over a {@link LoadTestRun}: no I/O, no sockets. The CLI
5
+ * (`main.ts`) writes the returned string to stdout (and optionally to
6
+ * `--report <path>`). Keeping it pure lets the structural pieces a human
7
+ * scans for (platform, target, drop rate, the per-phase latency table) be
8
+ * pinned independently of any real connection behaviour.
9
+ */
10
+
11
+ import type { LoadTestConfig } from './config.js';
12
+ import { type LoadTestSummary, PHASE_NAMES, type PhaseName, type PhaseSummary } from './metrics.js';
13
+
14
+ /** Everything needed to render a report: config + aggregate + timing. */
15
+ export interface LoadTestRun {
16
+ config: LoadTestConfig;
17
+ summary: LoadTestSummary;
18
+ /** When the run started (UTC). */
19
+ startedAt: Date;
20
+ /** When the run finished (UTC). */
21
+ finishedAt: Date;
22
+ /** Wall-clock duration of the run (ms). */
23
+ durationMs: number;
24
+ }
25
+
26
+ /**
27
+ * Formats a millisecond duration into a compact human-readable string:
28
+ * `<1s → "X.XXms"`, `<60s → "X.XXs"`, otherwise `"XmYY.YYs"`.
29
+ */
30
+ export function formatMs(ms: number): string {
31
+ if (ms < 1000) return `${ms.toFixed(2)}ms`;
32
+ if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`;
33
+ const totalSec = ms / 1000;
34
+ const minutes = Math.floor(totalSec / 60);
35
+ const seconds = totalSec - minutes * 60;
36
+ return `${minutes}m${seconds.toFixed(2)}s`;
37
+ }
38
+
39
+ /** Formats a `[0,1]` fraction as a percentage with two decimals. */
40
+ export function formatPercent(fraction: number): string {
41
+ return `${(fraction * 100).toFixed(2)}%`;
42
+ }
43
+
44
+ /**
45
+ * Renders the run as a markdown document. Layout:
46
+ *
47
+ * # ServerlessIRCd Load Test Report
48
+ * - header (platform, target, date, duration)
49
+ * - ## Configuration table
50
+ * - ## Results table (attempted / succeeded / failed / drop rate)
51
+ * - ## Latency (ms) table — one row per phase
52
+ * - ## Notes — cost modeling is platform-specific
53
+ */
54
+ export function generateReport(run: LoadTestRun): string {
55
+ const { config, summary, startedAt, durationMs } = run;
56
+ const lines: string[] = [];
57
+ lines.push('# ServerlessIRCd Load Test Report', '');
58
+ lines.push(`- **Platform:** ${config.platform}`);
59
+ lines.push(`- **Target:** ${config.target}`);
60
+ lines.push(`- **Date:** ${startedAt.toISOString()}`);
61
+ lines.push(`- **Duration:** ${formatMs(durationMs)}`, '');
62
+
63
+ lines.push('## Configuration', '');
64
+ lines.push('| Setting | Value |');
65
+ lines.push('|---|---|');
66
+ lines.push(`| Connections | ${config.connections} |`);
67
+ lines.push(`| Concurrency | ${config.connectConcurrency} |`);
68
+ lines.push(`| Ramp | ${formatMs(config.rampMs)} |`);
69
+ lines.push(`| Channel | ${config.channel} |`);
70
+ lines.push(`| Messages/client | ${config.messagesPerClient} |`);
71
+ lines.push(`| Echo-message | ${config.echoMessage ? 'on' : 'off'} |`);
72
+ lines.push('');
73
+
74
+ lines.push('## Results', '');
75
+ lines.push('| Metric | Value |');
76
+ lines.push('|---|---|');
77
+ lines.push(`| Attempted | ${summary.totalClients} |`);
78
+ lines.push(`| Succeeded | ${summary.succeeded} |`);
79
+ lines.push(`| Failed | ${summary.failed} |`);
80
+ lines.push(`| Drop rate | ${formatPercent(summary.dropRate)} |`);
81
+ lines.push('');
82
+
83
+ lines.push('## Latency (ms)', '');
84
+ lines.push('| Phase | Count | Min | p50 | p95 | p99 | Max |');
85
+ lines.push('|---|---:|---:|---:|---:|---:|---:|');
86
+ for (const phase of PHASE_NAMES) {
87
+ lines.push(renderPhaseRow(phase, summary.phases[phase]));
88
+ }
89
+ lines.push('');
90
+
91
+ lines.push('## Notes', '');
92
+ lines.push(
93
+ 'Cost modeling is platform-specific (Cloudflare Workers vs AWS API Gateway ' +
94
+ 'WebSocket pricing) and is not computed here; operators should multiply the ' +
95
+ "connection-minutes and message counts above by the deployed platform's unit " +
96
+ 'price and record the result alongside this report.',
97
+ );
98
+ lines.push('');
99
+ return lines.join('\n');
100
+ }
101
+
102
+ /** Renders one latency-table row, right-aligning the numeric columns. */
103
+ function renderPhaseRow(phase: PhaseName, s: PhaseSummary): string {
104
+ const f = (n: number): string => n.toFixed(2);
105
+ return `| ${phase} | ${s.count} | ${f(s.min)} | ${f(s.p50)} | ${f(s.p95)} | ${f(s.p99)} | ${f(s.max)} |`;
106
+ }