mirror of
https://github.com/nvms/prsm.git
synced 2025-12-13 07:20:52 +00:00
cleanup
This commit is contained in:
parent
49caf89101
commit
d31d083ee3
34
packages/mesh/.gitignore
vendored
34
packages/mesh/.gitignore
vendored
@ -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
|
||||
@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
src
|
||||
docker-compose.yml
|
||||
bun.lock
|
||||
vitest.config.ts
|
||||
@ -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=="],
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 “who’s 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 — it’s up to you. In the example above, we’re grouping by `userId` if present, or falling back to `connectionId` so that all connections are still shown individually when needed.
|
||||
|
||||
### Rendering to the DOM
|
||||
|
||||
Here’s 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)
|
||||
```
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export { MeshClient, Status, applyPatch } from "./client";
|
||||
export { Connection } from "./connection";
|
||||
export { CodeError } from "../common/codeerror";
|
||||
@ -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 = [];
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export enum Status {
|
||||
ONLINE = 3,
|
||||
CONNECTING = 2,
|
||||
RECONNECTING = 1,
|
||||
OFFLINE = 0,
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export { MeshClient, applyPatch } from "./client";
|
||||
export { MeshServer, type MeshContext, type SocketMiddleware } from "./server";
|
||||
export { type CodeError } from "./common/codeerror";
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export class Ping {
|
||||
interval: ReturnType<typeof setTimeout> | undefined;
|
||||
}
|
||||
@ -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;
|
||||
@ -1,2 +0,0 @@
|
||||
export const PUB_SUB_CHANNEL_PREFIX = "mesh:pubsub:";
|
||||
export const RECORD_PUB_SUB_CHANNEL = "mesh:record-updates";
|
||||
@ -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}`;
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
maxThreads: 1,
|
||||
},
|
||||
forks: {
|
||||
singleFork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user