This commit is contained in:
nvms 2025-04-22 11:17:31 -04:00
parent 49caf89101
commit d31d083ee3
46 changed files with 0 additions and 8125 deletions

View File

@ -1,34 +0,0 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@ -1,5 +0,0 @@
node_modules
src
docker-compose.yml
bun.lock
vitest.config.ts

View File

@ -1,399 +0,0 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "keepalive-multi",
"dependencies": {
"deasync": "^0.1.30",
"fast-json-patch": "^3.1.1",
"ioredis": "^5.6.1",
"uuid": "^11.1.0",
"ws": "^8.18.1",
},
"devDependencies": {
"@types/bun": "latest",
"@types/deasync": "^0.1.5",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",
"vitest": "^3.1.1",
},
"peerDependencies": {
"typescript": "^5.8.3",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.2", "", { "os": "android", "cpu": "x64" }, "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.2", "", { "os": "linux", "cpu": "arm" }, "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.2", "", { "os": "none", "cpu": "arm64" }, "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.2", "", { "os": "none", "cpu": "x64" }, "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="],
"@ioredis/commands": ["@ioredis/commands@1.2.0", "", {}, "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.0", "", { "os": "android", "cpu": "arm64" }, "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.40.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.40.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg=="],
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.40.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.40.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ=="],
"@types/bun": ["@types/bun@1.2.9", "", { "dependencies": { "bun-types": "1.2.9" } }, "sha512-epShhLGQYc4Bv/aceHbmBhOz1XgUnuTZgcxjxk+WXwNyDXavv5QHD1QEFV0FwbTSQtNq6g4ZcV6y0vZakTjswg=="],
"@types/deasync": ["@types/deasync@0.1.5", "", {}, "sha512-mLov/tw+fOX4ZsrT9xuHOJv8xToOpNsp6W4gp8VDHy2qniJ58izyOzHlisnz5r8HdZ+WItDHtANWZy/W0JEJwg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="],
"@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitest/expect": ["@vitest/expect@3.1.1", "", { "dependencies": { "@vitest/spy": "3.1.1", "@vitest/utils": "3.1.1", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA=="],
"@vitest/mocker": ["@vitest/mocker@3.1.1", "", { "dependencies": { "@vitest/spy": "3.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.1.1", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA=="],
"@vitest/runner": ["@vitest/runner@3.1.1", "", { "dependencies": { "@vitest/utils": "3.1.1", "pathe": "^2.0.3" } }, "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA=="],
"@vitest/snapshot": ["@vitest/snapshot@3.1.1", "", { "dependencies": { "@vitest/pretty-format": "3.1.1", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw=="],
"@vitest/spy": ["@vitest/spy@3.1.1", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ=="],
"@vitest/utils": ["@vitest/utils@3.1.1", "", { "dependencies": { "@vitest/pretty-format": "3.1.1", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg=="],
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="],
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chai": ["chai@5.2.0", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"deasync": ["deasync@0.1.30", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^1.7.1" } }, "sha512-OaAjvEQuQ9tJsKG4oHO9nV1UHTwb2Qc2+fadB0VeVtD0Z9wiG1XPGLJ4W3aLhAoQSYTaLROFRbd5X20Dkzf7MQ=="],
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"es-module-lexer": ["es-module-lexer@1.6.0", "", {}, "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ=="],
"esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.2.1", "", {}, "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="],
"fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="],
"fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"ioredis": ["ioredis@5.6.1", "", { "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"loupe": ["loupe@3.1.3", "", {}, "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"rollup": ["rollup@4.40.0", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.0", "@rollup/rollup-android-arm64": "4.40.0", "@rollup/rollup-darwin-arm64": "4.40.0", "@rollup/rollup-darwin-x64": "4.40.0", "@rollup/rollup-freebsd-arm64": "4.40.0", "@rollup/rollup-freebsd-x64": "4.40.0", "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", "@rollup/rollup-linux-arm-musleabihf": "4.40.0", "@rollup/rollup-linux-arm64-gnu": "4.40.0", "@rollup/rollup-linux-arm64-musl": "4.40.0", "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-musl": "4.40.0", "@rollup/rollup-linux-s390x-gnu": "4.40.0", "@rollup/rollup-linux-x64-gnu": "4.40.0", "@rollup/rollup-linux-x64-musl": "4.40.0", "@rollup/rollup-win32-arm64-msvc": "4.40.0", "@rollup/rollup-win32-ia32-msvc": "4.40.0", "@rollup/rollup-win32-x64-msvc": "4.40.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="],
"tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsup": ["tsup@8.4.0", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.25.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vite": ["vite@6.3.0", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.3", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.12" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-9aC0n4pr6hIbvi1YOpFjwQ+QOTGssvbJKoeYkuHHGWwlXfdxQlI8L2qNMo9awEEcCPSiS+5mJZk5jH1PAqoDeQ=="],
"vite-node": ["vite-node@3.1.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w=="],
"vitest": ["vitest@3.1.1", "", { "dependencies": { "@vitest/expect": "3.1.1", "@vitest/mocker": "3.1.1", "@vitest/pretty-format": "^3.1.1", "@vitest/runner": "3.1.1", "@vitest/snapshot": "3.1.1", "@vitest/spy": "3.1.1", "@vitest/utils": "3.1.1", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.2.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.1", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.1.1", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.1.1", "@vitest/ui": "3.1.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

View File

@ -1,16 +0,0 @@
version: "3.8"
services:
redis:
image: redis:7
ports:
- "6379:6379"
command: ["redis-server", "--save", "", "--appendonly", "no"]
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
- redis

View File

@ -1,61 +0,0 @@
{
"name": "@prsm/mesh",
"version": "1.0.7",
"type": "module",
"exports": {
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js",
"require": "./dist/server/index.cjs"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js",
"require": "./dist/client/index.cjs"
},
"./client-utils": {
"types": "./dist/client-utils/index.d.ts",
"import": "./dist/client-utils/index.js",
"require": "./dist/client-utils/index.cjs"
}
},
"typesVersions": {
"*": {
"server": [
"dist/server/index.d.ts"
],
"client": [
"dist/client/index.d.ts"
],
"client-utils": [
"dist/client-utils/index.d.ts"
]
}
},
"scripts": {
"build": "bun run build:prep && bun run build:server && bun run build:client && bun run build:client-utils",
"build:client": "tsup src/client/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/client",
"build:prep": "rm -rf dist && mkdir dist && mkdir dist/server && mkdir dist/client && mkdir dist/client-utils",
"build:server": "tsup src/server/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/server",
"build:client-utils": "tsup src/client-utils/index.ts --format cjs,esm --dts --clean --minify --out-dir dist/client-utils",
"test": "vitest"
},
"dependencies": {
"deasync": "^0.1.30",
"fast-json-patch": "^3.1.1",
"ioredis": "^5.6.1",
"uuid": "^11.1.0",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/bun": "latest",
"@types/deasync": "^0.1.5",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",
"vitest": "^3.1.1"
},
"peerDependencies": {
"typescript": "^5.8.3"
}
}

View File

@ -1,80 +0,0 @@
## Deduplicated Presence
Sometimes, a single user may have multiple connections (tabs, devices) in a room. By default, `subscribePresence(...)` emits events for each connection individually — so a single user might appear multiple times.
The `createDedupedPresenceHandler` utility helps you group those events into a single presence entry per logical entity — such as a user — using whatever logic you define.
This is useful for:
- Showing a clean “whos online” list
- Displaying a single “typing...” indicator per user
- Tracking presence by user, session, device, or any custom identifier
### Usage
```ts
import { createDedupedPresenceHandler } from "@prsm/mesh/client-utils";
import { client } from "./client"; // your MeshClient instance
const handler = createDedupedPresenceHandler({
getGroupId: async (connectionId) => {
// Group by userId if available, otherwise fallback to connectionId
const metadata = await client.getConnectionMetadata(connectionId);
return metadata.userId ?? connectionId;
},
onUpdate: (groups) => {
// `groups` is a Map<groupId, group>
const users = Array.from(groups.entries()).map(([groupId, group]) => ({
id: groupId,
state: group.state,
tabCount: group.members.size,
}));
// Defined below
renderPresenceList(users);
},
});
await client.subscribePresence("room:chat", handler);
```
**What does `groups` contain?**
Each `group` looks like this:
```ts
{
representative: "conn123", // Most recent connection to update state
state: { status: "typing" }, // Most recent presence state (or null)
timestamp: 1713748000000, // Time of last state update
members: new Set(["conn123", "conn456"]) // All connections in the group
}
```
You can group by basically anything in `getGroupId` — connection metadata, session cookies, localStorage — its up to you. In the example above, were grouping by `userId` if present, or falling back to `connectionId` so that all connections are still shown individually when needed.
### Rendering to the DOM
Heres a simple example that displays deduplicated users in the UI:
```ts
function renderPresenceList(users) {
const container = document.querySelector("#presence");
container.innerHTML = users
.map((user) => {
const status = user.state?.status ?? "idle";
return `
<div>
<strong>${user.id}</strong>: ${status} (tabs: ${user.tabCount})
</div>`;
})
.join("");
}
```
Shows something like:
```ts
Alice: typing (tabs: 2)
conn-m9sdkxww000007079ff77: idle (tabs: 1)
```

View File

@ -1,68 +0,0 @@
import type { PresenceUpdate } from "../client/client";
type DedupedPresenceGroup = {
representative: string;
state: any | null;
timestamp: number | null;
members: Set<string>;
};
export interface CreateDedupedPresenceHandlerOptions {
getGroupId: (connectionId: string) => Promise<string | null>;
onUpdate: (groups: Map<string, DedupedPresenceGroup>) => void;
}
export function createDedupedPresenceHandler(
options: CreateDedupedPresenceHandlerOptions
) {
const { getGroupId, onUpdate } = options;
const groupMap = new Map<string, DedupedPresenceGroup>();
const connectionToGroup = new Map<string, string>();
return async (update: PresenceUpdate) => {
const { connectionId, type, timestamp = Date.now() } = update;
let groupId = connectionToGroup.get(connectionId);
if (!groupId) {
groupId = (await getGroupId(connectionId)) ?? `conn:${connectionId}`;
connectionToGroup.set(connectionId, groupId);
}
let group = groupMap.get(groupId);
if (type === "join") {
if (!group) {
group = {
representative: connectionId,
state: null,
timestamp: null,
members: new Set(),
};
groupMap.set(groupId, group);
}
group.members.add(connectionId);
}
if (type === "leave" && group) {
group.members.delete(connectionId);
if (group.members.size === 0) {
groupMap.delete(groupId);
} else if (group.representative === connectionId) {
group.representative = group.members.values().next().value!;
}
}
if (type === "state" && group) {
const { state } = update;
if (!group.timestamp || timestamp >= group.timestamp) {
group.state = state;
group.timestamp = timestamp;
group.representative = connectionId;
}
}
onUpdate(groupMap);
};
}

View File

@ -1,877 +0,0 @@
import deasync from "deasync";
import { EventEmitter } from "node:events";
import { WebSocket } from "ws";
import { CodeError } from "../common/codeerror";
import { Status } from "../common/status";
import { Connection } from "./connection";
import type { Operation } from "fast-json-patch";
export { Status } from "../common/status";
export { applyPatch } from "fast-json-patch";
export type PresenceUpdate =
| {
type: "join" | "leave";
connectionId: string;
roomName: string;
timestamp?: number;
}
| {
type: "state";
connectionId: string;
roomName: string;
state: any;
timestamp?: number;
};
export type PresenceUpdateCallback = (
update: PresenceUpdate
) => void | Promise<void>;
export type MeshClientOptions = Partial<{
/**
* The number of milliseconds to wait before considering the connection closed due to inactivity.
* When this happens, the connection will be closed and a reconnect will be attempted if
* {@link MeshClientOptions.shouldReconnect} is true. This number should match the server's
* `pingInterval` option.
*
* @default 30000
*/
pingTimeout: number;
/**
* The maximum number of consecutive ping intervals the client will wait
* for a ping message before considering the connection closed.
* A value of 1 means the client must receive a ping within roughly 2 * pingTimeout
* before attempting to reconnect.
*
* @default 1
*/
maxMissedPings: number;
/**
* Whether or not to reconnect automatically.
*
* @default true
*/
shouldReconnect: boolean;
/**
* The number of milliseconds to wait between reconnect attempts.
*
* @default 2000
*/
reconnectInterval: number;
/**
* The number of times to attempt to reconnect before giving up and
* emitting a `reconnectfailed` event.
*
* @default Infinity
*/
maxReconnectAttempts: number;
}>;
export class MeshClient extends EventEmitter {
connection: Connection;
url: string;
socket: WebSocket | null = null;
pingTimeout: ReturnType<typeof setTimeout> | undefined;
missedPings = 0;
options: Required<MeshClientOptions>;
isReconnecting = false;
private _status: Status = Status.OFFLINE;
private recordSubscriptions: Map<
string, // recordId
{
callback: (update: {
recordId: string;
full?: any;
patch?: Operation[];
version: number;
}) => void | Promise<void>;
localVersion: number;
mode: "patch" | "full";
}
> = new Map();
private presenceSubscriptions: Map<
string, // roomName
(update: {
type: "join" | "leave" | "state";
connectionId: string;
roomName: string;
timestamp: number;
state?: Record<string, any> | null;
metadata?: any;
}) => void | Promise<void>
> = new Map();
constructor(url: string, opts: MeshClientOptions = {}) {
super();
this.url = url;
this.connection = new Connection(null);
this.options = {
pingTimeout: opts.pingTimeout ?? 30_000,
maxMissedPings: opts.maxMissedPings ?? 1,
shouldReconnect: opts.shouldReconnect ?? true,
reconnectInterval: opts.reconnectInterval ?? 2_000,
maxReconnectAttempts: opts.maxReconnectAttempts ?? Infinity,
};
this.setupConnectionEvents();
}
get status(): Status {
return this._status;
}
private setupConnectionEvents(): void {
this.connection.on("message", (data) => {
this.emit("message", data);
if (data.command === "mesh/record-update") {
this.handleRecordUpdate(data.payload);
} else if (data.command === "mesh/presence-update") {
this.handlePresenceUpdate(data.payload);
} else if (data.command === "mesh/subscription-message") {
this.emit(data.command, data.payload);
} else {
const systemCommands = [
"ping",
"pong",
"latency",
"latency:request",
"latency:response",
];
if (data.command && !systemCommands.includes(data.command)) {
this.emit(data.command, data.payload);
}
}
});
this.connection.on("close", () => {
this._status = Status.OFFLINE;
this.emit("close");
this.reconnect();
});
this.connection.on("error", (error) => {
this.emit("error", error);
});
this.connection.on("ping", () => {
this.heartbeat();
this.emit("ping");
});
this.connection.on("latency", (data) => {
this.emit("latency", data);
});
}
/**
* Connect to the WebSocket server.
*
* @returns {Promise<void>} A promise that resolves when the connection is established.
*/
connect(): Promise<void> {
if (this._status === Status.ONLINE) {
return Promise.resolve();
}
if (
this._status === Status.CONNECTING ||
this._status === Status.RECONNECTING
) {
return new Promise((resolve, reject) => {
const onConnect = () => {
this.removeListener("connect", onConnect);
this.removeListener("error", onError);
resolve();
};
const onError = (error: Error) => {
this.removeListener("connect", onConnect);
this.removeListener("error", onError);
reject(error);
};
this.once("connect", onConnect);
this.once("error", onError);
});
}
this._status = Status.CONNECTING;
return new Promise((resolve, reject) => {
try {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this._status = Status.ONLINE;
this.connection.socket = this.socket;
this.connection.status = Status.ONLINE;
this.connection.applyListeners();
this.heartbeat();
this.emit("connect");
resolve();
};
this.socket.onerror = (error) => {
this._status = Status.OFFLINE;
reject(
new CodeError(
"WebSocket connection error",
"ECONNECTION",
"ConnectionError"
)
);
};
} catch (error) {
this._status = Status.OFFLINE;
reject(error);
}
});
}
private heartbeat(): void {
this.missedPings = 0;
if (!this.pingTimeout) {
this.pingTimeout = setTimeout(() => {
this.checkPingStatus();
}, this.options.pingTimeout);
}
}
private checkPingStatus(): void {
this.missedPings++;
if (this.missedPings > this.options.maxMissedPings) {
if (this.options.shouldReconnect) {
this.reconnect();
}
} else {
this.pingTimeout = setTimeout(() => {
this.checkPingStatus();
}, this.options.pingTimeout);
}
}
/**
* Disconnect the client from the server.
* The client will not attempt to reconnect.
*
* @returns {Promise<void>} A promise that resolves when the connection is closed.
*/
close(): Promise<void> {
this.options.shouldReconnect = false;
if (this._status === Status.OFFLINE) {
return Promise.resolve();
}
return new Promise((resolve) => {
const onClose = () => {
this.removeListener("close", onClose);
this._status = Status.OFFLINE;
this.emit("disconnect");
resolve();
};
this.once("close", onClose);
clearTimeout(this.pingTimeout);
this.pingTimeout = undefined;
if (this.socket) {
this.socket.close();
}
});
}
private reconnect(): void {
if (!this.options.shouldReconnect || this.isReconnecting) {
return;
}
this._status = Status.RECONNECTING;
this.isReconnecting = true;
// Reset ping tracking
clearTimeout(this.pingTimeout);
this.pingTimeout = undefined;
this.missedPings = 0;
let attempt = 1;
if (this.socket) {
try {
this.socket.close();
} catch (e) {
// ignore errors during close
}
}
const connect = () => {
this.socket = new WebSocket(this.url);
this.socket.onerror = () => {
attempt++;
if (attempt <= this.options.maxReconnectAttempts) {
setTimeout(connect, this.options.reconnectInterval);
return;
}
this.isReconnecting = false;
this._status = Status.OFFLINE;
this.emit("reconnectfailed");
};
this.socket.onopen = () => {
this.isReconnecting = false;
this._status = Status.ONLINE;
this.connection.socket = this.socket;
this.connection.status = Status.ONLINE;
this.connection.applyListeners(true);
this.heartbeat();
this.emit("connect");
this.emit("reconnect");
};
};
connect();
}
/**
* Send a command to the server and wait for a response.
*
* @param {string} command - The command name to send.
* @param {unknown} payload - The payload to send with the command.
* @param {number} expiresIn - Timeout in milliseconds.
* @returns {Promise<unknown>} A promise that resolves with the command result.
*/
command(
command: string,
payload?: any,
expiresIn: number = 30000
): Promise<any> {
if (this._status !== Status.ONLINE) {
return this.connect()
.then(() => this.connection.command(command, payload, expiresIn))
.catch((error) => Promise.reject(error));
}
return this.connection.command(command, payload, expiresIn);
}
/**
* Synchronously executes a command by internally invoking the asynchronous `command` method,
* blocking the event loop until the asynchronous operation completes. The function returns
* the result of the command, or throws an error if the command fails.
*
* @param {string} command - The command to execute.
* @param {*} [payload] - Optional payload to send with the command.
* @param {number} [expiresIn=30000] - Optional time in milliseconds before the command expires. Defaults to 30,000 ms.
* @returns {*} The result of the executed command.
* @throws {Error} Throws an error if the command fails.
*/
commandSync(command: string, payload?: any, expiresIn: number = 30000): any {
let result: any;
let error: Error | undefined;
let done = false;
this.command(command, payload, expiresIn)
.then((res) => {
result = res;
done = true;
})
.catch((err) => {
error = err;
done = true;
});
// block the event loop until the async operation is done
deasync.loopWhile(() => !done);
if (error) {
throw error;
}
return result;
}
private async handlePresenceUpdate(payload: {
type: "join" | "leave" | "state";
connectionId: string;
roomName: string;
timestamp: number;
state?: Record<string, any> | null;
metadata?: any;
}) {
const { roomName } = payload;
const callback = this.presenceSubscriptions.get(roomName);
if (callback) {
await callback(payload);
}
}
private async handleRecordUpdate(payload: {
recordId: string;
full?: any;
patch?: Operation[];
version: number;
}) {
const { recordId, full, patch, version } = payload;
const subscription = this.recordSubscriptions.get(recordId);
if (!subscription) {
return;
}
if (patch) {
if (version !== subscription.localVersion + 1) {
// desync
console.warn(
`[MeshClient] Desync detected for record ${recordId}. Expected version ${
subscription.localVersion + 1
}, got ${version}. Resubscribing to request full record.`
);
// unsubscribe and resubscribe to force a full update
await this.unsubscribeRecord(recordId);
await this.subscribeRecord(recordId, subscription.callback, {
mode: subscription.mode,
});
return;
}
subscription.localVersion = version;
await subscription.callback({ recordId, patch, version });
return;
}
if (full !== undefined) {
subscription.localVersion = version;
await subscription.callback({ recordId, full, version });
}
}
/**
* Subscribes to a specific channel and registers a callback to be invoked
* whenever a message is received on that channel. Optionally retrieves a
* limited number of historical messages and passes them to the callback upon subscription.
*
* @param {string} channel - The name of the channel to subscribe to.
* @param {(message: string) => void | Promise<void>} callback - The function to be called for each message received on the channel.
* @param {{ historyLimit?: number }} [options] - Optional subscription options, such as the maximum number of historical messages to retrieve.
* @returns {Promise<{ success: boolean; history: string[] }>} A promise that resolves with the subscription result,
* including a success flag and an array of historical messages.
*/
subscribeChannel(
channel: string,
callback: (message: string) => void | Promise<void>,
options?: { historyLimit?: number }
): Promise<{ success: boolean; history: string[] }> {
this.on(
"mesh/subscription-message",
async (data: { channel: string; message: string }) => {
if (data.channel === channel) {
await callback(data.message);
}
}
);
const historyLimit = options?.historyLimit;
return this.command("mesh/subscribe-channel", {
channel,
historyLimit,
}).then((result) => {
if (result.success && result.history && result.history.length > 0) {
result.history.forEach((message: string) => {
callback(message);
});
}
return {
success: result.success,
history: result.history || [],
};
});
}
/**
* Unsubscribes from a specified channel.
*
* @param {string} channel - The name of the channel to unsubscribe from.
* @returns {Promise<boolean>} A promise that resolves to true if the unsubscription is successful, or false otherwise.
*/
unsubscribeChannel(channel: string): Promise<boolean> {
return this.command("mesh/unsubscribe-channel", { channel });
}
/**
* Subscribes to a specific record and registers a callback for updates.
*
* @param {string} recordId - The ID of the record to subscribe to.
* @param {(update: { full?: any; patch?: Operation[]; version: number }) => void | Promise<void>} callback - Function called on updates.
* @param {{ mode?: "patch" | "full" }} [options] - Subscription mode ('patch' or 'full', default 'full').
* @returns {Promise<{ success: boolean; record: any | null; version: number }>} Initial state of the record.
*/
async subscribeRecord(
recordId: string,
callback: (update: {
recordId: string;
full?: any;
patch?: Operation[];
version: number;
}) => void | Promise<void>,
options?: { mode?: "patch" | "full" }
): Promise<{ success: boolean; record: any | null; version: number }> {
const mode = options?.mode ?? "full";
try {
const result = await this.command("mesh/subscribe-record", {
recordId,
mode,
});
if (result.success) {
this.recordSubscriptions.set(recordId, {
callback,
localVersion: result.version,
mode,
});
await callback({
recordId,
full: result.record,
version: result.version,
});
}
return {
success: result.success,
record: result.record ?? null,
version: result.version ?? 0,
};
} catch (error) {
console.error(
`[MeshClient] Failed to subscribe to record ${recordId}:`,
error
);
return { success: false, record: null, version: 0 };
}
}
/**
* Unsubscribes from a specific record.
*
* @param {string} recordId - The ID of the record to unsubscribe from.
* @returns {Promise<boolean>} True if successful, false otherwise.
*/
async unsubscribeRecord(recordId: string): Promise<boolean> {
try {
const success = await this.command("mesh/unsubscribe-record", {
recordId,
});
if (success) {
this.recordSubscriptions.delete(recordId);
}
return success;
} catch (error) {
console.error(
`[MeshClient] Failed to unsubscribe from record ${recordId}:`,
error
);
return false;
}
}
/**
* Publishes an update to a specific record if the client has write permissions.
*
* @param {string} recordId - The ID of the record to update.
* @param {any} newValue - The new value for the record.
* @returns {Promise<boolean>} True if the update was successfully published, false otherwise.
*/
async publishRecordUpdate(recordId: string, newValue: any): Promise<boolean> {
try {
const result = await this.command("mesh/publish-record-update", {
recordId,
newValue,
});
return result.success === true;
} catch (error) {
console.error(
`[MeshClient] Failed to publish update for record ${recordId}:`,
error
);
return false;
}
}
/**
* Attempts to join the specified room and optionally subscribes to presence updates.
* If a callback for presence updates is provided, the method subscribes to presence changes and invokes the callback when updates occur.
*
* @param {string} roomName - The name of the room to join.
* @param {PresenceUpdateCallback=} onPresenceUpdate - Optional callback to receive presence updates for the room.
* @returns {Promise<{ success: boolean; present: string[] }>} A promise that resolves with an object indicating whether joining was successful and the list of present members.
* @throws {Error} If an error occurs during the join or subscription process, the promise may be rejected with the error.
*/
async joinRoom(
roomName: string,
onPresenceUpdate?: PresenceUpdateCallback
): Promise<{ success: boolean; present: string[] }> {
const joinResult = await this.command("mesh/join-room", { roomName });
if (!joinResult.success) {
return { success: false, present: [] };
}
if (!onPresenceUpdate) {
return { success: true, present: joinResult.present || [] };
}
const { success: subSuccess, present } = await this.subscribePresence(
roomName,
onPresenceUpdate
);
return { success: subSuccess, present };
}
/**
* Leaves the specified room and unsubscribes from presence updates if subscribed.
*
* @param {string} roomName - The name of the room to leave.
* @returns {Promise<{ success: boolean }>} A promise that resolves to an object indicating whether leaving the room was successful.
* @throws {Error} If the underlying command or unsubscribe operation fails, the promise may be rejected with an error.
*/
async leaveRoom(roomName: string): Promise<{ success: boolean }> {
const result = await this.command("mesh/leave-room", { roomName });
if (result.success && this.presenceSubscriptions.has(roomName)) {
await this.unsubscribePresence(roomName);
}
return { success: result.success };
}
/**
* Subscribes to presence updates for a specific room.
*
* @param {string} roomName - The name of the room to subscribe to presence updates for.
* @param {PresenceUpdateCallback} callback - Function called on presence updates.
* @returns {Promise<{ success: boolean; present: string[]; states?: Record<string, Record<string, any>> }>} Initial state of presence in the room.
*/
async subscribePresence(
roomName: string,
callback: PresenceUpdateCallback
): Promise<{
success: boolean;
present: string[];
states?: Record<string, Record<string, any>>;
}> {
try {
const result = await this.command("mesh/subscribe-presence", {
roomName,
});
if (result.success) {
this.presenceSubscriptions.set(roomName, callback as any);
}
return {
success: result.success,
present: result.present || [],
states: result.states || {},
};
} catch (error) {
console.error(
`[MeshClient] Failed to subscribe to presence for room ${roomName}:`,
error
);
return { success: false, present: [] };
}
}
/**
* Unsubscribes from presence updates for a specific room.
*
* @param {string} roomName - The name of the room to unsubscribe from.
* @returns {Promise<boolean>} True if successful, false otherwise.
*/
async unsubscribePresence(roomName: string): Promise<boolean> {
try {
const success = await this.command("mesh/unsubscribe-presence", {
roomName,
});
if (success) {
this.presenceSubscriptions.delete(roomName);
}
return success;
} catch (error) {
console.error(
`[MeshClient] Failed to unsubscribe from presence for room ${roomName}:`,
error
);
return false;
}
}
/**
* Publishes a presence state for the current client in a room
*
* @param {string} roomName - The name of the room
* @param {object} options - Options including state and optional TTL
* @param {Record<string, any>} options.state - The state object to publish
* @param {number} [options.expireAfter] - Optional TTL in milliseconds
* @returns {Promise<boolean>} True if successful, false otherwise
*/
async publishPresenceState(
roomName: string,
options: {
state: Record<string, any>;
expireAfter?: number; // optional, in milliseconds
}
): Promise<boolean> {
try {
return await this.command("mesh/publish-presence-state", {
roomName,
state: options.state,
expireAfter: options.expireAfter,
});
} catch (error) {
console.error(
`[MeshClient] Failed to publish presence state for room ${roomName}:`,
error
);
return false;
}
}
/**
* Clears the presence state for the current client in a room
*
* @param {string} roomName - The name of the room
* @returns {Promise<boolean>} True if successful, false otherwise
*/
async clearPresenceState(roomName: string): Promise<boolean> {
try {
return await this.command("mesh/clear-presence-state", {
roomName,
});
} catch (error) {
console.error(
`[MeshClient] Failed to clear presence state for room ${roomName}:`,
error
);
return false;
}
}
/**
* Gets metadata for a specific room.
*
* @param {string} roomName - The name of the room to get metadata for.
* @returns {Promise<any>} A promise that resolves with the room metadata.
*/
async getRoomMetadata(roomName: string): Promise<any> {
try {
const result = await this.command("mesh/get-room-metadata", {
roomName,
});
return result.metadata;
} catch (error) {
console.error(
`[MeshClient] Failed to get metadata for room ${roomName}:`,
error
);
return null;
}
}
/**
* Gets metadata for a connection.
*
* @param {string} [connectionId] - The ID of the connection to get metadata for. If not provided, gets metadata for the current connection.
* @returns {Promise<any>} A promise that resolves with the connection metadata.
*/
async getConnectionMetadata(connectionId?: string): Promise<any> {
try {
if (connectionId) {
const result = await this.command("mesh/get-connection-metadata", {
connectionId,
});
return result.metadata;
} else {
const result = await this.command("mesh/get-my-connection-metadata");
return result.metadata;
}
} catch (error) {
const idText = connectionId ? ` ${connectionId}` : "";
console.error(
`[MeshClient] Failed to get metadata for connection${idText}:`,
error
);
return null;
}
}
/**
* Register a callback for the connect event.
* This event is emitted when the client successfully connects to the server.
*
* @param {() => void} callback - The function to call when the client connects
* @returns {this} The client instance for chaining
*/
onConnect(callback: () => void): this {
this.on("connect", callback);
return this;
}
/**
* Register a callback for the disconnect event.
* This event is emitted when the client disconnects from the server.
*
* @param {() => void} callback - The function to call when the client disconnects
* @returns {this} The client instance for chaining
*/
onDisconnect(callback: () => void): this {
this.on("disconnect", callback);
return this;
}
/**
* Register a callback for the reconnect event.
* This event is emitted when the client successfully reconnects to the server
* after a disconnection.
*
* @param {() => void} callback - The function to call when the client reconnects
* @returns {this} The client instance for chaining
*/
onReconnect(callback: () => void): this {
this.on("reconnect", callback);
return this;
}
/**
* Register a callback for the reconnect failed event.
* This event is emitted when the client fails to reconnect after reaching
* the maximum number of reconnect attempts.
*
* @param {() => void} callback - The function to call when reconnection fails
* @returns {this} The client instance for chaining
*/
onReconnectFailed(callback: () => void): this {
this.on("reconnectfailed", callback);
return this;
}
}

View File

@ -1,170 +0,0 @@
import { EventEmitter } from "node:events";
import { WebSocket } from "ws";
import { CodeError } from "../common/codeerror";
import { type Command, parseCommand, stringifyCommand } from "../common/message";
import { Status } from "../common/status";
import { IdManager } from "./ids";
import { Queue } from "./queue";
export class Connection extends EventEmitter {
socket: WebSocket | null = null;
ids = new IdManager();
queue = new Queue();
callbacks: { [id: number]: (result: any, error?: Error) => void } = {};
status: Status = Status.OFFLINE;
constructor(socket: WebSocket | null) {
super();
this.socket = socket;
if (socket) {
this.applyListeners();
}
}
get isDead(): boolean {
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
}
send(command: Command): boolean {
try {
if (!this.isDead) {
this.socket?.send(stringifyCommand(command));
return true;
}
return false;
} catch (e) {
return false;
}
}
sendWithQueue(command: Command, expiresIn: number): boolean {
const success = this.send(command);
if (!success) {
this.queue.add(command, expiresIn);
}
return success;
}
applyListeners(reconnection = false): void {
if (!this.socket) return;
const drainQueue = () => {
while (!this.queue.isEmpty) {
const item = this.queue.pop();
if (item) {
this.send(item.value);
}
}
};
if (reconnection) {
drainQueue();
}
this.socket.onclose = () => {
this.status = Status.OFFLINE;
this.emit("close");
this.emit("disconnect");
};
this.socket.onerror = (error) => {
this.emit("error", error);
};
this.socket.onmessage = (event: any) => {
try {
const data = parseCommand(event.data as string);
this.emit("message", data);
if (data.command === "latency:request") {
this.emit("latency:request", data.payload);
this.command("latency:response", data.payload, null);
} else if (data.command === "latency") {
this.emit("latency", data.payload);
} else if (data.command === "ping") {
this.emit("ping");
this.command("pong", {}, null);
} else {
this.emit(data.command, data.payload);
}
if (data.id !== undefined && this.callbacks[data.id]) {
// @ts-ignore
this.callbacks[data.id](data.payload);
}
} catch (error) {
this.emit("error", error);
}
};
}
command(
command: string,
payload: any,
expiresIn: number | null = 30_000,
callback?: (result: any, error?: Error) => void
): Promise<any> {
const id = this.ids.reserve();
const cmd: Command = { id, command, payload: payload ?? {} };
this.sendWithQueue(cmd, expiresIn || 30000);
if (expiresIn === null) {
this.ids.release(id);
return Promise.resolve();
}
const responsePromise = new Promise<any>((resolve, reject) => {
this.callbacks[id] = (result: any, error?: Error) => {
this.ids.release(id);
delete this.callbacks[id];
if (error) {
reject(error);
} else {
resolve(result);
}
};
});
const timeoutPromise = new Promise<any>((_, reject) => {
setTimeout(() => {
if (!this.callbacks[id]) return;
this.ids.release(id);
delete this.callbacks[id];
reject(
new CodeError(
`Command timed out after ${expiresIn}ms.`,
"ETIMEOUT",
"TimeoutError"
)
);
}, expiresIn);
});
if (typeof callback === "function") {
Promise.race([responsePromise, timeoutPromise])
.then((result) => callback(result))
.catch((error) => callback(null, error));
return responsePromise;
}
return Promise.race([responsePromise, timeoutPromise]);
}
close(): boolean {
if (this.isDead) return false;
try {
this.socket?.close();
return true;
} catch (e) {
return false;
}
}
}

View File

@ -1,44 +0,0 @@
export class IdManager {
ids: Array<true | false> = [];
index: number = 0;
maxIndex: number;
constructor(maxIndex: number = 2 ** 16 - 1) {
this.maxIndex = maxIndex;
}
release(id: number) {
if (id < 0 || id > this.maxIndex) {
throw new TypeError(
`ID must be between 0 and ${this.maxIndex}. Got ${id}.`
);
}
this.ids[id] = false;
}
reserve(): number {
const startIndex = this.index;
while (true) {
const i = this.index;
if (!this.ids[i]) {
this.ids[i] = true;
return i;
}
if (this.index >= this.maxIndex) {
this.index = 0;
} else {
this.index++;
}
if (this.index === startIndex) {
throw new Error(
`All IDs are reserved. Make sure to release IDs when they are no longer used.`
);
}
}
}
}

View File

@ -1,3 +0,0 @@
export { MeshClient, Status, applyPatch } from "./client";
export { Connection } from "./connection";
export { CodeError } from "../common/codeerror";

View File

@ -1,46 +0,0 @@
import { type Command } from "../common/message";
export class QueueItem {
value: Command;
private expiration: number;
constructor(value: Command, expiresIn: number) {
this.value = value;
this.expiration = Date.now() + expiresIn;
}
get expiresIn(): number {
return this.expiration - Date.now();
}
get isExpired(): boolean {
return Date.now() > this.expiration;
}
}
export class Queue {
private items: QueueItem[] = [];
add(item: Command, expiresIn: number): void {
this.items.push(new QueueItem(item, expiresIn));
}
get isEmpty(): boolean {
this.items = this.items.filter((item) => !item.isExpired);
return this.items.length === 0;
}
pop(): QueueItem | null {
while (this.items.length > 0) {
const item = this.items.shift();
if (item && !item.isExpired) {
return item;
}
}
return null;
}
clear(): void {
this.items = [];
}
}

View File

@ -1,12 +0,0 @@
export class CodeError extends Error {
code?: string;
name: string;
constructor(message: string, code?: string, name?: string) {
super(message);
if (typeof code === "string") {
this.code = code;
}
this.name = typeof name === "string" ? name : "CodeError";
}
}

View File

@ -1,17 +0,0 @@
export interface Command {
id?: number;
command: string;
payload: any;
}
export function parseCommand(data: string): Command {
try {
return JSON.parse(data) as Command;
} catch (e) {
return { command: "", payload: {} };
}
}
export function stringifyCommand(command: Command): string {
return JSON.stringify(command);
}

View File

@ -1,6 +0,0 @@
export enum Status {
ONLINE = 3,
CONNECTING = 2,
RECONNECTING = 1,
OFFLINE = 0,
}

View File

@ -1,3 +0,0 @@
export { MeshClient, applyPatch } from "./client";
export { MeshServer, type MeshContext, type SocketMiddleware } from "./server";
export { type CodeError } from "./common/codeerror";

View File

@ -1,147 +0,0 @@
import { EventEmitter } from "node:events";
import { IncomingMessage } from "node:http";
import { WebSocket } from "ws";
import {
type Command,
parseCommand,
stringifyCommand,
} from "../common/message";
import { Status } from "../common/status";
import { Latency } from "./latency";
import { Ping } from "./ping";
import type { MeshServerOptions } from "./";
import { getCreateId } from "./utils/ids";
const getId = getCreateId({ init: Date.now(), len: 4 });
export class Connection extends EventEmitter {
id: string;
socket: WebSocket;
alive = true;
missedPongs = 0;
latency!: Latency;
ping!: Ping;
remoteAddress: string;
connectionOptions: MeshServerOptions;
status: Status = Status.ONLINE;
constructor(
socket: WebSocket,
req: IncomingMessage,
options: MeshServerOptions
) {
super();
this.socket = socket;
this.id = getId();
this.remoteAddress = req.socket.remoteAddress!;
this.connectionOptions = options;
this.applyListeners();
this.startIntervals();
}
get isDead(): boolean {
return !this.socket || this.socket.readyState !== WebSocket.OPEN;
}
private startIntervals(): void {
this.latency = new Latency();
this.ping = new Ping();
this.latency.interval = setInterval(() => {
if (!this.alive) {
return;
}
if (typeof this.latency.ms === "number") {
this.send({ command: "latency", payload: this.latency.ms });
}
this.latency.onRequest();
this.send({ command: "latency:request", payload: {} });
}, this.connectionOptions.latencyInterval);
this.ping.interval = setInterval(() => {
if (!this.alive) {
this.missedPongs++;
const maxMissedPongs = this.connectionOptions.maxMissedPongs ?? 1;
if (this.missedPongs > maxMissedPongs) {
this.close();
return;
}
} else {
this.missedPongs = 0;
}
this.alive = false;
this.send({ command: "ping", payload: {} });
}, this.connectionOptions.pingInterval);
}
stopIntervals(): void {
clearInterval(this.latency.interval);
clearInterval(this.ping.interval);
}
private applyListeners(): void {
this.socket.on("close", () => {
this.status = Status.OFFLINE;
this.emit("close");
});
this.socket.on("error", (error) => {
this.emit("error", error);
});
this.socket.on("message", (data: Buffer) => {
try {
const command = parseCommand(data.toString());
if (command.command === "latency:response") {
this.latency.onResponse();
return;
} else if (command.command === "pong") {
this.alive = true;
this.missedPongs = 0;
// this refreshes presence TTL for all rooms this connection is in
this.emit("pong", this.id);
return;
}
this.emit("message", data);
} catch (error) {
this.emit("error", error);
}
});
}
send(cmd: Command): boolean {
if (this.isDead) return false;
try {
this.socket.send(stringifyCommand(cmd));
return true;
} catch (error) {
this.emit("error", error);
return false;
}
}
async close(): Promise<boolean> {
if (this.isDead) return false;
try {
await new Promise<void>((resolve, reject) => {
this.socket.once("close", resolve);
this.socket.once("error", reject);
this.socket.close();
});
return true;
} catch (error) {
this.emit("error", error);
return false;
}
}
}

View File

@ -1,12 +0,0 @@
export { MeshServer } from "./mesh-server";
export { MeshContext } from "./mesh-context";
export type {
SocketMiddleware,
MeshServerOptions,
ChannelPattern,
} from "./types";
export { RoomManager } from "./managers/room";
export { RecordManager } from "./managers/record";
export { ConnectionManager } from "./managers/connection";
export { PresenceManager } from "./managers/presence";
export { Connection } from "./connection";

View File

@ -1,15 +0,0 @@
export class Latency {
start = 0;
end = 0;
ms = 0;
interval: ReturnType<typeof setTimeout> | undefined;
onRequest() {
this.start = Date.now();
}
onResponse() {
this.end = Date.now();
this.ms = this.end - this.start;
}
}

View File

@ -1,212 +0,0 @@
import type { Connection } from "../connection";
import type { Command } from "../../common/message";
import type { ConnectionManager } from "./connection";
import type { RoomManager } from "./room";
import type Redis from "ioredis";
export class BroadcastManager {
private connectionManager: ConnectionManager;
private roomManager: RoomManager;
private instanceId: string;
private pubClient: Redis;
private getPubSubChannel: (instanceId: string) => string;
private emitError: (error: Error) => void;
constructor(
connectionManager: ConnectionManager,
roomManager: RoomManager,
instanceId: string,
pubClient: any,
getPubSubChannel: (instanceId: string) => string,
emitError: (error: Error) => void
) {
this.connectionManager = connectionManager;
this.roomManager = roomManager;
this.instanceId = instanceId;
this.pubClient = pubClient;
this.getPubSubChannel = getPubSubChannel;
this.emitError = emitError;
}
/**
* Broadcasts a command and payload to a set of connections or all available connections.
*
* @param {string} command - The command to be broadcasted.
* @param {any} payload - The data associated with the command.
* @param {Connection[]=} connections - (Optional) A specific list of connections to broadcast to. If not provided, the command will be sent to all connections.
*
* @throws {Error} Emits an "error" event if broadcasting fails.
*/
async broadcast(command: string, payload: any, connections?: Connection[]) {
const cmd: Command = { command, payload };
try {
if (connections) {
const allConnectionIds = connections.map(({ id }) => id);
const connectionIds =
await this.connectionManager.getAllConnectionIds();
const filteredIds = allConnectionIds.filter((id) =>
connectionIds.includes(id)
);
await this.publishOrSend(filteredIds, cmd);
} else {
const allConnectionIds =
await this.connectionManager.getAllConnectionIds();
await this.publishOrSend(allConnectionIds, cmd);
}
} catch (err) {
this.emitError(
new Error(`Failed to broadcast command "${command}": ${err}`)
);
}
}
/**
* Broadcasts a command and associated payload to all active connections within the specified room.
*
* @param {string} roomName - The name of the room whose connections will receive the broadcast.
* @param {string} command - The command to be broadcasted to the connections.
* @param {unknown} payload - The data payload associated with the command.
* @returns {Promise<void>} A promise that resolves when the broadcast operation is complete.
* @throws {Error} If the broadcast operation fails, an error is thrown and the promise is rejected.
*/
async broadcastRoom(
roomName: string,
command: string,
payload: any
): Promise<void> {
const connectionIds = await this.roomManager.getRoomConnectionIds(roomName);
try {
await this.publishOrSend(connectionIds, { command, payload });
} catch (err) {
this.emitError(
new Error(`Failed to broadcast command "${command}": ${err}`)
);
}
}
/**
* Broadcasts a command and payload to all active connections except for the specified one(s).
* Excludes the provided connection(s) from receiving the broadcast.
*
* @param {string} command - The command to broadcast to connections.
* @param {any} payload - The payload to send along with the command.
* @param {Connection | Connection[]} exclude - A single connection or an array of connections to exclude from the broadcast.
* @returns {Promise<void>} A promise that resolves when the broadcast is complete.
* @emits {Error} Emits an "error" event if broadcasting the command fails.
*/
async broadcastExclude(
command: string,
payload: any,
exclude: Connection | Connection[]
): Promise<void> {
const excludedIds = new Set(
(Array.isArray(exclude) ? exclude : [exclude]).map(({ id }) => id)
);
try {
const connectionIds = (
await this.connectionManager.getAllConnectionIds()
).filter((id: string) => !excludedIds.has(id));
await this.publishOrSend(connectionIds, { command, payload });
} catch (err) {
this.emitError(
new Error(`Failed to broadcast command "${command}": ${err}`)
);
}
}
/**
* Broadcasts a command with a payload to all connections in a specified room,
* excluding one or more given connections. If the broadcast fails, emits an error event.
*
* @param {string} roomName - The name of the room to broadcast to.
* @param {string} command - The command to broadcast.
* @param {any} payload - The payload to send with the command.
* @param {Connection | Connection[]} exclude - A connection or array of connections to exclude from the broadcast.
* @returns {Promise<void>} A promise that resolves when the broadcast is complete.
* @emits {Error} Emits an error event if broadcasting fails.
*/
async broadcastRoomExclude(
roomName: string,
command: string,
payload: any,
exclude: Connection | Connection[]
): Promise<void> {
const excludedIds = new Set(
(Array.isArray(exclude) ? exclude : [exclude]).map(({ id }) => id)
);
try {
const connectionIds = (
await this.roomManager.getRoomConnectionIds(roomName)
).filter((id: string) => !excludedIds.has(id));
await this.publishOrSend(connectionIds, { command, payload });
} catch (err) {
this.emitError(
new Error(`Failed to broadcast command "${command}": ${err}`)
);
}
}
private async publishOrSend(connectionIds: string[], command: Command) {
if (connectionIds.length === 0) {
return;
}
// get instance mapping for the target connection IDs
const connectionInstanceMapping =
await this.connectionManager.getInstanceIdsForConnections(connectionIds);
const instanceMap: { [instanceId: string]: string[] } = {};
// group connection IDs by instance ID
for (const connectionId of connectionIds) {
const instanceId = connectionInstanceMapping[connectionId];
if (instanceId) {
if (!instanceMap[instanceId]) {
instanceMap[instanceId] = [];
}
instanceMap[instanceId].push(connectionId);
}
}
// publish command to each instance
for (const [instanceId, targetConnectionIds] of Object.entries(
instanceMap
)) {
if (targetConnectionIds.length === 0) continue;
if (instanceId === this.instanceId) {
// send locally
targetConnectionIds.forEach((connectionId) => {
const connection =
this.connectionManager.getLocalConnection(connectionId);
if (connection && !connection.isDead) {
connection.send(command);
}
});
} else {
// publish to remote instance via pubsub
const messagePayload = {
targetConnectionIds,
command,
};
const message = JSON.stringify(messagePayload);
try {
await this.pubClient.publish(
this.getPubSubChannel(instanceId),
message
);
} catch (err) {
this.emitError(
new Error(`Failed to publish command "${command.command}": ${err}`)
);
}
}
}
}
}

View File

@ -1,200 +0,0 @@
import type { Redis } from "ioredis";
import type { Connection } from "../connection";
import type { ChannelPattern } from "../types";
export class ChannelManager {
private redis: Redis;
private pubClient: Redis;
private subClient: Redis;
private exposedChannels: ChannelPattern[] = [];
private channelGuards: Map<
ChannelPattern,
(connection: Connection, channel: string) => Promise<boolean> | boolean
> = new Map();
private channelSubscriptions: { [channel: string]: Set<Connection> } = {};
private emitError: (error: Error) => void;
constructor(
redis: Redis,
pubClient: Redis,
subClient: Redis,
emitError: (error: Error) => void
) {
this.redis = redis;
this.pubClient = pubClient;
this.subClient = subClient;
this.emitError = emitError;
}
/**
* Exposes a channel for external access and optionally associates a guard function
* to control access to that channel. The guard function determines whether a given
* connection is permitted to access the channel.
*
* @param {ChannelPattern} channel - The channel or pattern to expose.
* @param {(connection: Connection, channel: string) => Promise<boolean> | boolean} [guard] -
* Optional guard function that receives the connection and channel name, returning
* a boolean or a promise that resolves to a boolean indicating whether access is allowed.
* @returns {void}
*/
exposeChannel(
channel: ChannelPattern,
guard?: (
connection: Connection,
channel: string
) => Promise<boolean> | boolean
): void {
this.exposedChannels.push(channel);
if (guard) {
this.channelGuards.set(channel, guard);
}
}
/**
* Checks if a channel is exposed and if the connection has access to it.
*
* @param channel - The channel to check
* @param connection - The connection requesting access
* @returns A promise that resolves to true if the channel is exposed and the connection has access
*/
async isChannelExposed(
channel: string,
connection: Connection
): Promise<boolean> {
const matchedPattern = this.exposedChannels.find((pattern) =>
typeof pattern === "string" ? pattern === channel : pattern.test(channel)
);
if (!matchedPattern) {
return false;
}
const guard = this.channelGuards.get(matchedPattern);
if (guard) {
try {
return await Promise.resolve(guard(connection, channel));
} catch (e) {
return false;
}
}
return true;
}
/**
* Publishes a message to a specified channel and optionally maintains a history of messages.
*
* @param {string} channel - The name of the channel to which the message will be published.
* @param {any} message - The message to be published. Will not be stringified automatically for you. You need to do that yourself.
* @param {number} [history=0] - The number of historical messages to retain for the channel. Defaults to 0, meaning no history is retained.
* If greater than 0, the message will be added to the channel's history and the history will be trimmed to the specified size.
* @returns {Promise<void>} A Promise that resolves once the message has been published and, if applicable, the history has been updated.
* @throws {Error} This function may throw an error if the underlying `pubClient` operations (e.g., `lpush`, `ltrim`, `publish`) fail.
*/
async publishToChannel(
channel: string,
message: any,
history: number = 0
): Promise<void> {
const parsedHistory = parseInt(history as any, 10);
if (!isNaN(parsedHistory) && parsedHistory > 0) {
await this.pubClient.lpush(`history:${channel}`, message);
await this.pubClient.ltrim(`history:${channel}`, 0, parsedHistory);
}
await this.pubClient.publish(channel, message);
}
/**
* Subscribes a connection to a channel
*
* @param channel - The channel to subscribe to
* @param connection - The connection to subscribe
*/
addSubscription(channel: string, connection: Connection): void {
if (!this.channelSubscriptions[channel]) {
this.channelSubscriptions[channel] = new Set();
}
this.channelSubscriptions[channel].add(connection);
}
/**
* Unsubscribes a connection from a channel
*
* @param channel - The channel to unsubscribe from
* @param connection - The connection to unsubscribe
* @returns true if the connection was subscribed and is now unsubscribed, false otherwise
*/
removeSubscription(channel: string, connection: Connection): boolean {
if (this.channelSubscriptions[channel]) {
this.channelSubscriptions[channel].delete(connection);
if (this.channelSubscriptions[channel].size === 0) {
delete this.channelSubscriptions[channel];
}
return true;
}
return false;
}
/**
* Gets all subscribers for a channel
*
* @param channel - The channel to get subscribers for
* @returns A set of connections subscribed to the channel, or undefined if none
*/
getSubscribers(channel: string): Set<Connection> | undefined {
return this.channelSubscriptions[channel];
}
/**
* Subscribes to a Redis channel
*
* @param channel - The channel to subscribe to
* @returns A promise that resolves when the subscription is complete
*/
async subscribeToRedisChannel(channel: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.subClient.subscribe(channel, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Unsubscribes from a Redis channel
*
* @param channel - The channel to unsubscribe from
* @returns A promise that resolves when the unsubscription is complete
*/
async unsubscribeFromRedisChannel(channel: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.subClient.unsubscribe(channel, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/**
* Gets channel history from Redis
*
* @param channel - The channel to get history for
* @param limit - The maximum number of history items to retrieve
* @returns A promise that resolves to an array of history items
*/
async getChannelHistory(channel: string, limit: number): Promise<string[]> {
const historyKey = `history:${channel}`;
return this.redis.lrange(historyKey, 0, limit - 1);
}
/**
* Cleans up all subscriptions for a connection
*
* @param connection - The connection to clean up
*/
cleanupConnection(connection: Connection): void {
for (const channel in this.channelSubscriptions) {
this.removeSubscription(channel, connection);
}
}
}

View File

@ -1,144 +0,0 @@
import { CodeError } from "../../client";
import { MeshContext } from "../mesh-context";
import type { Connection } from "../connection";
import type { SocketMiddleware } from "../types";
export class CommandManager {
private commands: {
[command: string]: (context: MeshContext<any>) => Promise<any> | any;
} = {};
private globalMiddlewares: SocketMiddleware[] = [];
private middlewares: { [key: string]: SocketMiddleware[] } = {};
private emitError: (error: Error) => void;
constructor(emitError: (error: Error) => void) {
this.emitError = emitError;
}
/**
* Registers a command with an associated callback and optional middleware.
*
* @template T The type for `MeshContext.payload`. Defaults to `any`.
* @template U The command's return value type. Defaults to `any`.
* @param {string} command - The unique identifier for the command to register.
* @param {(context: MeshContext<T>) => Promise<U> | U} callback - The function to execute when the command is invoked. It receives a `MeshContext` of type `T` and may return a value of type `U` or a `Promise` resolving to `U`.
* @param {SocketMiddleware[]} [middlewares=[]] - An optional array of middleware functions to apply to the command. Defaults to an empty array.
* @throws {Error} May throw an error if the command registration or middleware addition fails.
*/
exposeCommand<T = any, U = any>(
command: string,
callback: (context: MeshContext<T>) => Promise<U> | U,
middlewares: SocketMiddleware[] = []
) {
this.commands[command] = callback;
if (middlewares.length > 0) {
this.useMiddlewareWithCommand(command, middlewares);
}
}
/**
* Adds one or more middleware functions to the global middleware stack.
*
* @param {SocketMiddleware[]} middlewares - An array of middleware functions to be added. Each middleware
* is expected to conform to the `SocketMiddleware` type.
* @returns {void}
* @throws {Error} If the provided middlewares are not valid or fail validation (if applicable).
*/
useMiddleware(...middlewares: SocketMiddleware[]): void {
this.globalMiddlewares.push(...middlewares);
}
/**
* Adds an array of middleware functions to a specific command.
*
* @param {string} command - The name of the command to associate the middleware with.
* @param {SocketMiddleware[]} middlewares - An array of middleware functions to be added to the command.
* @returns {void}
*/
useMiddlewareWithCommand(
command: string,
middlewares: SocketMiddleware[]
): void {
if (middlewares.length) {
this.middlewares[command] = this.middlewares[command] || [];
this.middlewares[command] = middlewares.concat(this.middlewares[command]);
}
}
/**
* Runs a command with the given parameters
*
* @param id - The command ID
* @param commandName - The name of the command to run
* @param payload - The payload for the command
* @param connection - The connection that initiated the command
* @param server - The server instance
*/
async runCommand(
id: number,
commandName: string,
payload: any,
connection: Connection,
server: any
) {
const context = new MeshContext(server, commandName, connection, payload);
try {
if (!this.commands[commandName]) {
throw new CodeError(
`Command "${commandName}" not found`,
"ENOTFOUND",
"CommandError"
);
}
if (this.globalMiddlewares.length) {
for (const middleware of this.globalMiddlewares) {
await middleware(context);
}
}
if (this.middlewares[commandName]) {
for (const middleware of this.middlewares[commandName]) {
await middleware(context);
}
}
const result = await this.commands[commandName](context);
connection.send({ id, command: commandName, payload: result });
} catch (err) {
const errorPayload =
err instanceof Error
? {
error: err.message,
code: (err as CodeError).code || "ESERVER",
name: err.name || "Error",
}
: { error: String(err), code: "EUNKNOWN", name: "UnknownError" };
connection.send({ id, command: commandName, payload: errorPayload });
}
}
/**
* Gets all registered commands
*
* @returns An object mapping command names to their handler functions
*/
getCommands(): {
[command: string]: (context: MeshContext<any>) => Promise<any> | any;
} {
return this.commands;
}
/**
* Checks if a command is registered
*
* @param commandName - The name of the command to check
* @returns true if the command is registered, false otherwise
*/
hasCommand(commandName: string): boolean {
return !!this.commands[commandName];
}
}

View File

@ -1,161 +0,0 @@
import type Redis from "ioredis";
import type { Connection } from "../connection";
import type { RoomManager } from "./room";
const CONNECTIONS_HASH_KEY = "mesh:connections";
const INSTANCE_CONNECTIONS_KEY_PREFIX = "mesh:connections:";
export class ConnectionManager {
private redis: Redis;
private instanceId: string;
private localConnections: { [id: string]: Connection } = {};
private roomManager: RoomManager;
constructor(redis: Redis, instanceId: string, roomManager: RoomManager) {
this.redis = redis;
this.instanceId = instanceId;
this.roomManager = roomManager;
}
getLocalConnections(): Connection[] {
return Object.values(this.localConnections);
}
getLocalConnection(id: string): Connection | null {
return this.localConnections[id] ?? null;
}
async registerConnection(connection: Connection): Promise<void> {
this.localConnections[connection.id] = connection;
const pipeline = this.redis.pipeline();
pipeline.hset(CONNECTIONS_HASH_KEY, connection.id, this.instanceId);
pipeline.sadd(
this.getInstanceConnectionsKey(this.instanceId),
connection.id
);
await pipeline.exec();
}
private getInstanceConnectionsKey(instanceId: string): string {
return `${INSTANCE_CONNECTIONS_KEY_PREFIX}${instanceId}`;
}
private async deregisterConnection(connection: Connection): Promise<void> {
const instanceId = await this.getInstanceIdForConnection(connection);
if (!instanceId) {
return;
}
const pipeline = this.redis.pipeline();
pipeline.hdel(CONNECTIONS_HASH_KEY, connection.id);
pipeline.srem(this.getInstanceConnectionsKey(instanceId), connection.id);
await pipeline.exec();
}
private async getInstanceIdForConnection(
connection: Connection
): Promise<string | null> {
return this.redis.hget(CONNECTIONS_HASH_KEY, connection.id);
}
async getInstanceIdsForConnections(
connectionIds: string[]
): Promise<{ [connectionId: string]: string | null }> {
if (connectionIds.length === 0) {
return {};
}
const instanceIds = await this.redis.hmget(
CONNECTIONS_HASH_KEY,
...connectionIds
);
const result: { [connectionId: string]: string | null } = {};
connectionIds.forEach((id, index) => {
result[id] = instanceIds[index] ?? null;
});
return result;
}
async getAllConnectionIds(): Promise<string[]> {
return this.redis.hkeys(CONNECTIONS_HASH_KEY);
}
async getLocalConnectionIds(): Promise<string[]> {
return this.redis.smembers(this.getInstanceConnectionsKey(this.instanceId));
}
/**
* Sets metadata for a given connection in the Redis hash.
* Serializes the metadata as a JSON string and stores it under the connection's ID.
*
* @param {Connection} connection - The connection object whose metadata is being set.
* @param {any} metadata - The metadata to associate with the connection.
* @returns {Promise<void>} A promise that resolves when the metadata has been successfully set.
* @throws {Error} If an error occurs while executing the Redis pipeline.
*/
async setMetadata(connection: Connection, metadata: any) {
const pipeline = this.redis.pipeline();
pipeline.hset(
CONNECTIONS_HASH_KEY,
connection.id,
JSON.stringify(metadata)
);
await pipeline.exec();
}
/**
* Retrieves and parses metadata for the given connection from Redis.
*
* @param {Connection} connection - The connection object whose metadata is to be retrieved.
* @returns {Promise<any|null>} A promise that resolves to the parsed metadata object if found, or null if no metadata exists.
* @throws {SyntaxError} If the stored metadata is not valid JSON and fails to parse.
* @throws {Error} If a Redis error occurs during retrieval.
*/
async getMetadata(connection: Connection) {
const metadata = await this.redis.hget(CONNECTIONS_HASH_KEY, connection.id);
return metadata ? JSON.parse(metadata) : null;
}
/**
* Retrieves metadata for all available connections by fetching all connection IDs,
* obtaining their associated metadata, and parsing the metadata as JSON.
*
* @returns {Promise<Array<{ [connectionId: string]: any }>>}
* A promise that resolves to an array of objects, each mapping a connection ID to its parsed metadata object, or `null` if no metadata is available.
* @throws {Error} If an error occurs while fetching connection IDs, retrieving metadata, or parsing JSON.
*/
async getAllMetadata(): Promise<Array<{ [connectionId: string]: any }>> {
const connectionIds = await this.getAllConnectionIds();
const metadata = await this.getInstanceIdsForConnections(connectionIds);
return connectionIds.map((id) => ({
[id]: metadata[id] ? JSON.parse(metadata[id]) : null,
}));
}
/**
* Retrieves all metadata objects for each connection in the specified room.
* Each returned object maps a connection ID to its associated metadata, which is parsed from JSON.
* If no metadata is found for a connection, the value is set to null.
*
* @param {string} roomName - The name of the room for which to retrieve connection metadata.
* @returns {Promise<Array<{ [connectionId: string]: any }>>} A promise that resolves to an array of objects,
* each containing a connection ID as the key and its metadata as the value (or null if not available).
* @throws {Error} If there is an error retrieving connection IDs or metadata, the promise will be rejected with the error.
*/
async getAllMetadataForRoom(
roomName: string
): Promise<Array<{ [connectionId: string]: any }>> {
const connectionIds = await this.roomManager.getRoomConnectionIds(roomName);
const metadata = await this.getInstanceIdsForConnections(connectionIds);
return connectionIds.map((id) => ({
[id]: metadata[id] ? JSON.parse(metadata[id]) : null,
}));
}
async cleanupConnection(connection: Connection): Promise<void> {
await this.deregisterConnection(connection);
}
}

View File

@ -1,388 +0,0 @@
import type { Redis } from "ioredis";
import type { Connection } from "../connection";
import type { RoomManager } from "./room";
import type { RedisManager } from "./redis";
type ChannelPattern = string | RegExp;
export class PresenceManager {
private redis: Redis;
private roomManager: RoomManager;
private redisManager: RedisManager;
private presenceExpirationEventsEnabled: boolean;
private getExpiredEventsPattern(): string {
const dbIndex = (this.redis as any).options?.db ?? 0;
return `__keyevent@${dbIndex}__:expired`;
}
private readonly PRESENCE_KEY_PATTERN = /^mesh:presence:room:(.+):conn:(.+)$/;
private readonly PRESENCE_STATE_KEY_PATTERN =
/^mesh:presence:state:(.+):conn:(.+)$/;
private trackedRooms: ChannelPattern[] = [];
private roomGuards: Map<
ChannelPattern,
(connection: Connection, roomName: string) => Promise<boolean> | boolean
> = new Map();
private roomTTLs: Map<ChannelPattern, number> = new Map();
private defaultTTL = 30_000; // 30 seconds default TTL
constructor(
redis: Redis,
roomManager: RoomManager,
redisManager: RedisManager,
enableExpirationEvents: boolean = true
) {
this.redis = redis;
this.roomManager = roomManager;
this.redisManager = redisManager;
this.presenceExpirationEventsEnabled = enableExpirationEvents;
if (this.presenceExpirationEventsEnabled) {
this.subscribeToExpirationEvents();
}
}
/**
* Subscribes to Redis keyspace notifications for expired presence keys
*/
private subscribeToExpirationEvents(): void {
const { subClient } = this.redisManager;
const pattern = this.getExpiredEventsPattern();
subClient.psubscribe(pattern);
subClient.on("pmessage", (pattern, channel, key) => {
if (
this.PRESENCE_KEY_PATTERN.test(key) ||
this.PRESENCE_STATE_KEY_PATTERN.test(key)
) {
this.handleExpiredKey(key);
}
});
}
/**
* Handles an expired key notification
*/
private async handleExpiredKey(key: string): Promise<void> {
try {
// Check if it's a presence key
let match = key.match(this.PRESENCE_KEY_PATTERN);
if (match && match[1] && match[2]) {
const roomName = match[1];
const connectionId = match[2];
await this.markOffline(connectionId, roomName);
return;
}
// Check if it's a presence state key
match = key.match(this.PRESENCE_STATE_KEY_PATTERN);
if (match && match[1] && match[2]) {
const roomName = match[1];
const connectionId = match[2];
await this.publishPresenceStateUpdate(roomName, connectionId, null);
}
} catch (err) {
console.error("[PresenceManager] Failed to handle expired key:", err);
}
}
trackRoom(
roomPattern: ChannelPattern,
guardOrOptions?:
| ((
connection: Connection,
roomName: string
) => Promise<boolean> | boolean)
| {
ttl?: number;
guard?: (
connection: Connection,
roomName: string
) => Promise<boolean> | boolean;
}
): void {
this.trackedRooms.push(roomPattern);
if (typeof guardOrOptions === "function") {
this.roomGuards.set(roomPattern, guardOrOptions);
} else if (guardOrOptions && typeof guardOrOptions === "object") {
if (guardOrOptions.guard) {
this.roomGuards.set(roomPattern, guardOrOptions.guard);
}
if (guardOrOptions.ttl && typeof guardOrOptions.ttl === "number") {
this.roomTTLs.set(roomPattern, guardOrOptions.ttl);
}
}
}
async isRoomTracked(
roomName: string,
connection?: Connection
): Promise<boolean> {
const matchedPattern = this.trackedRooms.find((pattern) =>
typeof pattern === "string"
? pattern === roomName
: pattern.test(roomName)
);
if (!matchedPattern) {
return false;
}
if (connection) {
const guard = this.roomGuards.get(matchedPattern);
if (guard) {
try {
return await Promise.resolve(guard(connection, roomName));
} catch (e) {
return false;
}
}
}
return true;
}
getRoomTTL(roomName: string): number {
const matchedPattern = this.trackedRooms.find((pattern) =>
typeof pattern === "string"
? pattern === roomName
: pattern.test(roomName)
);
if (matchedPattern) {
const ttl = this.roomTTLs.get(matchedPattern);
if (ttl !== undefined) {
return ttl;
}
}
return this.defaultTTL;
}
private presenceRoomKey(roomName: string): string {
return `mesh:presence:room:${roomName}`;
}
private presenceConnectionKey(
roomName: string,
connectionId: string
): string {
return `mesh:presence:room:${roomName}:conn:${connectionId}`;
}
private presenceStateKey(roomName: string, connectionId: string): string {
return `mesh:presence:state:${roomName}:conn:${connectionId}`;
}
async markOnline(connectionId: string, roomName: string): Promise<void> {
const roomKey = this.presenceRoomKey(roomName);
const connKey = this.presenceConnectionKey(roomName, connectionId);
const ttl = this.getRoomTTL(roomName);
const pipeline = this.redis.pipeline();
pipeline.sadd(roomKey, connectionId);
const ttlSeconds = Math.max(1, Math.floor(ttl / 1000));
pipeline.set(connKey, "", "EX", ttlSeconds);
await pipeline.exec();
await this.publishPresenceUpdate(roomName, connectionId, "join");
}
async markOffline(connectionId: string, roomName: string): Promise<void> {
const roomKey = this.presenceRoomKey(roomName);
const connKey = this.presenceConnectionKey(roomName, connectionId);
const stateKey = this.presenceStateKey(roomName, connectionId);
const pipeline = this.redis.pipeline();
pipeline.srem(roomKey, connectionId);
pipeline.del(connKey);
pipeline.del(stateKey);
await pipeline.exec();
await this.publishPresenceUpdate(roomName, connectionId, "leave");
}
async refreshPresence(connectionId: string, roomName: string): Promise<void> {
const connKey = this.presenceConnectionKey(roomName, connectionId);
const ttl = this.getRoomTTL(roomName);
const ttlSeconds = Math.max(1, Math.floor(ttl / 1000));
await this.redis.set(connKey, "", "EX", ttlSeconds);
}
async getPresentConnections(roomName: string): Promise<string[]> {
return this.redis.smembers(this.presenceRoomKey(roomName));
}
private async publishPresenceUpdate(
roomName: string,
connectionId: string,
type: "join" | "leave"
): Promise<void> {
const channel = `mesh:presence:updates:${roomName}`;
const message = JSON.stringify({
type,
connectionId,
roomName,
timestamp: Date.now(),
});
await this.redis.publish(channel, message);
}
/**
* Publishes a presence state for a connection in a room
*
* @param connectionId The ID of the connection
* @param roomName The name of the room
* @param state The state object to publish
* @param expireAfter Optional TTL in milliseconds
*/
async publishPresenceState(
connectionId: string,
roomName: string,
state: Record<string, any>,
expireAfter?: number
): Promise<void> {
const key = this.presenceStateKey(roomName, connectionId);
const value = JSON.stringify(state);
const pipeline = this.redis.pipeline();
if (expireAfter && expireAfter > 0) {
pipeline.set(key, value, "PX", expireAfter);
} else {
pipeline.set(key, value);
}
await pipeline.exec();
await this.publishPresenceStateUpdate(roomName, connectionId, state);
}
/**
* Clears the presence state for a connection in a room
*
* @param connectionId The ID of the connection
* @param roomName The name of the room
*/
async clearPresenceState(
connectionId: string,
roomName: string
): Promise<void> {
const key = this.presenceStateKey(roomName, connectionId);
await this.redis.del(key);
await this.publishPresenceStateUpdate(roomName, connectionId, null);
}
/**
* Gets the current presence state for a connection in a room
*
* @param connectionId The ID of the connection
* @param roomName The name of the room
* @returns The presence state or null if not found
*/
async getPresenceState(
connectionId: string,
roomName: string
): Promise<Record<string, any> | null> {
const key = this.presenceStateKey(roomName, connectionId);
const value = await this.redis.get(key);
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch (e) {
console.error(`[PresenceManager] Failed to parse presence state: ${e}`);
return null;
}
}
/**
* Gets all presence states for a room
*
* @param roomName The name of the room
* @returns A map of connection IDs to their presence states
*/
async getAllPresenceStates(
roomName: string
): Promise<Map<string, Record<string, any>>> {
const result = new Map<string, Record<string, any>>();
const connections = await this.getPresentConnections(roomName);
if (connections.length === 0) {
return result;
}
const pipeline = this.redis.pipeline();
for (const connectionId of connections) {
pipeline.get(this.presenceStateKey(roomName, connectionId));
}
const responses = await pipeline.exec();
if (!responses) {
return result;
}
for (let i = 0; i < connections.length; i++) {
const connectionId = connections[i];
if (!connectionId) continue;
const [err, value] = responses[i] || [];
if (err || !value) {
continue;
}
try {
const state = JSON.parse(value as string);
result.set(connectionId, state);
} catch (e) {
console.error(`[PresenceManager] Failed to parse presence state: ${e}`);
}
}
return result;
}
/**
* Publishes a presence state update to Redis
*
* @param roomName The name of the room
* @param connectionId The ID of the connection
* @param state The state object or null
*/
private async publishPresenceStateUpdate(
roomName: string,
connectionId: string,
state: Record<string, any> | null
): Promise<void> {
const channel = `mesh:presence:updates:${roomName}`;
const message = JSON.stringify({
type: "state",
connectionId,
roomName,
state,
timestamp: Date.now(),
});
await this.redis.publish(channel, message);
}
async cleanupConnection(connection: Connection): Promise<void> {
const connectionId = connection.id;
const rooms = await this.roomManager.getRoomsForConnection(connectionId);
for (const roomName of rooms) {
if (await this.isRoomTracked(roomName)) {
await this.markOffline(connectionId, roomName);
}
}
}
}

View File

@ -1,223 +0,0 @@
import type { Redis } from "ioredis";
import type { Connection } from "../connection";
import type { ConnectionManager } from "./connection";
import type { PubSubMessagePayload, RecordUpdatePubSubPayload } from "../types";
import {
PUB_SUB_CHANNEL_PREFIX,
RECORD_PUB_SUB_CHANNEL,
} from "../utils/constants";
export class PubSubManager {
private subClient: Redis;
private instanceId: string;
private connectionManager: ConnectionManager;
private recordSubscriptions: Map<
string, // recordId
Map<string, "patch" | "full"> // connectionId -> mode
>;
private getChannelSubscriptions: (
channel: string
) => Set<Connection> | undefined;
private emitError: (error: Error) => void;
private _subscriptionPromise!: Promise<void>;
constructor(
subClient: Redis,
instanceId: string,
connectionManager: ConnectionManager,
recordSubscriptions: Map<string, Map<string, "patch" | "full">>,
getChannelSubscriptions: (channel: string) => Set<Connection> | undefined,
emitError: (error: Error) => void
) {
this.subClient = subClient;
this.instanceId = instanceId;
this.connectionManager = connectionManager;
this.recordSubscriptions = recordSubscriptions;
this.getChannelSubscriptions = getChannelSubscriptions;
this.emitError = emitError;
}
/**
* Subscribes to the instance channel and sets up message handlers
*
* @returns A promise that resolves when the subscription is complete
*/
subscribeToInstanceChannel(): Promise<void> {
const channel = `${PUB_SUB_CHANNEL_PREFIX}${this.instanceId}`;
this._subscriptionPromise = new Promise((resolve, reject) => {
this.subClient.subscribe(channel, RECORD_PUB_SUB_CHANNEL);
this.subClient.psubscribe("mesh:presence:updates:*", (err) => {
if (err) {
this.emitError(
new Error(
`Failed to subscribe to channels ${channel}, ${RECORD_PUB_SUB_CHANNEL}:`,
{ cause: err }
)
);
reject(err);
return;
}
resolve();
});
});
this.setupMessageHandlers();
return this._subscriptionPromise;
}
/**
* Sets up message handlers for the subscribed channels
*/
private setupMessageHandlers(): void {
this.subClient.on("message", async (channel, message) => {
if (channel.startsWith(PUB_SUB_CHANNEL_PREFIX)) {
this.handleInstancePubSubMessage(channel, message);
} else if (channel === RECORD_PUB_SUB_CHANNEL) {
this.handleRecordUpdatePubSubMessage(message);
} else {
const subscribers = this.getChannelSubscriptions(channel);
if (subscribers) {
for (const connection of subscribers) {
if (!connection.isDead) {
connection.send({
command: "mesh/subscription-message",
payload: { channel, message },
});
}
}
}
}
});
this.subClient.on("pmessage", async (pattern, channel, message) => {
if (pattern === "mesh:presence:updates:*") {
// channel here is the actual channel, e.g., mesh:presence:updates:roomName
const subscribers = this.getChannelSubscriptions(channel);
if (subscribers) {
try {
const payload = JSON.parse(message);
subscribers.forEach((connection: Connection) => {
if (!connection.isDead) {
connection.send({
command: "mesh/presence-update",
payload: payload,
});
} else {
// clean up dead connections from subscription list
subscribers.delete(connection);
}
});
} catch (e) {
this.emitError(
new Error(`Failed to parse presence update: ${message}`)
);
}
}
}
});
}
/**
* Handles messages from the instance PubSub channel
*
* @param channel - The channel the message was received on
* @param message - The message content
*/
private handleInstancePubSubMessage(channel: string, message: string) {
try {
const parsedMessage = JSON.parse(message) as PubSubMessagePayload;
if (
!parsedMessage ||
!Array.isArray(parsedMessage.targetConnectionIds) ||
!parsedMessage.command ||
typeof parsedMessage.command.command !== "string"
) {
throw new Error("Invalid message format");
}
const { targetConnectionIds, command } = parsedMessage;
targetConnectionIds.forEach((connectionId) => {
const connection =
this.connectionManager.getLocalConnection(connectionId);
if (connection && !connection.isDead) {
connection.send(command);
}
});
} catch (err) {
this.emitError(new Error(`Failed to parse message: ${message}`));
}
}
/**
* Handles record update messages from the record PubSub channel
*
* @param message - The message content
*/
private handleRecordUpdatePubSubMessage(message: string) {
try {
const parsedMessage = JSON.parse(message) as RecordUpdatePubSubPayload;
const { recordId, newValue, patch, version } = parsedMessage;
if (!recordId || typeof version !== "number") {
throw new Error("Invalid record update message format");
}
const subscribers = this.recordSubscriptions.get(recordId);
if (!subscribers) {
return;
}
subscribers.forEach((mode, connectionId) => {
const connection =
this.connectionManager.getLocalConnection(connectionId);
if (connection && !connection.isDead) {
if (mode === "patch" && patch) {
connection.send({
command: "mesh/record-update",
payload: { recordId, patch, version },
});
} else if (mode === "full" && newValue !== undefined) {
connection.send({
command: "mesh/record-update",
payload: { recordId, full: newValue, version },
});
}
} else if (!connection) {
subscribers.delete(connectionId);
if (subscribers.size === 0) {
this.recordSubscriptions.delete(recordId);
}
}
});
} catch (err) {
this.emitError(
new Error(`Failed to parse record update message: ${message}`)
);
}
}
/**
* Gets the subscription promise
*
* @returns The subscription promise
*/
getSubscriptionPromise(): Promise<void> {
return this._subscriptionPromise;
}
/**
* Gets the PubSub channel for an instance
*
* @param instanceId - The instance ID
* @returns The PubSub channel name
*/
getPubSubChannel(instanceId: string): string {
return `${PUB_SUB_CHANNEL_PREFIX}${instanceId}`;
}
}

View File

@ -1,270 +0,0 @@
import type { Redis } from "ioredis";
import type { Connection } from "../connection";
import type { ChannelPattern } from "../types";
import type { RecordManager } from "./record";
import type { Operation } from "fast-json-patch";
import { RECORD_PUB_SUB_CHANNEL } from "../utils/constants";
export class RecordSubscriptionManager {
private pubClient: Redis;
private recordManager: RecordManager;
private exposedRecords: ChannelPattern[] = [];
private exposedWritableRecords: ChannelPattern[] = [];
private recordGuards: Map<
ChannelPattern,
(connection: Connection, recordId: string) => Promise<boolean> | boolean
> = new Map();
private writableRecordGuards: Map<
ChannelPattern,
(connection: Connection, recordId: string) => Promise<boolean> | boolean
> = new Map();
private recordSubscriptions: Map<
string, // recordId
Map<string, "patch" | "full"> // connectionId -> mode
> = new Map();
private emitError: (error: Error) => void;
constructor(
pubClient: Redis,
recordManager: RecordManager,
emitError: (error: Error) => void
) {
this.pubClient = pubClient;
this.recordManager = recordManager;
this.emitError = emitError;
}
/**
* Exposes a record or pattern for client subscriptions, optionally adding a guard function.
*
* @param {ChannelPattern} recordPattern - The record ID or pattern to expose.
* @param {(connection: Connection, recordId: string) => Promise<boolean> | boolean} [guard] - Optional guard function.
*/
exposeRecord(
recordPattern: ChannelPattern,
guard?: (
connection: Connection,
recordId: string
) => Promise<boolean> | boolean
): void {
this.exposedRecords.push(recordPattern);
if (guard) {
this.recordGuards.set(recordPattern, guard);
}
}
/**
* Exposes a record or pattern for client writes, optionally adding a guard function.
*
* @param {ChannelPattern} recordPattern - The record ID or pattern to expose as writable.
* @param {(connection: Connection, recordId: string) => Promise<boolean> | boolean} [guard] - Optional guard function.
*/
exposeWritableRecord(
recordPattern: ChannelPattern,
guard?: (
connection: Connection,
recordId: string
) => Promise<boolean> | boolean
): void {
this.exposedWritableRecords.push(recordPattern);
if (guard) {
this.writableRecordGuards.set(recordPattern, guard);
}
}
/**
* Checks if a record is exposed for reading
*
* @param recordId - The record ID to check
* @param connection - The connection requesting access
* @returns A promise that resolves to true if the record is exposed and the connection has access
*/
async isRecordExposed(
recordId: string,
connection: Connection
): Promise<boolean> {
const readPattern = this.exposedRecords.find((pattern) =>
typeof pattern === "string"
? pattern === recordId
: pattern.test(recordId)
);
let canRead = false;
if (readPattern) {
const guard = this.recordGuards.get(readPattern);
if (guard) {
try {
canRead = await Promise.resolve(guard(connection, recordId));
} catch (e) {
canRead = false;
}
} else {
canRead = true;
}
}
if (canRead) {
return true;
}
// if exposed as writable, it is implicitly readable
const writePattern = this.exposedWritableRecords.find((pattern) =>
typeof pattern === "string"
? pattern === recordId
: pattern.test(recordId)
);
// If exposed as writable, it's readable. No need to check the *write* guard here.
if (writePattern) {
return true;
}
return false;
}
/**
* Checks if a record is exposed for writing
*
* @param recordId - The record ID to check
* @param connection - The connection requesting access
* @returns A promise that resolves to true if the record is writable and the connection has access
*/
async isRecordWritable(
recordId: string,
connection: Connection
): Promise<boolean> {
const matchedPattern = this.exposedWritableRecords.find((pattern) =>
typeof pattern === "string"
? pattern === recordId
: pattern.test(recordId)
);
if (!matchedPattern) {
return false;
}
const guard = this.writableRecordGuards.get(matchedPattern);
if (guard) {
try {
return await Promise.resolve(guard(connection, recordId));
} catch (e) {
return false;
}
}
return true;
}
/**
* Subscribes a connection to a record
*
* @param recordId - The record ID to subscribe to
* @param connectionId - The connection ID to subscribe
* @param mode - The subscription mode (patch or full)
*/
addSubscription(
recordId: string,
connectionId: string,
mode: "patch" | "full"
): void {
if (!this.recordSubscriptions.has(recordId)) {
this.recordSubscriptions.set(recordId, new Map());
}
this.recordSubscriptions.get(recordId)!.set(connectionId, mode);
}
/**
* Unsubscribes a connection from a record
*
* @param recordId - The record ID to unsubscribe from
* @param connectionId - The connection ID to unsubscribe
* @returns true if the connection was subscribed and is now unsubscribed, false otherwise
*/
removeSubscription(recordId: string, connectionId: string): boolean {
const recordSubs = this.recordSubscriptions.get(recordId);
if (recordSubs?.has(connectionId)) {
recordSubs.delete(connectionId);
if (recordSubs.size === 0) {
this.recordSubscriptions.delete(recordId);
}
return true;
}
return false;
}
/**
* Gets all subscribers for a record
*
* @param recordId - The record ID to get subscribers for
* @returns A map of connection IDs to subscription modes, or undefined if none
*/
getSubscribers(recordId: string): Map<string, "patch" | "full"> | undefined {
return this.recordSubscriptions.get(recordId);
}
/**
* Updates a record, persists it to Redis, increments its version, computes a patch,
* and publishes the update via Redis pub/sub.
*
* @param {string} recordId - The ID of the record to update.
* @param {any} newValue - The new value for the record.
* @returns {Promise<void>}
* @throws {Error} If the update fails.
*/
async publishRecordUpdate(recordId: string, newValue: any): Promise<void> {
const updateResult = await this.recordManager.publishUpdate(
recordId,
newValue
);
if (!updateResult) {
return;
}
const { patch, version } = updateResult;
const messagePayload = {
recordId,
newValue,
patch,
version,
};
try {
await this.pubClient.publish(
RECORD_PUB_SUB_CHANNEL,
JSON.stringify(messagePayload)
);
} catch (err) {
this.emitError(
new Error(`Failed to publish record update for "${recordId}": ${err}`)
);
}
}
/**
* Cleans up all subscriptions for a connection
*
* @param connection - The connection to clean up
*/
cleanupConnection(connection: Connection): void {
const connectionId = connection.id;
this.recordSubscriptions.forEach((subscribers, recordId) => {
if (subscribers.has(connectionId)) {
subscribers.delete(connectionId);
if (subscribers.size === 0) {
this.recordSubscriptions.delete(recordId);
}
}
});
}
/**
* Gets all record subscriptions
*
* @returns The record subscriptions map
*/
getRecordSubscriptions(): Map<string, Map<string, "patch" | "full">> {
return this.recordSubscriptions;
}
}

View File

@ -1,129 +0,0 @@
import type { Redis } from "ioredis";
import jsonpatch, { type Operation } from "fast-json-patch";
const RECORD_KEY_PREFIX = "mesh:record:";
const RECORD_VERSION_KEY_PREFIX = "mesh:record-version:";
export class RecordManager {
private redis: Redis;
constructor(redis: Redis) {
this.redis = redis;
}
private recordKey(recordId: string): string {
return `${RECORD_KEY_PREFIX}${recordId}`;
}
private recordVersionKey(recordId: string): string {
return `${RECORD_VERSION_KEY_PREFIX}${recordId}`;
}
/**
* Retrieves a record from Redis by its unique identifier. Attempts to parse
* the stored data as JSON before returning. If the record does not exist,
* returns null.
*
* @param {string} recordId - The unique identifier of the record to retrieve.
* @returns {Promise<any | null>} A promise that resolves to the parsed record object,
* or null if the record does not exist.
* @throws {SyntaxError} If the stored data is not valid JSON and cannot be parsed.
* @throws {Error} If an error occurs during the Redis operation.
*/
async getRecord(recordId: string): Promise<any | null> {
const data = await this.redis.get(this.recordKey(recordId));
return data ? JSON.parse(data) : null;
}
/**
* Retrieves the version number associated with the specified record ID from Redis.
* If no version is found, returns 0.
*
* @param {string} recordId - The unique identifier for the record whose version is to be retrieved.
* @returns {Promise<number>} A promise that resolves to the version number of the record. Returns 0 if not found.
* @throws {Error} If there is an issue communicating with Redis or parsing the version.
*/
async getVersion(recordId: string): Promise<number> {
const version = await this.redis.get(this.recordVersionKey(recordId));
return version ? parseInt(version, 10) : 0;
}
/**
* Retrieves a record and its associated version from Redis.
* Fetches both the record data and its version by their respective keys.
*
* @param {string} recordId - The unique identifier for the record to retrieve.
* @returns {Promise<{ record: any | null; version: number }>}
* A promise that resolves to an object containing the parsed record (or null if not found)
* and its version number (0 if version data is not found or invalid).
* @throws {Error} If there is a Redis error or if JSON parsing fails for the record data.
*/
async getRecordAndVersion(
recordId: string
): Promise<{ record: any | null; version: number }> {
const pipeline = this.redis.pipeline();
pipeline.get(this.recordKey(recordId));
pipeline.get(this.recordVersionKey(recordId));
const results = await pipeline.exec();
const recordData = results?.[0]?.[1] as string | null;
const versionData = results?.[1]?.[1] as string | null;
const record = recordData ? JSON.parse(recordData) : null;
const version = versionData ? parseInt(versionData, 10) : 0;
return { record, version };
}
/**
* Publishes an update to a record by computing and applying a JSON Patch,
* incrementing the version, and persisting the updated value and version in Redis.
* If there are no changes between the old and new value, returns null.
*
* @param {string} recordId - The unique identifier of the record to update.
* @param {any} newValue - The new value to set for the record.
* @returns {Promise<{ patch: Operation[]; version: number } | null>}
* A promise resolving to an object containing the JSON Patch operations and new version number,
* or null if there were no changes to publish.
* @throws {Error} If there is a failure reading or writing to Redis, or during patch computation, the promise will be rejected with the error.
*/
async publishUpdate(
recordId: string,
newValue: any
): Promise<{ patch: Operation[]; version: number } | null> {
const recordKey = this.recordKey(recordId);
const versionKey = this.recordVersionKey(recordId);
const { record: oldValue, version: oldVersion } =
await this.getRecordAndVersion(recordId);
const patch = jsonpatch.compare(oldValue ?? {}, newValue ?? {});
if (patch.length === 0) {
return null;
}
const newVersion = oldVersion + 1;
const pipeline = this.redis.pipeline();
pipeline.set(recordKey, JSON.stringify(newValue));
pipeline.set(versionKey, newVersion.toString());
await pipeline.exec();
return { patch, version: newVersion };
}
/**
* Deletes a record and its associated version from Redis storage.
*
* @param {string} recordId - The unique identifier of the record to be deleted.
* @returns {Promise<void>} A promise that resolves when the record and its version have been deleted.
* @throws {Error} If an error occurs during the Redis pipeline execution, the promise will be rejected with the error.
*/
async deleteRecord(recordId: string): Promise<void> {
const pipeline = this.redis.pipeline();
pipeline.del(this.recordKey(recordId));
pipeline.del(this.recordVersionKey(recordId));
await pipeline.exec();
}
}

View File

@ -1,142 +0,0 @@
import { Redis, type RedisOptions } from "ioredis";
export class RedisManager {
private _redis: Redis | null = null;
private _pubClient: Redis | null = null;
private _subClient: Redis | null = null;
private _isShuttingDown = false;
private _options: RedisOptions | null = null;
/**
* Initializes Redis connections with the provided options
*
* @param options - Redis connection options
* @param onError - Error handler callback
*/
initialize(options: RedisOptions, onError: (err: Error) => void): void {
this._options = options;
this._redis = new Redis({
retryStrategy: (times: number) => {
if (this._isShuttingDown) {
return null;
}
if (times > 10) {
return null;
}
return Math.min(1000 * Math.pow(2, times), 30000);
},
...options,
});
this._redis.on("error", (err) => {
onError(new Error(`Redis error: ${err}`));
});
this._pubClient = this._redis.duplicate();
this._subClient = this._redis.duplicate();
}
/**
* Gets the main Redis client
*
* @returns The Redis client
* @throws Error if Redis is not initialized
*/
get redis(): Redis {
if (!this._redis) {
throw new Error("Redis not initialized");
}
return this._redis;
}
/**
* Gets the Redis client for publishing
*
* @returns The publishing Redis client
* @throws Error if Redis is not initialized
*/
get pubClient(): Redis {
if (!this._pubClient) {
throw new Error("Redis pub client not initialized");
}
return this._pubClient;
}
/**
* Gets the Redis client for subscribing
*
* @returns The subscribing Redis client
* @throws Error if Redis is not initialized
*/
get subClient(): Redis {
if (!this._subClient) {
throw new Error("Redis sub client not initialized");
}
return this._subClient;
}
/**
* Disconnects all Redis clients
*/
disconnect(): void {
this._isShuttingDown = true;
if (this._pubClient) {
this._pubClient.disconnect();
this._pubClient = null;
}
if (this._subClient) {
this._subClient.disconnect();
this._subClient = null;
}
if (this._redis) {
this._redis.disconnect();
this._redis = null;
}
}
/**
* Checks if Redis is shutting down
*
* @returns true if Redis is shutting down, false otherwise
*/
get isShuttingDown(): boolean {
return this._isShuttingDown;
}
/**
* Sets the shutting down state
*
* @param value - The new shutting down state
*/
set isShuttingDown(value: boolean) {
this._isShuttingDown = value;
}
/**
* Enables Redis keyspace notifications for expired events by updating the
* "notify-keyspace-events" configuration. Ensures that both keyevent ('E')
* and expired event ('x') notifications are enabled. If they are not already
* present, the method appends them to the current configuration.
*
* @returns {Promise<void>} A promise that resolves when the configuration has been updated.
* @throws {Error} If the Redis CONFIG commands fail or the connection encounters an error.
*/
async enableKeyspaceNotifications(): Promise<void> {
const result = await this.redis.config("GET", "notify-keyspace-events");
const currentConfig =
Array.isArray(result) && result.length > 1 ? result[1] : "";
// add expired events notification if not already enabled
// 'E' enables keyevent notifications, 'x' enables expired events
let newConfig = currentConfig || "";
if (!newConfig.includes("E")) newConfig += "E";
if (!newConfig.includes("x")) newConfig += "x";
await this.redis.config("SET", "notify-keyspace-events", newConfig);
}
}

View File

@ -1,249 +0,0 @@
import Redis from "ioredis";
import type { Connection } from "../connection";
export class RoomManager {
private redis: Redis;
constructor(redis: Redis) {
this.redis = redis;
}
private roomKey(roomName: string) {
return `mesh:room:${roomName}`;
}
private connectionsRoomKey(connectionId: string) {
return `mesh:connection:${connectionId}:rooms`;
}
private roomMetadataKey(roomName: string) {
return `mesh:roommeta:${roomName}`;
}
/**
* Retrieves all connection IDs associated with the specified room.
*
* @param {string} roomName - The name of the room for which to fetch connection IDs.
* @returns {Promise<string[]>} A promise that resolves to an array of connection IDs in the room.
* @throws {Error} If there is an issue communicating with Redis or retrieving the data, the promise will be rejected with an error.
*/
async getRoomConnectionIds(roomName: string): Promise<string[]> {
return this.redis.smembers(this.roomKey(roomName));
}
/**
* Checks whether a given connection (by object or ID) is a member of a specified room.
*
* @param {string} roomName - The name of the room to check for membership.
* @param {Connection | string} connection - The connection object or connection ID to check.
* @returns {Promise<boolean>} A promise that resolves to true if the connection is in the room, false otherwise.
* @throws {Error} If there is an issue communicating with Redis or processing the request, the promise may be rejected with an error.
*/
async connectionIsInRoom(
roomName: string,
connection: Connection | string
): Promise<boolean> {
const connectionId =
typeof connection === "string" ? connection : connection.id;
return !!(await this.redis.sismember(this.roomKey(roomName), connectionId));
}
/**
* Adds a connection to a specified room, associating the connection ID with the room name
* in Redis. Supports both `Connection` objects and connection IDs as strings.
*
* @param {string} roomName - The name of the room to add the connection to.
* @param {Connection | string} connection - The connection object or connection ID to add to the room.
* @returns {Promise<void>} A promise that resolves when the operation is complete.
* @throws {Error} If an error occurs while updating Redis, the promise will be rejected with the error.
*/
async addToRoom(
roomName: string,
connection: Connection | string
): Promise<void> {
const connectionId =
typeof connection === "string" ? connection : connection.id;
await this.redis.sadd(this.roomKey(roomName), connectionId);
await this.redis.sadd(this.connectionsRoomKey(connectionId), roomName);
}
/**
* Retrieves a list of rooms that the specified connection is currently a member of.
*
* @param {Connection | string} connection - The connection object or connection ID for which to retrieve room memberships.
* @returns {Promise<string[]>} A promise that resolves to an array of room names associated with the connection.
* @throws {Error} If the underlying Redis operation fails, the promise will be rejected with an error.
*/
async getRoomsForConnection(
connection: Connection | string
): Promise<string[]> {
const connectionId =
typeof connection === "string" ? connection : connection.id;
return await this.redis.smembers(this.connectionsRoomKey(connectionId));
}
/**
* Removes a connection from a specified room and updates Redis accordingly.
* Accepts either a Connection object or a string representing the connection ID.
* Updates both the room's set of connections and the connection's set of rooms in Redis.
*
* @param {string} roomName - The name of the room from which to remove the connection.
* @param {Connection | string} connection - The connection to be removed, specified as either a Connection object or a connection ID string.
* @returns {Promise<void>} A promise that resolves when the removal is complete.
* @throws {Error} If there is an error executing the Redis pipeline, the promise will be rejected with the error.
*/
async removeFromRoom(
roomName: string,
connection: Connection | string
): Promise<void> {
const connectionId =
typeof connection === "string" ? connection : connection.id;
const pipeline = this.redis.pipeline();
pipeline.srem(this.roomKey(roomName), connectionId);
pipeline.srem(this.connectionsRoomKey(connectionId), roomName);
await pipeline.exec();
}
/**
* Removes the specified connection from all rooms it is a member of and deletes its room membership record.
*
* @param {Connection | string} connection - The connection object or its unique identifier to be removed from all rooms.
* @returns {Promise<void>} A promise that resolves once the removal from all rooms is complete.
* @throws {Error} If an error occurs during Redis operations, the promise will be rejected with the error.
*/
async removeFromAllRooms(connection: Connection | string) {
const connectionId =
typeof connection === "string" ? connection : connection.id;
const rooms = await this.redis.smembers(
this.connectionsRoomKey(connectionId)
);
const pipeline = this.redis.pipeline();
for (const room of rooms) {
pipeline.srem(this.roomKey(room), connectionId);
}
pipeline.del(this.connectionsRoomKey(connectionId));
await pipeline.exec();
}
/**
* Removes all associations and metadata for the specified room. This includes
* removing the room from all connected clients, deleting the room's key, and
* deleting any associated metadata in Redis.
*
* @param {string} roomName - The name of the room to be cleared.
* @returns {Promise<void>} A promise that resolves when the room and its metadata have been cleared.
* @throws {Error} If an error occurs while interacting with Redis, the promise will be rejected with the error.
*/
async clearRoom(roomName: string) {
const connectionIds = await this.getRoomConnectionIds(roomName);
const pipeline = this.redis.pipeline();
for (const connectionId of connectionIds) {
pipeline.srem(this.connectionsRoomKey(connectionId), roomName);
}
pipeline.del(this.roomKey(roomName));
pipeline.del(this.roomMetadataKey(roomName));
await pipeline.exec();
}
/**
* Cleans up all Redis references for a given connection by removing the connection
* from all rooms it is associated with and deleting the connection's room key.
*
* @param {Connection} connection - The connection object whose references should be cleaned up.
* @returns {Promise<void>} A promise that resolves when the cleanup is complete.
* @throws {Error} If an error occurs while interacting with Redis, the promise will be rejected with the error.
*/
async cleanupConnection(connection: Connection): Promise<void> {
const rooms = await this.redis.smembers(
this.connectionsRoomKey(connection.id)
);
const pipeline = this.redis.pipeline();
for (const room of rooms) {
pipeline.srem(this.roomKey(room), connection.id);
}
pipeline.del(this.connectionsRoomKey(connection.id));
await pipeline.exec();
}
/**
* Sets the metadata for a given room by storing the serialized metadata
* object in Redis under the room's metadata key.
*
* @param {string} roomName - The unique name of the room whose metadata is being set.
* @param {any} metadata - The metadata object to associate with the room. This object will be stringified before storage.
* @returns {Promise<void>} A promise that resolves when the metadata has been successfully set.
* @throws {Error} If an error occurs while storing metadata in Redis, the promise will be rejected with the error.
*/
async setMetadata(roomName: string, metadata: any): Promise<void> {
await this.redis.hset(
this.roomMetadataKey(roomName),
"data",
JSON.stringify(metadata)
);
}
/**
* Retrieves and parses metadata associated with the specified room from Redis storage.
*
* @param {string} roomName - The name of the room whose metadata is to be retrieved.
* @returns {Promise<any | null>} A promise that resolves to the parsed metadata object if found,
* or null if no metadata exists for the given room.
* @throws {SyntaxError} If the retrieved data is not valid JSON and cannot be parsed.
* @throws {Error} If there is an issue communicating with Redis.
*/
async getMetadata(roomName: string): Promise<any | null> {
const data = await this.redis.hget(this.roomMetadataKey(roomName), "data");
return data ? JSON.parse(data) : null;
}
/**
* Updates the metadata for the specified room by merging the current metadata
* with the provided partial update object. The merged result is then saved as
* the new metadata for the room.
*
* @param {string} roomName - The name of the room whose metadata is to be updated.
* @param {any} partialUpdate - An object containing the fields to update within the room's metadata.
* @returns {Promise<void>} A promise that resolves when the metadata update is complete.
* @throws {Error} If retrieving or setting metadata fails, the promise will be rejected with the error.
*/
async updateMetadata(roomName: string, partialUpdate: any): Promise<void> {
const currentMetadata = (await this.getMetadata(roomName)) || {};
const updatedMetadata = { ...currentMetadata, ...partialUpdate };
await this.setMetadata(roomName, updatedMetadata);
}
/**
* Retrieves and returns all room metadata stored in Redis.
* Fetches all keys matching the pattern "mesh:roommeta:*", retrieves their "data" fields,
* parses them as JSON, and returns an object mapping room names to their metadata.
*
* @returns {Promise<{ [roomName: string]: any }>} A promise that resolves to an object mapping room names to their metadata.
* @throws {SyntaxError} If the stored metadata cannot be parsed as JSON, an error is logged and the room is omitted from the result.
*/
async getAllMetadata(): Promise<{ [roomName: string]: any }> {
const keys = await this.redis.keys("mesh:roommeta:*");
const metadata: { [roomName: string]: any } = {};
if (keys.length === 0) {
return metadata;
}
const pipeline = this.redis.pipeline();
keys.forEach((key) => pipeline.hget(key, "data"));
const results = await pipeline.exec();
keys.forEach((key, index) => {
const roomName = key.replace("mesh:roommeta:", "");
const data = results?.[index]?.[1];
if (data) {
try {
metadata[roomName] = JSON.parse(data as string);
} catch (e) {
console.error(`Failed to parse metadata for room ${roomName}:`, e);
}
}
});
return metadata;
}
}

View File

@ -1,21 +0,0 @@
import type { Connection } from "./connection";
import type { MeshServer } from "./mesh-server";
export class MeshContext<T = any> {
server: MeshServer;
command: string;
connection: Connection;
payload: T;
constructor(
server: MeshServer,
command: string,
connection: Connection,
payload: T
) {
this.server = server;
this.command = command;
this.connection = connection;
this.payload = payload;
}
}

View File

@ -1,918 +0,0 @@
import { IncomingMessage } from "node:http";
import { v4 as uuidv4 } from "uuid";
import { WebSocketServer } from "ws";
import { Status } from "../client";
import { parseCommand } from "../common/message";
import { Connection } from "./connection";
import { MeshContext } from "./mesh-context";
import { ConnectionManager } from "./managers/connection";
import { PresenceManager } from "./managers/presence";
import { RecordManager } from "./managers/record";
import { RoomManager } from "./managers/room";
import { BroadcastManager } from "./managers/broadcast";
import { ChannelManager } from "./managers/channel";
import { CommandManager } from "./managers/command";
import { PubSubManager } from "./managers/pubsub";
import { RecordSubscriptionManager } from "./managers/record-subscription";
import { RedisManager } from "./managers/redis";
import type {
ChannelPattern,
MeshServerOptions,
SocketMiddleware,
} from "./types";
import { PUB_SUB_CHANNEL_PREFIX } from "./utils/constants";
export class MeshServer extends WebSocketServer {
readonly instanceId: string;
private redisManager: RedisManager;
private commandManager: CommandManager;
private channelManager: ChannelManager;
private pubSubManager: PubSubManager;
private recordSubscriptionManager: RecordSubscriptionManager;
private broadcastManager: BroadcastManager;
roomManager: RoomManager;
recordManager: RecordManager;
connectionManager: ConnectionManager;
presenceManager: PresenceManager;
serverOptions: MeshServerOptions;
status: Status = Status.OFFLINE;
private _listening = false;
get listening(): boolean {
return this._listening;
}
set listening(value: boolean) {
this._listening = value;
this.status = value ? Status.ONLINE : Status.OFFLINE;
}
constructor(opts: MeshServerOptions) {
super(opts);
this.instanceId = uuidv4();
this.serverOptions = {
...opts,
pingInterval: opts.pingInterval ?? 30_000,
latencyInterval: opts.latencyInterval ?? 5_000,
maxMissedPongs: opts.maxMissedPongs ?? 1,
};
this.redisManager = new RedisManager();
this.redisManager.initialize(opts.redisOptions, (err) =>
this.emit("error", err)
);
this.roomManager = new RoomManager(this.redisManager.redis);
this.recordManager = new RecordManager(this.redisManager.redis);
this.connectionManager = new ConnectionManager(
this.redisManager.pubClient,
this.instanceId,
this.roomManager
);
this.presenceManager = new PresenceManager(
this.redisManager.redis,
this.roomManager,
this.redisManager,
this.serverOptions.enablePresenceExpirationEvents
);
if (this.serverOptions.enablePresenceExpirationEvents) {
this.redisManager
.enableKeyspaceNotifications()
.catch((err) =>
this.emit(
"error",
new Error(`Failed to enable keyspace notifications: ${err}`)
)
);
}
this.commandManager = new CommandManager((err) => this.emit("error", err));
this.channelManager = new ChannelManager(
this.redisManager.redis,
this.redisManager.pubClient,
this.redisManager.subClient,
(err) => this.emit("error", err)
);
this.recordSubscriptionManager = new RecordSubscriptionManager(
this.redisManager.pubClient,
this.recordManager,
(err) => this.emit("error", err)
);
this.pubSubManager = new PubSubManager(
this.redisManager.subClient,
this.instanceId,
this.connectionManager,
this.recordSubscriptionManager.getRecordSubscriptions(),
this.channelManager.getSubscribers.bind(this.channelManager),
(err) => this.emit("error", err)
);
this.broadcastManager = new BroadcastManager(
this.connectionManager,
this.roomManager,
this.instanceId,
this.redisManager.pubClient,
(instanceId) => `${PUB_SUB_CHANNEL_PREFIX}${instanceId}`,
(err) => this.emit("error", err)
);
this.on("listening", () => {
this.listening = true;
});
this.on("error", (err) => {
console.error(`[MeshServer] Error: ${err}`);
});
this.on("close", () => {
this.listening = false;
});
this.pubSubManager.subscribeToInstanceChannel();
this.registerBuiltinCommands();
this.registerRecordCommands();
this.applyListeners();
}
/**
* Waits until the service is ready by ensuring it is listening and the instance channel subscription is established.
*
* @returns {Promise<void>} A promise that resolves when the service is fully ready.
* @throws {Error} If the readiness process fails or if any awaited promise rejects.
*/
async ready(): Promise<void> {
const listeningPromise = this.listening
? Promise.resolve()
: new Promise<void>((resolve) => this.once("listening", resolve));
await Promise.all([
listeningPromise,
this.pubSubManager.getSubscriptionPromise(),
]);
}
private applyListeners() {
this.on("connection", async (socket, req: IncomingMessage) => {
const connection = new Connection(socket, req, this.serverOptions);
connection.on("message", (buffer: Buffer) => {
try {
const data = buffer.toString();
const command = parseCommand(data);
if (
command.id !== undefined &&
!["latency:response", "pong"].includes(command.command)
) {
this.commandManager.runCommand(
command.id,
command.command,
command.payload,
connection,
this
);
}
} catch (err) {
this.emit("error", err);
}
});
try {
await this.connectionManager.registerConnection(connection);
} catch (error) {
connection.close();
return;
}
this.emit("connected", connection);
connection.on("close", async () => {
await this.cleanupConnection(connection);
this.emit("disconnected", connection);
});
connection.on("error", (err) => {
this.emit("clientError", err, connection);
});
connection.on("pong", async (connectionId) => {
try {
const rooms = await this.roomManager.getRoomsForConnection(
connectionId
);
for (const roomName of rooms) {
if (await this.presenceManager.isRoomTracked(roomName)) {
await this.presenceManager.refreshPresence(
connectionId,
roomName
);
}
}
} catch (err) {
this.emit("error", new Error(`Failed to refresh presence: ${err}`));
}
});
});
}
// #region Command Management
/**
* Registers a command with an associated callback and optional middleware.
*
* @template T The type for `MeshContext.payload`. Defaults to `any`.
* @template U The command's return value type. Defaults to `any`.
* @param {string} command - The unique identifier for the command to register.
* @param {(context: MeshContext<T>) => Promise<U> | U} callback - The function to execute when the command is invoked. It receives a `MeshContext` of type `T` and may return a value of type `U` or a `Promise` resolving to `U`.
* @param {SocketMiddleware[]} [middlewares=[]] - An optional array of middleware functions to apply to the command. Defaults to an empty array.
* @throws {Error} May throw an error if the command registration or middleware addition fails.
*/
exposeCommand<T = any, U = any>(
command: string,
callback: (context: MeshContext<T>) => Promise<U> | U,
middlewares: SocketMiddleware[] = []
) {
this.commandManager.exposeCommand(command, callback, middlewares);
}
/**
* Adds one or more middleware functions to the global middleware stack.
*
* @param {SocketMiddleware[]} middlewares - An array of middleware functions to be added. Each middleware
* is expected to conform to the `SocketMiddleware` type.
* @returns {void}
* @throws {Error} If the provided middlewares are not valid or fail validation (if applicable).
*/
useMiddleware(...middlewares: SocketMiddleware[]): void {
this.commandManager.useMiddleware(...middlewares);
}
/**
* Adds an array of middleware functions to a specific command.
*
* @param {string} command - The name of the command to associate the middleware with.
* @param {SocketMiddleware[]} middlewares - An array of middleware functions to be added to the command.
* @returns {void}
*/
useMiddlewareWithCommand(
command: string,
middlewares: SocketMiddleware[]
): void {
this.commandManager.useMiddlewareWithCommand(command, middlewares);
}
// #endregion
// #region Channel Management
/**
* Exposes a channel for external access and optionally associates a guard function
* to control access to that channel. The guard function determines whether a given
* connection is permitted to access the channel.
*
* @param {ChannelPattern} channel - The channel or pattern to expose.
* @param {(connection: Connection, channel: string) => Promise<boolean> | boolean} [guard] -
* Optional guard function that receives the connection and channel name, returning
* a boolean or a promise that resolves to a boolean indicating whether access is allowed.
* @returns {void}
*/
exposeChannel(
channel: ChannelPattern,
guard?: (
connection: Connection,
channel: string
) => Promise<boolean> | boolean
): void {
this.channelManager.exposeChannel(channel, guard);
}
/**
* Publishes a message to a specified channel and optionally maintains a history of messages.
*
* @param {string} channel - The name of the channel to which the message will be published.
* @param {any} message - The message to be published. Will not be stringified automatically for you. You need to do that yourself.
* @param {number} [history=0] - The number of historical messages to retain for the channel. Defaults to 0, meaning no history is retained.
* If greater than 0, the message will be added to the channel's history and the history will be trimmed to the specified size.
* @returns {Promise<void>} A Promise that resolves once the message has been published and, if applicable, the history has been updated.
* @throws {Error} This function may throw an error if the underlying `pubClient` operations (e.g., `lpush`, `ltrim`, `publish`) fail.
*/
async publishToChannel(
channel: string,
message: any,
history: number = 0
): Promise<void> {
return this.channelManager.publishToChannel(channel, message, history);
}
// #endregion
// #region Record Management
/**
* Exposes a record or pattern for client subscriptions, optionally adding a guard function.
*
* @param {ChannelPattern} recordPattern - The record ID or pattern to expose.
* @param {(connection: Connection, recordId: string) => Promise<boolean> | boolean} [guard] - Optional guard function.
*/
exposeRecord(
recordPattern: ChannelPattern,
guard?: (
connection: Connection,
recordId: string
) => Promise<boolean> | boolean
): void {
this.recordSubscriptionManager.exposeRecord(recordPattern, guard);
}
/**
* Exposes a record or pattern for client writes, optionally adding a guard function.
*
* @param {ChannelPattern} recordPattern - The record ID or pattern to expose as writable.
* @param {(connection: Connection, recordId: string) => Promise<boolean> | boolean} [guard] - Optional guard function.
*/
exposeWritableRecord(
recordPattern: ChannelPattern,
guard?: (
connection: Connection,
recordId: string
) => Promise<boolean> | boolean
): void {
this.recordSubscriptionManager.exposeWritableRecord(recordPattern, guard);
}
/**
* Updates a record, persists it to Redis, increments its version, computes a patch,
* and publishes the update via Redis pub/sub.
*
* @param {string} recordId - The ID of the record to update.
* @param {any} newValue - The new value for the record.
* @returns {Promise<void>}
* @throws {Error} If the update fails.
*/
async publishRecordUpdate(recordId: string, newValue: any): Promise<void> {
return this.recordSubscriptionManager.publishRecordUpdate(
recordId,
newValue
);
}
// #endregion
// #region Room Management
async isInRoom(roomName: string, connection: Connection | string) {
const connectionId =
typeof connection === "string" ? connection : connection.id;
return this.roomManager.connectionIsInRoom(roomName, connectionId);
}
async addToRoom(roomName: string, connection: Connection | string) {
const connectionId =
typeof connection === "string" ? connection : connection.id;
await this.roomManager.addToRoom(roomName, connection);
if (await this.presenceManager.isRoomTracked(roomName)) {
await this.presenceManager.markOnline(connectionId, roomName);
}
}
async removeFromRoom(roomName: string, connection: Connection | string) {
const connectionId =
typeof connection === "string" ? connection : connection.id;
if (await this.presenceManager.isRoomTracked(roomName)) {
await this.presenceManager.markOffline(connectionId, roomName);
}
return this.roomManager.removeFromRoom(roomName, connection);
}
async removeFromAllRooms(connection: Connection | string) {
return this.roomManager.removeFromAllRooms(connection);
}
async clearRoom(roomName: string) {
return this.roomManager.clearRoom(roomName);
}
async getRoomMembers(roomName: string): Promise<string[]> {
return this.roomManager.getRoomConnectionIds(roomName);
}
// #endregion
// #region Broadcasting
/**
* Broadcasts a command and payload to a set of connections or all available connections.
*
* @param {string} command - The command to be broadcasted.
* @param {any} payload - The data associated with the command.
* @param {Connection[]=} connections - (Optional) A specific list of connections to broadcast to. If not provided, the command will be sent to all connections.
*
* @throws {Error} Emits an "error" event if broadcasting fails.
*/
async broadcast(command: string, payload: any, connections?: Connection[]) {
return this.broadcastManager.broadcast(command, payload, connections);
}
/**
* Broadcasts a command and associated payload to all active connections within the specified room.
*
* @param {string} roomName - The name of the room whose connections will receive the broadcast.
* @param {string} command - The command to be broadcasted to the connections.
* @param {unknown} payload - The data payload associated with the command.
* @returns {Promise<void>} A promise that resolves when the broadcast operation is complete.
* @throws {Error} If the broadcast operation fails, an error is thrown and the promise is rejected.
*/
async broadcastRoom(
roomName: string,
command: string,
payload: any
): Promise<void> {
return this.broadcastManager.broadcastRoom(roomName, command, payload);
}
/**
* Broadcasts a command and payload to all active connections except for the specified one(s).
* Excludes the provided connection(s) from receiving the broadcast.
*
* @param {string} command - The command to broadcast to connections.
* @param {any} payload - The payload to send along with the command.
* @param {Connection | Connection[]} exclude - A single connection or an array of connections to exclude from the broadcast.
* @returns {Promise<void>} A promise that resolves when the broadcast is complete.
* @emits {Error} Emits an "error" event if broadcasting the command fails.
*/
async broadcastExclude(
command: string,
payload: any,
exclude: Connection | Connection[]
): Promise<void> {
return this.broadcastManager.broadcastExclude(command, payload, exclude);
}
/**
* Broadcasts a command with a payload to all connections in a specified room,
* excluding one or more given connections. If the broadcast fails, emits an error event.
*
* @param {string} roomName - The name of the room to broadcast to.
* @param {string} command - The command to broadcast.
* @param {any} payload - The payload to send with the command.
* @param {Connection | Connection[]} exclude - A connection or array of connections to exclude from the broadcast.
* @returns {Promise<void>} A promise that resolves when the broadcast is complete.
* @emits {Error} Emits an error event if broadcasting fails.
*/
async broadcastRoomExclude(
roomName: string,
command: string,
payload: any,
exclude: Connection | Connection[]
): Promise<void> {
return this.broadcastManager.broadcastRoomExclude(
roomName,
command,
payload,
exclude
);
}
// #endregion
// #region Presence Management
trackPresence(
roomPattern: string | RegExp,
guardOrOptions?:
| ((
connection: Connection,
roomName: string
) => Promise<boolean> | boolean)
| {
ttl?: number;
guard?: (
connection: Connection,
roomName: string
) => Promise<boolean> | boolean;
}
): void {
this.presenceManager.trackRoom(roomPattern, guardOrOptions);
}
// #endregion
// #region Command Registration
private registerBuiltinCommands() {
this.exposeCommand<
{ channel: string; historyLimit?: number },
{ success: boolean; history?: string[] }
>("mesh/subscribe-channel", async (ctx) => {
const { channel, historyLimit } = ctx.payload;
if (
!(await this.channelManager.isChannelExposed(channel, ctx.connection))
) {
return { success: false, history: [] };
}
try {
if (!this.channelManager.getSubscribers(channel)) {
await this.channelManager.subscribeToRedisChannel(channel);
}
this.channelManager.addSubscription(channel, ctx.connection);
let history: string[] = [];
if (historyLimit && historyLimit > 0) {
history = await this.channelManager.getChannelHistory(
channel,
historyLimit
);
}
return {
success: true,
history,
};
} catch (e) {
return { success: false, history: [] };
}
});
this.exposeCommand<{ channel: string }, boolean>(
"mesh/unsubscribe-channel",
async (ctx) => {
const { channel } = ctx.payload;
const wasSubscribed = this.channelManager.removeSubscription(
channel,
ctx.connection
);
if (wasSubscribed && !this.channelManager.getSubscribers(channel)) {
await this.channelManager.unsubscribeFromRedisChannel(channel);
}
return wasSubscribed;
}
);
this.exposeCommand<
{ roomName: string },
{ success: boolean; present: string[] }
>("mesh/join-room", async (ctx) => {
const { roomName } = ctx.payload;
await this.addToRoom(roomName, ctx.connection);
const present = await this.presenceManager.getPresentConnections(
roomName
);
return { success: true, present };
});
this.exposeCommand<{ roomName: string }, { success: boolean }>(
"mesh/leave-room",
async (ctx) => {
const { roomName } = ctx.payload;
await this.removeFromRoom(roomName, ctx.connection);
return { success: true };
}
);
this.exposeCommand<{ connectionId: string }, { metadata: any }>(
"mesh/get-connection-metadata",
async (ctx) => {
const { connectionId } = ctx.payload;
// Try to get the local connection first
const connection =
this.connectionManager.getLocalConnection(connectionId);
if (connection) {
// If we have the connection locally, use it to get metadata
const metadata = await this.connectionManager.getMetadata(connection);
return { metadata };
} else {
// If the connection is not local, we need to get metadata directly from Redis
// This is a workaround since we don't have direct access to the connection
const metadata = await this.redisManager.redis.hget(
"mesh:connections",
connectionId
);
return { metadata: metadata ? JSON.parse(metadata) : null };
}
}
);
this.exposeCommand<{}, { metadata: any }>(
"mesh/get-my-connection-metadata",
async (ctx) => {
const connectionId = ctx.connection.id;
const connection =
this.connectionManager.getLocalConnection(connectionId);
if (connection) {
const metadata = await this.connectionManager.getMetadata(connection);
return { metadata };
} else {
const metadata = await this.redisManager.redis.hget(
"mesh:connections",
connectionId
);
return { metadata: metadata ? JSON.parse(metadata) : null };
}
}
);
this.exposeCommand<{ roomName: string }, { metadata: any }>(
"mesh/get-room-metadata",
async (ctx) => {
const { roomName } = ctx.payload;
const metadata = await this.roomManager.getMetadata(roomName);
return { metadata };
}
);
}
private registerRecordCommands() {
this.exposeCommand<
{ recordId: string; mode?: "patch" | "full" },
{ success: boolean; record?: any; version?: number }
>("mesh/subscribe-record", async (ctx) => {
const { recordId, mode = "full" } = ctx.payload;
const connectionId = ctx.connection.id;
if (
!(await this.recordSubscriptionManager.isRecordExposed(
recordId,
ctx.connection
))
) {
return { success: false };
}
try {
const { record, version } =
await this.recordManager.getRecordAndVersion(recordId);
this.recordSubscriptionManager.addSubscription(
recordId,
connectionId,
mode
);
return { success: true, record, version };
} catch (e) {
console.error(`Failed to subscribe to record ${recordId}:`, e);
return { success: false };
}
});
this.exposeCommand<{ recordId: string }, boolean>(
"mesh/unsubscribe-record",
async (ctx) => {
const { recordId } = ctx.payload;
const connectionId = ctx.connection.id;
return this.recordSubscriptionManager.removeSubscription(
recordId,
connectionId
);
}
);
this.exposeCommand<
{ recordId: string; newValue: any },
{ success: boolean }
>("mesh/publish-record-update", async (ctx) => {
const { recordId, newValue } = ctx.payload;
if (
!(await this.recordSubscriptionManager.isRecordWritable(
recordId,
ctx.connection
))
) {
throw new Error(
`Record "${recordId}" is not writable by this connection.`
);
}
try {
await this.publishRecordUpdate(recordId, newValue);
return { success: true };
} catch (e: any) {
throw new Error(
`Failed to publish update for record "${recordId}": ${e.message}`
);
}
});
this.exposeCommand<
{ roomName: string },
{
success: boolean;
present: string[];
states?: Record<string, Record<string, any>>;
}
>("mesh/subscribe-presence", async (ctx) => {
const { roomName } = ctx.payload;
if (
!(await this.presenceManager.isRoomTracked(roomName, ctx.connection))
) {
return { success: false, present: [] };
}
try {
const presenceChannel = `mesh:presence:updates:${roomName}`;
if (!this.channelManager.getSubscribers(presenceChannel)) {
this.channelManager.addSubscription(presenceChannel, ctx.connection);
}
const present = await this.presenceManager.getPresentConnections(
roomName
);
// get all presence states for the room
const statesMap = await this.presenceManager.getAllPresenceStates(
roomName
);
const states: Record<string, Record<string, any>> = {};
statesMap.forEach((state, connectionId) => {
states[connectionId] = state;
});
return {
success: true,
present,
states,
};
} catch (e) {
console.error(
`Failed to subscribe to presence for room ${roomName}:`,
e
);
return { success: false, present: [] };
}
});
this.exposeCommand<{ roomName: string }, boolean>(
"mesh/unsubscribe-presence",
async (ctx) => {
const { roomName } = ctx.payload;
const presenceChannel = `mesh:presence:updates:${roomName}`;
return this.channelManager.removeSubscription(
presenceChannel,
ctx.connection
);
}
);
this.exposeCommand<
{ roomName: string; state: Record<string, any>; expireAfter?: number },
boolean
>("mesh/publish-presence-state", async (ctx) => {
const { roomName, state, expireAfter } = ctx.payload;
const connectionId = ctx.connection.id;
if (!state) {
return false;
}
// ensure presence is tracked for this room and the connection is in the room
if (
!(await this.presenceManager.isRoomTracked(roomName, ctx.connection)) ||
!(await this.isInRoom(roomName, connectionId))
) {
return false;
}
try {
await this.presenceManager.publishPresenceState(
connectionId,
roomName,
state,
expireAfter
);
return true;
} catch (e) {
console.error(
`Failed to publish presence state for room ${roomName}:`,
e
);
return false;
}
});
this.exposeCommand<{ roomName: string }, boolean>(
"mesh/clear-presence-state",
async (ctx) => {
const { roomName } = ctx.payload;
const connectionId = ctx.connection.id;
// ensure presence is tracked for this room and the connection is in the room
if (
!(await this.presenceManager.isRoomTracked(
roomName,
ctx.connection
)) ||
!(await this.isInRoom(roomName, connectionId))
) {
return false;
}
try {
await this.presenceManager.clearPresenceState(connectionId, roomName);
return true;
} catch (e) {
console.error(
`Failed to clear presence state for room ${roomName}:`,
e
);
return false;
}
}
);
}
// #endregion
private async cleanupConnection(connection: Connection) {
connection.stopIntervals();
try {
await this.presenceManager.cleanupConnection(connection);
await this.connectionManager.cleanupConnection(connection);
await this.roomManager.cleanupConnection(connection);
this.recordSubscriptionManager.cleanupConnection(connection);
this.channelManager.cleanupConnection(connection);
} catch (err) {
this.emit("error", new Error(`Failed to clean up connection: ${err}`));
}
}
/**
* Gracefully closes all active connections, cleans up resources,
* and shuts down the service. Optionally accepts a callback function
* that will be invoked once shutdown is complete or if an error occurs.
*
* @param {((err?: Error) => void)=} callback - Optional callback to be invoked when closing is complete or if an error occurs.
* @returns {Promise<void>} A promise that resolves when shutdown is complete.
* @throws {Error} If an error occurs during shutdown, the promise will be rejected with the error.
*/
async close(callback?: (err?: Error) => void): Promise<void> {
this.redisManager.isShuttingDown = true;
const connections = Object.values(
this.connectionManager.getLocalConnections()
);
await Promise.all(
connections.map(async (connection) => {
if (!connection.isDead) {
await connection.close();
}
await this.cleanupConnection(connection);
})
);
await new Promise<void>((resolve, reject) => {
super.close((err?: Error) => {
if (err) reject(err);
else resolve();
});
});
this.redisManager.disconnect();
this.listening = false;
this.removeAllListeners();
if (callback) {
callback();
}
}
/**
* Registers a callback function to be executed when a new connection is established.
*
* @param {(connection: Connection) => Promise<void> | void} callback - The function to execute when a new connection is established.
* @returns {MeshServer} The server instance for method chaining.
*/
onConnection(
callback: (connection: Connection) => Promise<void> | void
): MeshServer {
this.on("connected", callback);
return this;
}
/**
* Registers a callback function to be executed when a connection is closed.
*
* @param {(connection: Connection) => Promise<void> | void} callback - The function to execute when a connection is closed.
* @returns {MeshServer} The server instance for method chaining.
*/
onDisconnection(
callback: (connection: Connection) => Promise<void> | void
): MeshServer {
this.on("disconnected", callback);
return this;
}
}

View File

@ -1,3 +0,0 @@
export class Ping {
interval: ReturnType<typeof setTimeout> | undefined;
}

View File

@ -1,63 +0,0 @@
import type { ServerOptions } from "ws";
import type { RedisOptions } from "ioredis";
import type { Operation } from "fast-json-patch";
import type { Connection } from "./connection";
import type { Command } from "../common/message";
import type { MeshContext } from "./mesh-context";
export type SocketMiddleware = (
context: MeshContext<any>
) => any | Promise<any>;
export type PubSubMessagePayload = {
targetConnectionIds: string[];
command: Command;
};
export type RecordUpdatePubSubPayload = {
recordId: string;
newValue?: any;
patch?: Operation[];
version: number;
};
export type MeshServerOptions = ServerOptions & {
/**
* The interval at which to send ping messages to the client.
*
* @default 30000
*/
pingInterval?: number;
/**
* The interval at which to send both latency requests and updates to the client.
*
* @default 5000
*/
latencyInterval?: number;
redisOptions: RedisOptions;
/**
* Whether to enable Redis keyspace notifications for presence expiration.
* When enabled, connections will be automatically marked as offline when their presence TTL expires.
*
* @default true
*/
enablePresenceExpirationEvents?: boolean;
/**
* The maximum number of consecutive ping intervals the server will wait
* for a pong response before considering the client disconnected.
* A value of 1 means the client must respond within roughly 2 * pingInterval
* before being disconnected. Setting it to 0 is not recommended as it will
* immediately disconnect the client if it doesn't respond to the first ping in
* exactly `pingInterval` milliseconds, which doesn't provide wiggle room for
* network latency.
*
* @see pingInterval
* @default 1
*/
maxMissedPongs?: number;
};
export type ChannelPattern = string | RegExp;

View File

@ -1,2 +0,0 @@
export const PUB_SUB_CHANNEL_PREFIX = "mesh:pubsub:";
export const RECORD_PUB_SUB_CHANNEL = "mesh:record-updates";

View File

@ -1,47 +0,0 @@
import { getRandomValues } from "node:crypto";
const HEX: string[] = [];
for (let i = 0; i < 256; i++) {
HEX[i] = (i + 256).toString(16).substring(1);
}
function pad(str: string, size: number) {
const s = `000000${str}`;
return s.substring(s.length - size);
}
const SHARD_COUNT = 32;
export function getCreateId(opts: { init: number; len: number }) {
const len = opts.len || 16;
let str = "";
let num = 0;
const discreteValues = 1_679_616; // Math.pow(36, 4)
let current = opts.init + Math.ceil(discreteValues / 2);
function counter() {
if (current >= discreteValues) current = 0;
current++;
return (current - 1).toString(16);
}
return () => {
if (!str || num === 256) {
const bytes = new Uint8Array(len);
getRandomValues(bytes);
str = Array.from(bytes, (b) => HEX[b])
.join("")
.substring(0, len);
num = 0;
}
const date = Date.now().toString(36);
const paddedCounter = pad(counter(), 6);
const hex = HEX[num++]!;
const shardKey = parseInt(hex, 16) % SHARD_COUNT;
return `conn-${date}${paddedCounter}${hex}${str}${shardKey}`;
};
}

View File

@ -1,181 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient, Status } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("MeshServer", () => {
const port = 8126;
let server: MeshServer;
let clientA: MeshClient;
let clientB: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
await server.ready();
clientA = new MeshClient(`ws://localhost:${port}`);
clientB = new MeshClient(`ws://localhost:${port}`);
});
afterEach(async () => {
await clientA.close();
await clientB.close();
await server.close();
});
test("clients can connect to the server", async () => {
await clientA.connect();
expect(clientA.status).toBe(Status.ONLINE);
await clientB.connect();
expect(clientB.status).toBe(Status.ONLINE);
});
test("clients can disconnect from the server", async () => {
await clientA.connect();
expect(clientA.status).toBe(Status.ONLINE);
await clientA.close();
expect(clientA.status).toBe(Status.OFFLINE);
});
test("clients can send a command and receive a response", async () => {
server.exposeCommand("echo", async (c) => `echo: ${c.payload}`);
await clientA.connect();
const response = await clientA.command("echo", "Hello, World!");
expect(response).toBe("echo: Hello, World!");
await clientA.close();
});
describe("metadata", () => {
test("server can store metadata for a connection", async () => {
await clientA.connect();
await clientB.connect();
const metadataA = { name: "Client A", id: 1 };
const metadataB = { name: "Client B", id: 2 };
const connectionA = server.connectionManager.getLocalConnections()[0]!;
const connectionB = server.connectionManager.getLocalConnections()[1]!;
await server.connectionManager.setMetadata(connectionA, metadataA);
await server.connectionManager.setMetadata(connectionB, metadataB);
const storedMetadataA = await server.connectionManager.getMetadata(
connectionA
);
const storedMetadataB = await server.connectionManager.getMetadata(
connectionB
);
expect(storedMetadataA).toEqual(metadataA);
expect(storedMetadataB).toEqual(metadataB);
const allMetadata = await server.connectionManager.getAllMetadata();
expect(allMetadata).toEqual([
{ [connectionA.id]: metadataA },
{ [connectionB.id]: metadataB },
]);
const allMetadataFromNonExistentRoom =
await server.connectionManager.getAllMetadataForRoom(
"non-existent-room"
);
expect(allMetadataFromNonExistentRoom).toEqual([]);
});
test("server can retrieve metadata for a room of connections", async () => {
await clientA.connect();
await clientB.connect();
const metadataA = { name: "Client A", id: 1 };
const metadataB = { name: "Client B", id: 2 };
const connectionA = server.connectionManager.getLocalConnections()[0]!;
const connectionB = server.connectionManager.getLocalConnections()[1]!;
await server.connectionManager.setMetadata(connectionA, metadataA);
await server.connectionManager.setMetadata(connectionB, metadataB);
await server.addToRoom("room-a", connectionA);
await server.addToRoom("room-b", connectionB);
const roomAMetadata =
await server.connectionManager.getAllMetadataForRoom("room-a");
expect(roomAMetadata).toEqual([{ [connectionA.id]: metadataA }]);
const roomBMetadata =
await server.connectionManager.getAllMetadataForRoom("room-b");
expect(roomBMetadata).toEqual([{ [connectionB.id]: metadataB }]);
});
test("onConnection callback is executed when a client connects", async () => {
let connectionReceived: any = null;
const connectionPromise = new Promise<void>((resolve) => {
server.onConnection((connection) => {
connectionReceived = connection;
resolve();
});
});
await clientA.connect();
await connectionPromise;
expect(connectionReceived).not.toBeNull();
if (!connectionReceived) {
return;
}
expect(connectionReceived.id).toBeDefined();
expect(connectionReceived.isDead).toBe(false);
const connections = server.connectionManager.getLocalConnections();
expect(connections).toContain(connectionReceived);
});
test("onDisconnection callback is executed when a client disconnects", async () => {
let disconnectedConnection: any = null;
const disconnectionPromise = new Promise<void>((resolve) => {
server.onDisconnection((connection) => {
disconnectedConnection = connection;
resolve();
});
});
await clientA.connect();
await wait(100);
const connections = server.connectionManager.getLocalConnections();
const connectionBeforeDisconnect = connections[0];
expect(connectionBeforeDisconnect).toBeDefined();
const connectionId = connectionBeforeDisconnect?.id;
await clientA.close();
await disconnectionPromise;
expect(disconnectedConnection).not.toBeNull();
if (disconnectedConnection && connectionId) {
expect(disconnectedConnection.id).toBe(connectionId);
expect(disconnectedConnection.isDead).toBe(true);
}
});
});
});

View File

@ -1,334 +0,0 @@
import { describe, test, expect, vi } from "vitest";
import { createDedupedPresenceHandler } from "../client-utils";
import type { PresenceUpdate } from "../client/client";
describe("createDedupedPresenceHandler", () => {
test("adds a new group when a connection joins and is resolved to a new groupId", async () => {
const getGroupId = vi
.fn()
.mockImplementation(
async (connectionId) => `group:${connectionId.substring(0, 3)}`
);
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
const update: PresenceUpdate = {
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
};
await handler(update);
expect(getGroupId).toHaveBeenCalledWith("conn123");
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
expect(groupMap.size).toBe(1);
expect(groupMap.has("group:con")).toBe(true);
const group = groupMap.get("group:con");
expect(group.representative).toBe("conn123");
expect(group.members.size).toBe(1);
expect(group.members.has("conn123")).toBe(true);
});
test("adds the connection to an existing group if another connection already resolved to the same groupId", async () => {
const getGroupId = vi
.fn()
.mockImplementation(async (connectionId) => "group:same");
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
// first connection joins
await handler({
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
});
onUpdate.mockClear();
// second connection joins with same group ID
await handler({
type: "join",
connectionId: "conn456",
roomName: "test-room",
timestamp: 1001,
});
expect(getGroupId).toHaveBeenCalledWith("conn456");
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
expect(groupMap.size).toBe(1);
const group = groupMap.get("group:same");
// first connection remains the representative
expect(group.representative).toBe("conn123");
expect(group.members.size).toBe(2);
expect(group.members.has("conn123")).toBe(true);
expect(group.members.has("conn456")).toBe(true);
});
test("removes the group when the last connection in that group leaves", async () => {
const getGroupId = vi
.fn()
.mockImplementation(async (connectionId) => "group:test");
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
// connection joins
await handler({
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
});
onUpdate.mockClear();
// connection leaves
await handler({
type: "leave",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1001,
});
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
// group should be removed
expect(groupMap.size).toBe(0);
});
test("promotes a new representative when the current representative leaves", async () => {
const getGroupId = vi
.fn()
.mockImplementation(async (connectionId) => "group:test");
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
// first connection joins (becomes representative)
await handler({
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
});
// second connection joins
await handler({
type: "join",
connectionId: "conn456",
roomName: "test-room",
timestamp: 1001,
});
onUpdate.mockClear();
// representative leaves
await handler({
type: "leave",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1002,
});
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
expect(groupMap.size).toBe(1);
const group = groupMap.get("group:test");
// second connection should be promoted
expect(group.representative).toBe("conn456");
expect(group.members.size).toBe(1);
expect(group.members.has("conn456")).toBe(true);
});
test("updates state when a state update is received", async () => {
const getGroupId = vi
.fn()
.mockImplementation(async (connectionId) => "group:test");
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
// connection joins
await handler({
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
});
onUpdate.mockClear();
// connection updates state
await handler({
type: "state",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1001,
state: { status: "typing" },
});
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
const group = groupMap.get("group:test");
expect(group.state).toEqual({ status: "typing" });
expect(group.timestamp).toBe(1001);
});
test("only updates state if timestamp is newer", async () => {
const getGroupId = vi
.fn()
.mockImplementation(async (connectionId) => "group:test");
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
// two connections join the same group
await handler({
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
});
await handler({
type: "join",
connectionId: "conn456",
roomName: "test-room",
timestamp: 1001,
});
// first connection sets state
await handler({
type: "state",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1002,
state: { status: "typing" },
});
onUpdate.mockClear();
// second connection tries to set state with older timestamp
await handler({
type: "state",
connectionId: "conn456",
roomName: "test-room",
timestamp: 1001,
state: { status: "idle" },
});
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
const group = groupMap.get("group:test");
// should keep the first state
expect(group.state).toEqual({ status: "typing" });
expect(group.timestamp).toBe(1002);
// representative should not change
expect(group.representative).toBe("conn123");
});
test("changes representative when state is updated with newer timestamp", async () => {
const getGroupId = vi
.fn()
.mockImplementation(async (connectionId) => "group:test");
const onUpdate = vi.fn();
const handler = createDedupedPresenceHandler({
getGroupId,
onUpdate,
});
// two connections join the same group
await handler({
type: "join",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1000,
});
await handler({
type: "join",
connectionId: "conn456",
roomName: "test-room",
timestamp: 1001,
});
// first connection sets state
await handler({
type: "state",
connectionId: "conn123",
roomName: "test-room",
timestamp: 1002,
state: { status: "typing" },
});
onUpdate.mockClear();
// second connection sets state with newer timestamp
await handler({
type: "state",
connectionId: "conn456",
roomName: "test-room",
timestamp: 1003,
state: { status: "idle" },
});
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalled();
const groupMap = onUpdate.mock.calls![0]![0] as Map<string, any>;
const group = groupMap.get("group:test");
// should update to new state
expect(group.state).toEqual({ status: "idle" });
expect(group.timestamp).toBe(1003);
// representative should change
expect(group.representative).toBe("conn456");
});
});

View File

@ -1,264 +0,0 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient, Status } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
describe("MeshClient", () => {
const port = 8127;
let server: MeshServer;
let client: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
await server.ready();
client = new MeshClient(`ws://localhost:${port}`);
});
afterEach(async () => {
await client.close();
await server.close();
});
test("command times out when server doesn't respond", async () => {
server.exposeCommand("never-responds", async () => new Promise(() => {}));
await client.connect();
await expect(
client.command("never-responds", "Should time out", 100)
).rejects.toThrow(/timed out/);
}, 2000);
test("an unknown command should return an error object", async () => {
await client.connect();
const result = await client.command("unknown-command", "Should fail");
expect(result).toEqual({
code: "ENOTFOUND",
error: 'Command "unknown-command" not found',
name: "CommandError",
});
});
test("thrown servers errors are serialized to the client", async () => {
server.exposeCommand("throws-error", async () => {
throw new Error("This is a test error");
});
await client.connect();
const result = await client.command("throws-error", "Should fail");
expect(result).toEqual({
code: "ESERVER",
error: "This is a test error",
name: "Error",
});
});
test("handles large payloads without issue", async () => {
server.exposeCommand("echo", async (ctx) => ctx.payload);
await client.connect();
const largeData = {
array: Array(1000)
.fill(0)
.map((_, i) => `item-${i}`),
nested: {
deep: {
object: {
with: "lots of data",
},
},
},
};
const result = await client.command("echo", largeData, 200);
expect(result).toEqual(largeData);
});
test("client emits 'connect' event on successful connection", async () =>
new Promise<void>((resolve) => {
client.on("connect", () => {
expect(client.status).toBe(Status.ONLINE);
resolve();
});
client.connect();
}));
test("client emits 'disconnect' and 'close' events on successful disconnection", async () =>
new Promise<void>((resolve) => {
let disconnectEmitted = false;
let closeEmitted = false;
const checkBothEvents = () => {
if (disconnectEmitted && closeEmitted) {
resolve();
}
};
client.on("disconnect", () => {
expect(client.status).toBe(Status.OFFLINE);
disconnectEmitted = true;
checkBothEvents();
});
client.on("close", () => {
expect(client.status).toBe(Status.OFFLINE);
closeEmitted = true;
checkBothEvents();
});
client.connect().then(() => {
client.close();
});
}));
test("client emits 'message' event on receiving a message", async () =>
new Promise<void>((resolve) => {
client.on("message", (data) => {
expect(data).toEqual({ command: "hello", payload: "world" });
resolve();
});
client.connect().then(() => {
server.broadcast("hello", "world");
});
}));
test("client receives 'ping' messages", async () => {
const server = new MeshServer({
port: 8130,
pingInterval: 10,
maxMissedPongs: 10,
redisOptions: { host: REDIS_HOST, port: REDIS_PORT },
});
await server.ready();
const client = new MeshClient(`ws://localhost:8130`, {
pingTimeout: 10,
maxMissedPings: 10,
});
await client.connect();
return new Promise<void>((resolve) => {
client.on("ping", () => {
expect(client.status).toBe(Status.ONLINE);
resolve();
});
client.on("close", () => {
expect(client.status).toBe(Status.OFFLINE);
});
});
});
test("client receives 'latency' messages", async () => {
const server = new MeshServer({
port: 8131,
pingInterval: 10,
latencyInterval: 10,
maxMissedPongs: 10,
redisOptions: { host: REDIS_HOST, port: REDIS_PORT },
});
await server.ready();
const client = new MeshClient(`ws://localhost:8131`, {
pingTimeout: 10,
maxMissedPings: 10,
});
await client.connect();
return new Promise<void>((resolve) => {
client.on("latency", (data) => {
expect(data).toBeDefined();
resolve();
});
client.on("close", () => {
expect(client.status).toBe(Status.OFFLINE);
});
});
});
test("client can get room metadata", async () => {
const roomName = "test-room";
const metadata = { key: "value", nested: { data: true } };
await client.connect();
await client.joinRoom(roomName);
await server.roomManager.setMetadata(roomName, metadata);
const retrievedMetadata = await client.getRoomMetadata(roomName);
expect(retrievedMetadata).toEqual(metadata);
});
test("client can get connection metadata", async () => {
await client.connect();
const clientConnection = server.connectionManager.getLocalConnections()[0]!;
const metadata = { user: "test-user", permissions: ["read", "write"] };
await server.connectionManager.setMetadata(clientConnection, metadata);
const retrievedMetadata = await client.getConnectionMetadata(
clientConnection.id
);
expect(retrievedMetadata).toEqual(metadata);
});
test("client can get their own connection metadata", async () => {
await client.connect();
const clientConnection = server.connectionManager.getLocalConnections()[0]!;
const metadata = { user: "test-user", permissions: ["read", "write"] };
await server.connectionManager.setMetadata(clientConnection, metadata);
const retrievedMetadata = await client.getConnectionMetadata();
expect(retrievedMetadata).toEqual(metadata);
});
test("helper methods register event listeners correctly", async () => {
const connectSpy = vi.fn();
const disconnectSpy = vi.fn();
const reconnectSpy = vi.fn();
const reconnectFailedSpy = vi.fn();
client
.onConnect(connectSpy)
.onDisconnect(disconnectSpy)
.onReconnect(reconnectSpy)
.onReconnectFailed(reconnectFailedSpy);
await client.connect();
expect(connectSpy).toHaveBeenCalled();
client.emit("disconnect");
expect(disconnectSpy).toHaveBeenCalled();
client.emit("reconnect");
expect(reconnectSpy).toHaveBeenCalled();
client.emit("reconnectfailed");
expect(reconnectFailedSpy).toHaveBeenCalled();
});
});

View File

@ -1,155 +0,0 @@
import {
describe,
test,
expect,
beforeEach,
afterEach,
} from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
describe.sequential("Multiple instances", () => {
let serverA: MeshServer;
let serverB: MeshServer;
let clientA: MeshClient;
let clientB: MeshClient;
beforeEach(async () => {
await flushRedis();
serverA = createTestServer(6677);
serverB = createTestServer(6688);
await serverA.ready();
await serverB.ready();
clientA = new MeshClient(`ws://localhost:${6677}`);
clientB = new MeshClient(`ws://localhost:${6688}`);
});
afterEach(async () => {
await clientA.close();
await clientB.close();
await serverA.close();
await serverB.close();
});
test("broadcast should work across instances", async () => {
serverA.exposeCommand("broadcast", async (ctx) => {
await serverA.broadcast("hello", "Hello!");
});
await clientA.connect();
await clientB.connect();
let receivedA = false;
let receivedB = false;
clientA.on("hello", (data) => {
if (data === "Hello!") receivedA = true;
});
clientB.on("hello", (data) => {
if (data === "Hello!") receivedB = true;
});
await clientA.command("broadcast", {});
// wait for both events, or timeout
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (!(receivedA && receivedB)) return;
clearTimeout(timeout);
clearInterval(interval);
resolve();
}, 10);
const timeout = setTimeout(() => {
clearInterval(interval);
resolve();
}, 10000);
});
expect(receivedA).toBe(true);
expect(receivedB).toBe(true);
}, 10000);
test("broadcastRoom should work across instances", async () => {
[serverA, serverB].forEach((server) =>
server.exposeCommand("join-room", async (ctx) => {
await server.addToRoom(ctx.payload.room, ctx.connection);
return { joined: true };
})
);
serverA.exposeCommand("broadcast-room", async (ctx) => {
await serverA.broadcastRoom(
ctx.payload.room,
"room-message",
ctx.payload.message
);
return { sent: true };
});
await clientA.connect();
await clientB.connect();
let receivedA = false;
let receivedB = false;
clientA.on("room-message", (data) => {
if (data === "hello") receivedA = true;
});
clientB.on("room-message", (data) => {
if (data === "hello") receivedB = true;
});
await clientA.command("join-room", { room: "test-room" });
await clientB.command("join-room", { room: "test-room" });
await clientA.command("broadcast-room", {
room: "test-room",
message: "hello",
});
// wait for both events, or timeout
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (!(receivedA && receivedB)) return;
clearTimeout(timeout);
clearInterval(interval);
resolve();
}, 10);
const timeout = setTimeout(() => {
clearInterval(interval);
resolve();
}, 10000);
});
expect(receivedA).toBe(true);
expect(receivedB).toBe(true);
}, 10000);
});

