From 81bc0cecbe3b1a6fd4a2b13a4deca7bfe32d9194 Mon Sep 17 00:00:00 2001 From: Fendy Date: Wed, 15 Jul 2026 22:49:51 +0800 Subject: [PATCH] migrate from github --- .dockerignore | 11 + .gitignore | 1 + Dockerfile | 6 + Dockerfile.prod | 29 + package-lock.json | 1222 +++++++++++++++++++++++++++++++++++++++++ package.json | 24 + server.js | 36 ++ src/bootstrap.js | 17 + src/config.js | 31 ++ src/doc-loader.js | 96 ++++ src/grpc/client.js | 37 ++ src/grpc/document.js | 76 +++ src/grpc/index.js | 17 + src/grpc/system.js | 32 ++ src/http/docs.js | 63 +++ src/http/helpers.js | 20 + src/http/probes.js | 45 ++ src/http/router.js | 77 +++ src/http/server.js | 18 + src/key.js | 32 ++ src/lifecycle.js | 72 +++ src/mq.js | 83 +++ src/persistence.js | 55 ++ src/redis/client.js | 40 ++ src/redis/document.js | 87 +++ src/redis/index.js | 21 + src/redis/room.js | 43 ++ src/utils.js | 38 ++ src/ws-gateway.js | 29 + src/ws-utils.js | 222 ++++++++ 30 files changed, 2580 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.prod create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js create mode 100644 src/bootstrap.js create mode 100644 src/config.js create mode 100644 src/doc-loader.js create mode 100644 src/grpc/client.js create mode 100644 src/grpc/document.js create mode 100644 src/grpc/index.js create mode 100644 src/grpc/system.js create mode 100644 src/http/docs.js create mode 100644 src/http/helpers.js create mode 100644 src/http/probes.js create mode 100644 src/http/router.js create mode 100644 src/http/server.js create mode 100644 src/key.js create mode 100644 src/lifecycle.js create mode 100644 src/mq.js create mode 100644 src/persistence.js create mode 100644 src/redis/client.js create mode 100644 src/redis/document.js create mode 100644 src/redis/index.js create mode 100644 src/redis/room.js create mode 100644 src/utils.js create mode 100644 src/ws-gateway.js create mode 100644 src/ws-utils.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f0f820 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.git/ +.vscode/ +.idea/ +src/gen/ +dist-ssr/ + +*.log +*.local +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bca335 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gen/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2642503 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM node:20-alpine +WORKDIR /app + +EXPOSE 1234 + +CMD ["sh", "-c", "npm install && node server.js"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..70b8d63 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,29 @@ +FROM node:20-alpine AS builder + +RUN npm install -g grpc-tools protoc-gen-js + +WORKDIR /app +COPY collab/package.json collab/package-lock.json* ./ +RUN npm install + +COPY proto ./proto + +RUN mkdir -p gen && \ + PROTOC="/usr/local/bin/grpc_tools_node_protoc" && \ + GRPC_PLUGIN="/usr/local/bin/grpc_tools_node_protoc_plugin" && \ + "$PROTOC" -I proto \ + --plugin=protoc-gen-grpc="$GRPC_PLUGIN" \ + --grpc_out=grpc_js:gen \ + --js_out=import_style=commonjs,binary:gen \ + proto/yoresee_doc/v1/yoresee_doc.proto + +FROM node:20-alpine +WORKDIR /app +RUN mkdir -p src +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/gen ./src/gen +COPY collab/. . + +EXPOSE 1234 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f77496d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1222 @@ +{ + "name": "yoresee-doc-collab", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yoresee-doc-collab", + "version": "0.1.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.0", + "@grpc/proto-loader": "^0.7.13", + "amqplib": "^0.10.9", + "google-protobuf": "^4.0.2", + "redis": "^4.6.0", + "ws": "^8.18.0", + "y-redis": "^1.0.3", + "y-websocket": "^1.5.4", + "yjs": "^13.6.16" + }, + "devDependencies": { + "protoc": "^34.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/abstract-leveldown": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", + "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "immediate": "^3.2.3", + "level-concat-iterator": "~2.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/amqplib": { + "version": "0.10.9", + "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz", + "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==", + "license": "MIT", + "dependencies": { + "buffer-more-ints": "~1.0.0", + "url-parse": "~1.5.10" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-more-ints": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", + "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deferred-leveldown": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz", + "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.1", + "inherits": "^2.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encoding-down": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", + "integrity": "sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "abstract-leveldown": "^6.2.1", + "inherits": "^2.0.3", + "level-codec": "^9.0.0", + "level-errors": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/google-protobuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-4.0.2.tgz", + "integrity": "sha512-yD2fqbNgvJPuQwdKJiPdbUcXveNRxgqy070gzsBsCyFJA8Qdj9oxa9xtkddb/JEhcDk0RD5SfGUWg+nhINfMxA==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/immediate": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, + "node_modules/ioredis": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.31.0.tgz", + "integrity": "sha512-tVrCrc4LWJwX82GD79dZ0teZQGq+5KJEGpXJRgzHOrhHtLgF9ME6rTwDV5+HN5bjnvmtrnS8ioXhflY16sy2HQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.0.2", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "lodash.isarguments": "^3.1.0", + "p-map": "^2.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/level": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", + "integrity": "sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==", + "license": "MIT", + "optional": true, + "dependencies": { + "level-js": "^5.0.0", + "level-packager": "^5.1.0", + "leveldown": "^5.4.0" + }, + "engines": { + "node": ">=8.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/level-codec": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", + "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", + "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-concat-iterator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", + "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-2.0.1.tgz", + "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "errno": "~0.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-iterator-stream": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz", + "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.4.0", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-js": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/level-js/-/level-js-5.0.2.tgz", + "integrity": "sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==", + "deprecated": "Superseded by browser-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.3", + "buffer": "^5.5.0", + "inherits": "^2.0.3", + "ltgt": "^2.1.2" + } + }, + "node_modules/level-packager": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", + "integrity": "sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "encoding-down": "^6.3.0", + "levelup": "^4.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-supports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leveldown": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-5.6.0.tgz", + "integrity": "sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==", + "deprecated": "Superseded by classic-level (https://github.com/Level/community#faq)", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.1", + "napi-macros": "~2.0.0", + "node-gyp-build": "~4.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/levelup": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/levelup/-/levelup-4.4.0.tgz", + "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", + "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", + "license": "MIT", + "optional": true, + "dependencies": { + "deferred-leveldown": "~5.3.0", + "level-errors": "~2.0.0", + "level-iterator-stream": "~4.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/ltgt": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-macros": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", + "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", + "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protoc": { + "version": "34.0.0", + "resolved": "https://registry.npmjs.org/protoc/-/protoc-34.0.0.tgz", + "integrity": "sha512-xaBPda5uAi/wu6VgVrBsLKe9MZbogilWeaxtaBTw5sMQUj5s4Hvywi5dHqbLnGbCCoOV4J9Qj74OnT8wMI6WUg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "protoc": "protoc.cjs" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "license": "MIT", + "optional": true + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y-leveldb": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", + "integrity": "sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "level": "^6.0.1", + "lib0": "^0.2.31" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-redis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/y-redis/-/y-redis-1.0.3.tgz", + "integrity": "sha512-kCMgQR2uLtui5E38Ou7skF1axw5AlW01xQqdJh7DFgYlkwppR1ANboxFpSvU5FcLZyQYSpQmzKFVytdxbZmG8w==", + "license": "MIT", + "dependencies": { + "ioredis": "^4.27.6", + "lib0": "^0.2.42" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.2" + } + }, + "node_modules/y-websocket": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-1.5.4.tgz", + "integrity": "sha512-Y3021uy0anOIHqAPyAZbNDoR05JuMEGjRNI8c+K9MHzVS8dWoImdJUjccljAznc8H2L7WkIXhRHZ1igWNRSgPw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.52", + "lodash.debounce": "^4.0.8", + "y-protocols": "^1.0.5" + }, + "bin": { + "y-websocket": "bin/server.js", + "y-websocket-server": "bin/server.js" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^6.2.1", + "y-leveldb": "^0.1.0" + }, + "peerDependencies": { + "yjs": "^13.5.6" + } + }, + "node_modules/y-websocket/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "license": "MIT", + "optional": true, + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..444fc52 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "yoresee-doc-collab", + "version": "0.1.0", + "private": true, + "main": "server.js", + "type": "commonjs", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@grpc/grpc-js": "^1.12.0", + "@grpc/proto-loader": "^0.7.13", + "amqplib": "^0.10.9", + "google-protobuf": "^4.0.2", + "redis": "^4.6.0", + "ws": "^8.18.0", + "y-redis": "^1.0.3", + "y-websocket": "^1.5.4", + "yjs": "^13.6.16" + }, + "devDependencies": { + "protoc": "^34.0.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..93922da --- /dev/null +++ b/server.js @@ -0,0 +1,36 @@ +const wsUtils = require('./src/ws-utils'); +const { setupWSConnection } = wsUtils; +const config = require('./src/config'); +const redis = require('./src/redis'); +const grpc = require('./src/grpc'); +const mq = require('./src/mq'); +const { createHTTPServer } = require('./src/http/server'); +const { bindWebSocketGateway } = require('./src/ws-gateway'); +const { startServer } = require('./src/bootstrap'); +const { createGracefulShutdown, registerShutdownSignals } = require('./src/lifecycle'); + +let isDraining = false; + +const server = createHTTPServer({ + redis, + wsUtils, + getIsDraining: () => isDraining +}); +const wss = bindWebSocketGateway({ + server, + redis, + setupWSConnection +}); +startServer({ server, config, redis, grpc }); + +const shutdown = createGracefulShutdown({ + server, + wss, + mq, + redis, + onDraining: () => { + isDraining = true; + } +}); + +registerShutdownSignals(shutdown); diff --git a/src/bootstrap.js b/src/bootstrap.js new file mode 100644 index 0000000..685e60c --- /dev/null +++ b/src/bootstrap.js @@ -0,0 +1,17 @@ +function startServer({ server, config, redis, grpc }) { + server.listen(config.port, async () => { + await redis.initRedis(); + redis.initYRedis(); + grpc.initGrpcClient(); + + await grpc.healthCheck(); + + console.log(`Collab server listening on ${config.port}`); + console.log(`Redis config: ${config.redisHost}:${config.redisPort}/${config.redisDb}`); + console.log('HTTP API: /api/active-rooms'); + }); +} + +module.exports = { + startServer +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a87c9d2 --- /dev/null +++ b/src/config.js @@ -0,0 +1,31 @@ +const port = Number(process.env.PORT || 1234); + +const redisHost = process.env.REDIS_HOST || 'redis'; +const redisPort = Number(process.env.REDIS_PORT || 6379); +const redisPassword = process.env.REDIS_PASSWORD || ''; +const redisDb = Number(process.env.REDIS_DB || 0); + +const backendAddr = process.env.BACKEND_ADDR || 'backend:9090'; +const dirtyDocTopic = process.env.DIRTY_DOC_TOPIC || 'collab.dirty_docs'; +const dirtyDocMqType = process.env.DIRTY_DOC_MQ || 'redis'; +const rabbitmqUrl = process.env.RABBITMQ_URL || ''; +const internalRpcKey = process.env.INTERNAL_RPC_KEY || ''; +const dirtyDocSetKey = process.env.DIRTY_DOC_SET_KEY || 'collab:yjs:dirty:doc'; +const dirtyDocNotifyThreshold = Number(process.env.DIRTY_DOC_NOTIFY_THRESHOLD || 200); +const docUpdatesTTL = Number(process.env.DOC_UPDATES_TTL || 259200); + +module.exports = { + port, + redisHost, + redisPort, + redisPassword, + redisDb, + backendAddr, + dirtyDocTopic, + dirtyDocMqType, + rabbitmqUrl, + internalRpcKey, + dirtyDocSetKey, + dirtyDocNotifyThreshold, + docUpdatesTTL +}; diff --git a/src/doc-loader.js b/src/doc-loader.js new file mode 100644 index 0000000..0a351a8 --- /dev/null +++ b/src/doc-loader.js @@ -0,0 +1,96 @@ +const Y = require('yjs'); +const redis = require('./redis'); +const backend = require('./grpc'); +const { keyCollabDocUpdates } = require('./key'); + +const snapshotTimeoutMs = Number(process.env.BACKEND_SNAPSHOT_TIMEOUT_MS || 3000); + +async function loadDoc(docId) { + const start = Date.now(); + console.log(`[doc-loader] start docId=${docId}`); + const redisKey = keyCollabDocUpdates(docId); + const backendDocId = docId; + + try { + const updates = await redis.getListBuffers(redisKey); + console.log(`[doc-loader] redis ${docId} ${updates && updates.length ? 'hit' : 'miss'}`); + + const doc = new Y.Doc(); + if (updates && updates.length > 0) { + for (const update of updates) { + if (update && update.length > 0) { + Y.applyUpdate(doc, update); + } + } + return doc; + } + + console.log(`[doc-loader] no redis cache, trying snapshot docId=${docId}`); + try { + console.log(`[doc-loader] requesting snapshot docId=${docId}`); + const snapshot = await Promise.race([ + backend.getDocumentYjsSnapshot(backendDocId), + new Promise((_, reject) => setTimeout(() => reject(new Error('snapshot timeout')), snapshotTimeoutMs)) + ]); + console.log(`[doc-loader] received snapshot docId=${docId} length=${snapshot ? snapshot.length : 0}`); + + if (snapshot && snapshot.length > 0) { + console.log(`[doc-loader] applying snapshot docId=${docId} bytes=${snapshot.length}`); + Y.applyUpdate(doc, snapshot); + console.log(`[doc-loader] saving snapshot to redis docId=${docId}`); + await redis.replaceListBuffer(redisKey, snapshot); + console.log(`[doc-loader] apply snapshot docId=${docId} bytes=${snapshot.length} took=${Date.now() - start}ms`); + } else { + console.log(`[doc-loader] snapshot empty docId=${docId} took=${Date.now() - start}ms`); + console.log(`[doc-loader] trying to get document content docId=${docId}`); + const content = await backend.getDocumentContent(backendDocId); + console.log(`[doc-loader] received content docId=${docId} length=${content ? content.length : 0}`); + + if (content) { + console.log(`[doc-loader] seeding content docId=${docId} length=${content.length}`); + const ytext = doc.getText('content'); + ytext.insert(0, content); + const initialUpdate = Y.encodeStateAsUpdate(doc); + console.log(`[doc-loader] saving initial update to redis docId=${docId}`); + await redis.replaceListBuffer(redisKey, initialUpdate); + console.log(`[doc-loader] seeded content docId=${docId} bytes=${initialUpdate.length} took=${Date.now() - start}ms`); + } else { + console.log(`[doc-loader] no content to seed docId=${docId} took=${Date.now() - start}ms`); + } + } + } catch (err) { + console.error(`[doc-loader] snapshot error docId=${docId} err=${err.message} stack=${err.stack} took=${Date.now() - start}ms`); + // Fallback to getting document content when snapshot fails + try { + console.log(`[doc-loader] fallback: trying to get document content docId=${docId}`); + const content = await backend.getDocumentContent(backendDocId); + console.log(`[doc-loader] fallback: received content docId=${docId} length=${content ? content.length : 0}`); + + if (content) { + console.log(`[doc-loader] fallback: seeding content docId=${docId} length=${content.length}`); + const ytext = doc.getText('content'); + ytext.insert(0, content); + const initialUpdate = Y.encodeStateAsUpdate(doc); + console.log(`[doc-loader] fallback: saving initial update to redis docId=${docId}`); + await redis.replaceListBuffer(redisKey, initialUpdate); + console.log(`[doc-loader] fallback seeded content docId=${docId} bytes=${initialUpdate.length} took=${Date.now() - start}ms`); + } else { + console.log(`[doc-loader] fallback no content to seed docId=${docId} took=${Date.now() - start}ms`); + } + } catch (fallbackErr) { + console.error(`[doc-loader] fallback error docId=${docId} err=${fallbackErr.message} stack=${fallbackErr.stack} took=${Date.now() - start}ms`); + } + } + + console.log(`[doc-loader] returning doc docId=${docId} took=${Date.now() - start}ms`); + return doc; + } catch (err) { + console.error(`[doc-loader] fatal error docId=${docId} err=${err.message} stack=${err.stack} took=${Date.now() - start}ms`); + // Return empty doc as last resort + return new Y.Doc(); + } +} + +module.exports = { + loadDoc +}; diff --git a/src/grpc/client.js b/src/grpc/client.js new file mode 100644 index 0000000..067eaca --- /dev/null +++ b/src/grpc/client.js @@ -0,0 +1,37 @@ +const grpc = require('@grpc/grpc-js'); +const { DocumentServiceClient, SystemServiceClient } = require('../gen/yoresee_doc/v1/yoresee_doc_grpc_pb'); +const config = require('../config'); + +let documentServiceClient = null; +let systemServiceClient = null; + +const buildMetadata = () => { + const md = new grpc.Metadata(); + if (config.internalRpcKey) { + md.set('x-internal-key', config.internalRpcKey); + } + return md; +}; + +function initGrpcClient() { + try { + const opts = { + 'grpc.default_service_config': JSON.stringify({ + loadBalancingConfig: [{ round_robin: {} }] + }) + }; + const creds = grpc.credentials.createInsecure(); + documentServiceClient = new DocumentServiceClient(config.backendAddr, creds, opts); + systemServiceClient = new SystemServiceClient(config.backendAddr, creds, opts); + console.log(`gRPC clients initialized, connecting to ${config.backendAddr}`); + } catch (err) { + console.error('Failed to initialize gRPC client:', err.message); + } +} + +module.exports = { + initGrpcClient, + buildMetadata, + getDocumentServiceClient: () => documentServiceClient, + getSystemServiceClient: () => systemServiceClient, +}; diff --git a/src/grpc/document.js b/src/grpc/document.js new file mode 100644 index 0000000..5add360 --- /dev/null +++ b/src/grpc/document.js @@ -0,0 +1,76 @@ +const pb = require('../gen/yoresee_doc/v1/yoresee_doc_pb'); +const { getDocumentServiceClient, buildMetadata } = require('./client'); + +async function getDocumentContent(documentExternalId) { + return new Promise((resolve, reject) => { + const client = getDocumentServiceClient(); + if (!client) { + return reject(new Error('gRPC client not initialized')); + } + const request = new pb.GetDocumentContentRequest(); + request.setDocumentExternalId(documentExternalId); + + client.getDocumentContent(request, buildMetadata(), (err, response) => { + if (err) { + return reject(err); + } + const base = response?.getBase?.(); + if (base && base.getCode && base.getCode() !== 0) { + return reject(new Error(base.getMessage() || 'Failed to get document content')); + } + resolve(response?.getContent?.() || ''); + }); + }); +} + +async function getDocumentYjsSnapshot(documentExternalId) { + return new Promise((resolve, reject) => { + const client = getDocumentServiceClient(); + if (!client) { + return reject(new Error('gRPC client not initialized')); + } + const request = new pb.GetDocumentYjsSnapshotRequest(); + request.setDocumentExternalId(documentExternalId); + + client.getDocumentYjsSnapshot(request, buildMetadata(), (err, response) => { + if (err) { + return reject(err); + } + const base = response?.getBase?.(); + if (base && base.getCode && base.getCode() !== 0) { + return reject(new Error(base.getMessage() || 'Failed to get document snapshot')); + } + const state = response?.getState_asU8?.(); + resolve(state && state.length ? Buffer.from(state) : null); + }); + }); +} + +async function saveDocumentYjsSnapshot(documentExternalId, state) { + return new Promise((resolve, reject) => { + const client = getDocumentServiceClient(); + if (!client) { + return reject(new Error('gRPC client not initialized')); + } + const request = new pb.SaveDocumentYjsSnapshotRequest(); + request.setDocumentExternalId(documentExternalId); + request.setState(state); + + client.saveDocumentYjsSnapshot(request, buildMetadata(), (err, response) => { + if (err) { + return reject(err); + } + const base = response?.getBase?.(); + if (base && base.getCode && base.getCode() !== 0) { + return reject(new Error(base.getMessage() || 'Failed to save document snapshot')); + } + resolve(true); + }); + }); +} + +module.exports = { + getDocumentContent, + getDocumentYjsSnapshot, + saveDocumentYjsSnapshot, +}; diff --git a/src/grpc/index.js b/src/grpc/index.js new file mode 100644 index 0000000..e8ad276 --- /dev/null +++ b/src/grpc/index.js @@ -0,0 +1,17 @@ +const { initGrpcClient, getDocumentServiceClient, getSystemServiceClient } = require('./client'); +const { getDocumentContent, getDocumentYjsSnapshot, saveDocumentYjsSnapshot } = require('./document'); +const { healthCheck } = require('./system'); + +module.exports = { + initGrpcClient, + getDocumentContent, + getDocumentYjsSnapshot, + saveDocumentYjsSnapshot, + healthCheck, + get documentServiceClient() { + return getDocumentServiceClient(); + }, + get systemServiceClient() { + return getSystemServiceClient(); + }, +}; diff --git a/src/grpc/system.js b/src/grpc/system.js new file mode 100644 index 0000000..bec0215 --- /dev/null +++ b/src/grpc/system.js @@ -0,0 +1,32 @@ +const pb = require('../gen/yoresee_doc/v1/yoresee_doc_pb'); +const { getSystemServiceClient, buildMetadata } = require('./client'); + +async function healthCheck() { + const client = getSystemServiceClient(); + if (!client) { + console.log('System service client not initialized, skipping health check'); + return; + } + + try { + const request = new pb.HealthRequest(); + client.health(request, buildMetadata(), (err, response) => { + if (err) { + console.error('Health check failed:', err); + } else { + const base = response?.getBase?.(); + if (base && base.getCode && base.getCode() === 0) { + console.log('Health check successful'); + } else { + console.error('Health check failed:', base ? base.getMessage() : 'Unknown error'); + } + } + }); + } catch (err) { + console.error('Failed to execute health check:', err.message); + } +} + +module.exports = { + healthCheck, +}; diff --git a/src/http/docs.js b/src/http/docs.js new file mode 100644 index 0000000..892061d --- /dev/null +++ b/src/http/docs.js @@ -0,0 +1,63 @@ +const Y = require('yjs'); +const { keyCollabDocUpdates } = require('../key'); + +function findDocInMemory(docs, docId) { + if (!docs) { + return null; + } + return docs.get(`doc-${docId}`) || docs.get(docId) || null; +} + +async function buildDocFromRedis(redis, docId) { + const redisKey = keyCollabDocUpdates(docId); + const updates = await redis.getListBuffers(redisKey); + if (!updates || updates.length === 0) { + return null; + } + + const doc = new Y.Doc(); + for (const update of updates) { + if (update && update.length > 0) { + Y.applyUpdate(doc, update); + } + } + return doc; +} + +async function loadDocForRead(docs, redis, docId) { + const inMemoryDoc = findDocInMemory(docs, docId); + if (inMemoryDoc) { + return inMemoryDoc; + } + return buildDocFromRedis(redis, docId); +} + +const XML_FRAGMENT_DOC_TYPES = new Set(['yoresee_rich_text']); + +function encodeSnapshotResponse(doc, docType) { + const update = Y.encodeStateAsUpdate(doc); + let content = ''; + + if (XML_FRAGMENT_DOC_TYPES.has(docType)) { + const fragment = doc.getXmlFragment('content'); + content = fragment ? fragment.toString() : ''; + } else { + const ytext = doc.getText('content'); + content = ytext ? ytext.toString() : ''; + } + + return { + state: Buffer.from(update).toString('base64'), + content + }; +} + +function encodeBinaryUpdate(doc) { + return Buffer.from(Y.encodeStateAsUpdate(doc)); +} + +module.exports = { + loadDocForRead, + encodeSnapshotResponse, + encodeBinaryUpdate +}; diff --git a/src/http/helpers.js b/src/http/helpers.js new file mode 100644 index 0000000..57ff7b0 --- /dev/null +++ b/src/http/helpers.js @@ -0,0 +1,20 @@ +function writeJson(res, status, payload) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +function writeText(res, status, body) { + res.writeHead(status); + res.end(body); +} + +function getRequestURL(req) { + const host = req.headers && req.headers.host ? req.headers.host : 'localhost'; + return new URL(req.url || '/', `http://${host}`); +} + +module.exports = { + writeJson, + writeText, + getRequestURL +}; diff --git a/src/http/probes.js b/src/http/probes.js new file mode 100644 index 0000000..44dff05 --- /dev/null +++ b/src/http/probes.js @@ -0,0 +1,45 @@ +const { writeJson } = require('./helpers'); + +function buildReadinessState(isDraining, redisClient) { + const ready = !isDraining && !!redisClient; + return { + ready, + redisState: redisClient ? 'connected' : 'disconnected', + detail: isDraining ? 'server is draining' : undefined + }; +} + +function handleProbeRequest(pathname, res, { isDraining, redisClient }) { + if (pathname === '/livez') { + writeJson(res, 200, { status: 'ok' }); + return true; + } + + const state = buildReadinessState(isDraining, redisClient); + + if (pathname === '/readyz') { + writeJson(res, state.ready ? 200 : 503, { + status: state.ready ? 'ok' : 'not_ready', + redis: state.redisState, + detail: state.detail + }); + return true; + } + + if (pathname === '/health') { + writeJson(res, 200, { + status: 'ok', + redis: state.redisState, + persistence: 'custom', + ready: state.ready ? 'true' : 'false', + detail: state.detail + }); + return true; + } + + return false; +} + +module.exports = { + handleProbeRequest +}; diff --git a/src/http/router.js b/src/http/router.js new file mode 100644 index 0000000..b3f1afe --- /dev/null +++ b/src/http/router.js @@ -0,0 +1,77 @@ +const { writeJson, writeText, getRequestURL } = require('./helpers'); +const { handleProbeRequest } = require('./probes'); +const { loadDocForRead, encodeSnapshotResponse, encodeBinaryUpdate } = require('./docs'); + +function decodeDocID(pathname, prefix) { + try { + return decodeURIComponent(pathname.slice(prefix.length)); + } catch (_err) { + return ''; + } +} + +function createRequestHandler({ redis, wsUtils, getIsDraining }) { + const docs = wsUtils.docs; + + return async function handleRequest(req, res) { + try { + const url = getRequestURL(req); + const pathname = url.pathname; + + if (handleProbeRequest(pathname, res, { isDraining: getIsDraining(), redisClient: redis.redisClient })) { + return; + } + + if (pathname === '/api/active-rooms') { + const rooms = await redis.getActiveRooms(); + writeJson(res, 200, { rooms, count: rooms.length }); + return; + } + + if (pathname.startsWith('/internal/yjs/doc-snapshot/')) { + const docId = decodeDocID(pathname, '/internal/yjs/doc-snapshot/'); + if (!docId) { + writeText(res, 400, 'doc id required'); + return; + } + + const docType = url.searchParams.get('type') || ''; + const doc = await loadDocForRead(docs, redis, docId); + if (!doc) { + writeText(res, 404, 'doc not loaded'); + return; + } + + writeJson(res, 200, encodeSnapshotResponse(doc, docType)); + return; + } + + if (pathname.startsWith('/internal/yjs/doc/')) { + const docId = decodeDocID(pathname, '/internal/yjs/doc/'); + if (!docId) { + writeText(res, 400, 'doc id required'); + return; + } + + const doc = await loadDocForRead(docs, redis, docId); + if (!doc) { + writeText(res, 404, 'doc not loaded'); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + res.end(encodeBinaryUpdate(doc)); + return; + } + + writeText(res, 404, ''); + } catch (err) { + console.error('Request handling failed:', err); + writeText(res, 500, 'internal error'); + } + }; +} + +module.exports = { + createRequestHandler +}; diff --git a/src/http/server.js b/src/http/server.js new file mode 100644 index 0000000..772129a --- /dev/null +++ b/src/http/server.js @@ -0,0 +1,18 @@ +const http = require('http'); +const { createRequestHandler } = require('./router'); + +function createHTTPServer({ redis, wsUtils, getIsDraining }) { + const requestHandler = createRequestHandler({ + redis, + wsUtils, + getIsDraining + }); + + return http.createServer((req, res) => { + void requestHandler(req, res); + }); +} + +module.exports = { + createHTTPServer +}; diff --git a/src/key.js b/src/key.js new file mode 100644 index 0000000..fc57380 --- /dev/null +++ b/src/key.js @@ -0,0 +1,32 @@ +function keyCollabDocUpdates(docId) { + return `collab:yjs:doc:updates:${docId}`; +} + +function keyCollabRoom(docId) { + return `collab:room:doc-${docId}`; +} + +function keyCollabRoomRaw(roomName) { + return `collab:room:${roomName}`; +} + +function keyCollabRoomPattern() { + return 'collab:room:*'; +} + +function parseRoomName(redisKey) { + return redisKey.slice('collab:room:'.length); +} + +function keyCollabYjs(roomName) { + return `collab:yjs:${roomName}`; +} + +module.exports = { + keyCollabDocUpdates, + keyCollabRoom, + keyCollabRoomRaw, + keyCollabRoomPattern, + parseRoomName, + keyCollabYjs, +}; diff --git a/src/lifecycle.js b/src/lifecycle.js new file mode 100644 index 0000000..b3f5c8a --- /dev/null +++ b/src/lifecycle.js @@ -0,0 +1,72 @@ +function closeServerGracefully(server) { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function closeWssGracefully(wss) { + return new Promise((resolve) => { + wss.close(() => resolve()); + }); +} + +function createGracefulShutdown({ + server, + wss, + mq, + redis, + onDraining, + timeoutMs = 10000 +}) { + let shuttingDown = false; + + return async function shutdown(signal) { + if (shuttingDown) { + return; + } + shuttingDown = true; + onDraining(); + console.log(`Received ${signal}, start graceful shutdown`); + + const forceExitTimer = setTimeout(() => { + console.error('Graceful shutdown timeout, force exiting'); + process.exit(1); + }, timeoutMs); + forceExitTimer.unref(); + + await Promise.all([ + closeWssGracefully(wss), + closeServerGracefully(server) + ]); + + try { + await mq.close(); + } catch (err) { + console.error('Failed to close MQ:', err); + } + + try { + await redis.closeRedis(); + } catch (err) { + console.error('Failed to close Redis:', err); + } + + clearTimeout(forceExitTimer); + process.exit(0); + }; +} + +function registerShutdownSignals(shutdown) { + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); +} + +module.exports = { + createGracefulShutdown, + registerShutdownSignals +}; diff --git a/src/mq.js b/src/mq.js new file mode 100644 index 0000000..5f21ea2 --- /dev/null +++ b/src/mq.js @@ -0,0 +1,83 @@ +const amqp = require('amqplib'); +const config = require('./config'); +const redis = require('./redis'); + +let rabbitConn = null; +let rabbitChannel = null; + +const shouldPublishRedis = () => { + const mode = (config.dirtyDocMqType || 'redis').toLowerCase(); + return mode === 'redis' || mode === 'both'; +}; + +const shouldPublishRabbit = () => { + const mode = (config.dirtyDocMqType || 'redis').toLowerCase(); + return mode === 'rabbitmq' || mode === 'rabbit' || mode === 'both'; +}; + +const getRabbitChannel = async () => { + if (!config.rabbitmqUrl) { + return null; + } + if (rabbitChannel) { + return rabbitChannel; + } + rabbitConn = await amqp.connect(config.rabbitmqUrl); + rabbitChannel = await rabbitConn.createChannel(); + return rabbitChannel; +}; + +const publishRabbit = async (topic, payload) => { + const channel = await getRabbitChannel(); + if (!channel) { + return; + } + await channel.assertExchange(topic, 'topic', { durable: true }); + channel.publish(topic, '', Buffer.from(payload), { + contentType: 'application/json' + }); +}; + +const publishRedis = async (topic, payload) => { + if (!redis.redisClient) { + return; + } + await redis.redisClient.publish(topic, payload); +}; + +const publishDirtyDoc = async (docId) => { + if (!docId) { + return; + } + const payload = JSON.stringify({ doc_id: docId, ts: Date.now() }); + try { + if (shouldPublishRedis()) { + await publishRedis(config.dirtyDocTopic, payload); + } + } catch (err) { + console.error(`Failed to publish dirty doc ${docId} to redis:`, err.message); + } + try { + if (shouldPublishRabbit()) { + await publishRabbit(config.dirtyDocTopic, payload); + } + } catch (err) { + console.error(`Failed to publish dirty doc ${docId} to rabbitmq:`, err.message); + } +}; + +const close = async () => { + if (rabbitChannel) { + await rabbitChannel.close(); + rabbitChannel = null; + } + if (rabbitConn) { + await rabbitConn.close(); + rabbitConn = null; + } +}; + +module.exports = { + publishDirtyDoc, + close +}; diff --git a/src/persistence.js b/src/persistence.js new file mode 100644 index 0000000..72000ae --- /dev/null +++ b/src/persistence.js @@ -0,0 +1,55 @@ +const Y = require('yjs'); +const redis = require('./redis'); +const mq = require('./mq'); +const config = require('./config'); +const { keyCollabDocUpdates } = require('./key'); + +const compactThreshold = parseInt(process.env.DOC_UPDATE_COMPACT_THRESHOLD, 10) || 1000; +const dirtyNotifyThreshold = config.dirtyDocNotifyThreshold; + +const updateCounts = new Map(); +const notifyCounts = new Map(); +const dirtyLogged = new Set(); + +const persistUpdate = async (docId, doc, update) => { + if (!docId) { + return; + } + const redisKey = keyCollabDocUpdates(docId); + await redis.appendListBuffer(redisKey, update); + await redis.addDirtyDoc(docId); + await redis.updateRoomActiveTime(`doc-${docId}`); + if (!dirtyLogged.has(docId)) { + dirtyLogged.add(docId); + console.log(`[collab] dirty marked docId=${docId} redisKey=${redisKey}`); + } + + const nextNotify = (notifyCounts.get(docId) || 0) + 1; + if (nextNotify >= dirtyNotifyThreshold) { + await mq.publishDirtyDoc(docId); + notifyCounts.set(docId, 0); + } else { + notifyCounts.set(docId, nextNotify); + } + + const nextCount = (updateCounts.get(docId) || 0) + 1; + if (nextCount >= compactThreshold) { + const merged = Y.encodeStateAsUpdate(doc); + await redis.replaceListBuffer(redisKey, merged); + updateCounts.set(docId, 0); + } else { + updateCounts.set(docId, nextCount); + } +}; + +const attachPersistence = (doc, docId) => { + if (doc._persistenceBound) { + return; + } + doc._persistenceBound = true; + doc.on('update', (update) => { + void persistUpdate(docId, doc, update); + }); +}; + +module.exports = { persistUpdate, attachPersistence }; diff --git a/src/redis/client.js b/src/redis/client.js new file mode 100644 index 0000000..013e5ee --- /dev/null +++ b/src/redis/client.js @@ -0,0 +1,40 @@ +const { createClient } = require('redis'); +const config = require('../config'); + +let redisClient = null; + +async function initRedis() { + redisClient = createClient({ + socket: { + host: config.redisHost, + port: config.redisPort + }, + password: config.redisPassword || undefined, + database: config.redisDb + }); + + redisClient.on('error', (err) => console.error('Redis Client Error', err)); + + try { + await redisClient.connect(); + console.log(`Connected to Redis at ${config.redisHost}:${config.redisPort}`); + } catch (err) { + console.error('Failed to connect to Redis:', err); + } +} + +function initYRedis() { + console.log('Yjs Redis persistence initialized (custom loader)'); +} + +async function closeRedis() { + if (redisClient) { + await redisClient.quit(); + } +} + +function getClient() { + return redisClient; +} + +module.exports = { initRedis, initYRedis, closeRedis, getClient }; diff --git a/src/redis/document.js b/src/redis/document.js new file mode 100644 index 0000000..1fa60e7 --- /dev/null +++ b/src/redis/document.js @@ -0,0 +1,87 @@ +const { commandOptions } = require('redis'); +const { getClient } = require('./client'); +const config = require('../config'); + +async function getListBuffers(key) { + const client = getClient(); + if (!client) return []; + try { + const items = await client.lRange(commandOptions({ returnBuffers: true }), key, 0, -1); + return items || []; + } catch (err) { + console.error(`Failed to lrange buffer for ${key}:`, err.message); + return []; + } +} + +async function appendListBuffer(key, buffer) { + const client = getClient(); + if (!client) return; + try { + const payload = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + await client.rPush(key, payload); + if (config.docUpdatesTTL > 0) { + await client.expire(key, config.docUpdatesTTL); + } + } catch (err) { + console.error(`Failed to rpush buffer for ${key}:`, err.message); + } +} + +async function replaceListBuffer(key, buffer) { + const client = getClient(); + if (!client) return; + try { + const payload = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + const pipeline = client.multi(); + pipeline.del(key); + pipeline.rPush(key, payload); + if (config.docUpdatesTTL > 0) { + pipeline.expire(key, config.docUpdatesTTL); + } + await pipeline.exec(); + } catch (err) { + console.error(`Failed to replace list buffer for ${key}:`, err.message); + } +} + +async function addDirtyDoc(docId) { + const client = getClient(); + if (!client) return; + try { + await client.sAdd(config.dirtyDocSetKey, docId); + } catch (err) { + console.error(`Failed to add dirty doc ${docId}:`, err.message); + } +} + +async function removeDirtyDoc(docId) { + const client = getClient(); + if (!client) return; + try { + await client.sRem(config.dirtyDocSetKey, docId); + } catch (err) { + console.error(`Failed to remove dirty doc ${docId}:`, err.message); + } +} + +async function isDirtyDoc(docId) { + const client = getClient(); + if (!client) return false; + try { + const exists = await client.sIsMember(config.dirtyDocSetKey, docId); + return !!exists; + } catch (err) { + console.error(`Failed to check dirty doc ${docId}:`, err.message); + return false; + } +} + +module.exports = { + getListBuffers, + appendListBuffer, + replaceListBuffer, + addDirtyDoc, + removeDirtyDoc, + isDirtyDoc, +}; diff --git a/src/redis/index.js b/src/redis/index.js new file mode 100644 index 0000000..ed615ed --- /dev/null +++ b/src/redis/index.js @@ -0,0 +1,21 @@ +const { initRedis, initYRedis, closeRedis, getClient } = require('./client'); +const { updateRoomActiveTime, getActiveRooms, checkRoomExists } = require('./room'); +const { getListBuffers, appendListBuffer, replaceListBuffer, addDirtyDoc, removeDirtyDoc, isDirtyDoc } = require('./document'); + +module.exports = { + initRedis, + initYRedis, + closeRedis, + updateRoomActiveTime, + getActiveRooms, + checkRoomExists, + getListBuffers, + appendListBuffer, + replaceListBuffer, + addDirtyDoc, + removeDirtyDoc, + isDirtyDoc, + get redisClient() { + return getClient(); + }, +}; diff --git a/src/redis/room.js b/src/redis/room.js new file mode 100644 index 0000000..6b483fc --- /dev/null +++ b/src/redis/room.js @@ -0,0 +1,43 @@ +const { getClient } = require('./client'); +const { keyCollabRoomRaw, keyCollabRoomPattern, parseRoomName, keyCollabYjs } = require('../key'); + +async function updateRoomActiveTime(roomName) { + const client = getClient(); + if (!client) return; + + try { + const key = keyCollabRoomRaw(roomName); + await client.set(key, Date.now().toString(), { EX: 3600 * 24 }); + } catch (err) { + console.error('Failed to update room active time:', err); + } +} + +async function getActiveRooms() { + const client = getClient(); + if (!client) return []; + + try { + const keys = await client.keys(keyCollabRoomPattern()); + return keys.map(k => parseRoomName(k)); + } catch (err) { + console.error('Failed to get active rooms:', err); + return []; + } +} + +async function checkRoomExists(roomName) { + const client = getClient(); + if (!client) return false; + + try { + const yjsKey = keyCollabYjs(roomName); + const exists = await client.exists(yjsKey); + return exists; + } catch (err) { + console.error(`Failed to check room ${roomName}:`, err.message); + return false; + } +} + +module.exports = { updateRoomActiveTime, getActiveRooms, checkRoomExists }; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..3ef9621 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,38 @@ +const Y = require('yjs'); +const redis = require('./redis'); +const grpc = require('./grpc'); + +async function checkAndInitRoom(roomName) { + if (!redis.redisClient || !grpc.documentServiceClient) { + return null; + } + + try { + const exists = await redis.checkRoomExists(roomName); + if (exists) { + console.log(`Room ${roomName} already has data in Redis, skipping init`); + return null; + } + + const docId = roomName.replace(/^doc-/, ''); + console.log(`Room ${roomName} is empty, fetching content from backend for doc ${docId}...`); + + const content = await grpc.getDocumentContent(docId); + if (content) { + console.log(`Got content from backend, length: ${content.length}`); + const ydoc = new Y.Doc(); + const ytext = ydoc.getText('content'); + ytext.insert(0, content); + return ydoc; + } + + return null; + } catch (err) { + console.error(`Failed to check/init room ${roomName}:`, err.message); + return null; + } +} + +module.exports = { + checkAndInitRoom +}; \ No newline at end of file diff --git a/src/ws-gateway.js b/src/ws-gateway.js new file mode 100644 index 0000000..953997f --- /dev/null +++ b/src/ws-gateway.js @@ -0,0 +1,29 @@ +const WebSocket = require('ws'); + +function bindWebSocketGateway({ server, redis, setupWSConnection }) { + const wss = new WebSocket.Server({ server }); + + wss.on('connection', async (conn, req) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const roomName = url.pathname.slice(1); + + if (roomName) { + redis.updateRoomActiveTime(roomName); + } + + try { + await setupWSConnection(conn, req, { + gc: true + }); + } catch (err) { + console.error('Failed to setup WS connection:', err); + conn.close(); + } + }); + + return wss; +} + +module.exports = { + bindWebSocketGateway +}; diff --git a/src/ws-utils.js b/src/ws-utils.js new file mode 100644 index 0000000..c9ffacb --- /dev/null +++ b/src/ws-utils.js @@ -0,0 +1,222 @@ +const Y = require('yjs'); +const syncProtocol = require('y-protocols/dist/sync.cjs'); +const awarenessProtocol = require('y-protocols/dist/awareness.cjs'); + +const encoding = require('lib0/dist/encoding.cjs'); +const decoding = require('lib0/dist/decoding.cjs'); +const { loadDoc } = require('./doc-loader'); +const { attachPersistence } = require('./persistence'); + +const wsReadyStateConnecting = 0; +const wsReadyStateOpen = 1; +const wsReadyStateClosed = 3; + +const gcEnabled = process.env.GC !== 'false' && process.env.GC !== '0'; + +const messageSync = 0; +const messageAwareness = 1; + +const docs = new Map(); +const docPromises = new Map(); + +exports.docs = docs; + +const extractDocId = (docName) => { + if (!docName) { + return ''; + } + if (docName.startsWith('doc-')) { + return docName.slice(4); + } + return docName; +}; + +// ── WebSocket send / close ────────────────────────────────── + +const send = (doc, conn, m) => { + if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) { + closeConn(doc, conn); + } + try { + conn.send(m, err => { + if (err != null) { + closeConn(doc, conn); + } + }); + } catch (err) { + closeConn(doc, conn); + } +}; + +const closeConn = (doc, conn) => { + if (doc.conns.has(conn)) { + const controlledIds = doc.conns.get(conn); + doc.conns.delete(conn); + awarenessProtocol.removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); + if (doc.conns.size === 0) { + docs.delete(doc.name); + doc.destroy(); + } + } + conn.close(); +}; + +// ── WSSharedDoc ───────────────────────────────────────────── + +const updateHandler = (update, origin, doc) => { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + doc.conns.forEach((_, conn) => send(doc, conn, message)); +}; + +class WSSharedDoc extends Y.Doc { + constructor(name) { + super({ gc: gcEnabled }); + this.name = name; + this.conns = new Map(); + this.awareness = new awarenessProtocol.Awareness(this); + this.awareness.setLocalState(null); + + const awarenessChangeHandler = ({ added, updated, removed }, conn) => { + const changedClients = added.concat(updated, removed); + if (conn !== null) { + const connControlledIDs = this.conns.get(conn); + if (connControlledIDs !== undefined) { + added.forEach(clientID => { connControlledIDs.add(clientID); }); + removed.forEach(clientID => { connControlledIDs.delete(clientID); }); + } + } + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageAwareness); + encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)); + const buff = encoding.toUint8Array(encoder); + this.conns.forEach((_, c) => { send(this, c, buff); }); + }; + this.awareness.on('update', awarenessChangeHandler); + this.on('update', updateHandler); + } +} + +// ── Document lifecycle ────────────────────────────────────── + +const createYDoc = async (docname, gc = true) => { + const doc = new WSSharedDoc(docname); + doc.gc = gc; + + const docId = extractDocId(docname); + try { + const loadedDoc = await loadDoc(docId); + const update = Y.encodeStateAsUpdate(loadedDoc); + if (update && update.length > 0) { + Y.applyUpdate(doc, update); + } + } catch (err) { + console.error(`Failed to load doc ${docId}:`, err.message); + } + + attachPersistence(doc, docId); + return doc; +}; + +const getYDoc = async (docname, gc = true) => { + if (docPromises.has(docname)) { + console.log(`[ws-utils] returning existing promise docname=${docname}`); + return docPromises.get(docname); + } + console.log(`[ws-utils] creating new doc docname=${docname}`); + const createPromise = createYDoc(docname, gc) + .finally(() => { docPromises.delete(docname); }); + docPromises.set(docname, createPromise); + return createPromise; +}; + +// ── Message handler ───────────────────────────────────────── + +const messageListener = (conn, doc, message) => { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case messageSync: + encoding.writeVarUint(encoder, messageSync); + syncProtocol.readSyncMessage(decoder, encoder, doc, conn); + if (encoding.length(encoder) > 1) { + send(doc, conn, encoding.toUint8Array(encoder)); + } + break; + case messageAwareness: { + awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); + break; + } + default: + break; + } + } catch (err) { + console.error(err); + doc.emit('error', [err]); + } +}; + +// ── Connection setup ──────────────────────────────────────── + +exports.setupWSConnection = async (conn, req, { docName = req.url.slice(1).split('?')[0], gc = true } = {}) => { + const pendingMessages = []; + let ready = false; + let closed = false; + + let doc = null; + + conn.on('message', message => { + if (!ready) { + pendingMessages.push(message); + return; + } + if (doc) { + messageListener(conn, doc, new Uint8Array(message)); + } + }); + conn.on('close', () => { + closed = true; + if (ready) { + closeConn(doc, conn); + } + }); + conn.on('error', () => { + closed = true; + if (ready) { + closeConn(doc, conn); + } + }); + + doc = await getYDoc(docName, gc); + if (closed || conn.readyState === wsReadyStateClosed) { + return; + } + + doc.conns.set(conn, new Set()); + ready = true; + + for (const message of pendingMessages) { + messageListener(conn, doc, new Uint8Array(message)); + } + pendingMessages.length = 0; + + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.writeSyncStep1(encoder, doc); + send(doc, conn, encoding.toUint8Array(encoder)); + + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + const awarenessEncoder = encoding.createEncoder(); + encoding.writeVarUint(awarenessEncoder, messageAwareness); + encoding.writeVarUint8Array( + awarenessEncoder, + awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) + ); + send(doc, conn, encoding.toUint8Array(awarenessEncoder)); + } +};