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
@@ -27,35 +27,50 @@
27
27
 
28
28
  import {
29
29
  type AccountStore,
30
+ type AwayStore,
30
31
  type ChanName,
31
32
  type ChannelState,
32
33
  type Clock,
33
34
  type ConnSnapshot,
34
35
  type ConnectionState,
36
+ DEFAULT_MONITOR_LIMIT,
37
+ DEFAULT_MULTILINE_MAX_BYTES,
35
38
  Effect,
36
39
  type Effect as EffectType,
37
40
  EmptyMotdProvider,
38
41
  type IdFactory,
39
42
  type IrcMessage,
43
+ LABELED_RESPONSE_CAP_NAME,
40
44
  type Logger,
41
45
  type MessageStore,
46
+ type MonitorStore,
42
47
  type MotdProvider,
43
48
  type MtlsIdentityProvider,
49
+ type MultilineEntry,
44
50
  type NickHistoryStore,
45
51
  NoopLoggerInstance,
46
52
  Numerics,
47
53
  type RawLine,
54
+ type ReadMarkerStore,
48
55
  type ServerConfig,
49
56
  type ServerStats,
57
+ applyLabelToEffects,
50
58
  buildCtx,
59
+ buildMultilineFanout,
51
60
  chathistoryReducer,
61
+ extractLabel,
62
+ finalizeMultilineBatch,
52
63
  handleJoinZero,
64
+ hostmaskOf,
53
65
  isChannelTarget,
54
66
  isonReducer,
55
67
  killReducer,
56
68
  listReducer,
57
69
  lusersReducer,
70
+ monitorReducer,
71
+ multilineByteLength,
58
72
  parse,
73
+ recordMultilineMessage,
59
74
  rehashLine,
60
75
  rehashReducer,
61
76
  statsReducer,
@@ -153,6 +168,42 @@ export interface ConnectionActorOptions {
153
168
  * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
154
169
  */
155
170
  readonly history?: NickHistoryStore;
171
+ /**
172
+ * Read-marker persistence source for IRCv3 `draft/read-marker` (absent
173
+ * when the deployment does not persist read markers). When bound,
174
+ * `ctx.readMarkers` is defined so a `+draft/read-marker=<msgid>` TAGMSG
175
+ * from an identified, cap-enabled client updates the per-`(account,
176
+ * channel)` marker, and SASL identify seeds the connection's
177
+ * `lastReadMarkers` from the stored values. Omitted (→ `ctx.readMarkers`
178
+ * undefined) preserves the session-only marker behaviour: the connection
179
+ * still tracks its own read position via chathistory playback, but the
180
+ * marker is not shared across connections or restored on reconnect.
181
+ */
182
+ readonly readMarkers?: ReadMarkerStore;
183
+ /**
184
+ * Away-status persistence source for IRCv3 `draft/pre-away` (absent
185
+ * when the deployment does not persist away reasons). When bound,
186
+ * `ctx.away` is defined so a set/clear of `AWAY` from an identified
187
+ * connection mirrors the change into the per-`account` record, and
188
+ * SASL identify replays the stored reason onto the connection (emitting
189
+ * `306 RPL_NOWAWAY`) so a reconnect restores the user's away state.
190
+ * Omitted (→ `ctx.away` undefined) preserves the session-only
191
+ * behaviour: the connection still tracks its own `AWAY` but the reason
192
+ * is not shared across connections or restored on reconnect.
193
+ */
194
+ readonly away?: AwayStore;
195
+ /**
196
+ * Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
197
+ * when MONITOR push notifications are disabled). When bound, the actor
198
+ * syncs each connection's watchlist into the store and the store's
199
+ * online/offline hooks fan `730`/`731` lines out to subscribed watchers.
200
+ * Omitted (→ no push notifications) preserves the existing behaviour
201
+ * for callers that have not opted in: `MONITOR +nick` still works
202
+ * synchronously (the reducer resolves online/offline status against
203
+ * the store's `onlineNicks` snapshot) but no async push fires when a
204
+ * watched nick later connects or quits.
205
+ */
206
+ readonly monitor?: MonitorStore;
156
207
  /**
157
208
  * Network-statistics source for the `LUSERS` and `STATS` commands
158
209
  * (absent when no stats backend is wired). When bound, the actor
@@ -186,6 +237,16 @@ export interface ConnectionActorOptions {
186
237
  * pipeline is shared unchanged across both transports.
187
238
  */
188
239
  readonly transport?: Transport;
240
+ /**
241
+ * Transport-level TLS fact (PLAN §5 / docs/PlanExtensions.md "user mode
242
+ * `S`"). Set to `true` when the connection arrived over TLS — a `wss://`
243
+ * WebSocket or an `irc+tls://` raw TCP connection. The actor stamps it
244
+ * onto `state.secure` at construction; the registration reducer then
245
+ * surfaces it as read-only user mode `S` (visible in `221`/`WHOIS`,
246
+ * rejected by the `MODE` parser with `501`). Defaults to `false` so
247
+ * plain-transport tests and deployments keep the pre-TLS-mode behaviour.
248
+ */
249
+ readonly secure?: boolean;
189
250
  }
190
251
 
191
252
  export class ConnectionActor {
@@ -201,6 +262,9 @@ export class ConnectionActor {
201
262
  private readonly accounts: AccountStore | undefined;
202
263
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
203
264
  private readonly history: NickHistoryStore | undefined;
265
+ private readonly readMarkers: ReadMarkerStore | undefined;
266
+ private readonly away: AwayStore | undefined;
267
+ private readonly monitor: MonitorStore | undefined;
204
268
  private readonly stats: ServerStats | undefined;
205
269
  private readonly reducers: RoutedReducers;
206
270
  /**
@@ -222,6 +286,22 @@ export class ConnectionActor {
222
286
  * that need a non-undefined id in test assertions.
223
287
  */
224
288
  private readonly pinnedTraceId: string | undefined;
289
+ /**
290
+ * In-progress IRCv3 `draft/multiline` batch, if any. Set when the client
291
+ * sends `BATCH +id draft/multiline :<target>` (and the connection
292
+ * negotiated the cap), populated by each intervening `PRIVMSG`/`NOTICE`,
293
+ * and cleared when the closing `BATCH -id` is finalized. While set, the
294
+ * actor diverts `PRIVMSG`/`NOTICE` into the accumulator instead of
295
+ * running the normal channel/user reducers.
296
+ */
297
+ private openMultilineBatch:
298
+ | {
299
+ readonly id: string;
300
+ readonly target: string;
301
+ command: 'PRIVMSG' | 'NOTICE';
302
+ readonly entries: MultilineEntry[];
303
+ }
304
+ | undefined;
225
305
 
226
306
  constructor(opts: ConnectionActorOptions) {
227
307
  this.state = opts.state;
@@ -236,6 +316,9 @@ export class ConnectionActor {
236
316
  this.accounts = opts.accounts;
237
317
  this.mtlsIdentity = opts.mtlsIdentity;
238
318
  this.history = opts.history;
319
+ this.readMarkers = opts.readMarkers;
320
+ this.away = opts.away;
321
+ this.monitor = opts.monitor;
239
322
  this.stats = opts.stats;
240
323
  this.logger = opts.logger ?? NoopLoggerInstance;
241
324
  this.pinnedTraceId = opts.traceId;
@@ -251,6 +334,15 @@ export class ConnectionActor {
251
334
  // regardless of which authority owns the command's state.
252
335
  this.reducers = buildRoutedReducers(opts.serverConfig);
253
336
  this.transport = opts.transport ?? new WsTextFrameTransport();
337
+ // Surface the transport's TLS fact on the connection state so the
338
+ // registration reducer can stamp read-only user mode `S` from it.
339
+ // Only stamped when the caller explicitly asserts the fact; adapters
340
+ // that reconstruct the actor from persisted state (where `secure` was
341
+ // already written at construction time) omit the option so the
342
+ // deserialized value survives untouched.
343
+ if (opts.secure !== undefined) {
344
+ this.state.secure = opts.secure;
345
+ }
254
346
  }
255
347
 
256
348
  /**
@@ -315,12 +407,75 @@ export class ConnectionActor {
315
407
  // the channel authority (e.g. CF ChannelDO). Done before `route` so the
316
408
  // synchronous reducers observe up-to-date rosters / modes / topics.
317
409
  await this.prefetchChannelState(msg);
318
- const effects = await this.route(msg);
410
+ const label = extractLabel(msg.tags);
411
+ const effects = await this.route(msg, label);
412
+ const labeled = this.applyLabel(effects, label);
413
+ await dispatch(labeled, this.runtime, this.dispatchOpts(log, traceId));
414
+ // After dispatch completes the bound MonitorStore (if any) may have
415
+ // enqueued `730`/`731` pushes for ReserveNick/ChangeNick/ReleaseNick
416
+ // transitions; turn each into one `Send` effect and dispatch them in a
417
+ // follow-up pass. Kept separate from the original effects array so the
418
+ // reducer's outputs continue to dedupe against themselves, not against
419
+ // fan-out sends.
420
+ await this.drainMonitorPushes(log, traceId);
421
+ }
422
+
423
+ /**
424
+ * Drains pending {@link MonitorPush}es from the bound {@link MonitorStore}
425
+ * and dispatches one `Send` effect per push. The watcher's nick is
426
+ * resolved via the live {@link ConnectionState} so the wire line carries
427
+ * the canonical spelling (`*` placeholder when the watcher is no longer
428
+ * registered — the runtime drops the send in that case anyway).
429
+ */
430
+ private async drainMonitorPushes(log: Logger, traceId: string): Promise<void> {
431
+ if (this.monitor === undefined) return;
432
+ const pushes = this.monitor.drainPushes();
433
+ if (pushes.length === 0) return;
434
+ const effects: EffectType[] = [];
435
+ for (const push of pushes) {
436
+ const watcher = await this.runtime.getConnection(push.connId);
437
+ const watcherNick = watcher?.nick ?? '*';
438
+ const code = push.status === 'online' ? Numerics.RPL_MONONLINE : Numerics.RPL_MONOFFLINE;
439
+ const codeStr = code.toString().padStart(3, '0');
440
+ effects.push(
441
+ Effect.send(push.connId, [
442
+ { text: `:${this.serverConfig.serverName} ${codeStr} ${watcherNick} :${push.display}` },
443
+ ]),
444
+ );
445
+ }
446
+ // `effects` has one entry per push; pushes was non-empty (early
447
+ // return above), so the second dispatch always has work to do.
319
448
  await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
320
449
  }
321
450
 
322
451
  private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
323
- return { logger: log, traceId, connectionId: this.state.id };
452
+ return {
453
+ logger: log,
454
+ traceId,
455
+ connectionId: this.state.id,
456
+ ...(this.monitor !== undefined ? { monitor: this.monitor } : {}),
457
+ };
458
+ }
459
+
460
+ /**
461
+ * IRCv3 `labeled-response` wrapping.
462
+ *
463
+ * When the inbound message carried a `+label` client tag AND the requester
464
+ * negotiated both `batch` and `labeled-response`, every `Send`-to-self the
465
+ * reducer emitted is gathered into one `BATCH +label labeled-response …
466
+ * -label` frame (and an empty batch is emitted when there was no reply).
467
+ * Otherwise the effect list is returned unchanged.
468
+ *
469
+ * The label is read back from {@link Ctx.label} (plumbed through `route`)
470
+ * so the correlation is attributable to the same per-invocation context the
471
+ * reducers observed.
472
+ */
473
+ private applyLabel(effects: EffectType[], label: string | undefined): EffectType[] {
474
+ if (label === undefined) return effects;
475
+ if (!this.state.caps.has('batch') || !this.state.caps.has(LABELED_RESPONSE_CAP_NAME)) {
476
+ return effects;
477
+ }
478
+ return applyLabelToEffects(effects, { label, connId: this.state.id });
324
479
  }
325
480
 
326
481
  /**
@@ -336,7 +491,7 @@ export class ConnectionActor {
336
491
  await Promise.all(targets.map((name) => refresh.call(this.channels, name)));
337
492
  }
338
493
 
339
- private async route(msg: IrcMessage): Promise<EffectType[]> {
494
+ private async route(msg: IrcMessage, label: string | undefined): Promise<EffectType[]> {
340
495
  const ctx = buildCtx({
341
496
  serverConfig: this.serverConfig,
342
497
  clock: this.clock,
@@ -347,8 +502,21 @@ export class ConnectionActor {
347
502
  ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
348
503
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
349
504
  ...(this.history !== undefined ? { history: this.history } : {}),
505
+ ...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
506
+ ...(this.away !== undefined ? { away: this.away } : {}),
507
+ ...(label !== undefined ? { label } : {}),
350
508
  });
351
509
 
510
+ // While a `draft/multiline` batch is open, intervening PRIVMSG/NOTICE
511
+ // lines are accumulated into the batch instead of being delivered
512
+ // immediately. The closing BATCH -id finalizes and fans the batch out.
513
+ if (
514
+ this.openMultilineBatch !== undefined &&
515
+ (msg.command === 'PRIVMSG' || msg.command === 'NOTICE')
516
+ ) {
517
+ return this.accumulateMultilineLine(msg);
518
+ }
519
+
352
520
  switch (msg.command) {
353
521
  // -------- Connection-authority commands --------
354
522
  case 'PING':
@@ -373,6 +541,8 @@ export class ConnectionActor {
373
541
  return this.reducers.cap(this.state, msg, ctx).effects;
374
542
  case 'AUTHENTICATE':
375
543
  return this.reducers.authenticate(this.state, msg, ctx).effects;
544
+ case 'BATCH':
545
+ return this.routeBatch(msg, ctx);
376
546
  case 'AWAY':
377
547
  return this.reducers.away(this.state, msg, ctx).effects;
378
548
  case 'SETNAME':
@@ -391,6 +561,8 @@ export class ConnectionActor {
391
561
  return this.routeUserhost(msg, ctx);
392
562
  case 'ISON':
393
563
  return this.routeIson(msg, ctx);
564
+ case 'MONITOR':
565
+ return this.routeMonitor(msg, ctx);
394
566
 
395
567
  // -------- Channel-authority commands --------
396
568
  case 'JOIN':
@@ -756,6 +928,74 @@ export class ConnectionActor {
756
928
  return isonReducer(online, msg, ctx).effects;
757
929
  }
758
930
 
931
+ /**
932
+ * Routes `MONITOR <sub> [<nicks>]` to the monitor reducer. Resolves the
933
+ * currently-online nicks (folded → display) via the nick registry so the
934
+ * reducer can emit `730`/`731` synchronously. The reducer also updates
935
+ * `state.monitorList`; the actor then forwards adds/removes to the bound
936
+ * {@link MonitorStore} (when present) so future online/offline
937
+ * transitions push `730`/`731` to the watcher.
938
+ */
939
+ private async routeMonitor(
940
+ msg: IrcMessage,
941
+ ctx: ReturnType<typeof buildCtx>,
942
+ ): Promise<EffectType[]> {
943
+ // Resolve every nick the reducer might consult — the union of the
944
+ // existing watchlist and any newly-requested targets — so the reducer
945
+ // can emit `730`/`731` synchronously without an O(N) lookupNick call
946
+ // per nick inside its own loop. Mirrors the routeIson pattern.
947
+ const requested =
948
+ msg.params[1] !== undefined
949
+ ? msg.params[1]
950
+ .split(',')
951
+ .map((s) => s.trim())
952
+ .filter((s) => s.length > 0)
953
+ : [];
954
+ const candidates = new Set<string>(ctx.connection.monitorList ?? []);
955
+ for (const n of requested) candidates.add(n);
956
+ const online = await this.resolveOnlineMap(candidates);
957
+
958
+ const before = new Set(ctx.connection.monitorList ?? []);
959
+ const effects = monitorReducer(online, DEFAULT_MONITOR_LIMIT, msg, ctx).effects;
960
+ // Sync the watchlist delta (if any) into the bound MonitorStore so
961
+ // later ReserveNick / ReleaseNick dispatches can fire push effects.
962
+ if (this.monitor !== undefined) {
963
+ const after = ctx.connection.monitorList ?? new Set<string>();
964
+ this.monitor.sync(ctx.connId, before, after);
965
+ // Remove the connection's subscription entirely when its watchlist
966
+ // becomes empty — keeps the reverse index tidy.
967
+ if (after.size === 0) {
968
+ this.monitor.unsubscribe(ctx.connId);
969
+ }
970
+ }
971
+ return effects;
972
+ }
973
+
974
+ /**
975
+ * Resolves each candidate nick via the runtime's nick registry and
976
+ * returns a folded-keyed → display-spelling map of the online subset.
977
+ * Mirrors {@link routeIson}'s per-nick lookup loop.
978
+ */
979
+ private async resolveOnlineMap(candidates: Iterable<string>): Promise<Map<string, string>> {
980
+ const online = new Map<string, string>();
981
+ for (const nick of candidates) {
982
+ const connId = await this.runtime.lookupNick(nick);
983
+ if (connId === null) continue;
984
+ const conn = await this.runtime.getConnection(connId);
985
+ // `conn` can be null in the race where lookupNick resolved but the
986
+ // connection has since torn down (the nick map lags the connection
987
+ // map). When the connection is alive its nick is defined (the
988
+ // registry only resolves nicks that have been reserved, which sets
989
+ // `nick` on the state), so the optional chain is purely defensive
990
+ // against that race.
991
+ const display = conn?.nick;
992
+ if (display !== undefined) {
993
+ online.set(display.toLowerCase(), display);
994
+ }
995
+ }
996
+ return online;
997
+ }
998
+
759
999
  /**
760
1000
  * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
761
1001
  * reducer. The command is query-only and must NOT create a channel as a
@@ -769,6 +1009,213 @@ export class ConnectionActor {
769
1009
  * Cap-gating, param validation, and batch framing all live in the pure
770
1010
  * reducer — this method only resolves the channel and delegates.
771
1011
  */
1012
+ /**
1013
+ * Routes an inbound `BATCH +id …` / `BATCH -id` frame. The only
1014
+ * client-initiated batch type this server honours is `draft/multiline`
1015
+ * (and only when the connection negotiated both `batch` and
1016
+ * `draft/multiline`); every other form falls through to
1017
+ * {@link unknownCommandEffect} (`421`), preserving the pre-batch behaviour.
1018
+ *
1019
+ * On `+id draft/multiline :<target>` the actor opens the accumulator
1020
+ * ({@link openMultilineBatch}); intervening PRIVMSG/NOTICE frames populate
1021
+ * it ({@link accumulateMultilineLine}); `BATCH -id` finalizes the batch
1022
+ * via the pure {@link finalizeMultilineBatch} reducer (channel target) or
1023
+ * the actor-side per-recipient path (nick target) and returns its fanout
1024
+ * effects.
1025
+ */
1026
+ private async routeBatch(
1027
+ msg: IrcMessage,
1028
+ ctx: ReturnType<typeof buildCtx>,
1029
+ ): Promise<EffectType[]> {
1030
+ const marker = msg.params[0] ?? '';
1031
+ // `+<id>` opens a batch; `-<id>` closes one. Anything else is malformed.
1032
+ if (marker.startsWith('+')) {
1033
+ return this.beginMultilineBatch(marker.slice(1), msg, ctx);
1034
+ }
1035
+ if (marker.startsWith('-')) {
1036
+ return this.closeMultilineBatch(marker.slice(1), ctx);
1037
+ }
1038
+ return [this.unknownCommandEffect(msg.command)];
1039
+ }
1040
+
1041
+ /**
1042
+ * Opens a `draft/multiline` accumulator. Requires the connection to have
1043
+ * negotiated both `batch` and `draft/multiline`; otherwise the open is
1044
+ * rejected as an unknown command (the client cannot legally send the
1045
+ * frame without the caps).
1046
+ */
1047
+ private beginMultilineBatch(
1048
+ id: string,
1049
+ msg: IrcMessage,
1050
+ _ctx: ReturnType<typeof buildCtx>,
1051
+ ): EffectType[] {
1052
+ // Cap gate: the client must have negotiated both caps to use the frame.
1053
+ if (!this.state.caps.has('batch') || !this.state.caps.has('draft/multiline')) {
1054
+ return [this.unknownCommandEffect(msg.command)];
1055
+ }
1056
+ // Only the `draft/multiline` batch type is supported client→server.
1057
+ const type = msg.params[1];
1058
+ const target = msg.params[2];
1059
+ if (type !== 'draft/multiline' || target === undefined || target.length === 0) {
1060
+ return [this.unknownCommandEffect(msg.command)];
1061
+ }
1062
+ // Reject re-open while another batch is mid-flight: the IRCv3 batch spec
1063
+ // forbids reusing a ref id while one is open. Surface as 421 so the
1064
+ // client does not silently lose the first batch's lines.
1065
+ if (this.openMultilineBatch !== undefined) {
1066
+ return [this.unknownCommandEffect(msg.command)];
1067
+ }
1068
+ this.openMultilineBatch = { id, target, command: 'PRIVMSG', entries: [] };
1069
+ // The open marker itself produces no server→client reply.
1070
+ return [];
1071
+ }
1072
+
1073
+ /**
1074
+ * Accumulates one intervening PRIVMSG/NOTICE line into the open
1075
+ * `draft/multiline` batch. The inner line's target must match the batch
1076
+ * target and (after the first line) its command must match too; a
1077
+ * mismatch aborts the batch with a `FAIL` and clears the accumulator.
1078
+ */
1079
+ private accumulateMultilineLine(msg: IrcMessage): EffectType[] {
1080
+ const open = this.openMultilineBatch;
1081
+ if (open === undefined) return [];
1082
+ const target = msg.params[0];
1083
+ const text = msg.params[1] ?? '';
1084
+ // Target mismatch: the spec requires every inner line to address the
1085
+ // batch's target. Abort the batch.
1086
+ if (target !== open.target) {
1087
+ this.openMultilineBatch = undefined;
1088
+ return [
1089
+ Effect.send(this.state.id, [
1090
+ {
1091
+ text: `:${this.serverConfig.serverName} FAIL BATCH MULTILINE_INVALID_TARGET ${open.target} :Inner line target does not match the batch target`,
1092
+ },
1093
+ ]),
1094
+ ];
1095
+ }
1096
+ const command = msg.command as 'PRIVMSG' | 'NOTICE';
1097
+ // The first inner line pins the command; subsequent lines must match.
1098
+ if (open.entries.length === 0) {
1099
+ open.command = command;
1100
+ } else if (command !== open.command) {
1101
+ this.openMultilineBatch = undefined;
1102
+ return [
1103
+ Effect.send(this.state.id, [
1104
+ {
1105
+ text: `:${this.serverConfig.serverName} FAIL BATCH MULTILINE_MIXED_COMMANDS ${open.target} :Multi-line batch mixes PRIVMSG and NOTICE`,
1106
+ },
1107
+ ]),
1108
+ ];
1109
+ }
1110
+ open.entries.push({ text });
1111
+ return [];
1112
+ }
1113
+
1114
+ /**
1115
+ * Finalizes an open `draft/multiline` batch on `BATCH -id`. Channel targets
1116
+ * delegate to the pure {@link finalizeMultilineBatch} reducer (cap-split
1117
+ * Broadcast). Nick targets use the actor-side per-recipient path
1118
+ * ({@link closeMultilineBatchToNick}) because the recipient is a single
1119
+ * connection whose caps must be resolved before delivery. An unknown /
1120
+ * mismatched id, or a close with no batch open, falls through to `421`.
1121
+ */
1122
+ private async closeMultilineBatch(
1123
+ id: string,
1124
+ ctx: ReturnType<typeof buildCtx>,
1125
+ ): Promise<EffectType[]> {
1126
+ const open = this.openMultilineBatch;
1127
+ if (open === undefined || open.id !== id) {
1128
+ return [this.unknownCommandEffect('BATCH')];
1129
+ }
1130
+ this.openMultilineBatch = undefined;
1131
+ const maxBytes = this.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
1132
+ if (isChannelTarget(open.target)) {
1133
+ const result = finalizeMultilineBatch(open.target, open.command, open.entries, maxBytes, ctx);
1134
+ return result.effects;
1135
+ }
1136
+ return this.closeMultilineBatchToNick(open, maxBytes, ctx);
1137
+ }
1138
+
1139
+ /**
1140
+ * Finalizes a nick-target multi-line batch. Resolves the single recipient
1141
+ * via the nick registry, inspects their caps, and delivers the appropriate
1142
+ * wire form: the batched replay to `draft/multiline`+`batch` peers, the
1143
+ * tagged joined PRIVMSG to `message-tags` peers, or the bare joined
1144
+ * PRIVMSG to legacy peers. PRIVMSG surfaces `401 ERR_NOSUCHNICK` when the
1145
+ * recipient is offline; NOTICE is silently dropped (RFC: NOTICE MUST NOT
1146
+ * generate numerics).
1147
+ */
1148
+ private async closeMultilineBatchToNick(
1149
+ open: {
1150
+ readonly id: string;
1151
+ readonly target: string;
1152
+ readonly command: 'PRIVMSG' | 'NOTICE';
1153
+ readonly entries: readonly MultilineEntry[];
1154
+ },
1155
+ maxBytes: number,
1156
+ ctx: ReturnType<typeof buildCtx>,
1157
+ ): Promise<EffectType[]> {
1158
+ if (open.entries.length === 0) {
1159
+ return [];
1160
+ }
1161
+
1162
+ const bytes = multilineByteLength(open.entries);
1163
+ if (bytes > maxBytes) {
1164
+ return [
1165
+ Effect.send(ctx.connId, [
1166
+ {
1167
+ text: `:${ctx.serverName} FAIL BATCH MULTILINE_MAX_BYTES ${open.target} :Multi-line batch exceeds the ${maxBytes} byte limit`,
1168
+ },
1169
+ ]),
1170
+ ];
1171
+ }
1172
+
1173
+ const recipientConnId = await this.runtime.lookupNick(open.target);
1174
+ if (recipientConnId === null) {
1175
+ return this.nickNotFoundEffects(open, ctx);
1176
+ }
1177
+ const recipient = await this.runtime.getConnection(recipientConnId);
1178
+ if (recipient === null) {
1179
+ return this.nickNotFoundEffects(open, ctx);
1180
+ }
1181
+
1182
+ const hostmask = hostmaskOf(this.state) ?? '?';
1183
+ const batchId = ctx.ids.batchId();
1184
+ const fanout = buildMultilineFanout(open.target, open.command, open.entries, hostmask, batchId);
1185
+ const msgid = ctx.ids.nonce();
1186
+ recordMultilineMessage(ctx, open.target, open.command, fanout.joined, msgid);
1187
+
1188
+ let lines: RawLine[];
1189
+ if (recipient.caps.has('draft/multiline') && recipient.caps.has('batch')) {
1190
+ lines = [...fanout.batchedReplay];
1191
+ } else if (recipient.caps.has('message-tags')) {
1192
+ lines = [fanout.taggedJoined];
1193
+ } else {
1194
+ lines = [fanout.bareJoined];
1195
+ }
1196
+
1197
+ return [Effect.send(recipientConnId, lines)];
1198
+ }
1199
+
1200
+ /**
1201
+ * Builds the `401 ERR_NOSUCHNICK` effect for an offline nick-target batch
1202
+ * close. PRIVMSG surfaces the error; NOTICE returns nothing (the RFC
1203
+ * forbids numerics in response to NOTICE).
1204
+ */
1205
+ private nickNotFoundEffects(
1206
+ open: { readonly target: string; readonly command: 'PRIVMSG' | 'NOTICE' },
1207
+ ctx: ReturnType<typeof buildCtx>,
1208
+ ): EffectType[] {
1209
+ if (open.command === 'NOTICE') return [];
1210
+ const nick = ctx.connection.nick ?? '*';
1211
+ const code = Numerics.ERR_NOSUCHNICK.toString().padStart(3, '0');
1212
+ return [
1213
+ Effect.send(ctx.connId, [
1214
+ { text: `:${ctx.serverName} ${code} ${nick} ${open.target} :No such nick/channel` },
1215
+ ]),
1216
+ ];
1217
+ }
1218
+
772
1219
  private async routeChathistory(
773
1220
  msg: IrcMessage,
774
1221
  ctx: ReturnType<typeof buildCtx>,