View File

@ -1,390 +0,0 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
pingInterval: 1000,
latencyInterval: 500,
enablePresenceExpirationEvents: true,
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("Presence State", () => {
const port = 8150;
let server: MeshServer;
let client1: MeshClient;
let client2: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
server.trackPresence(/^test:room:.*/);
await server.ready();
client1 = new MeshClient(`ws://localhost:${port}`);
client2 = new MeshClient(`ws://localhost:${port}`);
await client1.connect();
await client2.connect();
});
afterEach(async () => {
await client1.close();
await client2.close();
await server.close();
});
test("client can publish and receive presence states", async () => {
const roomName = "test:room:states";
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
const { success, present, states } = await client1.subscribePresence(
roomName,
callback
);
expect(success).toBe(true);
expect(states).toEqual({});
await client2.joinRoom(roomName);
await wait(100);
const state = { status: "typing" };
const publishSuccess = await client2.publishPresenceState(roomName, {
state,
});
expect(publishSuccess).toBe(true);
await wait(100);
// check that client1 got the state update
expect(callback).toHaveBeenCalledTimes(2); // join + state
expect(updates[1].type).toBe("state");
expect(typeof updates[1].connectionId).toBe("string");
expect(updates[1].state).toEqual(state);
const clearSuccess = await client2.clearPresenceState(roomName);
expect(clearSuccess).toBe(true);
await wait(100);
// check that client1 got the state clear
expect(callback).toHaveBeenCalledTimes(3);
expect(updates[2].type).toBe("state");
expect(updates[2].roomName).toBeDefined();
expect(updates[2].timestamp).toBeDefined();
expect(updates[2].state).toBeNull();
});
test("presence state expires after TTL", async () => {
const roomName = "test:room:state-ttl";
const shortTTL = 200; // 200ms
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
await client1.subscribePresence(roomName, callback);
await client2.joinRoom(roomName);
await wait(100);
// publish with short ttl
const state = { status: "typing" };
await client2.publishPresenceState(roomName, {
state,
expireAfter: shortTTL,
});
await wait(100);
// check that client1 got the update
expect(callback).toHaveBeenCalledTimes(2); // join + state
expect(updates[1].type).toBe("state");
expect(updates[1].state).toEqual(state);
// wait for ttl to expire
await wait(shortTTL + 100);
// check that client1 got the expiration
expect(callback).toHaveBeenCalledTimes(3);
expect(updates[2].type).toBe("state");
expect(updates[2].state).toBeNull();
});
test("initial presence subscription includes current states", async () => {
const roomName = "test:room:initial-states";
await client1.joinRoom(roomName);
const state1 = { status: "online", activity: "coding" };
await client1.publishPresenceState(roomName, { state: state1 });
await client2.joinRoom(roomName);
const state2 = { status: "away" };
await client2.publishPresenceState(roomName, { state: state2 });
await wait(100);
const client3 = new MeshClient(`ws://localhost:${port}`);
await client3.connect();
const callback = vi.fn();
const { success, present, states } = await client3.subscribePresence(
roomName,
callback
);
expect(success).toBe(true);
expect(present.length).toBe(2);
expect(Object.keys(states || {}).length).toBe(2);
// make sure states include both clients
const connections = server.connectionManager.getLocalConnections();
const client1Id = connections[0]?.id!;
const client2Id = connections[1]?.id!;
expect(states?.[client1Id]).toEqual(state1);
expect(states?.[client2Id]).toEqual(state2);
await client3.close();
});
test("presence state is cleared when client leaves room", async () => {
const roomName = "test:room:leave-clear";
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
await client1.subscribePresence(roomName, callback);
await client2.joinRoom(roomName);
await wait(100);
// publish state
const state = { status: "typing" };
await client2.publishPresenceState(roomName, { state });
await wait(100);
await client2.leaveRoom(roomName);
await wait(100);
// check that client1 got the leave event
expect(callback).toHaveBeenCalledTimes(3); // join + state + leave
expect(updates[2].type).toBe("leave");
// client2 rejoins but should have no state
await client2.joinRoom(roomName);
await wait(100);
const { states } = await client1.subscribePresence(roomName, () => {});
const connections = server.connectionManager.getLocalConnections();
const client2Id = connections.find((c) => c.id !== connections[0]?.id)?.id!;
expect(states?.[client2Id]).toBeUndefined();
});
test("presence state is cleared when client disconnects", async () => {
const roomName = "test:room:disconnect-clear";
const connections = server.connectionManager.getLocalConnections();
const connection2 = connections[1]!;
await server.addToRoom(roomName, connection2);
const state = { status: "typing" };
await client2.publishPresenceState(roomName, { state });
await wait(100);
// make sure state exists
let statesMap = await server.presenceManager.getAllPresenceStates(roomName);
expect(statesMap.has(connection2.id)).toBe(true);
expect(statesMap.get(connection2.id)).toEqual(state);
await client2.close();
await wait(200);
// make sure state is gone
statesMap = await server.presenceManager.getAllPresenceStates(roomName);
expect(statesMap.has(connection2.id)).toBe(false);
}, 10000);
test("double state overwrite (same connection)", async () => {
const roomName = "test:room:overwrite";
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
// subscribe to presence
await client1.subscribePresence(roomName, callback);
await client2.joinRoom(roomName);
await wait(100);
const state1 = { status: "typing" };
await client2.publishPresenceState(roomName, { state: state1 });
await wait(100);
// publish another state right away
const state2 = { status: "idle" };
await client2.publishPresenceState(roomName, { state: state2 });
await wait(100);
// check that client1 got both updates
expect(callback).toHaveBeenCalledTimes(3); // join + state1 + state2
expect(updates[1].type).toBe("state");
expect(updates[1].state).toEqual(state1);
expect(updates[2].type).toBe("state");
expect(updates[2].state).toEqual(state2);
// make sure only the latest state is kept
const statesMap = await server.presenceManager.getAllPresenceStates(
roomName
);
const connections = server.connectionManager.getLocalConnections();
const connection2 = connections[1]!;
expect(statesMap.get(connection2.id)).toEqual(state2);
});
test("no state if state key is omitted", async () => {
const roomName = "test:room:no-state";
await client1.joinRoom(roomName);
// @ts-ignore - Intentionally passing invalid params to test behavior
const result = await client1.publishPresenceState(roomName, {});
expect(result).toBe(false);
// make sure no state was stored
const statesMap = await server.presenceManager.getAllPresenceStates(
roomName
);
const connections = server.connectionManager.getLocalConnections();
const connection1 = connections[0]!;
expect(statesMap.has(connection1.id)).toBe(false);
});
test("error on publishing state to non-tracked room", async () => {
const roomName = "untracked:room";
await client1.joinRoom(roomName);
const state = { status: "typing" };
const result = await client1.publishPresenceState(roomName, { state });
// should fail - room isn't tracked
expect(result).toBe(false);
});
test("multiple TTL states expire independently", async () => {
const roomName = "test:room:multiple-ttl";
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
await client1.subscribePresence(roomName, callback);
await client1.joinRoom(roomName);
await client2.joinRoom(roomName);
await wait(100);
const shortTTL = 200; // 200ms
const state1 = { status: "typing" };
await client2.publishPresenceState(roomName, {
state: state1,
expireAfter: shortTTL,
});
const longTTL = 500; // 500ms
const state2 = { status: "away" };
await client1.publishPresenceState(roomName, {
state: state2,
expireAfter: longTTL,
});
await wait(100);
// check both states are stored
let statesMap = await server.presenceManager.getAllPresenceStates(roomName);
const connections = server.connectionManager.getLocalConnections();
const connection1 = connections[0]!;
const connection2 = connections[1]!;
expect(statesMap.get(connection1.id)).toEqual(state2);
expect(statesMap.get(connection2.id)).toEqual(state1);
await wait(shortTTL + 50);
// check only the short ttl state expired
statesMap = await server.presenceManager.getAllPresenceStates(roomName);
expect(statesMap.has(connection2.id)).toBe(false);
expect(statesMap.get(connection1.id)).toEqual(state2);
await wait(longTTL - shortTTL);
// check both states are now expired
statesMap = await server.presenceManager.getAllPresenceStates(roomName);
expect(statesMap.has(connection1.id)).toBe(false);
expect(statesMap.has(connection2.id)).toBe(false);
}, 10000);
test("room-scoped state isolation", async () => {
const room1 = "test:room:isolation-1";
const room2 = "test:room:isolation-2";
await client1.joinRoom(room1);
await client1.joinRoom(room2);
await wait(100);
const state1 = { status: "typing", room: "room1" };
const state2 = { status: "away", room: "room2" };
await client1.publishPresenceState(room1, { state: state1 });
await client1.publishPresenceState(room2, { state: state2 });
await wait(100);
// check states are tracked separately per room
const statesMap1 = await server.presenceManager.getAllPresenceStates(room1);
const statesMap2 = await server.presenceManager.getAllPresenceStates(room2);
const connections = server.connectionManager.getLocalConnections();
const connection1 = connections[0]!;
// each room should have the right state
expect(statesMap1.get(connection1.id)).toEqual(state1);
expect(statesMap2.get(connection1.id)).toEqual(state2);
// updating state in one room shouldn't affect the other
const updatedState1 = { status: "idle", room: "room1-updated" };
await client1.publishPresenceState(room1, { state: updatedState1 });
await wait(100);
const updatedStatesMap1 = await server.presenceManager.getAllPresenceStates(
room1
);
const updatedStatesMap2 = await server.presenceManager.getAllPresenceStates(
room2
);
expect(updatedStatesMap1.get(connection1.id)).toEqual(updatedState1);
expect(updatedStatesMap2.get(connection1.id)).toEqual(state2); // Still has original state
});
});

