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.
- package/.github/workflows/ci.yml +40 -3
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +474 -0
- package/README.md +219 -22
- package/apps/aws-stack/README.md +33 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +74 -9
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +2 -2
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/src/origin-allowlist.ts +99 -0
- package/apps/cf-worker/src/worker.ts +89 -9
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +18 -0
- package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
- package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
- package/apps/cf-worker/tests/smoke.test.ts +28 -2
- package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.test.toml +23 -0
- package/apps/cf-worker/wrangler.toml +59 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +9 -7
- package/apps/local-cli/tests/e2e.test.ts +112 -28
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
- package/apps/web/landing/index.html +217 -0
- package/apps/web/package.json +28 -0
- package/apps/web/scripts/build.mjs +125 -0
- package/apps/web/src/build-env.ts +43 -0
- package/apps/web/src/config-schema.ts +138 -0
- package/apps/web/static/config.json +28 -0
- package/apps/web/static/config.prod.json +28 -0
- package/apps/web/static/config.staging.json +28 -0
- package/apps/web/tests/build-env.test.ts +63 -0
- package/apps/web/tests/build-smoke.test.ts +103 -0
- package/apps/web/tests/config-schema.test.ts +432 -0
- package/apps/web/tests/workspace.test.ts +12 -0
- package/apps/web/tsconfig.json +10 -0
- package/apps/web/upstream/.browserslistrc +3 -0
- package/apps/web/upstream/.editorconfig +9 -0
- package/apps/web/upstream/.eslintignore +3 -0
- package/apps/web/upstream/.eslintrc.js +90 -0
- package/apps/web/upstream/.github/FUNDING.yml +12 -0
- package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/apps/web/upstream/.github/workflows/push.yml +26 -0
- package/apps/web/upstream/.github/workflows/release.yml +33 -0
- package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
- package/apps/web/upstream/.prettierrc.js +9 -0
- package/apps/web/upstream/.stylelintignore +5 -0
- package/apps/web/upstream/.stylelintrc.js +21 -0
- package/apps/web/upstream/LICENSE +201 -0
- package/apps/web/upstream/README.md +76 -0
- package/apps/web/upstream/babel.config.js +25 -0
- package/apps/web/upstream/crowdin.yml +3 -0
- package/apps/web/upstream/index.html +35 -0
- package/apps/web/upstream/jest.config.js +7 -0
- package/apps/web/upstream/jsdoc.json +22 -0
- package/apps/web/upstream/package.json +80 -0
- package/apps/web/upstream/scripts/pre-commit +43 -0
- package/apps/web/upstream/src/components/App.vue +516 -0
- package/apps/web/upstream/src/components/AppSettings.vue +587 -0
- package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
- package/apps/web/upstream/src/components/Avatar.vue +105 -0
- package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
- package/apps/web/upstream/src/components/BufferKey.vue +121 -0
- package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
- package/apps/web/upstream/src/components/Captcha.vue +85 -0
- package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
- package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
- package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
- package/apps/web/upstream/src/components/ChannelList.vue +345 -0
- package/apps/web/upstream/src/components/Container.vue +401 -0
- package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
- package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
- package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
- package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
- package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
- package/apps/web/upstream/src/components/MessageList.vue +1028 -0
- package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
- package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
- package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
- package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
- package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
- package/apps/web/upstream/src/components/Nicklist.vue +309 -0
- package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
- package/apps/web/upstream/src/components/NotConnected.vue +271 -0
- package/apps/web/upstream/src/components/SelfUser.vue +283 -0
- package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
- package/apps/web/upstream/src/components/ServerView.vue +126 -0
- package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
- package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
- package/apps/web/upstream/src/components/Sidebar.vue +429 -0
- package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
- package/apps/web/upstream/src/components/SidebarState.vue +145 -0
- package/apps/web/upstream/src/components/StartupError.vue +35 -0
- package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
- package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
- package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
- package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
- package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
- package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
- package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
- package/apps/web/upstream/src/components/UserBox.vue +728 -0
- package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
- package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
- package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
- package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
- package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
- package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
- package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
- package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
- package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
- package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
- package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
- package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
- package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
- package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
- package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
- package/apps/web/upstream/src/helpers/Colours.js +128 -0
- package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
- package/apps/web/upstream/src/helpers/Md5.js +193 -0
- package/apps/web/upstream/src/helpers/Misc.js +401 -0
- package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
- package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
- package/apps/web/upstream/src/libs/AudioManager.js +60 -0
- package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
- package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
- package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
- package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
- package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
- package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
- package/apps/web/upstream/src/libs/IPC.js +51 -0
- package/apps/web/upstream/src/libs/InputHandler.js +921 -0
- package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
- package/apps/web/upstream/src/libs/Logger.js +71 -0
- package/apps/web/upstream/src/libs/Message.js +169 -0
- package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
- package/apps/web/upstream/src/libs/MessageParser.js +249 -0
- package/apps/web/upstream/src/libs/Notifications.js +78 -0
- package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
- package/apps/web/upstream/src/libs/ServerSession.js +102 -0
- package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
- package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
- package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
- package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
- package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
- package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
- package/apps/web/upstream/src/libs/bufferTools.js +177 -0
- package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
- package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
- package/apps/web/upstream/src/libs/settingTools.js +31 -0
- package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
- package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
- package/apps/web/upstream/src/libs/state/UserState.js +89 -0
- package/apps/web/upstream/src/libs/state/common.js +26 -0
- package/apps/web/upstream/src/libs/state.js +961 -0
- package/apps/web/upstream/src/libs/storage/Local.js +51 -0
- package/apps/web/upstream/src/main.js +566 -0
- package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
- package/apps/web/upstream/src/res/configTemplates.js +373 -0
- package/apps/web/upstream/src/res/globalStyle.css +277 -0
- package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
- package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
- package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
- package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
- package/apps/web/upstream/src/res/localesList.json +27 -0
- package/apps/web/upstream/src/res/logo.png +0 -0
- package/apps/web/upstream/src/thirdparty/about.html +43 -0
- package/apps/web/upstream/src/thirdparty/index.js +5 -0
- package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
- package/apps/web/upstream/static/config.json +28 -0
- package/apps/web/upstream/static/emoticons/smile.png +0 -0
- package/apps/web/upstream/static/favicon.png +0 -0
- package/apps/web/upstream/static/highlight.mp3 +0 -0
- package/apps/web/upstream/static/highlight.ogg +0 -0
- package/apps/web/upstream/static/locales/.gitignore +2 -0
- package/apps/web/upstream/static/plugins/customise.html.example +10 -0
- package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
- package/apps/web/upstream/static/themes/common/base.css +1064 -0
- package/apps/web/upstream/static/themes/dark/theme.css +196 -0
- package/apps/web/upstream/static/themes/default/theme.css +92 -0
- package/apps/web/upstream/static/themes/elite/theme.css +248 -0
- package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
- package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
- package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
- package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
- package/apps/web/upstream/static/themes/sky/theme.css +107 -0
- package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
- package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
- package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
- package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
- package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
- package/apps/web/upstream/vue.config.js +150 -0
- package/apps/web/upstream/yarn.lock +9767 -0
- package/apps/web/vitest.config.ts +13 -0
- package/biome.json +2 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +16 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/docs/WebClientGuide.md +535 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/handlers/default.ts +3 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/serialize.ts +29 -1
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +9 -9
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +90 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/connection-do.ts +3 -0
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/tests/cf-harness.ts +12 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/reports/mutation/mutation.html +342 -0
- package/packages/irc-core/src/caps/capabilities.ts +37 -3
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +34 -0
- package/packages/irc-core/src/commands/isupport.ts +53 -0
- package/packages/irc-core/src/commands/mode.ts +8 -1
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +65 -10
- package/packages/irc-core/src/effects.ts +14 -0
- package/packages/irc-core/src/ports.ts +389 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +15 -0
- package/packages/irc-core/src/protocol/outbound.ts +16 -1
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +112 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/batch.test.ts +15 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/mode.test.ts +72 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +13 -0
- package/packages/irc-core/tests/parser.test.ts +109 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +450 -3
- package/packages/irc-server/src/dispatch.ts +108 -16
- package/packages/irc-server/tests/actor.test.ts +1432 -39
- package/packages/irc-server/tests/dispatch.test.ts +263 -2
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +24 -2
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic IRC load-test client.
|
|
3
|
+
*
|
|
4
|
+
* One {@link IrcLoadClient} drives a single WebSocket connection through
|
|
5
|
+
* the full IRC lifecycle the harness cares about:
|
|
6
|
+
*
|
|
7
|
+
* 1. **connect** — WebSocket handshake (`open` event).
|
|
8
|
+
* 2. **register** — `CAP LS` → (optional `CAP REQ :echo-message`) →
|
|
9
|
+
* `NICK`/`USER` → `CAP END`, measured until the server sends `001`.
|
|
10
|
+
* 3. **join** — `JOIN #chan`, measured until `366` (end of NAMES).
|
|
11
|
+
* 4. **privmsg** — N × `PRIVMSG #chan :body`, each measured until the
|
|
12
|
+
* `echo-message` self-echo arrives (so each message yields one
|
|
13
|
+
* round-trip sample).
|
|
14
|
+
*
|
|
15
|
+
* The client is transport-agnostic: it only needs a `ws://` / `wss://`
|
|
16
|
+
* target. {@link IrcLoadClient.run} resolves to a {@link ClientResult}
|
|
17
|
+
* carrying the per-phase latency samples and, on failure, the phase +
|
|
18
|
+
* reason (drop attribution). It never throws — failures become
|
|
19
|
+
* `ok: false` results so the harness can aggregate them into the drop
|
|
20
|
+
* rate without try/catch per client.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { WebSocket } from 'ws';
|
|
24
|
+
import type { LoadTestConfig } from './config.js';
|
|
25
|
+
import type { ClientResult, PhaseName } from './metrics.js';
|
|
26
|
+
|
|
27
|
+
/** WebSocket constructor type, accepted as an injectable dependency. */
|
|
28
|
+
export type WebSocketCtor = typeof WebSocket;
|
|
29
|
+
|
|
30
|
+
/** Options accepted by {@link IrcLoadClient}. */
|
|
31
|
+
export interface IrcLoadClientOptions {
|
|
32
|
+
/** 0-based client index; also disambiguates the nick and message bodies. */
|
|
33
|
+
id: number;
|
|
34
|
+
/** Resolved load-test config. */
|
|
35
|
+
config: LoadTestConfig;
|
|
36
|
+
/**
|
|
37
|
+
* Injectable WebSocket constructor. Defaults to the real `ws.WebSocket`;
|
|
38
|
+
* tests substitute it to drive the lifecycle against fakes. The real
|
|
39
|
+
* harness always uses the default.
|
|
40
|
+
*/
|
|
41
|
+
WebSocketCtor?: WebSocketCtor;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** IRCv3 WebSocket subprotocols offered when dialing (matches the forwarder). */
|
|
45
|
+
const WS_SUBPROTOCOLS = ['binary.ircv3.net', 'text.ircv3.net'] as const;
|
|
46
|
+
|
|
47
|
+
/** A pending line-waiter registered with `waitForLine`. */
|
|
48
|
+
interface LineWaiter {
|
|
49
|
+
predicate: (line: string) => boolean;
|
|
50
|
+
resolve: (line: string) => void;
|
|
51
|
+
reject: (err: Error) => void;
|
|
52
|
+
phase: PhaseName;
|
|
53
|
+
label: string;
|
|
54
|
+
timer: ReturnType<typeof setTimeout>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One synthetic IRC client. Construct one per desired connection and
|
|
59
|
+
* `await client.run()`; the client closes its own socket on completion
|
|
60
|
+
* (success or failure).
|
|
61
|
+
*/
|
|
62
|
+
export class IrcLoadClient {
|
|
63
|
+
private readonly id: number;
|
|
64
|
+
private readonly config: LoadTestConfig;
|
|
65
|
+
private readonly WebSocketCtor: WebSocketCtor;
|
|
66
|
+
private ws!: WebSocket;
|
|
67
|
+
private readonly samples: Record<PhaseName, number[]> = {
|
|
68
|
+
connect: [],
|
|
69
|
+
register: [],
|
|
70
|
+
join: [],
|
|
71
|
+
privmsg: [],
|
|
72
|
+
};
|
|
73
|
+
private readonly waiters: LineWaiter[] = [];
|
|
74
|
+
/** Set true once we have started tearing down so close/error are idempotent. */
|
|
75
|
+
private settled = false;
|
|
76
|
+
|
|
77
|
+
constructor(opts: IrcLoadClientOptions) {
|
|
78
|
+
this.id = opts.id;
|
|
79
|
+
this.config = opts.config;
|
|
80
|
+
this.WebSocketCtor = opts.WebSocketCtor ?? WebSocket;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The nick this client registers with (`<nickPrefix><id>`). */
|
|
84
|
+
get nick(): string {
|
|
85
|
+
return `${this.config.nickPrefix}${this.id}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Runs the full lifecycle, resolving to a {@link ClientResult}. Never
|
|
90
|
+
* rejects — every failure path is captured as `ok: false` with the
|
|
91
|
+
* phase and reason populated, so the caller can aggregate uniformly.
|
|
92
|
+
*/
|
|
93
|
+
async run(): Promise<ClientResult> {
|
|
94
|
+
const failedAt = (phase: PhaseName, err: unknown): ClientResult => ({
|
|
95
|
+
id: this.id,
|
|
96
|
+
ok: false,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
failedPhase: phase,
|
|
99
|
+
samples: this.samples,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await this.connect();
|
|
104
|
+
try {
|
|
105
|
+
await this.register();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return failedAt('register', err);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await this.join();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return failedAt('join', err);
|
|
113
|
+
}
|
|
114
|
+
if (this.config.messagesPerClient > 0) {
|
|
115
|
+
try {
|
|
116
|
+
await this.chat();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return failedAt('privmsg', err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { id: this.id, ok: true, samples: this.samples };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Only connect-phase failures land here (register/join/chat are
|
|
124
|
+
// caught above so a later-phase drop keeps its earlier samples).
|
|
125
|
+
return failedAt('connect', err);
|
|
126
|
+
} finally {
|
|
127
|
+
this.teardown();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Phase 1: open the WebSocket and record the handshake latency. */
|
|
132
|
+
private connect(): Promise<void> {
|
|
133
|
+
return new Promise<void>((resolve, reject) => {
|
|
134
|
+
const t0 = Date.now();
|
|
135
|
+
this.ws = new this.WebSocketCtor(this.config.target, [...WS_SUBPROTOCOLS]);
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
if (!this.settled) {
|
|
138
|
+
this.failWaiters(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
|
|
139
|
+
reject(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
|
|
140
|
+
}
|
|
141
|
+
}, this.config.connectTimeoutMs);
|
|
142
|
+
|
|
143
|
+
this.ws.once('open', () => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
this.samples.connect.push(Date.now() - t0);
|
|
146
|
+
// Route inbound frames. Attached here (not in the ctor) so a
|
|
147
|
+
// pre-open error doesn't race a message handler against the reject.
|
|
148
|
+
this.ws.on('message', (data) => this.onMessage(wsFrameToText(data)));
|
|
149
|
+
this.ws.once('close', () => this.onClose());
|
|
150
|
+
this.ws.on('error', (err) => this.onError(err));
|
|
151
|
+
resolve();
|
|
152
|
+
});
|
|
153
|
+
this.ws.once('error', (err) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
reject(err);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Phase 2: negotiate caps + register. Measures from the first
|
|
162
|
+
* registration byte sent to the `001` welcome numeric.
|
|
163
|
+
*/
|
|
164
|
+
private async register(): Promise<void> {
|
|
165
|
+
const t0 = Date.now();
|
|
166
|
+
if (this.config.echoMessage) {
|
|
167
|
+
this.send('CAP LS');
|
|
168
|
+
await this.waitForLine(
|
|
169
|
+
(l) => l.includes(' CAP * LS :'),
|
|
170
|
+
'register',
|
|
171
|
+
'CAP LS reply',
|
|
172
|
+
this.config.registerTimeoutMs,
|
|
173
|
+
);
|
|
174
|
+
this.send('CAP REQ :echo-message');
|
|
175
|
+
await this.waitForLine(
|
|
176
|
+
(l) => l.includes(' CAP * ACK :echo-message'),
|
|
177
|
+
'register',
|
|
178
|
+
'CAP ACK echo-message',
|
|
179
|
+
this.config.registerTimeoutMs,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
this.send(`NICK ${this.nick}`);
|
|
183
|
+
this.send(`USER ${this.nick} 0 * :${this.nick}`);
|
|
184
|
+
if (this.config.echoMessage) {
|
|
185
|
+
this.send('CAP END');
|
|
186
|
+
}
|
|
187
|
+
await this.waitForLine(
|
|
188
|
+
(l) => numericOf(l) === '001',
|
|
189
|
+
'register',
|
|
190
|
+
'001 welcome',
|
|
191
|
+
this.config.registerTimeoutMs,
|
|
192
|
+
);
|
|
193
|
+
this.samples.register.push(Date.now() - t0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Phase 3: JOIN the configured channel, measured until `366`. */
|
|
197
|
+
private async join(): Promise<void> {
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
this.send(`JOIN ${this.config.channel}`);
|
|
200
|
+
await this.waitForLine(
|
|
201
|
+
(l) => numericOf(l) === '366' && l.includes(this.config.channel),
|
|
202
|
+
'join',
|
|
203
|
+
'366 end of NAMES',
|
|
204
|
+
this.config.registerTimeoutMs,
|
|
205
|
+
);
|
|
206
|
+
this.samples.join.push(Date.now() - t0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Phase 4: send N PRIVMSGs and measure each echo round-trip. The body
|
|
211
|
+
* is unique per (client, message) so the echo match never collides
|
|
212
|
+
* with another client's broadcast in the shared channel.
|
|
213
|
+
*/
|
|
214
|
+
private async chat(): Promise<void> {
|
|
215
|
+
for (let i = 0; i < this.config.messagesPerClient; i++) {
|
|
216
|
+
const body = `msg-${this.id}-${i}`;
|
|
217
|
+
const t0 = Date.now();
|
|
218
|
+
this.send(`PRIVMSG ${this.config.channel} :${body}`);
|
|
219
|
+
await this.waitForLine(
|
|
220
|
+
(l) => l.includes('PRIVMSG') && l.includes(this.config.channel) && l.includes(body),
|
|
221
|
+
'privmsg',
|
|
222
|
+
`echo for ${body}`,
|
|
223
|
+
this.config.registerTimeoutMs,
|
|
224
|
+
);
|
|
225
|
+
this.samples.privmsg.push(Date.now() - t0);
|
|
226
|
+
if (i < this.config.messagesPerClient - 1 && this.config.messageIntervalMs > 0) {
|
|
227
|
+
await delay(this.config.messageIntervalMs);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Sends one bare IRC line as a single WebSocket text frame. */
|
|
233
|
+
private send(line: string): void {
|
|
234
|
+
// One message per frame, no trailing CR-LF — the canonical IRCv3 WS
|
|
235
|
+
// convention the server's ConnectionActor expects. The server is
|
|
236
|
+
// tolerant of CRLF too, but bare is the wire-spec form.
|
|
237
|
+
if (this.ws.readyState === this.ws.OPEN) {
|
|
238
|
+
this.ws.send(line);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Splits an inbound WS frame into IRC lines and dispatches to waiters. */
|
|
243
|
+
private onMessage(text: string): void {
|
|
244
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
245
|
+
if (line.length === 0) continue;
|
|
246
|
+
// Dispatch to the first matching waiter (FIFO preserves phase order).
|
|
247
|
+
const idx = this.waiters.findIndex((w) => w.predicate(line));
|
|
248
|
+
if (idx >= 0) {
|
|
249
|
+
const waiter = this.waiters[idx];
|
|
250
|
+
if (waiter !== undefined) {
|
|
251
|
+
this.waiters.splice(idx, 1);
|
|
252
|
+
clearTimeout(waiter.timer);
|
|
253
|
+
waiter.resolve(line);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Connection closed unexpectedly: fail every pending waiter. */
|
|
260
|
+
private onClose(): void {
|
|
261
|
+
if (this.settled) return;
|
|
262
|
+
this.failWaiters(new Error('connection closed by server'));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Underlying socket error: fail every pending waiter (once). */
|
|
266
|
+
private onError(err: Error): void {
|
|
267
|
+
if (this.settled) return;
|
|
268
|
+
this.failWaiters(err);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves the next line matching `predicate` within `timeoutMs`, else
|
|
273
|
+
* rejects with a timeout error tagged with `label`. The `phase` is
|
|
274
|
+
* recorded on the waiter purely so a close-during-wait error can be
|
|
275
|
+
* attributed; the harness reads the failed phase off the outer catch.
|
|
276
|
+
*/
|
|
277
|
+
private waitForLine(
|
|
278
|
+
predicate: (line: string) => boolean,
|
|
279
|
+
phase: PhaseName,
|
|
280
|
+
label: string,
|
|
281
|
+
timeoutMs: number,
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
return new Promise<string>((resolve, reject) => {
|
|
284
|
+
const timer = setTimeout(() => {
|
|
285
|
+
const idx = this.waiters.findIndex((w) => w.resolve === resolve);
|
|
286
|
+
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
287
|
+
reject(new Error(`timed out waiting for ${label} (${timeoutMs}ms)`));
|
|
288
|
+
}, timeoutMs);
|
|
289
|
+
this.waiters.push({ predicate, resolve, reject, phase, label, timer });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Fails every pending waiter (used on close/error/teardown). */
|
|
294
|
+
private failWaiters(err: Error): void {
|
|
295
|
+
const pending = this.waiters.splice(0, this.waiters.length);
|
|
296
|
+
for (const w of pending) {
|
|
297
|
+
clearTimeout(w.timer);
|
|
298
|
+
w.reject(err);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Idempotently closes the socket and drains waiters. */
|
|
303
|
+
private teardown(): void {
|
|
304
|
+
if (this.settled) return;
|
|
305
|
+
this.settled = true;
|
|
306
|
+
this.failWaiters(new Error('client teardown'));
|
|
307
|
+
try {
|
|
308
|
+
if (this.ws !== undefined) {
|
|
309
|
+
// QUIT is best-effort; ignore ready-state edge cases at teardown.
|
|
310
|
+
if (this.ws.readyState === this.ws.OPEN) {
|
|
311
|
+
this.ws.send(`QUIT :load-test client ${this.id}`);
|
|
312
|
+
}
|
|
313
|
+
this.ws.close();
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// c8 ignore next 1 - defensive: teardown must never throw back
|
|
317
|
+
// through run()'s finally; a half-closed socket emitting on close()
|
|
318
|
+
// is the only way to reach here.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Normalises a `ws` message payload to a UTF-8 string. Exported so the
|
|
325
|
+
* `Buffer[]` / `ArrayBuffer` / `Uint8Array` variants — which `ws` only
|
|
326
|
+
* emits under non-default `binaryType` / fragmentation settings — can be
|
|
327
|
+
* unit-tested without contriving real socket conditions. Mirrors the
|
|
328
|
+
* forwarder's `toBuffer` contract.
|
|
329
|
+
*/
|
|
330
|
+
export function wsFrameToText(data: unknown): string {
|
|
331
|
+
if (typeof data === 'string') return data;
|
|
332
|
+
if (Array.isArray(data)) {
|
|
333
|
+
return Buffer.concat(data as ReadonlyArray<Buffer>).toString('utf8');
|
|
334
|
+
}
|
|
335
|
+
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
|
336
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
|
|
337
|
+
return Buffer.from(data as Uint8Array).toString('utf8');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Extracts the IRC numeric (3-digit command) from a line, or `undefined`. */
|
|
341
|
+
function numericOf(line: string): string | undefined {
|
|
342
|
+
const parts = line.split(' ');
|
|
343
|
+
const start = parts[0]?.startsWith(':') ? 1 : 0;
|
|
344
|
+
const candidate = parts[start];
|
|
345
|
+
return candidate !== undefined && /^\d{3}$/u.test(candidate) ? candidate : undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Resolves after `ms` milliseconds. */
|
|
349
|
+
function delay(ms: number): Promise<void> {
|
|
350
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
351
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-test configuration: defaulting, validation, and CLI parsing.
|
|
3
|
+
*
|
|
4
|
+
* This is the fail-fast gate for a run (PLAN §6). A malformed
|
|
5
|
+
* target URL or a nonsensical concurrency value throws here, before the
|
|
6
|
+
* harness opens a single socket. The parsing mirrors the
|
|
7
|
+
* `tcp-ws-forwarder` CLI convention (bare positional URL allowed, explicit
|
|
8
|
+
* `--flag value` pairs, readable error on unknown args).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Fully-resolved configuration for a load-test run. Every field is set. */
|
|
12
|
+
export interface LoadTestConfig {
|
|
13
|
+
/** Upstream `ws://` / `wss://` URL the synthetic clients dial. */
|
|
14
|
+
target: string;
|
|
15
|
+
/**
|
|
16
|
+
* Human-readable platform label baked into the report header (e.g.
|
|
17
|
+
* `cf-staging`, `aws-prod`). Defaults to `unknown`.
|
|
18
|
+
*/
|
|
19
|
+
platform: string;
|
|
20
|
+
/** Total number of synthetic clients to open. */
|
|
21
|
+
connections: number;
|
|
22
|
+
/**
|
|
23
|
+
* Ramp-up window over which the connections are spread (ms). 0 means
|
|
24
|
+
* "open them as fast as `connectConcurrency` allows".
|
|
25
|
+
*/
|
|
26
|
+
rampMs: number;
|
|
27
|
+
/** Max simultaneous in-flight WebSocket handshakes. */
|
|
28
|
+
connectConcurrency: number;
|
|
29
|
+
/** Per-connection timeout for the WebSocket `open` event (ms). */
|
|
30
|
+
connectTimeoutMs: number;
|
|
31
|
+
/** Per-connection timeout for registration (NICK/USER → `001`) (ms). */
|
|
32
|
+
registerTimeoutMs: number;
|
|
33
|
+
/** Channel every client joins after registering. Must start with `#`. */
|
|
34
|
+
channel: string;
|
|
35
|
+
/** Prefix for generated nicks (clients are named `<prefix><index>`). */
|
|
36
|
+
nickPrefix: string;
|
|
37
|
+
/** Number of PRIVMSG lines each client sends after joining. 0 = skip chat. */
|
|
38
|
+
messagesPerClient: number;
|
|
39
|
+
/** Delay between successive PRIVMSGs from one client (ms). */
|
|
40
|
+
messageIntervalMs: number;
|
|
41
|
+
/**
|
|
42
|
+
* Whether to negotiate the `echo-message` cap so each PRIVMSG echoes
|
|
43
|
+
* back to the sender (enables per-message round-trip latency). Defaults
|
|
44
|
+
* to `true` when `messagesPerClient > 0`.
|
|
45
|
+
*/
|
|
46
|
+
echoMessage: boolean;
|
|
47
|
+
/** Optional path to write the rendered markdown report to. */
|
|
48
|
+
reportPath?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Defaults applied by {@link resolveConfig} when a field is omitted. */
|
|
52
|
+
export const DEFAULTS = {
|
|
53
|
+
platform: 'unknown',
|
|
54
|
+
connections: 1000,
|
|
55
|
+
rampMs: 0,
|
|
56
|
+
connectConcurrency: 100,
|
|
57
|
+
connectTimeoutMs: 10_000,
|
|
58
|
+
registerTimeoutMs: 15_000,
|
|
59
|
+
channel: '#loadtest',
|
|
60
|
+
nickPrefix: 'load',
|
|
61
|
+
messagesPerClient: 0,
|
|
62
|
+
messageIntervalMs: 100,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validates and resolves a partial config into a complete
|
|
67
|
+
* {@link LoadTestConfig}. Throws synchronously on any invalid value so the
|
|
68
|
+
* harness never starts a run with a broken configuration.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveConfig(input: Partial<LoadTestConfig>): LoadTestConfig {
|
|
71
|
+
if (input.target === undefined) {
|
|
72
|
+
throw new Error('load-test: --target <ws|wss url> is required');
|
|
73
|
+
}
|
|
74
|
+
// URL validation: re-uses the same scheme rule as the forwarder so a
|
|
75
|
+
// typo (`http://`) fails loudly rather than producing a confusing ws error.
|
|
76
|
+
let url: URL;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(input.target);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error(`load-test: invalid target URL (expected ws:// or wss://): ${input.target}`);
|
|
81
|
+
}
|
|
82
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`load-test: target URL must use ws:// or wss:// (got ${url.protocol}): ${input.target}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const connections = input.connections ?? DEFAULTS.connections;
|
|
89
|
+
if (!Number.isInteger(connections) || connections < 1) {
|
|
90
|
+
throw new Error(`load-test: --connections must be a positive integer (got ${connections})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const connectConcurrency = input.connectConcurrency ?? DEFAULTS.connectConcurrency;
|
|
94
|
+
if (!Number.isInteger(connectConcurrency) || connectConcurrency < 1) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`load-test: --concurrency must be a positive integer (got ${connectConcurrency})`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const messagesPerClient = input.messagesPerClient ?? DEFAULTS.messagesPerClient;
|
|
101
|
+
if (!Number.isInteger(messagesPerClient) || messagesPerClient < 0) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`load-test: --messages must be a non-negative integer (got ${messagesPerClient})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const channel = input.channel ?? DEFAULTS.channel;
|
|
108
|
+
if (!channel.startsWith('#')) {
|
|
109
|
+
throw new Error(`load-test: --channel must start with '#' (got ${channel})`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const connectTimeoutMs = input.connectTimeoutMs ?? DEFAULTS.connectTimeoutMs;
|
|
113
|
+
if (connectTimeoutMs <= 0) {
|
|
114
|
+
throw new Error(`load-test: connect timeout must be positive (got ${connectTimeoutMs})`);
|
|
115
|
+
}
|
|
116
|
+
const registerTimeoutMs = input.registerTimeoutMs ?? DEFAULTS.registerTimeoutMs;
|
|
117
|
+
if (registerTimeoutMs <= 0) {
|
|
118
|
+
throw new Error(`load-test: register timeout must be positive (got ${registerTimeoutMs})`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rampMs = input.rampMs ?? DEFAULTS.rampMs;
|
|
122
|
+
if (rampMs < 0) {
|
|
123
|
+
throw new Error(`load-test: --ramp-ms must be non-negative (got ${rampMs})`);
|
|
124
|
+
}
|
|
125
|
+
const messageIntervalMs = input.messageIntervalMs ?? DEFAULTS.messageIntervalMs;
|
|
126
|
+
if (messageIntervalMs < 0) {
|
|
127
|
+
throw new Error(`load-test: message interval must be non-negative (got ${messageIntervalMs})`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// echo-message defaults on when the client is expected to chat; an
|
|
131
|
+
// explicit override always wins.
|
|
132
|
+
const echoMessage =
|
|
133
|
+
input.echoMessage ?? (messagesPerClient > 0 ? true : (input.echoMessage ?? false));
|
|
134
|
+
|
|
135
|
+
const cfg: LoadTestConfig = {
|
|
136
|
+
target: input.target,
|
|
137
|
+
platform: input.platform ?? DEFAULTS.platform,
|
|
138
|
+
connections,
|
|
139
|
+
rampMs,
|
|
140
|
+
connectConcurrency,
|
|
141
|
+
connectTimeoutMs,
|
|
142
|
+
registerTimeoutMs,
|
|
143
|
+
channel,
|
|
144
|
+
nickPrefix: input.nickPrefix ?? DEFAULTS.nickPrefix,
|
|
145
|
+
messagesPerClient,
|
|
146
|
+
messageIntervalMs,
|
|
147
|
+
echoMessage,
|
|
148
|
+
};
|
|
149
|
+
if (input.reportPath !== undefined) {
|
|
150
|
+
cfg.reportPath = input.reportPath;
|
|
151
|
+
}
|
|
152
|
+
return cfg;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Parsed CLI flags, prior to defaulting/validation. */
|
|
156
|
+
type PartialConfig = Partial<LoadTestConfig>;
|
|
157
|
+
|
|
158
|
+
/** A flag that takes a string value, mapped to its config key. */
|
|
159
|
+
const STRING_FLAGS: Record<
|
|
160
|
+
string,
|
|
161
|
+
keyof Pick<LoadTestConfig, 'target' | 'channel' | 'nickPrefix' | 'platform' | 'reportPath'>
|
|
162
|
+
> = {
|
|
163
|
+
'--target': 'target',
|
|
164
|
+
'--channel': 'channel',
|
|
165
|
+
'--nick-prefix': 'nickPrefix',
|
|
166
|
+
'--platform': 'platform',
|
|
167
|
+
'--report': 'reportPath',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** A flag that takes an integer value, mapped to its config key. */
|
|
171
|
+
const NUM_FLAGS: Record<
|
|
172
|
+
string,
|
|
173
|
+
keyof Pick<
|
|
174
|
+
LoadTestConfig,
|
|
175
|
+
| 'connections'
|
|
176
|
+
| 'connectConcurrency'
|
|
177
|
+
| 'rampMs'
|
|
178
|
+
| 'messagesPerClient'
|
|
179
|
+
| 'messageIntervalMs'
|
|
180
|
+
| 'connectTimeoutMs'
|
|
181
|
+
| 'registerTimeoutMs'
|
|
182
|
+
>
|
|
183
|
+
> = {
|
|
184
|
+
'--connections': 'connections',
|
|
185
|
+
'--concurrency': 'connectConcurrency',
|
|
186
|
+
'--ramp-ms': 'rampMs',
|
|
187
|
+
'--messages': 'messagesPerClient',
|
|
188
|
+
'--message-interval-ms': 'messageIntervalMs',
|
|
189
|
+
'--connect-timeout-ms': 'connectTimeoutMs',
|
|
190
|
+
'--register-timeout-ms': 'registerTimeoutMs',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Sets one string-valued flag on the partial config (type-safe helper). */
|
|
194
|
+
function setStringFlag(
|
|
195
|
+
out: PartialConfig,
|
|
196
|
+
key: keyof Pick<LoadTestConfig, 'target' | 'channel' | 'nickPrefix' | 'platform' | 'reportPath'>,
|
|
197
|
+
value: string,
|
|
198
|
+
): void {
|
|
199
|
+
(out as Record<string, unknown>)[key] = value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Sets one integer-valued flag on the partial config (type-safe helper). */
|
|
203
|
+
function setNumFlag(
|
|
204
|
+
out: PartialConfig,
|
|
205
|
+
key: keyof Pick<
|
|
206
|
+
LoadTestConfig,
|
|
207
|
+
| 'connections'
|
|
208
|
+
| 'connectConcurrency'
|
|
209
|
+
| 'rampMs'
|
|
210
|
+
| 'messagesPerClient'
|
|
211
|
+
| 'messageIntervalMs'
|
|
212
|
+
| 'connectTimeoutMs'
|
|
213
|
+
| 'registerTimeoutMs'
|
|
214
|
+
>,
|
|
215
|
+
value: number,
|
|
216
|
+
): void {
|
|
217
|
+
(out as Record<string, unknown>)[key] = value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parses an argv array (`process.argv.slice(2)` shape) into a validated
|
|
222
|
+
* {@link LoadTestConfig}. Recognised flags:
|
|
223
|
+
*
|
|
224
|
+
* --target <url> Upstream ws:// or wss:// URL (required).
|
|
225
|
+
* --connections <n> Total synthetic clients (default 1000).
|
|
226
|
+
* --concurrency <n> Max simultaneous handshakes (default 100).
|
|
227
|
+
* --ramp-ms <n> Ramp-up window in ms (default 0).
|
|
228
|
+
* --channel <#name> Channel to join (default #loadtest).
|
|
229
|
+
* --nick-prefix <s> Nick prefix (default load).
|
|
230
|
+
* --messages <n> PRIVMSGs per client (default 0).
|
|
231
|
+
* --message-interval-ms <n> Delay between PRIVMSGs (default 100).
|
|
232
|
+
* --no-echo-message Disable echo-message negotiation.
|
|
233
|
+
* --connect-timeout-ms <n> WebSocket open timeout (default 10000).
|
|
234
|
+
* --register-timeout-ms <n> Registration timeout (default 15000).
|
|
235
|
+
* --platform <label> Report header label (default unknown).
|
|
236
|
+
* --report <path> Write the markdown report to this path.
|
|
237
|
+
* -h, --help Show help and exit.
|
|
238
|
+
*
|
|
239
|
+
* A bare positional URL is accepted as the target.
|
|
240
|
+
*/
|
|
241
|
+
export function parseArgs(argv: string[]): LoadTestConfig {
|
|
242
|
+
const out: PartialConfig = {};
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < argv.length; i++) {
|
|
245
|
+
const a = argv[i];
|
|
246
|
+
if (a === undefined) continue;
|
|
247
|
+
const next = argv[i + 1];
|
|
248
|
+
|
|
249
|
+
if (a === '--no-echo-message') {
|
|
250
|
+
out.echoMessage = false;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const stringKey = STRING_FLAGS[a];
|
|
255
|
+
if (stringKey !== undefined) {
|
|
256
|
+
if (next === undefined) {
|
|
257
|
+
throw new Error(`load-test: ${a} requires a value`);
|
|
258
|
+
}
|
|
259
|
+
setStringFlag(out, stringKey, next);
|
|
260
|
+
i++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const numKey = NUM_FLAGS[a];
|
|
264
|
+
if (numKey !== undefined) {
|
|
265
|
+
if (next === undefined) {
|
|
266
|
+
throw new Error(`load-test: ${a} requires a value`);
|
|
267
|
+
}
|
|
268
|
+
const n = Number.parseInt(next, 10);
|
|
269
|
+
if (!Number.isFinite(n)) {
|
|
270
|
+
throw new Error(`load-test: ${a} expects an integer (got ${next})`);
|
|
271
|
+
}
|
|
272
|
+
setNumFlag(out, numKey, n);
|
|
273
|
+
i++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Bare positional URL: `load-test wss://host/`.
|
|
278
|
+
if (!a.startsWith('-') && out.target === undefined) {
|
|
279
|
+
out.target = a;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
throw new Error(`load-test: unknown argument: ${a}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return resolveConfig(out);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const HELP_TEXT = [
|
|
290
|
+
'Usage: load-test --target <ws|wss url> [options]',
|
|
291
|
+
'',
|
|
292
|
+
'Synthetic WebSocket IRC client farm (PLAN §6). Opens N',
|
|
293
|
+
'connections, registers (NICK/USER), joins a channel, and optionally',
|
|
294
|
+
'chats. Captures p50/p95/p99 latency per phase and the drop rate, then',
|
|
295
|
+
'prints a markdown report.',
|
|
296
|
+
'',
|
|
297
|
+
'Options:',
|
|
298
|
+
' --target <url> Upstream ws:// or wss:// URL (required).',
|
|
299
|
+
' --connections <n> Total synthetic clients (default 1000).',
|
|
300
|
+
' --concurrency <n> Max simultaneous handshakes (default 100).',
|
|
301
|
+
' --ramp-ms <n> Ramp-up window in ms (default 0).',
|
|
302
|
+
' --channel <#name> Channel to join (default #loadtest).',
|
|
303
|
+
' --nick-prefix <s> Nick prefix (default load).',
|
|
304
|
+
' --messages <n> PRIVMSGs per client (default 0 = skip chat).',
|
|
305
|
+
' --message-interval-ms <n> Delay between PRIVMSGs (default 100).',
|
|
306
|
+
' --no-echo-message Disable echo-message negotiation.',
|
|
307
|
+
' --connect-timeout-ms <n> WebSocket open timeout (default 10000).',
|
|
308
|
+
' --register-timeout-ms <n> Registration timeout (default 15000).',
|
|
309
|
+
' --platform <label> Report header label (default unknown).',
|
|
310
|
+
' --report <path> Write the markdown report to this path.',
|
|
311
|
+
' -h, --help Show this help and exit.',
|
|
312
|
+
'',
|
|
313
|
+
].join('\n');
|