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
@@ -7,7 +7,8 @@
7
7
  * store (record → query round-trip) without needing DynamoDB Local.
8
8
  */
9
9
 
10
- import { DEFAULT_SERVER_CONFIG, type StoredMessage } from '@serverless-ircd/irc-core';
10
+ import type { StoredMessage } from '@serverless-ircd/irc-core';
11
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
11
12
  import { describe, expect, it } from 'vitest';
12
13
  import { bindMessageStore } from '../src/message-store.js';
13
14
 
@@ -26,7 +27,7 @@ function sampleMessage(chan: string, text: string): StoredMessage {
26
27
 
27
28
  describe('bindMessageStore', () => {
28
29
  it('returns a store that records and replays a message via LATEST', () => {
29
- const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
30
+ const store = bindMessageStore(makeTestServerConfig());
30
31
  store.record(sampleMessage('#aws', 'hello aws'));
31
32
 
32
33
  const result = store.query({ chan: '#aws', direction: 'latest', limit: 10 });
@@ -35,7 +36,7 @@ describe('bindMessageStore', () => {
35
36
  });
36
37
 
37
38
  it('returns a store scoped per channel (no cross-channel leak)', () => {
38
- const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
39
+ const store = bindMessageStore(makeTestServerConfig());
39
40
  store.record(sampleMessage('#a', 'in a'));
40
41
  store.record(sampleMessage('#b', 'in b'));
41
42
 
@@ -45,8 +46,8 @@ describe('bindMessageStore', () => {
45
46
  });
46
47
 
47
48
  it('returns an independent store instance per call', () => {
48
- const a = bindMessageStore(DEFAULT_SERVER_CONFIG);
49
- const b = bindMessageStore(DEFAULT_SERVER_CONFIG);
49
+ const a = bindMessageStore(makeTestServerConfig());
50
+ const b = bindMessageStore(makeTestServerConfig());
50
51
  a.record(sampleMessage('#aws', 'only in a'));
51
52
 
52
53
  expect(a.query({ chan: '#aws', direction: 'latest', limit: 10 })).toHaveLength(1);
@@ -9,11 +9,8 @@
9
9
  */
10
10
 
11
11
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
12
- import {
13
- DEFAULT_SERVER_CONFIG,
14
- type ParsedServerConfig,
15
- StaticMotdProvider,
16
- } from '@serverless-ircd/irc-core';
12
+ import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
13
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
17
14
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
15
  import { AwsRuntime } from '../src/aws-runtime.js';
19
16
  import type { PostToConnection } from '../src/aws-runtime.js';
@@ -30,12 +27,11 @@ import type { TablesConfig } from '../src/tables.js';
30
27
  const available = process.env.DDB_AVAILABLE === '1';
31
28
  const endpoint = process.env.DYNAMO_ENDPOINT;
32
29
 
33
- const SERVER_CONFIG: ParsedServerConfig = {
34
- ...DEFAULT_SERVER_CONFIG,
30
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
35
31
  serverName: 'irc.example.com',
36
32
  networkName: 'ExampleNet',
37
33
  motdLines: ['Welcome to the test IRC server.'],
38
- };
34
+ });
39
35
 
40
36
  const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
41
37
 
@@ -461,6 +457,62 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
461
457
  });
462
458
  expect(bSink.some((l) => l.includes('PRIVMSG #room :hello from alice'))).toBe(true);
463
459
  });
460
+
461
+ it('refreshChannel tolerates an empty channel snapshot without throwing', async () => {
462
+ // After the last member PARTs, the channel meta row persists but
463
+ // queryMembers returns no rows → snap.members.size === 0. The next
464
+ // frame referencing the channel triggers refreshChannel on the
465
+ // empty-members + existing-cache path. This test ensures that path
466
+ // does not throw and the channel remains usable for a re-JOIN.
467
+ const f = requireFx();
468
+ const opts = () => ({
469
+ dynamo: f.client,
470
+ tables: f.tables,
471
+ serverConfig: SERVER_CONFIG,
472
+ motd: MOTD,
473
+ messages: bindMessageStore(SERVER_CONFIG),
474
+ managementApi: f.mgmt,
475
+ });
476
+ const a = deriveNlbConnectionId('10.0.0.40', '50040');
477
+ f.mgmt.register(a, () => {});
478
+ // Register + JOIN + PART → cache populated then snapshot empty.
479
+ await handleNlbStream(nlbEvent('10.0.0.40', '50040', ''), opts());
480
+ await handleNlbStream(
481
+ nlbEvent('10.0.0.40', '50040', 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #nlbempty\r\n'),
482
+ opts(),
483
+ );
484
+ await handleNlbStream(nlbEvent('10.0.0.40', '50040', 'PART #nlbempty\r\n'), opts());
485
+ // Re-JOIN hits refreshChannel on the empty-members path; must not throw.
486
+ const res = await handleNlbStream(nlbEvent('10.0.0.40', '50040', 'JOIN #nlbempty\r\n'), opts());
487
+ expect(res.statusCode).toBe(200);
488
+ });
489
+
490
+ it('falls back to "unknown"/"0" when NLB headers are absent', async () => {
491
+ // `readHeader` returns undefined when no header key matches. The
492
+ // caller's `?? 'unknown'` / `?? '0'` fallbacks must apply so the
493
+ // handler does not crash on a malformed event (defensive).
494
+ const f = requireFx();
495
+ const eventWithoutHeaders: NlbStreamEvent = {
496
+ requestContext: {
497
+ elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' },
498
+ },
499
+ version: '2.0',
500
+ body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
501
+ isBase64Encoded: true,
502
+ headers: { 'some-other-header': 'value' },
503
+ };
504
+ const res = await handleNlbStream(eventWithoutHeaders, {
505
+ dynamo: f.client,
506
+ tables: f.tables,
507
+ serverConfig: SERVER_CONFIG,
508
+ motd: MOTD,
509
+ messages: bindMessageStore(SERVER_CONFIG),
510
+ managementApi: f.mgmt,
511
+ });
512
+ expect(res.statusCode).toBe(200);
513
+ // The fallback id (nlb-unknown-0) still received a PONG.
514
+ expect(res.body).toBeDefined();
515
+ });
464
516
  });
465
517
 
466
518
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -35,6 +35,7 @@
35
35
  "@cloudflare/workers-types": "^5.20260714.1",
36
36
  "@serverless-ircd/in-memory-runtime": "workspace:*",
37
37
  "@serverless-ircd/irc-test-support": "workspace:*",
38
+ "@vitest/coverage-istanbul": "^4.1.0",
38
39
  "@vitest/coverage-v8": "^4.1.0",
39
40
  "rimraf": "^6.0.0",
40
41
  "typescript": "^5.6.0",
@@ -466,6 +466,9 @@ export class ConnectionDO extends DurableObject<Env> {
466
466
  // deployment-wide uptime, tracked as future work.
467
467
  stats: new CfStats(runtime, WORKER_STARTUP_AT),
468
468
  transport: makeWsFrameTransport(mode),
469
+ // Cloudflare terminates TLS at the edge before the WebSocket reaches
470
+ // the DO, so every WS connection is secure → user mode `S`.
471
+ secure: true,
469
472
  ...(accounts !== undefined ? { accounts } : {}),
470
473
  ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
471
474
  logger,
@@ -34,6 +34,13 @@ export interface PersistedConnectionState {
34
34
  id: string;
35
35
  registration: RegistrationState;
36
36
  capNegotiating: boolean;
37
+ /**
38
+ * Transport TLS fact (user mode `S`). Optional on the persisted shape so
39
+ * records written before the field shipped (schema is otherwise
40
+ * unchanged) deserialize with a `false` default via the coalescing in
41
+ * {@link deserialize}. Always written by {@link serialize}.
42
+ */
43
+ secure?: boolean;
37
44
  caps: string[];
38
45
  joinedChannels: ChanName[];
39
46
  userModes: UserModes;
@@ -61,6 +68,7 @@ export function serialize(state: ConnectionState): PersistedConnectionState {
61
68
  id: state.id,
62
69
  registration: state.registration,
63
70
  capNegotiating: state.capNegotiating,
71
+ secure: state.secure,
64
72
  caps: [...state.caps],
65
73
  joinedChannels: [...state.joinedChannels],
66
74
  userModes: { ...state.userModes },
@@ -88,6 +96,9 @@ export function deserialize(record: unknown): ConnectionState {
88
96
  id: migrated.id,
89
97
  registration: migrated.registration,
90
98
  capNegotiating: migrated.capNegotiating,
99
+ // `secure` is absent on records persisted before user mode `S` shipped;
100
+ // `migrate` backfills it to false so the cast below is truthful.
101
+ secure: migrated.secure ?? false,
91
102
  caps: new Set<string>(migrated.caps),
92
103
  joinedChannels: new Set<ChanName>(migrated.joinedChannels),
93
104
  userModes: { ...migrated.userModes },
@@ -119,8 +130,19 @@ export function migrate(record: unknown): PersistedConnectionState {
119
130
  if (!isPlainObject(record)) {
120
131
  throw new Error('persisted connection state is not an object');
121
132
  }
133
+ // Backfill additive fields introduced by user mode `S` (TLS connected):
134
+ // `secure` and `userModes.tls`. Both default to false (plain transport),
135
+ // preserving the pre-feature behaviour for records persisted before the
136
+ // mode shipped. The schema version is unchanged because the additions are
137
+ // purely additive with safe defaults — no row transformation is needed
138
+ // beyond coalescing the missing keys.
139
+ if (record.secure === undefined) record.secure = false;
140
+ if (isPlainObject(record.userModes)) {
141
+ const um = record.userModes as Record<string, unknown>;
142
+ if (um.tls === undefined) um.tls = false;
143
+ }
122
144
  const version = typeof record.version === 'number' ? record.version : 0;
123
- // No migrations yet — v1 is the initial schema. Future work adds steps:
145
+ // No versioned migrations yet — v1 is the initial schema. Future work:
124
146
  // if (version < 2) record = migrate_v1_v2(record);
125
147
  // if (version < 3) record = migrate_v2_v3(record);
126
148
  if (version > PERSISTED_STATE_VERSION) {
@@ -131,9 +153,8 @@ export function migrate(record: unknown): PersistedConnectionState {
131
153
  }
132
154
  // The cast below is safe: we accept any JSON shape the storage layer
133
155
  // returns; production code wrote it via `serialize`, tests wrote it
134
- // through the same path. If a future field is missing, the
135
- // `exactOptionalPropertyTypes` discipline in `serialize` keeps
136
- // undefined keys out of the persisted record entirely.
156
+ // through the same path. Backfilling above keeps the additive
157
+ // `secure` / `userModes.tls` keys present even on pre-feature records.
137
158
  return record as unknown as PersistedConnectionState;
138
159
  }
139
160
 
@@ -119,7 +119,18 @@ class CfHarness implements IrcHarness {
119
119
  if (opts?.nick !== undefined) {
120
120
  await client.send(`NICK ${opts.nick}`);
121
121
  await client.send(`USER ${opts.nick} 0 * :${opts.nick}`);
122
- await client.waitForNumeric('001', DEFAULT_TIMEOUT_MS);
122
+ // Wait for the FULL registration block, not just 001. Unlike the
123
+ // in-memory runtime (where the whole welcome fires synchronously
124
+ // inside `send`), CF delivers the 001..376 lines asynchronously
125
+ // through the WebSocket. Returning on 001 leaves 002..376 in flight,
126
+ // which can pollute `received` after a test calls `received.length = 0`
127
+ // and trip "no numeric" assertions on slow/loaded runners. The
128
+ // MOTD tailer (376 RPL_ENDOFMOTD, or 422 ERR_NOMOTD when no MOTD is
129
+ // configured) is the last frame of the registration burst.
130
+ await client.waitForLine(
131
+ (l) => numericEquals(l, '376') || numericEquals(l, '422'),
132
+ DEFAULT_TIMEOUT_MS,
133
+ );
123
134
  this.nicks.set(opts.nick.toLowerCase(), client);
124
135
  }
125
136
 
@@ -652,3 +652,272 @@ describe('CfRuntime — reloadConfig (REHASH)', () => {
652
652
  await expect(rt.reloadConfig()).rejects.toThrow(/networkName/u);
653
653
  });
654
654
  });
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // Tests — broadcastWallops (global +w fan-out across every registry shard)
658
+ // ---------------------------------------------------------------------------
659
+
660
+ /**
661
+ * Opens a ConnectionDO, registers it with the given nick, and optionally
662
+ * flips user mode `w` on via a direct state mutation (avoids driving the
663
+ * full MODE handshake through the actor). Returns the live WebSocket and
664
+ * the connection's canonical hex id.
665
+ */
666
+ async function openWallopsCapableConn(
667
+ name: string,
668
+ opts: { wallops: boolean },
669
+ ): Promise<{ ws: WebSocket; hexId: string; received: string[] }> {
670
+ const ws = await openConnection(name);
671
+ await new Promise<void>((r) => setTimeout(r, 20));
672
+ ws.send(`NICK ${name}\r\n`);
673
+ ws.send(`USER ${name} 0 * :${name}\r\n`);
674
+ // Drain the registration numerics so they don't interfere with the
675
+ // assertions on wallops delivery below.
676
+ await new Promise<void>((r) => setTimeout(r, 100));
677
+
678
+ const hexId = await getConnectionHexId(name);
679
+ const received = drainSocket(ws);
680
+
681
+ if (opts.wallops) {
682
+ // Flip the +w mode directly on the live cached state. The DO is the
683
+ // authority for this state; broadcastWallops reads it back via
684
+ // getConnState() so what we mutate here is exactly what the fan-out
685
+ // will see.
686
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromString(hexId));
687
+ await runInDurableObject(stub, async (instance: unknown) => {
688
+ const doi = instance as {
689
+ __peekState?: () => Promise<
690
+ import('@serverless-ircd/irc-core').ConnectionState | undefined
691
+ >;
692
+ __pokeState?: (s: import('@serverless-ircd/irc-core').ConnectionState) => void;
693
+ };
694
+ const state = await doi.__peekState?.();
695
+ if (state !== undefined) state.userModes.wallops = true;
696
+ });
697
+ }
698
+
699
+ // Reserve the nick in the REAL registry under its hex id so
700
+ // broadcastWallops' shard walk finds it.
701
+ await reserveNickInRealRegistry(name, hexId);
702
+
703
+ return { ws, hexId, received };
704
+ }
705
+
706
+ describe('CfRuntime — broadcastWallops', () => {
707
+ it('delivers to every +w connection enumerated across all registry shards', async () => {
708
+ const realEnv = envWithRealDOs();
709
+
710
+ const a = await openWallopsCapableConn('wallops-a', { wallops: true });
711
+ const b = await openWallopsCapableConn('wallops-b', { wallops: true });
712
+ // A connection without +w must not receive the wallops.
713
+ const plain = await openWallopsCapableConn('wallops-plain', { wallops: false });
714
+
715
+ const rt = new CfRuntime(realEnv, 'conn-wallops-sender', recordingHandlers());
716
+ await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :hi fleet' }]);
717
+
718
+ await new Promise<void>((resolve) => {
719
+ const deadline = Date.now() + 1500;
720
+ const tick = (): void => {
721
+ const aGot = a.received.some((l) => l.includes('hi fleet'));
722
+ const bGot = b.received.some((l) => l.includes('hi fleet'));
723
+ if (aGot && bGot && Date.now() > deadline - 1000) {
724
+ resolve();
725
+ return;
726
+ }
727
+ if (Date.now() > deadline) {
728
+ resolve();
729
+ return;
730
+ }
731
+ setTimeout(tick, 20);
732
+ };
733
+ tick();
734
+ });
735
+
736
+ expect(a.received.some((l) => l.includes('hi fleet'))).toBe(true);
737
+ expect(b.received.some((l) => l.includes('hi fleet'))).toBe(true);
738
+ expect(plain.received.some((l) => l.includes('hi fleet'))).toBe(false);
739
+
740
+ a.ws.close();
741
+ b.ws.close();
742
+ plain.ws.close();
743
+ });
744
+
745
+ it('skips the excepted originator', async () => {
746
+ const realEnv = envWithRealDOs();
747
+ const sender = await openWallopsCapableConn('wallops-except-sender', { wallops: true });
748
+ const other = await openWallopsCapableConn('wallops-except-other', { wallops: true });
749
+
750
+ const rt = new CfRuntime(realEnv, sender.hexId, recordingHandlers());
751
+ await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :except me' }], sender.hexId);
752
+
753
+ await new Promise<void>((resolve) => {
754
+ const deadline = Date.now() + 1000;
755
+ const tick = (): void => {
756
+ if (other.received.some((l) => l.includes('except me'))) {
757
+ resolve();
758
+ return;
759
+ }
760
+ if (Date.now() > deadline) {
761
+ resolve();
762
+ return;
763
+ }
764
+ setTimeout(tick, 20);
765
+ };
766
+ tick();
767
+ });
768
+
769
+ expect(other.received.some((l) => l.includes('except me'))).toBe(true);
770
+ expect(sender.received.some((l) => l.includes('except me'))).toBe(false);
771
+
772
+ sender.ws.close();
773
+ other.ws.close();
774
+ });
775
+
776
+ it('is a no-op when the registry has no entries', async () => {
777
+ const realEnv = envWithRealDOs();
778
+ const handlers = recordingHandlers();
779
+ const rt = new CfRuntime(realEnv, 'conn-wallops-empty', handlers);
780
+ // Fresh storage (afterEach reset); no nick reservations anywhere.
781
+ await expect(
782
+ rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :nobody' }]),
783
+ ).resolves.toBeUndefined();
784
+ expect(handlers.sent).toEqual([]);
785
+ });
786
+
787
+ it('skips a connection whose persisted state is null without throwing', async () => {
788
+ // Reserve a nick pointing at a hex id whose ConnectionDO has no
789
+ // live state. `newUniqueId()` gives us a valid 64-hex DO id that
790
+ // is guaranteed not to collide with any connection we open here.
791
+ const realEnv = envWithRealDOs();
792
+ const ghostHex = env.CONNECTION_DO.newUniqueId().toString();
793
+ await reserveNickInRealRegistry('wallops-ghost', ghostHex);
794
+
795
+ const other = await openWallopsCapableConn('wallops-alongside-ghost', { wallops: true });
796
+
797
+ const rt = new CfRuntime(realEnv, 'conn-wallops-ghost-caller', recordingHandlers());
798
+ // Must not reject even though one enumerated conn reports null state.
799
+ await expect(
800
+ rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :ghost friendly' }]),
801
+ ).resolves.toBeUndefined();
802
+
803
+ await new Promise<void>((resolve) => {
804
+ const deadline = Date.now() + 1000;
805
+ const tick = (): void => {
806
+ if (other.received.some((l) => l.includes('ghost friendly'))) {
807
+ resolve();
808
+ return;
809
+ }
810
+ if (Date.now() > deadline) {
811
+ resolve();
812
+ return;
813
+ }
814
+ setTimeout(tick, 20);
815
+ };
816
+ tick();
817
+ });
818
+ expect(other.received.some((l) => l.includes('ghost friendly'))).toBe(true);
819
+
820
+ other.ws.close();
821
+ });
822
+ });
823
+
824
+ // ---------------------------------------------------------------------------
825
+ // Tests — changeNick shard routing + failure paths
826
+ // ---------------------------------------------------------------------------
827
+
828
+ describe('CfRuntime — changeNick shard routing', () => {
829
+ it('uses the single-shard RegistryDO.changeNick when old and new hash to the same shard', async () => {
830
+ // Pin registryShards to 1 so every nick routes to shard 0 — the
831
+ // same-shard branch in CfRuntime.changeNick is taken.
832
+ const realEnv = envWithRealDOs();
833
+ const rt = new CfRuntime(realEnv, 'conn-sameshard', recordingHandlers(), {
834
+ registryShards: 1,
835
+ });
836
+ expect(await rt.reserveNick('sn-old', 'conn-sameshard')).toEqual({ ok: true });
837
+
838
+ const ok = await rt.changeNick('conn-sameshard', 'sn-old', 'sn-new');
839
+ expect(ok).toBe(true);
840
+
841
+ // Look up under the same shard count the runtime used (1).
842
+ const key = registryKeyForNick('sn-new', 1);
843
+ const newOwner = (
844
+ (await env.REGISTRY_DO_REAL.get(
845
+ env.REGISTRY_DO_REAL.idFromName(key),
846
+ )) as unknown as RegistryRpc
847
+ ).lookupNick('sn-new');
848
+ expect(await newOwner).toBe('conn-sameshard');
849
+ const oldKey = registryKeyForNick('sn-old', 1);
850
+ const oldOwner = (
851
+ (await env.REGISTRY_DO_REAL.get(
852
+ env.REGISTRY_DO_REAL.idFromName(oldKey),
853
+ )) as unknown as RegistryRpc
854
+ ).lookupNick('sn-old');
855
+ expect(await oldOwner).toBeNull();
856
+ });
857
+
858
+ it('two-phase path returns false when the new nick is already taken cross-shard', async () => {
859
+ // Default shard count (32). Reserve `wn-taken` by another conn first,
860
+ // then attempt to changeNick to it from a different conn.
861
+ const realEnv = envWithRealDOs();
862
+ const rt = new CfRuntime(realEnv, 'conn-newowner', recordingHandlers());
863
+ expect(await rt.reserveNick('wn-current', 'conn-newowner')).toEqual({ ok: true });
864
+ expect(await rt.reserveNick('wn-taken', 'conn-someoneelse')).toEqual({ ok: true });
865
+
866
+ const ok = await rt.changeNick('conn-newowner', 'wn-current', 'wn-taken');
867
+ expect(ok).toBe(false);
868
+ // Old nick untouched because the reserve-new phase failed.
869
+ expect(await registryForNick(realEnv, 'wn-current').lookupNick('wn-current')).toBe(
870
+ 'conn-newowner',
871
+ );
872
+ expect(await registryForNick(realEnv, 'wn-taken').lookupNick('wn-taken')).toBe(
873
+ 'conn-someoneelse',
874
+ );
875
+ });
876
+ });
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // Tests — getConnection / getChannelConnections edge cases
880
+ // ---------------------------------------------------------------------------
881
+
882
+ describe('CfRuntime — getConnection and getChannelConnections branches', () => {
883
+ it('getConnection(this.connId) returns the live snapshot via handlers', async () => {
884
+ const stubEnv = envWithChannelStub();
885
+ const state = createConnection({ id: 'conn-getc-self', connectedSince: 0 });
886
+ state.nick = 'self';
887
+ const handlers: CfConnectionHandlers = {
888
+ send: (): void => {},
889
+ disconnect: (): void => {},
890
+ snapshot: () => state,
891
+ };
892
+ const rt = new CfRuntime(stubEnv, 'conn-getc-self', handlers);
893
+ const conn = await rt.getConnection('conn-getc-self');
894
+ expect(conn).not.toBeNull();
895
+ expect(conn?.nick).toBe('self');
896
+ });
897
+
898
+ it('getConnection(this.connId) returns null when handlers.snapshot is undefined', async () => {
899
+ const stubEnv = envWithChannelStub();
900
+ const rt = new CfRuntime(stubEnv, 'conn-getc-empty', recordingHandlers());
901
+ expect(await rt.getConnection('conn-getc-empty')).toBeNull();
902
+ });
903
+
904
+ it('getChannelConnections returns every member that reports a persisted state', async () => {
905
+ // A channel roster with one real member; the ConnectionDO is the
906
+ // authority for each member's state. getChannelConnections fans out
907
+ // one getConnState RPC per member and collects the results.
908
+ const realEnv = envWithRealDOs();
909
+ const rt = new CfRuntime(realEnv, 'conn-gcc-caller', recordingHandlers());
910
+
911
+ const member = await openWallopsCapableConn('gcc-real', { wallops: false });
912
+
913
+ await rt.applyChannelDelta('#gcc', {
914
+ memberships: [{ type: 'add' as const, conn: member.hexId, nick: 'gcc-real' }],
915
+ });
916
+
917
+ const conns = await rt.getChannelConnections('#gcc');
918
+ expect(conns.size).toBe(1);
919
+ expect(conns.get(member.hexId)?.nick).toBe('gcc-real');
920
+
921
+ member.ws.close();
922
+ });
923
+ });
@@ -478,3 +478,120 @@ describe('ChannelDO — scale', () => {
478
478
  expect(snapAfter?.members.length).toBe(0);
479
479
  }, 15_000);
480
480
  });
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Edge cases — empty-lines broadcast, cache reload, test hooks
484
+ // ---------------------------------------------------------------------------
485
+
486
+ describe('ChannelDO — broadcast + cache edge cases', () => {
487
+ it('broadcast is a no-op for an empty lines array', async () => {
488
+ const chan = '#empty-bcast';
489
+ await channel(chan.toLowerCase()).applyChannelDelta({
490
+ memberships: [{ type: 'add' as const, conn: 'ghost', nick: 'g' }],
491
+ });
492
+ // Must not reject and must not prune members (no fan-out attempted).
493
+ await expect(channel(chan.toLowerCase()).broadcast([])).resolves.toBeUndefined();
494
+ const snap = await channel(chan.toLowerCase()).getChannelSnapshot();
495
+ expect(snap?.members.length).toBe(1);
496
+ });
497
+
498
+ it('reloads persisted state from storage after __resetCache (deserializeChannel)', async () => {
499
+ // Mutate the channel, drop the in-memory cache, then read again.
500
+ // The read must go through deserializeChannel to rebuild Map/Set
501
+ // fields from the JSON-friendly persisted shape.
502
+ const chan = '#cache-reload';
503
+ const lower = chan.toLowerCase();
504
+ await channel(lower).applyChannelDelta({
505
+ memberships: [{ type: 'add' as const, conn: 'c-1', nick: 'alice' }],
506
+ topic: { text: 'hello topic', setter: 'alice', setAt: 100 },
507
+ });
508
+
509
+ // Drop the cache so the next read forces a storage reload.
510
+ await runInDurableObject(channelStub(lower), (instance: unknown) => {
511
+ const doi = instance as { __resetCache?: () => void };
512
+ doi.__resetCache?.();
513
+ });
514
+
515
+ const snap = await channel(lower).getChannelSnapshot();
516
+ expect(snap?.members.length).toBe(1);
517
+ expect(snap?.members[0]?.nick).toBe('alice');
518
+ expect(snap?.topic?.text).toBe('hello topic');
519
+ });
520
+
521
+ it('__peekState returns the live cached state before any reload', async () => {
522
+ const lower = '#peek-state';
523
+ await channel(lower).applyChannelDelta({
524
+ memberships: [{ type: 'add' as const, conn: 'c-peek', nick: 'peeker' }],
525
+ });
526
+ const cached = await runInDurableObject(channelStub(lower), async (instance: unknown) => {
527
+ const doi = instance as { __peekState?: () => Promise<unknown> };
528
+ return doi.__peekState?.();
529
+ });
530
+ expect(cached).toBeDefined();
531
+ // The cached state's members Map carries the entry we just added.
532
+ const members = (cached as { members: Map<string, unknown> }).members;
533
+ expect(members.get('c-peek')).toBeDefined();
534
+ });
535
+
536
+ it('__resetCache clears the in-memory cache', async () => {
537
+ const lower = '#reset-cache';
538
+ await channel(lower).applyChannelDelta({
539
+ memberships: [{ type: 'add' as const, conn: 'c-reset', nick: 'resetter' }],
540
+ });
541
+
542
+ await runInDurableObject(channelStub(lower), (instance: unknown) => {
543
+ const doi = instance as { __resetCache?: () => void };
544
+ doi.__resetCache?.();
545
+ });
546
+
547
+ const cached = await runInDurableObject(channelStub(lower), async (instance: unknown) => {
548
+ const doi = instance as { __peekState?: () => Promise<unknown> };
549
+ return doi.__peekState?.();
550
+ });
551
+ expect(cached).toBeUndefined();
552
+ });
553
+
554
+ it('broadcast with all-live members does not prune the roster', async () => {
555
+ // When every member has an OPEN WebSocket, deliver returns > 0 for
556
+ // each and the `pruned.length > 0` else branch is taken (no prune
557
+ // delta applied).
558
+ const chan = '#no-prune';
559
+ const alice = await connectAndRegister('conn-noprune-alice', 'alice');
560
+ const bob = await connectAndRegister('conn-noprune-bob', 'bob');
561
+
562
+ await channel(chan.toLowerCase()).applyChannelDelta({
563
+ memberships: [
564
+ { type: 'add', conn: alice.id, nick: 'alice' },
565
+ { type: 'add', conn: bob.id, nick: 'bob' },
566
+ ],
567
+ });
568
+
569
+ await channel(chan.toLowerCase()).broadcast(['PRIVMSG #no-prune :hello']);
570
+ await alice.client.waitForMessage((l) => l.startsWith('PRIVMSG #no-prune'));
571
+ await bob.client.waitForMessage((l) => l.startsWith('PRIVMSG #no-prune'));
572
+
573
+ // Both members survive — no prune occurred.
574
+ const snap = await channel(chan.toLowerCase()).getChannelSnapshot();
575
+ expect(snap?.members.length).toBe(2);
576
+
577
+ await alice.client.close();
578
+ await bob.client.close();
579
+ });
580
+
581
+ it('sweep on a channel with all-live members prunes nobody', async () => {
582
+ const chan = '#sweep-clean';
583
+ const alice = await connectAndRegister('conn-sweep-uniq-001', 'sweepop');
584
+
585
+ await channel(chan.toLowerCase()).applyChannelDelta({
586
+ memberships: [{ type: 'add', conn: alice.id, nick: 'alice' }],
587
+ });
588
+
589
+ const pruned = await channel(chan.toLowerCase()).sweep();
590
+ expect(pruned).toBe(0);
591
+
592
+ const snap = await channel(chan.toLowerCase()).getChannelSnapshot();
593
+ expect(snap?.members.length).toBe(1);
594
+
595
+ await alice.client.close();
596
+ });
597
+ });