View File

@ -1,470 +0,0 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
pingInterval: 1000,
latencyInterval: 500,
enablePresenceExpirationEvents: true,
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("Presence Subscription", () => {
const port = 8140;
let server: MeshServer;
let client1: MeshClient;
let client2: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
server.trackPresence(/^test:room:.*/);
server.trackPresence("guarded:room");
await server.ready();
client1 = new MeshClient(`ws://localhost:${port}`);
client2 = new MeshClient(`ws://localhost:${port}`);
});
afterEach(async () => {
await client1.close();
await client2.close();
await server.close();
});
test("client can subscribe to presence for a tracked room", async () => {
const roomName = "test:room:1";
await client1.connect();
const callback = vi.fn();
const { success, present } = await client1.subscribePresence(
roomName,
callback
);
expect(success).toBe(true);
expect(Array.isArray(present)).toBe(true);
expect(present.length).toBe(0);
});
test("client cannot subscribe to presence for an untracked room", async () => {
const roomName = "untracked:room";
await client1.connect();
const callback = vi.fn();
const { success, present } = await client1.subscribePresence(
roomName,
callback
);
expect(success).toBe(false);
expect(present.length).toBe(0);
expect(callback).not.toHaveBeenCalled();
});
test("presence guard prevents unauthorized subscriptions", async () => {
await client1.connect();
await client2.connect();
const connections = server.connectionManager.getLocalConnections();
const connection1Id = connections[0]?.id;
server.trackPresence(
"guarded:room",
(connection, roomName) => connection.id === connection1Id
);
const callback1 = vi.fn();
const result1 = await client1.subscribePresence("guarded:room", callback1);
const callback2 = vi.fn();
const result2 = await client2.subscribePresence("guarded:room", callback2);
expect(result1.success).toBe(true);
expect(result2.success).toBe(false);
expect(callback2).not.toHaveBeenCalled();
});
test("client receives presence updates when users join and leave", async () => {
const roomName = "test:room:updates";
await client1.connect();
await client2.connect();
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
await client1.subscribePresence(roomName, callback);
const connections = server.connectionManager.getLocalConnections();
await server.addToRoom(roomName, connections[1]!);
await wait(100);
expect(callback).toHaveBeenCalledTimes(1);
expect(updates[0].type).toBe("join");
expect(updates[0].roomName).toBe(roomName);
expect(typeof updates[0].connectionId).toBe("string");
expect(typeof updates[0].timestamp).toBe("number");
await server.removeFromRoom(roomName, connections[1]!);
await wait(100);
expect(callback).toHaveBeenCalledTimes(2);
expect(updates[1].type).toBe("leave");
expect(updates[1].roomName).toBe(roomName);
expect(typeof updates[1].connectionId).toBe("string");
expect(typeof updates[1].timestamp).toBe("number");
});
test("client stops receiving presence updates after unsubscribing", async () => {
const roomName = "test:room:unsub";
await client1.connect();
await client2.connect();
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
await client1.subscribePresence(roomName, callback);
const connections = server.connectionManager.getLocalConnections();
await server.addToRoom(roomName, connections[1]!);
await wait(100);
expect(callback).toHaveBeenCalledTimes(1);
const unsubSuccess = await client1.unsubscribePresence(roomName);
expect(unsubSuccess).toBe(true);
callback.mockReset();
await server.removeFromRoom(roomName, connections[1]!);
await wait(100);
expect(callback).not.toHaveBeenCalled();
});
test("presence is maintained with custom TTL", async () => {
const roomName = "test:room:ttl";
const shortTTL = 200;
server.trackPresence(roomName, { ttl: shortTTL });
await client1.connect();
await client2.connect();
const connections = server.connectionManager.getLocalConnections();
const connection2 = connections[1]!;
await server.addToRoom(roomName, connection2);
let present = await server.presenceManager.getPresentConnections(roomName);
expect(present).toContain(connection2.id);
// wait for less than TTL and verify still present
await wait(shortTTL / 2);
present = await server.presenceManager.getPresentConnections(roomName);
expect(present).toContain(connection2.id);
// simulate pong to refresh presence
connection2.emit("pong", connection2.id);
// wait for more than the original TTL
await wait(shortTTL + 100);
// should still be present because of the refresh
present = await server.presenceManager.getPresentConnections(roomName);
expect(present).toContain(connection2.id);
});
test("initial presence list is correct when subscribing", async () => {
const roomName = "test:room:initial";
await client1.connect();
await client2.connect();
const connections = server.connectionManager.getLocalConnections();
await server.addToRoom(roomName, connections[0]!);
await server.addToRoom(roomName, connections[1]!);
await wait(100);
const callback = vi.fn();
const { success, present } = await client1.subscribePresence(
roomName,
callback
);
expect(success).toBe(true);
expect(present.length).toBe(2);
expect(present).toContain(connections[0]!.id);
expect(present).toContain(connections[1]!.id);
});
test("presence is cleaned up when connection is closed", async () => {
const roomName = "test:room:cleanup";
await client1.connect();
await client2.connect();
const connections = server.connectionManager.getLocalConnections();
const connection2 = connections[1]!;
await server.addToRoom(roomName, connection2);
let present = await server.presenceManager.getPresentConnections(roomName);
expect(present).toContain(connection2.id);
await client2.close();
await wait(100);
present = await server.presenceManager.getPresentConnections(roomName);
expect(present).not.toContain(connection2.id);
});
test("presence is automatically cleaned up when TTL expires", async () => {
const roomName = "test:room:auto-cleanup";
const shortTTL = 1000;
const testServer = createTestServer(port + 100);
await testServer.ready();
testServer.trackPresence(roomName, { ttl: shortTTL });
const testClient = new MeshClient(`ws://localhost:${port + 100}`);
await testClient.connect();
const connections = testServer.connectionManager.getLocalConnections();
const connection = connections[0]!;
await testServer.addToRoom(roomName, connection);
let present = await testServer.presenceManager.getPresentConnections(
roomName
);
expect(present).toContain(connection.id);
// wait for more than the TTL to allow the key to expire and notification to be processed
await wait(shortTTL * 3);
// the connection should be automatically marked as offline when the key expires
present = await testServer.presenceManager.getPresentConnections(roomName);
expect(present).not.toContain(connection.id);
await testClient.close();
await testServer.close();
}, 10000);
});
describe("Presence Subscription (Multiple Instances)", () => {
let serverA: MeshServer;
let serverB: MeshServer;
let clientA: MeshClient;
let clientB: MeshClient;
let clientC: MeshClient;
const portA = 8141;
const portB = 8142;
const roomName = "test:room:multi-instance";
beforeEach(async () => {
await flushRedis();
serverA = createTestServer(portA);
serverB = createTestServer(portB);
// track presence on both servers
[serverA, serverB].forEach((server) => {
server.trackPresence(roomName);
});
await serverA.ready();
await serverB.ready();
// server a client:
clientA = new MeshClient(`ws://localhost:${portA}`);
// server b clients:
clientB = new MeshClient(`ws://localhost:${portB}`);
clientC = new MeshClient(`ws://localhost:${portB}`);
});
afterEach(async () => {
await clientA.close();
await clientB.close();
await clientC.close();
await serverA.close();
await serverB.close();
});
test("join event propagates across instances", async () => {
await clientA.connect(); // srv a
await clientB.connect(); // srv b
const connectionsB_Server = serverB.connectionManager.getLocalConnections();
const clientBId = connectionsB_Server[0]?.id;
expect(clientBId).toBeDefined();
const callbackA = vi.fn();
const { present: initialPresentA } = await clientA.subscribePresence(
roomName,
callbackA
);
expect(initialPresentA).toEqual([]); // empty room
const joinResultB = await clientB.joinRoom(roomName);
expect(joinResultB.success).toBe(true);
await wait(150);
// client a (srv a) receives join event from client b (srv b)
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackA).toHaveBeenCalledWith(
expect.objectContaining({
type: "join",
roomName: roomName,
connectionId: clientBId,
})
);
}, 10000);
test("leave event propagates across instances", async () => {
await clientA.connect();
await clientB.connect();
const connectionsB_Server = serverB.connectionManager.getLocalConnections();
const clientBId = connectionsB_Server[0]?.id;
expect(clientBId).toBeDefined();
const callbackA = vi.fn();
const { present: initialPresentA } = await clientA.subscribePresence(
roomName,
callbackA
);
expect(initialPresentA).toEqual([]);
await clientB.joinRoom(roomName);
await wait(150);
// client a receives join event from client b
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackA).toHaveBeenCalledWith(
expect.objectContaining({ type: "join", connectionId: clientBId })
);
// client B leaves the room via srv b
const leaveResultB = await clientB.leaveRoom(roomName);
expect(leaveResultB.success).toBe(true);
await wait(150);
// client a (srv a) receives leave event from client b (srv b)
expect(callbackA).toHaveBeenCalledTimes(2);
expect(callbackA).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: "leave",
roomName: roomName,
connectionId: clientBId,
})
);
}, 10000);
test("disconnect event propagates as leave across instances", async () => {
await clientA.connect();
await clientB.connect();
const connectionsB_Server = serverB.connectionManager.getLocalConnections();
const clientBId = connectionsB_Server[0]?.id;
expect(clientBId).toBeDefined();
const callbackA = vi.fn();
const { present: initialPresentA } = await clientA.subscribePresence(
roomName,
callbackA
);
expect(initialPresentA).toEqual([]);
await clientB.joinRoom(roomName);
await wait(150);
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackA).toHaveBeenCalledWith(
expect.objectContaining({ type: "join", connectionId: clientBId })
);
// client b disconnects from server b
await clientB.close();
await wait(150);
// client a receives leave event from client b's disconnection
expect(callbackA).toHaveBeenCalledTimes(2);
expect(callbackA).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: "leave",
roomName: roomName,
connectionId: clientBId,
})
);
}, 10000);
test("initial presence list includes users from other instances", async () => {
await clientA.connect();
await clientB.connect();
await clientC.connect();
const connectionsB_Server = serverB.connectionManager.getLocalConnections();
const clientBId = connectionsB_Server[0]?.id;
const clientCId = connectionsB_Server[1]?.id;
expect(clientBId).toBeDefined();
expect(clientCId).toBeDefined();
// client b -> srv b
await clientB.joinRoom(roomName);
// client c -> srv b
await clientC.joinRoom(roomName);
await wait(150);
// client a subscribes to presence from srv a
const callbackA = vi.fn();
const { success, present } = await clientA.subscribePresence(
roomName,
callbackA
);
expect(success).toBe(true);
// initial list contains client b and c
expect(present.length).toBe(2);
expect(present).toContain(clientBId);
expect(present).toContain(clientCId);
// callback not invoked yet because no events have occurred
expect(callbackA).not.toHaveBeenCalled();
}, 10000);
});

