diff --git a/packages/mesh/.gitignore b/packages/mesh/.gitignore deleted file mode 100644 index a14702c..0000000 --- a/packages/mesh/.gitignore +++ /dev/null @@ -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 diff --git a/packages/mesh/.npmignore b/packages/mesh/.npmignore deleted file mode 100644 index 36c31ff..0000000 --- a/packages/mesh/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -src -docker-compose.yml -bun.lock -vitest.config.ts \ No newline at end of file diff --git a/packages/mesh/bun.lock b/packages/mesh/bun.lock deleted file mode 100644 index b31428c..0000000 --- a/packages/mesh/bun.lock +++ /dev/null @@ -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=="], - } -} diff --git a/packages/mesh/docker-compose.yml b/packages/mesh/docker-compose.yml deleted file mode 100644 index a05de07..0000000 --- a/packages/mesh/docker-compose.yml +++ /dev/null @@ -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 diff --git a/packages/mesh/package.json b/packages/mesh/package.json deleted file mode 100644 index 0949eea..0000000 --- a/packages/mesh/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/mesh/src/client-utils/README.md b/packages/mesh/src/client-utils/README.md deleted file mode 100644 index 878b878..0000000 --- a/packages/mesh/src/client-utils/README.md +++ /dev/null @@ -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 - 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 ` -
- ${user.id}: ${status} (tabs: ${user.tabCount}) -
`; - }) - .join(""); -} -``` - -Shows something like: - -```ts -Alice: typing (tabs: 2) -conn-m9sdkxww000007079ff77: idle (tabs: 1) -``` diff --git a/packages/mesh/src/client-utils/index.ts b/packages/mesh/src/client-utils/index.ts deleted file mode 100644 index 14578e8..0000000 --- a/packages/mesh/src/client-utils/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PresenceUpdate } from "../client/client"; - -type DedupedPresenceGroup = { - representative: string; - state: any | null; - timestamp: number | null; - members: Set; -}; - -export interface CreateDedupedPresenceHandlerOptions { - getGroupId: (connectionId: string) => Promise; - onUpdate: (groups: Map) => void; -} - -export function createDedupedPresenceHandler( - options: CreateDedupedPresenceHandlerOptions -) { - const { getGroupId, onUpdate } = options; - - const groupMap = new Map(); - const connectionToGroup = new Map(); - - 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); - }; -} diff --git a/packages/mesh/src/client/client.ts b/packages/mesh/src/client/client.ts deleted file mode 100644 index 652b4b0..0000000 --- a/packages/mesh/src/client/client.ts +++ /dev/null @@ -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; - -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 | undefined; - missedPings = 0; - options: Required; - isReconnecting = false; - private _status: Status = Status.OFFLINE; - private recordSubscriptions: Map< - string, // recordId - { - callback: (update: { - recordId: string; - full?: any; - patch?: Operation[]; - version: number; - }) => void | Promise; - 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 | null; - metadata?: any; - }) => void | Promise - > = 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} A promise that resolves when the connection is established. - */ - connect(): Promise { - 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} A promise that resolves when the connection is closed. - */ - close(): Promise { - 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} A promise that resolves with the command result. - */ - command( - command: string, - payload?: any, - expiresIn: number = 30000 - ): Promise { - 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 | 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} 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, - 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} A promise that resolves to true if the unsubscription is successful, or false otherwise. - */ - unsubscribeChannel(channel: string): Promise { - 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} 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, - 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} True if successful, false otherwise. - */ - async unsubscribeRecord(recordId: string): Promise { - 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} True if the update was successfully published, false otherwise. - */ - async publishRecordUpdate(recordId: string, newValue: any): Promise { - 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> }>} Initial state of presence in the room. - */ - async subscribePresence( - roomName: string, - callback: PresenceUpdateCallback - ): Promise<{ - success: boolean; - present: string[]; - states?: Record>; - }> { - 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} True if successful, false otherwise. - */ - async unsubscribePresence(roomName: string): Promise { - 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} options.state - The state object to publish - * @param {number} [options.expireAfter] - Optional TTL in milliseconds - * @returns {Promise} True if successful, false otherwise - */ - async publishPresenceState( - roomName: string, - options: { - state: Record; - expireAfter?: number; // optional, in milliseconds - } - ): Promise { - 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} True if successful, false otherwise - */ - async clearPresenceState(roomName: string): Promise { - 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} A promise that resolves with the room metadata. - */ - async getRoomMetadata(roomName: string): Promise { - 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} A promise that resolves with the connection metadata. - */ - async getConnectionMetadata(connectionId?: string): Promise { - 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; - } -} diff --git a/packages/mesh/src/client/connection.ts b/packages/mesh/src/client/connection.ts deleted file mode 100644 index 63da94f..0000000 --- a/packages/mesh/src/client/connection.ts +++ /dev/null @@ -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 { - 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((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((_, 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; - } - } -} diff --git a/packages/mesh/src/client/ids.ts b/packages/mesh/src/client/ids.ts deleted file mode 100644 index 942ac49..0000000 --- a/packages/mesh/src/client/ids.ts +++ /dev/null @@ -1,44 +0,0 @@ -export class IdManager { - ids: Array = []; - 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.` - ); - } - } - } -} diff --git a/packages/mesh/src/client/index.ts b/packages/mesh/src/client/index.ts deleted file mode 100644 index 7657902..0000000 --- a/packages/mesh/src/client/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MeshClient, Status, applyPatch } from "./client"; -export { Connection } from "./connection"; -export { CodeError } from "../common/codeerror"; diff --git a/packages/mesh/src/client/queue.ts b/packages/mesh/src/client/queue.ts deleted file mode 100644 index 1b356c5..0000000 --- a/packages/mesh/src/client/queue.ts +++ /dev/null @@ -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 = []; - } -} diff --git a/packages/mesh/src/common/codeerror.ts b/packages/mesh/src/common/codeerror.ts deleted file mode 100644 index f9e1202..0000000 --- a/packages/mesh/src/common/codeerror.ts +++ /dev/null @@ -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"; - } -} diff --git a/packages/mesh/src/common/message.ts b/packages/mesh/src/common/message.ts deleted file mode 100644 index fd2321c..0000000 --- a/packages/mesh/src/common/message.ts +++ /dev/null @@ -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); -} diff --git a/packages/mesh/src/common/status.ts b/packages/mesh/src/common/status.ts deleted file mode 100644 index 4a51ab3..0000000 --- a/packages/mesh/src/common/status.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum Status { - ONLINE = 3, - CONNECTING = 2, - RECONNECTING = 1, - OFFLINE = 0, -} diff --git a/packages/mesh/src/index.ts b/packages/mesh/src/index.ts deleted file mode 100644 index 561ca76..0000000 --- a/packages/mesh/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MeshClient, applyPatch } from "./client"; -export { MeshServer, type MeshContext, type SocketMiddleware } from "./server"; -export { type CodeError } from "./common/codeerror"; diff --git a/packages/mesh/src/server/connection.ts b/packages/mesh/src/server/connection.ts deleted file mode 100644 index 43328d7..0000000 --- a/packages/mesh/src/server/connection.ts +++ /dev/null @@ -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 { - if (this.isDead) return false; - - try { - await new Promise((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; - } - } -} diff --git a/packages/mesh/src/server/index.ts b/packages/mesh/src/server/index.ts deleted file mode 100644 index a6a544b..0000000 --- a/packages/mesh/src/server/index.ts +++ /dev/null @@ -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"; diff --git a/packages/mesh/src/server/latency.ts b/packages/mesh/src/server/latency.ts deleted file mode 100644 index 6865094..0000000 --- a/packages/mesh/src/server/latency.ts +++ /dev/null @@ -1,15 +0,0 @@ -export class Latency { - start = 0; - end = 0; - ms = 0; - interval: ReturnType | undefined; - - onRequest() { - this.start = Date.now(); - } - - onResponse() { - this.end = Date.now(); - this.ms = this.end - this.start; - } -} diff --git a/packages/mesh/src/server/managers/broadcast.ts b/packages/mesh/src/server/managers/broadcast.ts deleted file mode 100644 index c7b9489..0000000 --- a/packages/mesh/src/server/managers/broadcast.ts +++ /dev/null @@ -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} 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 { - 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} 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 { - 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} 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 { - 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}`) - ); - } - } - } - } -} diff --git a/packages/mesh/src/server/managers/channel.ts b/packages/mesh/src/server/managers/channel.ts deleted file mode 100644 index 5022ac3..0000000 --- a/packages/mesh/src/server/managers/channel.ts +++ /dev/null @@ -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 - > = new Map(); - private channelSubscriptions: { [channel: string]: Set } = {}; - 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} [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 - ): 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 { - 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} 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 { - 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 | 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 { - return new Promise((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 { - return new Promise((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 { - 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); - } - } -} diff --git a/packages/mesh/src/server/managers/command.ts b/packages/mesh/src/server/managers/command.ts deleted file mode 100644 index b58e226..0000000 --- a/packages/mesh/src/server/managers/command.ts +++ /dev/null @@ -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) => Promise | 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) => Promise | 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( - command: string, - callback: (context: MeshContext) => Promise | 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) => Promise | 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]; - } -} diff --git a/packages/mesh/src/server/managers/connection.ts b/packages/mesh/src/server/managers/connection.ts deleted file mode 100644 index c83f05f..0000000 --- a/packages/mesh/src/server/managers/connection.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - return this.redis.hkeys(CONNECTIONS_HASH_KEY); - } - - async getLocalConnectionIds(): Promise { - 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} 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} 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>} - * 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> { - 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>} 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> { - 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 { - await this.deregisterConnection(connection); - } -} diff --git a/packages/mesh/src/server/managers/presence.ts b/packages/mesh/src/server/managers/presence.ts deleted file mode 100644 index 93a934c..0000000 --- a/packages/mesh/src/server/managers/presence.ts +++ /dev/null @@ -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 - > = new Map(); - private roomTTLs: Map = 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 { - 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) - | { - ttl?: number; - guard?: ( - connection: Connection, - roomName: string - ) => Promise | 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 { - 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 { - 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 { - 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 { - 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 { - return this.redis.smembers(this.presenceRoomKey(roomName)); - } - - private async publishPresenceUpdate( - roomName: string, - connectionId: string, - type: "join" | "leave" - ): Promise { - 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, - expireAfter?: number - ): Promise { - 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 { - 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 | 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>> { - const result = new Map>(); - 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 | null - ): Promise { - 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 { - 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); - } - } - } -} diff --git a/packages/mesh/src/server/managers/pubsub.ts b/packages/mesh/src/server/managers/pubsub.ts deleted file mode 100644 index 172fc03..0000000 --- a/packages/mesh/src/server/managers/pubsub.ts +++ /dev/null @@ -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 // connectionId -> mode - >; - private getChannelSubscriptions: ( - channel: string - ) => Set | undefined; - private emitError: (error: Error) => void; - private _subscriptionPromise!: Promise; - - constructor( - subClient: Redis, - instanceId: string, - connectionManager: ConnectionManager, - recordSubscriptions: Map>, - getChannelSubscriptions: (channel: string) => Set | 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 { - 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 { - 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}`; - } -} diff --git a/packages/mesh/src/server/managers/record-subscription.ts b/packages/mesh/src/server/managers/record-subscription.ts deleted file mode 100644 index b956f94..0000000 --- a/packages/mesh/src/server/managers/record-subscription.ts +++ /dev/null @@ -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 - > = new Map(); - private writableRecordGuards: Map< - ChannelPattern, - (connection: Connection, recordId: string) => Promise | boolean - > = new Map(); - private recordSubscriptions: Map< - string, // recordId - Map // 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} [guard] - Optional guard function. - */ - exposeRecord( - recordPattern: ChannelPattern, - guard?: ( - connection: Connection, - recordId: string - ) => Promise | 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} [guard] - Optional guard function. - */ - exposeWritableRecord( - recordPattern: ChannelPattern, - guard?: ( - connection: Connection, - recordId: string - ) => Promise | 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 { - 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 { - 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 | 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} - * @throws {Error} If the update fails. - */ - async publishRecordUpdate(recordId: string, newValue: any): Promise { - 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> { - return this.recordSubscriptions; - } -} diff --git a/packages/mesh/src/server/managers/record.ts b/packages/mesh/src/server/managers/record.ts deleted file mode 100644 index 12ebf8b..0000000 --- a/packages/mesh/src/server/managers/record.ts +++ /dev/null @@ -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} 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 { - 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} 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 { - 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} 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 { - const pipeline = this.redis.pipeline(); - pipeline.del(this.recordKey(recordId)); - pipeline.del(this.recordVersionKey(recordId)); - await pipeline.exec(); - } -} diff --git a/packages/mesh/src/server/managers/redis.ts b/packages/mesh/src/server/managers/redis.ts deleted file mode 100644 index f29d171..0000000 --- a/packages/mesh/src/server/managers/redis.ts +++ /dev/null @@ -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} 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 { - 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); - } -} diff --git a/packages/mesh/src/server/managers/room.ts b/packages/mesh/src/server/managers/room.ts deleted file mode 100644 index 31d2380..0000000 --- a/packages/mesh/src/server/managers/room.ts +++ /dev/null @@ -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} 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 { - 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} 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 { - 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} 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 { - 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} 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 { - 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} 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 { - 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} 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} 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} 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 { - 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} 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 { - 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} 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 { - 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} 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 { - 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; - } -} diff --git a/packages/mesh/src/server/mesh-context.ts b/packages/mesh/src/server/mesh-context.ts deleted file mode 100644 index ab56edd..0000000 --- a/packages/mesh/src/server/mesh-context.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Connection } from "./connection"; -import type { MeshServer } from "./mesh-server"; - -export class MeshContext { - 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; - } -} diff --git a/packages/mesh/src/server/mesh-server.ts b/packages/mesh/src/server/mesh-server.ts deleted file mode 100644 index 91d6cc0..0000000 --- a/packages/mesh/src/server/mesh-server.ts +++ /dev/null @@ -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} 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 { - const listeningPromise = this.listening - ? Promise.resolve() - : new Promise((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) => Promise | 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( - command: string, - callback: (context: MeshContext) => Promise | 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} [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 - ): 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} 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 { - 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} [guard] - Optional guard function. - */ - exposeRecord( - recordPattern: ChannelPattern, - guard?: ( - connection: Connection, - recordId: string - ) => Promise | 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} [guard] - Optional guard function. - */ - exposeWritableRecord( - recordPattern: ChannelPattern, - guard?: ( - connection: Connection, - recordId: string - ) => Promise | 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} - * @throws {Error} If the update fails. - */ - async publishRecordUpdate(recordId: string, newValue: any): Promise { - 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 { - 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} 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 { - 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} 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 { - 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} 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 { - return this.broadcastManager.broadcastRoomExclude( - roomName, - command, - payload, - exclude - ); - } - - // #endregion - - // #region Presence Management - - trackPresence( - roomPattern: string | RegExp, - guardOrOptions?: - | (( - connection: Connection, - roomName: string - ) => Promise | boolean) - | { - ttl?: number; - guard?: ( - connection: Connection, - roomName: string - ) => Promise | 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>; - } - >("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> = {}; - - 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; 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} 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 { - 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((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} 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 - ): MeshServer { - this.on("connected", callback); - return this; - } - - /** - * Registers a callback function to be executed when a connection is closed. - * - * @param {(connection: Connection) => Promise | 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 - ): MeshServer { - this.on("disconnected", callback); - return this; - } -} diff --git a/packages/mesh/src/server/ping.ts b/packages/mesh/src/server/ping.ts deleted file mode 100644 index 762bb53..0000000 --- a/packages/mesh/src/server/ping.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class Ping { - interval: ReturnType | undefined; -} diff --git a/packages/mesh/src/server/types.ts b/packages/mesh/src/server/types.ts deleted file mode 100644 index cedda6d..0000000 --- a/packages/mesh/src/server/types.ts +++ /dev/null @@ -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 | Promise; - -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; diff --git a/packages/mesh/src/server/utils/constants.ts b/packages/mesh/src/server/utils/constants.ts deleted file mode 100644 index 6aac51f..0000000 --- a/packages/mesh/src/server/utils/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const PUB_SUB_CHANNEL_PREFIX = "mesh:pubsub:"; -export const RECORD_PUB_SUB_CHANNEL = "mesh:record-updates"; diff --git a/packages/mesh/src/server/utils/ids.ts b/packages/mesh/src/server/utils/ids.ts deleted file mode 100644 index 692149f..0000000 --- a/packages/mesh/src/server/utils/ids.ts +++ /dev/null @@ -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}`; - }; -} diff --git a/packages/mesh/src/tests/basic.test.ts b/packages/mesh/src/tests/basic.test.ts deleted file mode 100644 index c0d17bd..0000000 --- a/packages/mesh/src/tests/basic.test.ts +++ /dev/null @@ -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((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((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); - } - }); - }); -}); diff --git a/packages/mesh/src/tests/client-utils.test.ts b/packages/mesh/src/tests/client-utils.test.ts deleted file mode 100644 index 19ed115..0000000 --- a/packages/mesh/src/tests/client-utils.test.ts +++ /dev/null @@ -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; - 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; - 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; - // 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; - 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; - 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; - 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; - 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"); - }); -}); diff --git a/packages/mesh/src/tests/client.test.ts b/packages/mesh/src/tests/client.test.ts deleted file mode 100644 index e2c2888..0000000 --- a/packages/mesh/src/tests/client.test.ts +++ /dev/null @@ -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((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((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((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((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((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(); - }); -}); diff --git a/packages/mesh/src/tests/multiple-instance.test.ts b/packages/mesh/src/tests/multiple-instance.test.ts deleted file mode 100644 index a2e3c31..0000000 --- a/packages/mesh/src/tests/multiple-instance.test.ts +++ /dev/null @@ -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((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((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); -}); diff --git a/packages/mesh/src/tests/presence-state.test.ts b/packages/mesh/src/tests/presence-state.test.ts deleted file mode 100644 index a49f6ed..0000000 --- a/packages/mesh/src/tests/presence-state.test.ts +++ /dev/null @@ -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 - }); -}); diff --git a/packages/mesh/src/tests/presence-subscription.test.ts b/packages/mesh/src/tests/presence-subscription.test.ts deleted file mode 100644 index 6d4e34b..0000000 --- a/packages/mesh/src/tests/presence-subscription.test.ts +++ /dev/null @@ -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); -}); diff --git a/packages/mesh/src/tests/record-subscription.test.ts b/packages/mesh/src/tests/record-subscription.test.ts deleted file mode 100644 index 88cbb7b..0000000 --- a/packages/mesh/src/tests/record-subscription.test.ts +++ /dev/null @@ -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); -}); diff --git a/packages/mesh/src/tests/rooms.test.ts b/packages/mesh/src/tests/rooms.test.ts deleted file mode 100644 index 1dfc774..0000000 --- a/packages/mesh/src/tests/rooms.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/mesh/src/tests/subscription.test.ts b/packages/mesh/src/tests/subscription.test.ts deleted file mode 100644 index 001772e..0000000 --- a/packages/mesh/src/tests/subscription.test.ts +++ /dev/null @@ -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((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((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((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((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((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((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); - }); -}); diff --git a/packages/mesh/tsconfig.json b/packages/mesh/tsconfig.json deleted file mode 100644 index ab0f0b0..0000000 --- a/packages/mesh/tsconfig.json +++ /dev/null @@ -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 - } -} diff --git a/packages/mesh/vitest.config.ts b/packages/mesh/vitest.config.ts deleted file mode 100644 index feadc07..0000000 --- a/packages/mesh/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - poolOptions: { - threads: { - singleThread: true, - maxThreads: 1, - }, - forks: { - singleFork: true, - }, - }, - }, -});