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,327 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `MONITOR` command.
3
+ *
4
+ * IRCv3 MONITOR spec <https://ircv3.net/specs/extensions/monitor-3.2.html>:
5
+ *
6
+ * - `MONITOR + <nick>{,<nick>}` — add targets to the watchlist.
7
+ * - `MONITOR - <nick>{,<nick>}` — remove targets from the watchlist.
8
+ * - `MONITOR C` — clear the watchlist.
9
+ * - `MONITOR L` — list the watchlist (`732` per line +
10
+ * `733` terminator).
11
+ * - `MONITOR S` — status: `730 RPL_MONONLINE` for the
12
+ * online subset, `731 RPL_MONOFFLINE`
13
+ * for the rest.
14
+ *
15
+ * Numerics:
16
+ * - `730 RPL_MONONLINE` `:<server> 730 <nick> :<online>{,<online>}`
17
+ * - `731 RPL_MONOFFLINE` `:<server> 731 <nick> :<offline>{,<offline>}`
18
+ * - `732 RPL_MONLIST` `:<server> 732 <nick> :<entry>`
19
+ * - `733 RPL_ENDOFMONLIST` `:<server> 733 <nick> :End of MONITOR list`
20
+ * - `734 ERR_MONLISTFULL` `:<server> 734 <nick> <limit> :<rejected>{,<rejected>}`
21
+ *
22
+ * The watchlist lives on {@link ConnectionState.monitorList} (folded nicks,
23
+ * case-insensitive membership). The reducer resolves each requested nick
24
+ * against the supplied `online` map (folded-keyed → display spelling); the
25
+ * actor builds that map from the nick registry before invoking the reducer,
26
+ * mirroring the {@link isonReducer} pattern.
27
+ *
28
+ * Asynchronous `730`/`731` pushes on online/offline transitions are emitted
29
+ * by the actor layer (it watches `ReserveNick` / `ReleaseNick` effects
30
+ * against the watchlists); the pure reducer only handles the request/reply
31
+ * subcommands above.
32
+ */
33
+
34
+ import { Effect } from '../effects.js';
35
+ import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { Numerics } from '../protocol/numerics.js';
37
+ import type { ConnectionState } from '../state/connection.js';
38
+ import type { Ctx, ReducerResult } from '../types.js';
39
+
40
+ /** Wire name of the IRCv3 MONITOR capability. */
41
+ export const MONITOR_CAP = 'monitor';
42
+
43
+ /** Default monitor cap (max watchlist size) advertised via `MONITOR=<n>`. */
44
+ export const DEFAULT_MONITOR_LIMIT = 30;
45
+
46
+ /**
47
+ * Handles the IRCv3 `MONITOR` subcommands.
48
+ *
49
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
50
+ * the requested nicks asynchronously before formatting — mirrors
51
+ * {@link isonReducer} / {@link whoisReducer}.
52
+ *
53
+ * @param online Folded-keyed → display-spelling map of currently
54
+ * online nicks (built by the actor from the nick
55
+ * registry).
56
+ * @param maxMonitor Server-wide cap on the per-connection watchlist size
57
+ * (advertised via `MONITOR=<n>` in `005`).
58
+ * @param msg Parsed `MONITOR <sub> [<args>]` message.
59
+ * @param ctx Per-invocation context.
60
+ */
61
+ export function monitorReducer(
62
+ online: ReadonlyMap<string, string>,
63
+ maxMonitor: number,
64
+ msg: { params: readonly string[] },
65
+ ctx: Ctx,
66
+ ): ReducerResult<ConnectionState> {
67
+ ctx.connection.lastSeen = ctx.clock.now();
68
+
69
+ const state = ctx.connection;
70
+ const nick = state.nick ?? '*';
71
+ const serverName = ctx.serverName;
72
+
73
+ const sub = (msg.params[0] ?? '').toUpperCase();
74
+
75
+ // No subcommand at all → 461 NEEDMOREPARAMS (the parser hands us an
76
+ // empty string when the sender wrote bare `MONITOR`). A non-empty but
77
+ // unknown subcommand (e.g. `MONITOR Z`) is also 461 with a different
78
+ // trailing string per the IRCv3 MONITOR dispatch table.
79
+ if (sub.length === 0) {
80
+ return {
81
+ state,
82
+ effects: [
83
+ Effect.send(ctx.connId, [
84
+ numericLine(
85
+ serverName,
86
+ Numerics.ERR_NEEDMOREPARAMS,
87
+ nick,
88
+ 'MONITOR',
89
+ 'Not enough parameters',
90
+ ),
91
+ ]),
92
+ ],
93
+ };
94
+ }
95
+
96
+ switch (sub) {
97
+ case '+':
98
+ return handleAdd(state, online, maxMonitor, msg.params[1] ?? '', nick, serverName);
99
+ case '-':
100
+ return handleRemove(state, msg.params[1] ?? '');
101
+ case 'C':
102
+ return handleClear(state);
103
+ case 'L':
104
+ return handleList(state, nick, serverName);
105
+ case 'S':
106
+ return handleStatus(state, online, nick, serverName);
107
+ default:
108
+ return {
109
+ state,
110
+ effects: [
111
+ Effect.send(ctx.connId, [
112
+ numericLine(
113
+ serverName,
114
+ Numerics.ERR_NEEDMOREPARAMS,
115
+ nick,
116
+ 'MONITOR',
117
+ 'Unknown command',
118
+ ),
119
+ ]),
120
+ ],
121
+ };
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Subcommand handlers
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function handleAdd(
130
+ state: ConnectionState,
131
+ online: ReadonlyMap<string, string>,
132
+ maxMonitor: number,
133
+ targetList: string,
134
+ nick: string,
135
+ serverName: string,
136
+ ): ReducerResult<ConnectionState> {
137
+ if (targetList.length === 0) {
138
+ return {
139
+ state,
140
+ effects: [
141
+ Effect.send(state.id, [
142
+ numericLine(
143
+ serverName,
144
+ Numerics.ERR_NEEDMOREPARAMS,
145
+ nick,
146
+ 'MONITOR',
147
+ 'Not enough parameters',
148
+ ),
149
+ ]),
150
+ ],
151
+ };
152
+ }
153
+
154
+ const watchlist = ensureWatchlist(state);
155
+
156
+ const added: { folded: string; display: string }[] = [];
157
+ const rejected: string[] = [];
158
+ for (const requested of splitNicks(targetList)) {
159
+ const folded = requested.toLowerCase();
160
+ if (watchlist.has(folded)) continue; // already monitored; no output
161
+ if (watchlist.size >= maxMonitor) {
162
+ rejected.push(requested);
163
+ continue;
164
+ }
165
+ watchlist.add(folded);
166
+ // For the display spelling prefer the online map (canonical) and fall
167
+ // back to the requester spelling when the nick is offline.
168
+ const display = online.get(folded) ?? requested;
169
+ added.push({ folded, display });
170
+ }
171
+
172
+ const effects: EffectType[] = [];
173
+
174
+ const onlineDisplays = added.filter((a) => online.has(a.folded)).map((a) => a.display);
175
+ const offlineDisplays = added.filter((a) => !online.has(a.folded)).map((a) => a.display);
176
+
177
+ if (onlineDisplays.length > 0) {
178
+ effects.push(
179
+ Effect.send(state.id, [
180
+ monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
181
+ ]),
182
+ );
183
+ }
184
+ if (offlineDisplays.length > 0) {
185
+ effects.push(
186
+ Effect.send(state.id, [
187
+ monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
188
+ ]),
189
+ );
190
+ }
191
+ if (rejected.length > 0) {
192
+ effects.push(
193
+ Effect.send(state.id, [monListFullLine(serverName, nick, maxMonitor, rejected.join(','))]),
194
+ );
195
+ }
196
+
197
+ return { state, effects };
198
+ }
199
+
200
+ function handleRemove(state: ConnectionState, targetList: string): ReducerResult<ConnectionState> {
201
+ if (state.monitorList === undefined) return { state, effects: [] };
202
+ if (targetList.length === 0) return { state, effects: [] };
203
+ for (const requested of splitNicks(targetList)) {
204
+ state.monitorList.delete(requested.toLowerCase());
205
+ }
206
+ return { state, effects: [] };
207
+ }
208
+
209
+ function handleClear(state: ConnectionState): ReducerResult<ConnectionState> {
210
+ // Allocate the set on first clear so callers observe a stable post-state
211
+ // (size 0) regardless of whether the watchlist previously existed.
212
+ if (state.monitorList === undefined) {
213
+ state.monitorList = new Set<string>();
214
+ } else {
215
+ state.monitorList.clear();
216
+ }
217
+ return { state, effects: [] };
218
+ }
219
+
220
+ function handleList(
221
+ state: ConnectionState,
222
+ nick: string,
223
+ serverName: string,
224
+ ): ReducerResult<ConnectionState> {
225
+ const effects: EffectType[] = [];
226
+ if (state.monitorList !== undefined) {
227
+ for (const entry of state.monitorList) {
228
+ effects.push(Effect.send(state.id, [monLine(Numerics.RPL_MONLIST, serverName, nick, entry)]));
229
+ }
230
+ }
231
+ effects.push(
232
+ Effect.send(state.id, [
233
+ monLine(Numerics.RPL_ENDOFMONLIST, serverName, nick, 'End of MONITOR list'),
234
+ ]),
235
+ );
236
+ return { state, effects };
237
+ }
238
+
239
+ function handleStatus(
240
+ state: ConnectionState,
241
+ online: ReadonlyMap<string, string>,
242
+ nick: string,
243
+ serverName: string,
244
+ ): ReducerResult<ConnectionState> {
245
+ if (state.monitorList === undefined || state.monitorList.size === 0) {
246
+ return { state, effects: [] };
247
+ }
248
+ const onlineDisplays: string[] = [];
249
+ const offlineDisplays: string[] = [];
250
+ for (const folded of state.monitorList) {
251
+ const display = online.get(folded);
252
+ if (display !== undefined) {
253
+ onlineDisplays.push(display);
254
+ } else {
255
+ offlineDisplays.push(folded);
256
+ }
257
+ }
258
+ const effects: EffectType[] = [];
259
+ if (onlineDisplays.length > 0) {
260
+ effects.push(
261
+ Effect.send(state.id, [
262
+ monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
263
+ ]),
264
+ );
265
+ }
266
+ if (offlineDisplays.length > 0) {
267
+ effects.push(
268
+ Effect.send(state.id, [
269
+ monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
270
+ ]),
271
+ );
272
+ }
273
+ return { state, effects };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Helpers
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Returns the connection's watchlist, lazily allocating it on first use.
282
+ * Existing persisted state migrates without a schema bump (the field stays
283
+ * optional; reducers that pre-date MONITOR never touch it).
284
+ */
285
+ function ensureWatchlist(state: ConnectionState): Set<string> {
286
+ if (state.monitorList === undefined) {
287
+ state.monitorList = new Set<string>();
288
+ }
289
+ return state.monitorList;
290
+ }
291
+
292
+ /** Splits a comma-separated nick list, dropping empty entries. */
293
+ function splitNicks(csv: string): string[] {
294
+ return csv
295
+ .split(',')
296
+ .map((s) => s.trim())
297
+ .filter((s) => s.length > 0);
298
+ }
299
+
300
+ /** `:<server> <code> <nick> :<trailing>` — used by 730/731/732/733. */
301
+ function monLine(code: number, serverName: string, nick: string, trailing: string): RawLine {
302
+ const codeStr = code.toString().padStart(3, '0');
303
+ return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
304
+ }
305
+
306
+ /** `:<server> 734 <nick> <limit> :<rejected>` (ERR_MONLISTFULL). */
307
+ function monListFullLine(
308
+ serverName: string,
309
+ nick: string,
310
+ limit: number,
311
+ rejected: string,
312
+ ): RawLine {
313
+ const codeStr = Numerics.ERR_MONLISTFULL.toString().padStart(3, '0');
314
+ return { text: `:${serverName} ${codeStr} ${nick} ${limit} :${rejected}` };
315
+ }
316
+
317
+ /** `:<server> <code> <nick> <cmd> :<trailing>` — used by 461-style errors. */
318
+ function numericLine(
319
+ serverName: string,
320
+ code: number,
321
+ nick: string,
322
+ cmd: string,
323
+ trailing: string,
324
+ ): RawLine {
325
+ const codeStr = code.toString().padStart(3, '0');
326
+ return { text: `:${serverName} ${codeStr} ${nick} ${cmd} :${trailing}` };
327
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Pure reducer for IRCv3 `draft/multiline` (multi-line `BATCH`).
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/multiline
5
+ *
6
+ * A cap-enabled client opens a multi-line batch with
7
+ * `BATCH +id draft/multiline :<target>`
8
+ * followed by N `PRIVMSG`/`NOTICE` lines addressed to the same target, then
9
+ * closes it with `BATCH -id`. The server concatenates the per-line trailing
10
+ * parameters with `\n`, enforces the advertised byte budget, and fans the
11
+ * result out:
12
+ *
13
+ * - to peers that also negotiated `draft/multiline`: a replayed
14
+ * `BATCH +newid draft/multiline :<target>` containing the original
15
+ * per-line `PRIVMSG`/`NOTICE` messages;
16
+ * - to everyone else: a single joined `PRIVMSG`/`NOTICE` carrying the
17
+ * `+draft/multiline` client tag so message-tags-aware (but not
18
+ * multiline-aware) clients can recognise the joined form.
19
+ *
20
+ * This module owns the pure finalization step (concatenation, byte-budget
21
+ * enforcement, fanout construction). The stateful accumulation of the inner
22
+ * lines — buffering `PRIVMSG`/`NOTICE` between the `BATCH +` and `BATCH -`
23
+ * markers — lives in the actor layer, which calls
24
+ * {@link finalizeMultilineBatch} once the batch closes.
25
+ */
26
+
27
+ import {
28
+ DEFAULT_MULTILINE_MAX_BYTES,
29
+ MULTILINE_CAP_NAME,
30
+ multilineCapValue,
31
+ } from '../caps/capabilities.js';
32
+ import { Effect } from '../effects.js';
33
+ import type { Effect as EffectType, RawLine } from '../effects.js';
34
+ import { hostmaskOf } from '../state/connection.js';
35
+ import type { Ctx } from '../types.js';
36
+
37
+ /**
38
+ * Wire name of the IRCv3 `draft/multiline` capability. Aliased from
39
+ * {@link MULTILINE_CAP_NAME} (the canonical home in the cap data table) for
40
+ * ergonomic use inside the reducer.
41
+ */
42
+ export const MULTILINE_CAP = MULTILINE_CAP_NAME;
43
+
44
+ export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
45
+
46
+ /**
47
+ * One accumulated inner line of a multi-line batch. The actor collects these
48
+ * as the client streams `PRIVMSG`/`NOTICE` lines between the batch markers,
49
+ * then hands the list to {@link finalizeMultilineBatch}. Only the trailing
50
+ * parameter (message text) is stored per line: the spec requires every inner
51
+ * line of a batch to share both the command and the target, so those live on
52
+ * the batch as a whole and are passed to the reducer separately.
53
+ */
54
+ export interface MultilineEntry {
55
+ /** The trailing parameter (message text) of the inner line. */
56
+ readonly text: string;
57
+ }
58
+
59
+ /** Result of {@link finalizeMultilineBatch}: either accepted or rejected. */
60
+ export type FinalizeMultilineResult =
61
+ | { readonly ok: true; readonly effects: EffectType[] }
62
+ | { readonly ok: false; readonly effects: EffectType[] };
63
+
64
+ /**
65
+ * Concatenates the per-line texts of a multi-line batch with `\n`, per the
66
+ * IRCv3 spec. The joined value is what legacy (non-multiline) peers receive
67
+ * as the trailing parameter of the single relayed PRIVMSG/NOTICE.
68
+ */
69
+ export function joinMultiline(entries: readonly MultilineEntry[]): string {
70
+ return entries.map((e) => e.text).join('\n');
71
+ }
72
+
73
+ /**
74
+ * Returns the byte length of the joined multi-line content as the byte budget
75
+ * sees it: the sum of every entry's UTF-8 byte length plus one byte per `\n`
76
+ * separator. This is the quantity compared against `draft/multiline=<max>`.
77
+ */
78
+ export function multilineByteLength(entries: readonly MultilineEntry[]): number {
79
+ if (entries.length === 0) return 0;
80
+ let total = 0;
81
+ for (const entry of entries) {
82
+ total += utf8ByteLength(entry.text);
83
+ }
84
+ // `(n - 1)` separators join `n` entries.
85
+ total += entries.length - 1;
86
+ return total;
87
+ }
88
+
89
+ /** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
90
+ const TEXT_ENCODER = new TextEncoder();
91
+
92
+ /** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
93
+ function utf8ByteLength(s: string): number {
94
+ return TEXT_ENCODER.encode(s).length;
95
+ }
96
+
97
+ /** Resolves the sender's hostmask, falling back to nick then `?`. */
98
+ function senderHostmask(conn: Ctx['connection']): string {
99
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
100
+ }
101
+
102
+ /**
103
+ * Pre-built wire lines for a finalized multi-line batch, independent of the
104
+ * delivery mechanism (channel `Broadcast` vs. nick-target `Send`). The caller
105
+ * picks the appropriate variant per recipient based on the recipient's caps:
106
+ *
107
+ * - `draft/multiline` + `batch` → `batchedReplay` (replayed BATCH frame).
108
+ * - `message-tags` only → `taggedJoined` (joined PRIVMSG with
109
+ * `+draft/multiline` client tag).
110
+ * - neither → `bareJoined` (joined PRIVMSG, no tag).
111
+ */
112
+ export interface MultilineFanout {
113
+ /** Replayed `BATCH +id draft/multiline <target>` … `BATCH -id`. */
114
+ readonly batchedReplay: RawLine[];
115
+ /** Joined PRIVMSG/NOTICE carrying the `+draft/multiline` client tag. */
116
+ readonly taggedJoined: RawLine;
117
+ /** Joined PRIVMSG/NOTICE with no tag prefix (for legacy peers). */
118
+ readonly bareJoined: RawLine;
119
+ /** The `\n`-joined trailing parameter (for chathistory recording). */
120
+ readonly joined: string;
121
+ }
122
+
123
+ /**
124
+ * Builds the wire-line variants for a finalized multi-line batch. Pure: no
125
+ * context, no effects — just string construction. Shared by the channel
126
+ * broadcast path ({@link finalizeMultilineBatch}) and the actor-side nick
127
+ * target path so both emit identical wire forms.
128
+ *
129
+ * @param target The batch target (channel name or nick).
130
+ * @param command The inner-line command (`PRIVMSG` or `NOTICE`).
131
+ * @param entries The accumulated inner lines in arrival order.
132
+ * @param hostmask The sender's `nick!user@host` (or fallback).
133
+ * @param batchId A fresh batch reference id for the replayed BATCH frame.
134
+ */
135
+ export function buildMultilineFanout(
136
+ target: string,
137
+ command: 'PRIVMSG' | 'NOTICE',
138
+ entries: readonly MultilineEntry[],
139
+ hostmask: string,
140
+ batchId: string,
141
+ ): MultilineFanout {
142
+ const joined = joinMultiline(entries);
143
+
144
+ const taggedJoined: RawLine = {
145
+ text: `@+draft/multiline :${hostmask} ${command} ${target} :${joined}`,
146
+ };
147
+ const bareJoined: RawLine = {
148
+ text: `:${hostmask} ${command} ${target} :${joined}`,
149
+ };
150
+
151
+ const inner: RawLine[] = entries.map(
152
+ (e) => ({ text: `:${hostmask} ${command} ${target} :${e.text}` }) as RawLine,
153
+ );
154
+ const batchedReplay: RawLine[] = [
155
+ { text: `BATCH +${batchId} draft/multiline ${target}` },
156
+ ...inner,
157
+ { text: `BATCH -${batchId}` },
158
+ ];
159
+
160
+ return { batchedReplay, taggedJoined, bareJoined, joined };
161
+ }
162
+
163
+ /**
164
+ * Finalizes a closed multi-line batch: enforces the byte budget and builds
165
+ * the fanout effects for both multiline-capable and legacy peers.
166
+ *
167
+ * On success the joined body is also recorded into the bound
168
+ * {@link MessageStore} (if any) as a single entry carrying the
169
+ * `+draft/multiline` client tag, so `CHATHISTORY` playback returns exactly
170
+ * one entry per batch.
171
+ *
172
+ * @param target The batch's relay target (channel name). The actor
173
+ * validates that every inner line addressed this same target
174
+ * before calling.
175
+ * @param command The inner-line command (`PRIVMSG` or `NOTICE`); the actor
176
+ * guarantees every inner line used the same one.
177
+ * @param entries The accumulated inner lines in arrival order.
178
+ * @param maxBytes The advertised byte budget (`draft/multiline=<n>`).
179
+ * @param ctx Per-invocation context.
180
+ * @returns `{ ok: true, effects }` on success; `{ ok: false, effects }`
181
+ * when the joined content exceeds `maxBytes` (the effects
182
+ * carry the `FAIL` rejection back to the sender).
183
+ */
184
+ export function finalizeMultilineBatch(
185
+ target: string,
186
+ command: 'PRIVMSG' | 'NOTICE',
187
+ entries: readonly MultilineEntry[],
188
+ maxBytes: number,
189
+ ctx: Ctx,
190
+ ): FinalizeMultilineResult {
191
+ if (entries.length === 0) {
192
+ return { ok: true, effects: [] };
193
+ }
194
+
195
+ const bytes = multilineByteLength(entries);
196
+ if (bytes > maxBytes) {
197
+ return {
198
+ ok: false,
199
+ effects: [
200
+ Effect.send(ctx.connId, [
201
+ {
202
+ text: `:${ctx.serverName} FAIL BATCH MULTILINE_MAX_BYTES ${target} :Multi-line batch exceeds the ${maxBytes} byte limit`,
203
+ },
204
+ ]),
205
+ ],
206
+ };
207
+ }
208
+
209
+ const hostmask = senderHostmask(ctx.connection);
210
+ const batchId = ctx.ids.batchId();
211
+ const fanout = buildMultilineFanout(target, command, entries, hostmask, batchId);
212
+ const msgid = ctx.ids.nonce();
213
+ recordMultilineMessage(ctx, target, command, fanout.joined, msgid);
214
+
215
+ const effects: EffectType[] = [
216
+ Effect.broadcast(
217
+ target,
218
+ [fanout.taggedJoined],
219
+ ctx.connId,
220
+ MULTILINE_CAP,
221
+ fanout.batchedReplay,
222
+ ),
223
+ ];
224
+
225
+ return { ok: true, effects };
226
+ }
227
+
228
+ /**
229
+ * Records one finalized multi-line batch into the bound {@link MessageStore}
230
+ * (when present) as a single chathistory entry. The joined body is stored
231
+ * verbatim, the assigned `msgid` is the chathistory pivot, and the
232
+ * `+draft/multiline` client tag is attached so the chathistory replay can
233
+ * mark the line as originating from a multi-line batch. Shared by the
234
+ * channel broadcast path ({@link finalizeMultilineBatch}) and the actor-side
235
+ * nick-target path.
236
+ */
237
+ export function recordMultilineMessage(
238
+ ctx: Ctx,
239
+ target: string,
240
+ command: 'PRIVMSG' | 'NOTICE',
241
+ joined: string,
242
+ msgid: string,
243
+ ): void {
244
+ if (ctx.messages === undefined) return;
245
+ ctx.messages.record({
246
+ msgid,
247
+ time: ctx.clock.now(),
248
+ chan: target.toLowerCase(),
249
+ command,
250
+ nick: (ctx.connection.nick as string) ?? '?',
251
+ user: ctx.connection.user as string,
252
+ host: ctx.connection.host as string,
253
+ text: joined,
254
+ clientTags: ['+draft/multiline'],
255
+ });
256
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pure helpers for IRCv3 `draft/pre-away` (persistent away status).
3
+ *
4
+ * Spec (work-in-progress): https://github.com/ircv3/ircv3-specifications/pull/415
5
+ *
6
+ * Flow:
7
+ * - The {@link awayReducer} sets/clears {@link ConnectionState.away} as
8
+ * usual. When an {@link AwayStore} is bound AND the connection is
9
+ * identified (has an `account`), the change is mirrored into the
10
+ * per-`account` record via {@link persistAway} (on set) or
11
+ * {@link clearPersistedAway} (on unset).
12
+ * - On SASL identify, the actor calls {@link replayPersistedAway} so a
13
+ * fresh connection's `away` is restored from the persisted reason and
14
+ * `306 RPL_NOWAWAY` is emitted as if the client had sent
15
+ * `AWAY :reason` themselves.
16
+ *
17
+ * Mirrors the {@link ReadMarkerStore} plumbing from the read-marker cap.
18
+ */
19
+
20
+ import { PRE_AWAY_CAP_NAME } from '../caps/capabilities.js';
21
+ import type { AwayStore } from '../ports.js';
22
+ import { Numerics } from '../protocol/numerics.js';
23
+ import type { ConnectionState } from '../state/connection.js';
24
+ import type { Ctx } from '../types.js';
25
+
26
+ /**
27
+ * Wire name of the IRCv3 `draft/pre-away` capability. Aliased from
28
+ * {@link PRE_AWAY_CAP_NAME} (the canonical home in the cap data table)
29
+ * for ergonomic use inside the reducer.
30
+ */
31
+ export const PRE_AWAY_CAP = PRE_AWAY_CAP_NAME;
32
+
33
+ /**
34
+ * Records the away reason for the connection's account and stamps the
35
+ * in-memory {@link ConnectionState.away} so the current session tracks
36
+ * the new reason.
37
+ *
38
+ * Mirrors {@link persistReadMarker}: the store write is gated on both an
39
+ * {@link AwayStore} being bound AND the connection being identified.
40
+ * Unidentified connections still get the in-memory write so the session's
41
+ * own view (numerics, WHO/WHOIS) stays consistent.
42
+ */
43
+ export function persistAway(ctx: Ctx, reason: string): void {
44
+ ctx.connection.away = reason;
45
+ const store = ctx.away;
46
+ const account = ctx.connection.account;
47
+ if (store !== undefined && account !== undefined) {
48
+ store.set(account, reason);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Clears the persisted away reason for the connection's account and the
54
+ * in-memory {@link ConnectionState.away}.
55
+ *
56
+ * Mirrors {@link persistAway}'s gating: a store write (delete) is gated
57
+ * on both a store being bound AND the connection being identified. The
58
+ * in-memory clear is unconditional so the session's own view stays
59
+ * consistent regardless of identification.
60
+ */
61
+ export function clearPersistedAway(ctx: Ctx): void {
62
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
63
+ delete ctx.connection.away;
64
+ const store = ctx.away;
65
+ const account = ctx.connection.account;
66
+ if (store !== undefined && account !== undefined) {
67
+ store.clear(account);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Restores a fresh connection's {@link ConnectionState.away} from the
73
+ * persisted reason for `account`. Called at SASL identify so a reconnect
74
+ * resumes the user's away state.
75
+ *
76
+ * Existing in-memory `away` is preserved: a session may have already set
77
+ * a different reason, and the session's own value wins. Returns the
78
+ * persisted reason (or `undefined`) so the caller can emit the
79
+ * `306 RPL_NOWAWAY` numeric only when the replay actually applied a value.
80
+ */
81
+ export function replayPersistedAway(
82
+ conn: ConnectionState,
83
+ store: AwayStore,
84
+ account: string,
85
+ ): string | undefined {
86
+ if (conn.away !== undefined) return undefined;
87
+ const reason = store.get(account);
88
+ if (reason === undefined) return undefined;
89
+ conn.away = reason;
90
+ return reason;
91
+ }
92
+
93
+ /**
94
+ * Builds the `306 RPL_NOWAWAY` line emitted after a successful away
95
+ * replay at identify time. Mirrors the line the {@link awayReducer}
96
+ * emits when a client sets `AWAY :reason` themselves, so the
97
+ * `draft/pre-away` replay is wire-identical to a direct AWAY set as far
98
+ * as the identified user is concerned.
99
+ *
100
+ * The companion `away-notify` broadcast (`:<hostmask> AWAY :<reason>` to
101
+ * cap-enabled peers in shared channels) is intentionally NOT emitted
102
+ * here: at SASL identify time the connection has not yet joined any
103
+ * channels, so the broadcast would have no audience. Peers learn the
104
+ * away state through the normal `AWAY` / `WHO` / `WHOIS` queries once
105
+ * the user joins.
106
+ */
107
+ export function nowAwayLine(conn: ConnectionState, serverName: string): { text: string } {
108
+ const nick = conn.nick ?? '*';
109
+ return {
110
+ text: `:${serverName} ${Numerics.RPL_NOWAWAY.toString().padStart(3, '0')} ${nick} :You have been marked as being away`,
111
+ };
112
+ }