View File

@ -1,726 +0,0 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
pingInterval: 1000,
latencyInterval: 500,
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("Record Subscription", () => {
const port = 8130;
let server: MeshServer;
let client1: MeshClient;
let client2: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
server.exposeRecord(/^test:record:.*/);
server.exposeRecord("guarded:record");
server.exposeWritableRecord(/^writable:record:.*/);
server.exposeWritableRecord("guarded:writable");
await server.ready();
client1 = new MeshClient(`ws://localhost:${port}`);
client2 = new MeshClient(`ws://localhost:${port}`);
});
afterEach(async () => {
await client1.close();
await client2.close();
await server.close();
});
test("client can subscribe to an exposed record and get initial state", async () => {
const recordId = "test:record:1";
const initialData = { count: 0, name: "initial" };
await server.publishRecordUpdate(recordId, initialData);
await client1.connect();
const callback = vi.fn();
const { success, record, version } = await client1.subscribeRecord(
recordId,
callback
);
expect(success).toBe(true);
expect(version).toBe(1);
expect(record).toEqual(initialData);
// callback is called once initially with the full record
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({
recordId,
full: initialData,
version: 1,
});
});
test("client cannot subscribe to an unexposed record", async () => {
await client1.connect();
const callback = vi.fn();
const { success, record, version } = await client1.subscribeRecord(
"unexposed:record",
callback
);
expect(success).toBe(false);
expect(version).toBe(0);
expect(record).toBeNull();
expect(callback).not.toHaveBeenCalled();
});
test("record guard prevents unauthorized subscriptions", async () => {
await client1.connect();
await client2.connect();
const connections = server.connectionManager.getLocalConnections();
const connection1Id = connections[0]?.id;
server.exposeRecord(
"guarded:record",
(connection, recId) => connection.id === connection1Id
);
const callback1 = vi.fn();
const result1 = await client1.subscribeRecord("guarded:record", callback1);
const callback2 = vi.fn();
const result2 = await client2.subscribeRecord("guarded:record", callback2);
expect(result1.success).toBe(true);
expect(result1.version).toBe(0); // nothing published yet
expect(result1.record).toBeNull();
expect(callback1).toHaveBeenCalledTimes(1); // initial call with null
expect(callback1).toHaveBeenCalledWith({
recordId: "guarded:record",
full: null,
version: 0,
});
expect(result2.success).toBe(false);
expect(result2.version).toBe(0);
expect(result2.record).toBeNull();
expect(callback2).not.toHaveBeenCalled();
});
test("client receives full updates by default", async () => {
const recordId = "test:record:full";
await client1.connect();
const updates: any[] = [];
const callback = (update: any) => {
updates.push(update);
};
await client1.subscribeRecord(recordId, callback);
const data1 = { count: 1 };
await server.publishRecordUpdate(recordId, data1);
await wait(50); // because pub/sub takes a bit
const data2 = { count: 2, name: "hello" };
await server.publishRecordUpdate(recordId, data2);
await wait(50);
expect(updates.length).toBe(3); // initial + 2 updates
expect(updates[0]).toEqual({ recordId, full: null, version: 0 });
expect(updates[1]).toEqual({ recordId, full: data1, version: 1 });
expect(updates[2]).toEqual({ recordId, full: data2, version: 2 });
});
test("client receives patch updates when mode is 'patch'", async () => {
const recordId = "test:record:patch";
await client1.connect();
const updates: any[] = [];
const callback = (update: any) => {
updates.push(update);
};
await client1.subscribeRecord(recordId, callback, { mode: "patch" });
const data1 = { count: 1 };
await server.publishRecordUpdate(recordId, data1);
await wait(50);
const data2 = { count: 1, name: "added" };
await server.publishRecordUpdate(recordId, data2);
await wait(50);
const data3 = { name: "added" };
await server.publishRecordUpdate(recordId, data3);
await wait(50);
expect(updates.length).toBe(4);
expect(updates[0]).toEqual({ recordId, full: null, version: 0 });
expect(updates[1]).toEqual({
recordId,
patch: [{ op: "add", path: "/count", value: 1 }],
version: 1,
});
expect(updates[2]).toEqual({
recordId,
patch: [{ op: "add", path: "/name", value: "added" }],
version: 2,
});
expect(updates[3]).toEqual({
recordId,
patch: [{ op: "remove", path: "/count" }],
version: 3,
});
});
test("multiple clients receive updates based on their mode", async () => {
const recordId = "test:record:multi";
await client1.connect();
await client2.connect();
const updates1: any[] = [];
const callback1 = (update: any) => {
updates1.push(update);
};
await client1.subscribeRecord(recordId, callback1);
const updates2: any[] = [];
const callback2 = (update: any) => {
updates2.push(update);
};
await client2.subscribeRecord(recordId, callback2, { mode: "patch" });
const data1 = { value: "a" };
await server.publishRecordUpdate(recordId, data1);
await wait(100);
const data2 = { value: "b" };
await server.publishRecordUpdate(recordId, data2);
await wait(100);
// client 1 wants full updates
expect(updates1.length).toBe(3);
expect(updates1[0]).toEqual({ recordId, full: null, version: 0 });
expect(updates1[1]).toEqual({ recordId, full: data1, version: 1 });
expect(updates1[2]).toEqual({ recordId, full: data2, version: 2 });
// client 2 wants patches
expect(updates2.length).toBe(3);
expect(updates2[0]).toEqual({ recordId, full: null, version: 0 });
expect(updates2[1]).toEqual({
recordId,
patch: [{ op: "add", path: "/value", value: "a" }],
version: 1,
});
expect(updates2[2]).toEqual({
recordId,
patch: [{ op: "replace", path: "/value", value: "b" }],
version: 2,
});
});
test("client stops receiving updates after unsubscribing", async () => {
const recordId = "test:record:unsub";
await client1.connect();
const updates: any[] = [];
const callback = (update: any) => {
updates.push(update);
};
await client1.subscribeRecord(recordId, callback);
await server.publishRecordUpdate(recordId, { count: 1 });
await wait(50);
const unsubSuccess = await client1.unsubscribeRecord(recordId);
expect(unsubSuccess).toBe(true);
await server.publishRecordUpdate(recordId, { count: 2 });
await wait(50);
expect(updates.length).toBe(2);
expect(updates[0]).toEqual({ recordId, full: null, version: 0 });
expect(updates[1]).toEqual({ recordId, full: { count: 1 }, version: 1 });
});
test("desync detection triggers resubscribe (patch mode)", async () => {
const recordId = "test:record:desync";
await client1.connect();
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
// spy on resub attempt
const commandSpy = vi.spyOn(client1.connection, "command");
await client1.subscribeRecord(recordId, callback, { mode: "patch" }); // v0, initial full
// v1
await server.publishRecordUpdate(recordId, { count: 1 });
await wait(50); // client receives v1 patch
// publish v2 and v3 without notifying client via pub/sub
const v2Result = await server.recordManager.publishUpdate(recordId, {
count: 2,
});
const v3Result = await server.recordManager.publishUpdate(recordId, {
count: 3,
});
expect(v2Result?.version).toBe(2);
expect(v3Result?.version).toBe(3);
// publish v4 via the proper mechanism, while client expects v2
const data4 = { count: 4 };
await server.publishRecordUpdate(recordId, data4); // v4
await wait(100); // allocate time for desync handling
expect(callback).toHaveBeenCalledTimes(3); // v0, v1, v4
expect(updates[0]).toEqual({ recordId, full: null, version: 0 });
expect(updates[1]).toEqual({
recordId,
patch: [{ op: "add", path: "/count", value: 1 }],
version: 1,
});
// third call is the full record after resync
expect(updates[2]).toEqual({ recordId, full: data4, version: 4 });
// verify unsubscribe and subscribe were called for resync
expect(commandSpy).toHaveBeenCalledWith(
"mesh/unsubscribe-record",
{ recordId },
30000
);
expect(commandSpy).toHaveBeenCalledWith(
"mesh/subscribe-record",
{
recordId,
mode: "patch",
},
30000
);
});
test("client can write to an exposed writable record", async () => {
const recordId = "writable:record:1";
await client1.connect();
await client2.connect();
const updatesClient2: any[] = [];
const callbackClient2 = vi.fn((update: any) => {
updatesClient2.push(update);
});
// check subscription success and initial call
const subResult = await client2.subscribeRecord(recordId, callbackClient2); // Subscribe before write
expect(subResult.success).toBe(true);
expect(subResult.record).toBeNull();
expect(subResult.version).toBe(0);
expect(callbackClient2).toHaveBeenCalledTimes(1);
expect(callbackClient2).toHaveBeenCalledWith({
recordId,
full: null,
version: 0,
});
const initialData = { value: "initial" };
// client 1 writes
const success = await client1.publishRecordUpdate(recordId, initialData);
expect(success).toBe(true);
await wait(150);
// client 2 received the update (initial call + 1 update)
expect(callbackClient2).toHaveBeenCalledTimes(2);
expect(updatesClient2.length).toBe(2);
expect(updatesClient2[1]).toEqual({
recordId,
full: initialData,
version: 1,
});
// verify server state
const { record, version } = await server.recordManager.getRecordAndVersion(
recordId
);
expect(record).toEqual(initialData);
expect(version).toBe(1);
});
test("client cannot write to a non-writable record (read-only exposed)", async () => {
const recordId = "test:record:readonly"; // exposed via exposeRecord, not exposeWritableRecord
await client1.connect();
const initialData = { value: "attempt" };
const success = await client1.publishRecordUpdate(recordId, initialData);
expect(success).toBe(false);
// verify server state hasn't changed
const { record, version } = await server.recordManager.getRecordAndVersion(
recordId
);
expect(record).toBeNull();
expect(version).toBe(0);
});
test("client cannot write to a record not exposed at all", async () => {
const recordId = "not:exposed:at:all";
await client1.connect();
const initialData = { value: "attempt" };
const success = await client1.publishRecordUpdate(recordId, initialData);
expect(success).toBe(false);
const { record, version } = await server.recordManager.getRecordAndVersion(
recordId
);
expect(record).toBeNull();
expect(version).toBe(0);
});
test("writable record guard prevents unauthorized writes", async () => {
const recordId = "guarded:writable";
await client1.connect();
await client2.connect();
const connections = server.connectionManager.getLocalConnections();
const connection1Id = connections[0]?.id;
// only client1 can write this record
server.exposeWritableRecord(
recordId,
(connection, recId) => connection.id === connection1Id
);
const data1 = { value: "from client 1" };
const success1 = await client1.publishRecordUpdate(recordId, data1);
expect(success1).toBe(true);
await wait(50);
let serverState = await server.recordManager.getRecordAndVersion(recordId);
expect(serverState.record).toEqual(data1);
expect(serverState.version).toBe(1);
const data2 = { value: "from client 2" };
const success2 = await client2.publishRecordUpdate(recordId, data2);
expect(success2).toBe(false);
await wait(50);
serverState = await server.recordManager.getRecordAndVersion(recordId);
expect(serverState.record).toEqual(data1); // unchanged
expect(serverState.version).toBe(1); // unchanged
});
test("update from client write propagates to other subscribed clients", async () => {
const recordId = "writable:record:propagate";
await client1.connect(); // writer
await client2.connect(); // subscriber
const updatesClient2: any[] = [];
const callbackClient2 = vi.fn((update: any) => {
updatesClient2.push(update);
});
const subResult = await client2.subscribeRecord(recordId, callbackClient2, {
mode: "patch",
});
expect(subResult.success).toBe(true);
expect(subResult.record).toBeNull();
expect(subResult.version).toBe(0);
expect(callbackClient2).toHaveBeenCalledTimes(1);
expect(callbackClient2).toHaveBeenCalledWith({
recordId,
full: null,
version: 0,
});
// client 1 writes
const data1 = { count: 1 };
await client1.publishRecordUpdate(recordId, data1);
await wait(100);
const data2 = { count: 1, name: "added" };
await client1.publishRecordUpdate(recordId, data2);
await wait(150);
// client 2 received the patches (initial call + 2 patches)
expect(callbackClient2).toHaveBeenCalledTimes(3);
expect(updatesClient2.length).toBe(3);
expect(updatesClient2[1]).toEqual({
recordId,
patch: [{ op: "add", path: "/count", value: 1 }],
version: 1,
});
expect(updatesClient2[2]).toEqual({
recordId,
patch: [{ op: "add", path: "/name", value: "added" }],
version: 2,
});
});
test("client can subscribe to primitive values in full mode", async () => {
const recordId = "test:record:primitive";
const initialValue = "initial value";
const updatedValue = "updated value";
await client1.connect();
await server.publishRecordUpdate(recordId, initialValue);
await wait(50);
const updates: any[] = [];
const callback = vi.fn((update: any) => {
updates.push(update);
});
const { success, record, version } = await client1.subscribeRecord(
recordId,
callback
);
expect(success).toBe(true);
expect(version).toBe(1);
expect(record).toEqual(initialValue);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({
recordId,
full: initialValue,
version: 1,
});
await server.publishRecordUpdate(recordId, updatedValue);
await wait(100);
expect(callback).toHaveBeenCalledTimes(2);
expect(updates.length).toBe(2);
expect(updates[1]).toEqual({
recordId,
full: updatedValue,
version: 2,
});
const serverState = await server.recordManager.getRecordAndVersion(
recordId
);
expect(serverState.record).toEqual(updatedValue);
expect(serverState.version).toBe(2);
});
});
describe("Record Subscription (Multiple Instances)", () => {
let serverA: MeshServer;
let serverB: MeshServer;
let clientA: MeshClient;
let clientB: MeshClient;
const portA = 8131;
const portB = 8132;
const recordId = "test:record:multi-instance";
const writableRecordId = "writable:record:multi-instance";
beforeEach(async () => {
await flushRedis();
serverA = createTestServer(portA);
serverB = createTestServer(portB);
[serverA, serverB].forEach((server) => {
server.exposeRecord(/^test:record:.*/);
server.exposeWritableRecord(/^writable:record:.*/);
});
await serverA.ready();
await serverB.ready();
clientA = new MeshClient(`ws://localhost:${portA}`);
clientB = new MeshClient(`ws://localhost:${portB}`);
});
afterEach(async () => {
await clientA.close();
await clientB.close();
await serverA.close();
await serverB.close();
});
test("server-published update propagates across instances (full mode)", async () => {
await clientA.connect();
await clientB.connect();
const callbackA = vi.fn();
const callbackB = vi.fn();
await clientA.subscribeRecord(recordId, callbackA);
await clientB.subscribeRecord(recordId, callbackB);
await wait(50);
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackB).toHaveBeenCalledTimes(1);
expect(callbackA).toHaveBeenCalledWith({
recordId,
full: null,
version: 0,
});
expect(callbackB).toHaveBeenCalledWith({
recordId,
full: null,
version: 0,
});
const data1 = { value: "update1" };
// server A publishes, should reach both clients
await serverA.publishRecordUpdate(recordId, data1);
await wait(150);
expect(callbackA).toHaveBeenCalledTimes(2);
expect(callbackB).toHaveBeenCalledTimes(2);
expect(callbackA).toHaveBeenCalledWith({
recordId,
full: data1,
version: 1,
});
expect(callbackB).toHaveBeenCalledWith({
recordId,
full: data1,
version: 1,
});
}, 10000);
test("client-published update propagates across instances (full mode)", async () => {
await clientA.connect();
await clientB.connect();
const callbackA = vi.fn();
const callbackB = vi.fn();
await clientA.subscribeRecord(writableRecordId, callbackA);
await clientB.subscribeRecord(writableRecordId, callbackB);
await wait(50);
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackB).toHaveBeenCalledTimes(1);
const data1 = { message: "hello from client A" };
// client A writes, should propagate to client B via server B
const writeSuccess = await clientA.publishRecordUpdate(
writableRecordId,
data1
);
expect(writeSuccess).toBe(true);
await wait(150);
expect(callbackA).toHaveBeenCalledTimes(2);
expect(callbackB).toHaveBeenCalledTimes(2);
expect(callbackA).toHaveBeenCalledWith({
recordId: writableRecordId,
full: data1,
version: 1,
});
expect(callbackB).toHaveBeenCalledWith({
recordId: writableRecordId,
full: data1,
version: 1,
});
}, 10000);
test("client-published update propagates across instances (patch mode)", async () => {
await clientA.connect();
await clientB.connect();
const callbackA = vi.fn();
const callbackB = vi.fn();
await clientA.subscribeRecord(writableRecordId, callbackA, {
mode: "patch",
});
await clientB.subscribeRecord(writableRecordId, callbackB, {
mode: "patch",
});
await wait(50);
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackB).toHaveBeenCalledTimes(1);
expect(callbackA).toHaveBeenCalledWith({
recordId: writableRecordId,
full: null,
version: 0,
});
expect(callbackB).toHaveBeenCalledWith({
recordId: writableRecordId,
full: null,
version: 0,
});
const data1 = { count: 1 };
// client A writes first update
let writeSuccess = await clientA.publishRecordUpdate(
writableRecordId,
data1
);
expect(writeSuccess).toBe(true);
await wait(150);
expect(callbackA).toHaveBeenCalledTimes(2);
expect(callbackB).toHaveBeenCalledTimes(2);
const patch1 = [{ op: "add", path: "/count", value: 1 }];
expect(callbackA).toHaveBeenCalledWith({
recordId: writableRecordId,
patch: patch1,
version: 1,
});
expect(callbackB).toHaveBeenCalledWith({
recordId: writableRecordId,
patch: patch1,
version: 1,
});
const data2 = { count: 1, name: "added" };
// client A writes second update
writeSuccess = await clientA.publishRecordUpdate(writableRecordId, data2);
expect(writeSuccess).toBe(true);
await wait(150);
expect(callbackA).toHaveBeenCalledTimes(3);
expect(callbackB).toHaveBeenCalledTimes(3);
const patch2 = [{ op: "add", path: "/name", value: "added" }];
expect(callbackA).toHaveBeenCalledWith({
recordId: writableRecordId,
patch: patch2,
version: 2,
});
expect(callbackB).toHaveBeenCalledWith({
recordId: writableRecordId,
patch: patch2,
version: 2,
});
}, 10000);
});

View File

@ -1,101 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
describe("MeshServer", () => {
const port = 8128;
let server: MeshServer;
let clientA: MeshClient;
let clientB: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
await server.ready();
clientA = new MeshClient(`ws://localhost:${port}`);
clientB = new MeshClient(`ws://localhost:${port}`);
});
afterEach(async () => {
await clientA.close();
await clientB.close();
await server.close();
});
test("isInRoom", async () => {
await clientA.connect();
await clientB.connect();
await clientA.joinRoom("room1");
await clientB.joinRoom("room1");
await clientA.joinRoom("room2");
const connectionA = server.connectionManager.getLocalConnections()[0]!;
const connectionB = server.connectionManager.getLocalConnections()[1]!;
expect(await server.isInRoom("room1", connectionA)).toBe(true);
expect(await server.isInRoom("room1", connectionB)).toBe(true);
expect(await server.isInRoom("room2", connectionA)).toBe(true);
expect(await server.isInRoom("room2", connectionB)).toBe(false);
expect(await server.isInRoom("room3", connectionA)).toBe(false);
});
test("room metadata", async () => {
const room1 = "meta-room-1";
const room2 = "meta-room-2";
const initialMeta1 = { topic: "General", owner: "userA" };
await server.roomManager.setMetadata(room1, initialMeta1);
let meta1 = await server.roomManager.getMetadata(room1);
expect(meta1).toEqual(initialMeta1);
const updateMeta1 = { topic: "Updated Topic", settings: { max: 10 } };
await server.roomManager.updateMetadata(room1, updateMeta1);
meta1 = await server.roomManager.getMetadata(room1);
expect(meta1).toEqual({ ...initialMeta1, ...updateMeta1 });
const initialMeta2 = { topic: "Gaming", private: true };
await server.roomManager.setMetadata(room2, initialMeta2);
expect(await server.roomManager.getMetadata(room2)).toEqual(initialMeta2);
expect(
await server.roomManager.getMetadata("non-existent-room")
).toBeNull();
const allMeta = await server.roomManager.getAllMetadata();
expect(allMeta).toEqual({
[room1]: { ...initialMeta1, ...updateMeta1 },
[room2]: initialMeta2,
});
await server.roomManager.clearRoom(room1);
expect(await server.roomManager.getMetadata(room1)).toBeNull();
});
});

View File

@ -1,274 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import Redis from "ioredis";
import { MeshServer } from "../server";
import { MeshClient } from "../client";
const REDIS_HOST = process.env.REDIS_HOST || "127.0.0.1";
const REDIS_PORT = process.env.REDIS_PORT
? parseInt(process.env.REDIS_PORT, 10)
: 6379;
const createTestServer = (port: number) =>
new MeshServer({
port,
redisOptions: {
host: REDIS_HOST,
port: REDIS_PORT,
},
});
const flushRedis = async () => {
const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT });
await redis.flushdb();
await redis.quit();
};
describe("Redis Channel Subscription", () => {
const port = 8129;
let server: MeshServer;
let client1: MeshClient;
let client2: MeshClient;
beforeEach(async () => {
await flushRedis();
server = createTestServer(port);
server.exposeChannel("test:channel");
server.exposeChannel("test:channel2");
await server.ready();
client1 = new MeshClient(`ws://localhost:${port}`);
client2 = new MeshClient(`ws://localhost:${port}`);
});
afterEach(async () => {
await client1.close();
await client2.close();
await server.close();
});
test("client can subscribe to a Redis channel", async () => {
await client1.connect();
const result = await client1.subscribeChannel("test:channel", () => {});
expect(result.success).toBe(true);
expect(Array.isArray(result.history)).toBe(true);
});
test("client cannot subscribe to an unexposed channel", async () => {
await client1.connect();
const result = await client1.subscribeChannel("unexposed:channel", () => {});
expect(result.success).toBe(false);
expect(Array.isArray(result.history)).toBe(true);
expect(result.history.length).toBe(0);
});
test("client receives messages from subscribed channel", async () => {
await client1.connect();
let receivedMessage: string | null = null;
await client1.subscribeChannel("test:channel", (message) => {
receivedMessage = message;
});
await server.publishToChannel("test:channel", "Hello, Redis!");
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (receivedMessage !== null) {
clearInterval(interval);
clearTimeout(timeout);
resolve();
}
}, 10);
const timeout = setTimeout(() => {
clearInterval(interval);
resolve();
}, 1000);
});
expect(receivedMessage).toBe("Hello, Redis!");
});
test("client can unsubscribe from a channel", async () => {
await client1.connect();
let messageCount = 0;
await client1.subscribeChannel("test:channel", () => {
messageCount++;
});
await server.publishToChannel("test:channel", "Message 1");
await new Promise<void>((resolve) => {
setTimeout(resolve, 100);
});
const unsubResult = await client1.unsubscribeChannel("test:channel");
expect(unsubResult).toBe(true);
await server.publishToChannel("test:channel", "Message 2");
await new Promise<void>((resolve) => {
setTimeout(resolve, 100);
});
expect(messageCount).toBe(1);
});
test("multiple clients can subscribe to the same channel", async () => {
await client1.connect();
await client2.connect();
let client1Received: string | null = null;
let client2Received: string | null = null;
await client1.subscribeChannel("test:channel", (message) => {
client1Received = message;
});
await client2.subscribeChannel("test:channel", (message) => {
client2Received = message;
});
await server.publishToChannel("test:channel", "Broadcast message");
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (client1Received !== null && client2Received !== null) {
clearInterval(interval);
clearTimeout(timeout);
resolve();
}
}, 10);
const timeout = setTimeout(() => {
clearInterval(interval);
resolve();
}, 1000);
});
expect(client1Received).toBe("Broadcast message");
expect(client2Received).toBe("Broadcast message");
});
test("messages are only delivered to subscribed channels", async () => {
await client1.connect();
const channel1Messages: string[] = [];
const channel2Messages: string[] = [];
await client1.subscribeChannel("test:channel", (message) => {
channel1Messages.push(message);
});
await client1.subscribeChannel("test:channel2", (message) => {
channel2Messages.push(message);
});
await server.publishToChannel("test:channel", "Message for channel 1");
await server.publishToChannel("test:channel2", "Message for channel 2");
await new Promise<void>((resolve) => {
setTimeout(resolve, 100);
});
expect(channel1Messages).toContain("Message for channel 1");
expect(channel1Messages).not.toContain("Message for channel 2");
expect(channel2Messages).toContain("Message for channel 2");
expect(channel2Messages).not.toContain("Message for channel 1");
});
test("unsubscribing from a non-subscribed channel returns false", async () => {
await client1.connect();
const result = await client1.unsubscribeChannel("not:subscribed");
expect(result).toBe(false);
});
test("channel guard prevents unauthorized subscriptions", async () => {
await client1.connect();
await client2.connect();
const connections = Object.values(
server.connectionManager.getLocalConnections()
);
const connection1 = connections[0]!;
// only allow the first client to subscribe to the channel
server.exposeChannel(
"guarded:channel",
(connection, channel) => connection.id === connection1.id
);
const result1 = await client1.subscribeChannel("guarded:channel", () => {});
const result2 = await client2.subscribeChannel("guarded:channel", () => {});
expect(result1.success).toBe(true);
expect(result2.success).toBe(false);
});
test("exposeChannel guard callback passes the correct channel name", async () => {
await client1.connect();
let receivedChannel: string | null = null;
server.exposeChannel("test:channel", (connection, channel) => {
receivedChannel = channel;
return true;
});
await client1.subscribeChannel("test:channel", () => {});
expect(receivedChannel).toBe("test:channel");
receivedChannel = null;
server.exposeChannel(/^test:channel:\d+$/, (connection, channel) => {
receivedChannel = channel;
return true;
});
await client1.subscribeChannel("test:channel:1", () => {});
expect(receivedChannel).toBe("test:channel:1");
});
test("client receives channel history when subscribing with historyLimit", async () => {
await client1.connect();
const historySize = 10;
await server.publishToChannel("test:channel", "History message 1", historySize);
await server.publishToChannel("test:channel", "History message 2", historySize);
await server.publishToChannel("test:channel", "History message 3", historySize);
await server.publishToChannel("test:channel", "History message 4", historySize);
await server.publishToChannel("test:channel", "History message 5", historySize);
const receivedMessages: string[] = [];
const { success, history } = await client1.subscribeChannel("test:channel", (message) => {
receivedMessages.push(message);
}, { historyLimit: 3 });
await new Promise<void>((resolve) => setTimeout(resolve, 100));
expect(success).toBe(true);
expect(Array.isArray(history)).toBe(true);
expect(history.length).toBe(3);
// ensure newest are first
expect(history[0]).toBe("History message 5");
expect(history[1]).toBe("History message 4");
expect(history[2]).toBe("History message 3");
expect(receivedMessages).toContain("History message 3");
expect(receivedMessages).toContain("History message 4");
expect(receivedMessages).toContain("History message 5");
expect(receivedMessages.length).toBe(3);
});
});

View File

@ -1,28 +0,0 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["esnext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -1,15 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
poolOptions: {
threads: {
singleThread: true,
maxThreads: 1,
},
forks: {
singleFork: true,
},
},
},
});