From acaa90e10fdd69522c440d1b60aaa622e667eb5f Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 3 Aug 2026 09:05:40 +0000 Subject: [PATCH] initial Commit --- .gitignore | 2 + package-lock.json | 1046 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 26 ++ src/index.ts | 54 +++ src/sse/client.ts | 243 +++++++++++ src/sse/index.ts | 1 + src/types.ts | 11 + src/ws/client.ts | 257 +++++++++++ src/ws/index.ts | 1 + src/ws/types.ts | 40 ++ tsconfig.json | 32 ++ 11 files changed, 1713 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/sse/client.ts create mode 100644 src/sse/index.ts create mode 100644 src/types.ts create mode 100644 src/ws/client.ts create mode 100644 src/ws/index.ts create mode 100644 src/ws/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2d59d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/dist \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9d4614f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1046 @@ +{ + "name": "minimal-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minimal-sync", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@xo-cash/crypto": "^0.0.2", + "@xo-cash/primitives": "^0.0.2", + "@xo-cash/utils": "file:../utils", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "tsx": "^4.23.1", + "typescript": "^7.0.2" + } + }, + "../utils": { + "name": "@xo-cash/utils", + "version": "0.0.2", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@xo-cash/types": "0.0.3", + "eventemitter3": "^5.0.4", + "zod": "^4.3.6" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@types/node": "^25.5.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "@xo-cash/templates": "0.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + } + }, + "node_modules/@bitauth/libauth": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.0.0.tgz", + "integrity": "sha512-3yoL31XpnhAnf5nDVMFk4xPqebxDwXrgYAwpa31ARJnV5A/eXWlpNYvCd6FTZPFM4VvKfjCBi+jRCrw1hOZ0Jg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@xo-cash/crypto": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@xo-cash/crypto/-/crypto-0.0.2.tgz", + "integrity": "sha512-23rJQ7PwOZW1IaBZTwTVIKpDMa8Rrqjf8tAzqFNBjqRPCQpHB2fMAokezu3x8ql31wJywRHZpZHBGfXLLvglLA==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@xo-cash/primitives": "0.0.2", + "@xo-cash/types": "0.0.3", + "@xo-cash/utils": "0.0.2" + } + }, + "node_modules/@xo-cash/crypto/node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@xo-cash/primitives": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@xo-cash/primitives/-/primitives-0.0.2.tgz", + "integrity": "sha512-lr+3cN0HlomXe/A41ptBRtLCjjDd/4+tSHcKQCk9ElNhCmEDGdVfWoapSllHj5OAlenJ7kXnxEfYrw0ya6uUSw==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8" + } + }, + "node_modules/@xo-cash/primitives/node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@xo-cash/types": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@xo-cash/types/-/types-0.0.3.tgz", + "integrity": "sha512-TgPEgp7C+GqGEyN7a7fpQa9uvUyY75Xir5BDBM/+C29OLdBoL8Rj+P8MWfcALtGxt+REFUdceNgFic26uCihng==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8" + } + }, + "node_modules/@xo-cash/types/node_modules/@bitauth/libauth": { + "version": "3.1.0-next.8", + "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz", + "integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@xo-cash/utils": { + "resolved": "../utils", + "link": true + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c33fc97 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "minimal-sync", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^26.1.1", + "tsx": "^4.23.1", + "typescript": "^7.0.2" + }, + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@xo-cash/crypto": "^0.0.2", + "@xo-cash/primitives": "^0.0.2", + "@xo-cash/utils": "file:../utils", + "zod": "^4.4.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d092649 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,54 @@ +import { PrivateKey } from "@xo-cash/primitives"; + +import { SSEClient } from "./sse/client.js"; +import { WsClient } from './ws/client.js'; + + +const testSSE = async () => { + const privateKey = PrivateKey.fromString('c440ce6ac5b63ae7bc4af9891b80b7a88ccdeebba5e43246885e9ec56f912ac2'); + const client = new SSEClient('https://v2.sync.xo.harvmaster.com', privateKey, { + onMessage: (message) => { + console.log(message); + }, + onError: (error) => { + console.error(error); + }, + }); + + await client.subscribe('test'); + + // await client.connect(); + + await client.write('test', {'message': 'Hello, world! this is the minimal sync client using SSE'}); + + const data = await client.read('test'); + console.log(data); + + // await client.disconnect(); +} + +const testWS = async () => { + const privateKey = PrivateKey.fromString('c440ce6ac5b63ae7bc4af9891b80b7a88ccdeebba5e43246885e9ec56f912ac2'); + const client = new WsClient('wss://v2.sync.xo.harvmaster.com', privateKey, { + onMessage: (message) => { + console.log(message); + }, + onError: (error) => { + console.error(error); + }, + }); + + await client.connect(); + await client.subscribe('test'); + + await client.write('test', {'message': 'Hello, world! this is the minimal sync client using WS'}); + + const data = await client.read('test'); + console.log(data); + + // await client.unsubscribe('test'); + // await client.disconnect(); +} + +// testSSE(); +testWS(); \ No newline at end of file diff --git a/src/sse/client.ts b/src/sse/client.ts new file mode 100644 index 0000000..3956f77 --- /dev/null +++ b/src/sse/client.ts @@ -0,0 +1,243 @@ +import { SSESession, toExtendedJson } from '@xo-cash/utils'; +import { PrivateKey } from '@xo-cash/primitives' +import { binToHex, flattenBinArray, secp256k1, sha256 } from '@bitauth/libauth' +import type { WriteRequest } from '../types.js'; + +export type AuthenticatedRequestHeaders = { + 'X-Public-Key': string; + 'X-Timestamp': string; + 'X-Signature': string; +}; + +export type SSEClientOptions = { + onMessage: (message: string) => void; + onError: (error: Error) => void; +}; + +export class SSEClient { + private sseSession: SSESession; + private readonly subscriptions: Set = new Set(); + private readonly messageListeners = new Set<(message: string) => void>(); + private readonly errorListeners = new Set<(error: Error) => void>(); + + private privateKey: PrivateKey; + private textEncoder = new TextEncoder(); + + private get subscriptionUrl() { + return `${this.url}/data/subscribe`; + } + + constructor(private readonly url: string, privateKey: PrivateKey, options: Partial = {}) { + this.privateKey = privateKey; + this.sseSession = new SSESession(`${this.url}/data/subscribe`, { + method: 'POST', + body: JSON.stringify({ resourceId: [ ...this.subscriptions ] }), + }); + this.messageListeners.add(options.onMessage ?? (() => {})); + this.errorListeners.add(options.onError ?? (() => {})); + } + + async connect() { + await this.sseSession.connect(); + } + + async disconnect() { + await this.sseSession.disconnect(); + } + + async reconnect() { + // Disconnect the current session + await this.sseSession.disconnect(); + this.sseSession.off('message'); + this.sseSession.off('error'); + + // Build the request body + const body = toExtendedJson({ resourceId: [ ...this.subscriptions ] }); + + // Re-create the session with the additional resource ID in the subscription list + this.sseSession = new SSESession(this.subscriptionUrl, { + onRequest: async (request) => { + // Create the request authentication headers + const requestBody = toExtendedJson(request.body); + const authHeaders = SSEClient.authenticateRequest(this.privateKey, requestBody); + + // Initialize the request headers if they don't exist + request.headers ??= {}; + + // Add the authentication headers to the request + request.headers = { + ...request.headers, + ...authHeaders, + } + + // Return the request with the authentication headers + return request; + }, + headers: { + 'Content-Type': 'application/json', + }, + body: body, + method: 'POST', + }); + + // Create a listener to re-emit the messages + this.sseSession.on('message', (message) => { + for (const listener of this.messageListeners) { + listener(message); + } + }); + + // Create a listener to re-emit the errors + this.sseSession.on('error', (error) => { + for (const listener of this.errorListeners) { + listener(error); + } + }); + + // Connect to the server + await this.connect(); + } + + // Send a POST /data/write request to the server + async write(resourceId: string, value: Record) { + const url = `${this.url}/data/write`; + + const valueStr = toExtendedJson(value); + const valueBytes = this.textEncoder.encode(valueStr); + const resource = SSEClient.makeWriteRequest(this.privateKey, resourceId, valueBytes); + + const bodyStr = toExtendedJson({ resources: [resource] }); + + const res = await fetch(url, { + method: 'POST', + + // Headers arent required unless we have payment service running + headers: { + 'Content-Type': 'application/json', + // ...SSEClient.authenticateRequest(this.privateKey, bodyStr), + }, + + body: bodyStr, + }); + + if (!res.ok) { + throw new Error(`Failed to write resource: ${res.statusText}`); + } + + return res.json(); + } + + // Send a POST /data/read request to the server + async read(resourceId: string) { + const url = `${this.url}/data/get`; + const body = toExtendedJson({ resourceId: [ resourceId ] }); + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // ...SSEClient.authenticateRequest(this.privateKey, body), + }, + body, + }); + + if (!res.ok) { + throw new Error(`Failed to read resource: ${res.statusText}`); + } + + return res.json(); + } + + // Destroy the current SSE Session, then re-create it with the additional resource ID in the subscription list + async subscribe(resourceId: string) { + this.subscriptions.add(resourceId); + + await this.reconnect(); + } + + // Destroy the current SSE Session, then re-create it with the additional resource ID in the subscription list + async unsubscribe(resourceId: string) { + this.subscriptions.delete(resourceId); + + await this.reconnect(); + } + + /** + * Derives a resource-scoped private key from the root key and resource id. + */ + private static deriveResourcePrivateKey(privateKey: Uint8Array, resourceId: string): Uint8Array { + const resourceIdBytes = new TextEncoder().encode(resourceId); + return sha256.hash(flattenBinArray([ privateKey, resourceIdBytes ])); + } + + /** + * Signs a canonical payload string and returns the compressed public key and DER signature. + */ + private static signPayload( + privateKey: Uint8Array, + payload: string, + ): { publicKey: string; signature: string } { + const payloadHash = sha256.hash(new TextEncoder().encode(payload)); + + const publicKey = secp256k1.derivePublicKeyCompressed(privateKey); + if (typeof publicKey === 'string') { + throw new Error('Failed to derive public key'); + } + + const signature = secp256k1.signMessageHashDER(privateKey, payloadHash); + if (typeof signature === 'string') { + throw new Error('Failed to sign message'); + } + + return { + publicKey: binToHex(publicKey), + signature: binToHex(signature), + }; + } + + /** + * Builds a signed write resource for POST /data/write. + * + * The signing key is derived per resource via sha256(privateKey + resourceId). + * The signature covers only the resource value, not the full request body. + */ + static makeWriteRequest( + privateKey: PrivateKey, + resourceId: string, + value: Uint8Array, + ): WriteRequest { + const derivedKey = SSEClient.deriveResourcePrivateKey(privateKey.toBytes(), resourceId); + const timestamp = Date.now(); + const payload = `${timestamp}${resourceId}${toExtendedJson(value)}`; + const { publicKey, signature } = SSEClient.signPayload(derivedKey, payload); + + return { + id: resourceId, + value, + publicKey, + timestamp, + signature, + }; + } + + /** + * Signs an entire request body for transport-layer authentication headers. + * + * Unlike {@link makeWriteRequest}, this uses the root private key and signs + * the full serialized body rather than an individual resource value. + */ + static authenticateRequest( + privateKey: PrivateKey, + body: string, + ): AuthenticatedRequestHeaders { + const timestamp = Date.now(); + const payload = `${timestamp}${body}`; + const { publicKey, signature } = SSEClient.signPayload(privateKey.toBytes(), payload); + + return { + 'X-Public-Key': publicKey, + 'X-Timestamp': timestamp.toString(), + 'X-Signature': signature, + }; + } +} diff --git a/src/sse/index.ts b/src/sse/index.ts new file mode 100644 index 0000000..8a23853 --- /dev/null +++ b/src/sse/index.ts @@ -0,0 +1 @@ +export * from './client.js'; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..bf57492 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +export type WriteRequest = { + id: string; + publicKey: string; + timestamp: number; + + /** + * sha256(timestamp + id + toExtendedJson(value)) + */ + signature: string; + value: unknown; +}; diff --git a/src/ws/client.ts b/src/ws/client.ts new file mode 100644 index 0000000..d157bc6 --- /dev/null +++ b/src/ws/client.ts @@ -0,0 +1,257 @@ +import { fromExtendedJson, toExtendedJson } from '@xo-cash/utils'; +import type { PrivateKey } from '@xo-cash/primitives'; + +import { SSEClient } from '../sse/client.js'; +import { WsMessageSchema, WsSuccessResponseSchema, WsErrorResponseSchema, type WsMessage } from './types.js'; + +/** A request sent over the sync server's WebSocket connection. */ +type WsRequest = { + id?: string; + path: string; + body?: unknown; +}; + +type PendingRequest = { + resolve: (body: unknown) => void; + reject: (error: Error) => void; +}; + +export type WsClientOptions = { + /** Called for server-pushed events and uncorrelated server errors. */ + onMessage: (message: WsMessage) => void; + + /** Called for transport errors and malformed server messages. */ + onError: (error: Error) => void; +}; + +/** + * Minimal WebSocket client for the XO sync server. + * + * This class intentionally owns only the basic WebSocket protocol: + * + * - one connection is opened at `/ws`; + * - request IDs correlate concurrent read/write/unsubscribe calls; + * - subscriptions stay on that connection and receive pushed events; and + * - a dropped connection fails pending requests but is not reconnected. + * + * A production client would normally wrap this class with retry, heartbeat, + * resubscription, and request-timeout behavior. Those concerns are omitted + * here to keep the transport demo easy to follow. + */ +export class WsClient { + private socket: WebSocket | undefined; + private readonly pendingRequests = new Map(); + private readonly messageListeners = new Set<(message: WsMessage) => void>(); + private readonly errorListeners = new Set<(error: Error) => void>(); + + constructor( + private readonly url: string, + private readonly privateKey: PrivateKey, + options: Partial = {}, + ) { + this.messageListeners.add(options.onMessage ?? (() => {})); + this.errorListeners.add(options.onError ?? (() => {})); + } + + /** Open the socket and resolve once the WebSocket handshake completes. */ + async connect(): Promise { + if (this.socket?.readyState === WebSocket.OPEN) { + return; + } + + await new Promise((resolve, reject) => { + const socket = new WebSocket(WsClient.httpToWsUrl(this.url)); + this.socket = socket; + + socket.onopen = () => resolve(); + + socket.onmessage = (event) => { + this.handleMessage(String(event.data)); + }; + + socket.onerror = () => { + const error = new Error('WebSocket connection failed'); + this.emitError(error); + reject(error); + }; + + socket.onclose = () => { + if (this.socket === socket) { + this.socket = undefined; + } + + this.rejectPendingRequests(new Error('WebSocket connection closed')); + }; + }); + } + + /** + * Close the socket. + * + * Closing a WebSocket automatically removes all of its server-side topics, + * so disconnect does not need to send an unsubscribe request first. + */ + async disconnect(): Promise { + const socket = this.socket; + this.socket = undefined; + + this.rejectPendingRequests(new Error('WebSocket client disconnected')); + + if (!socket || socket.readyState === WebSocket.CLOSED) { + return; + } + + // Wait for `close` so callers know the underlying connection is gone. + await new Promise((resolve) => { + socket.onclose = () => resolve(); + socket.close(); + }); + } + + /** Read every stored instance of one resource. */ + async read(resourceId: string): Promise { + return this.request('/data/get', { resourceId: [resourceId] }); + } + + /** + * Sign and write one resource, using the same wire format as the SSE client. + */ + async write(resourceId: string, value: Record): Promise { + const valueBytes = new TextEncoder().encode(toExtendedJson(value)); + const resource = SSEClient.makeWriteRequest( + this.privateKey, + resourceId, + valueBytes, + ); + + return this.request('/data/write', { resources: [resource] }); + } + + /** + * Register one resource topic on the current connection. + * + * The subscribe route is intentionally long-running and therefore does not + * send an acknowledgement. This method resolves after the request frame has + * been handed to the socket; future updates arrive through `onMessage`. + */ + async subscribe(resourceId: string): Promise { + this.send({ + path: '/data/subscribe', + body: { resourceId: [resourceId] }, + }); + } + + /** Remove one resource topic without closing the shared connection. */ + async unsubscribe(resourceId: string): Promise { + return this.request('/data/unsubscribe', { resourceId: [resourceId] }); + } + + /** Send a request and wait for the response carrying the same ID. */ + private async request(path: string, body?: unknown): Promise { + const id = crypto.randomUUID(); + + const response = new Promise((resolve, reject) => { + this.pendingRequests.set(id, { resolve, reject }); + }); + + try { + this.send({ id, path, body }); + } catch (error) { + // Avoid leaving a promise in the map when the socket was not open. + this.pendingRequests.delete(id); + throw error; + } + + return response; + } + + /** Encode one complete request envelope using the shared Extended JSON codec. */ + private send(request: WsRequest): void { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not connected'); + } + + this.socket.send(toExtendedJson(request)); + } + + /** Decode and route one frame received from the sync server. */ + private handleMessage(raw: string): void { + const { success, data: message } = WsMessageSchema.safeParse(fromExtendedJson(raw)); + if (!success) { + this.emitError(new Error('Invalid WebSocket message')); + return; + } + + // Only response/error messages with an outstanding ID are RPC replies. + // Application events are left for message listeners, even if they have IDs. + const pending = message.id ? this.pendingRequests.get(message.id) : undefined; + + // If the message has no outstanding ID, it is a subscription event so we will just notify the listeners + if (!pending) { + for (const listener of this.messageListeners) { + listener(message); + } + + return; + } + + // Delete the pending request + this.pendingRequests.delete(message.id!); + + // If the message is an error, reject the pending request + if (message.type === 'error') { + const error = WsErrorResponseSchema.parse(message); + pending.reject(new Error(`${error.error} (${error.statusCode})`)); + return; + } + + // If the message is a response, resolve the pending request + if (message.type === 'response') { + const response = WsSuccessResponseSchema.parse(message); + + // If the response is not successful, reject the pending request + if (response.statusCode < 200 || response.statusCode >= 300) { + pending.reject(new Error(`Request failed (${response.statusCode})`)); + return; + } + + // If the response is successful, resolve the pending request + pending.resolve(response.body); + } + } + + // Helper to emit an error to all error listeners + private emitError(error: Error): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + // Helper for when the client disconnects + private rejectPendingRequests(error: Error): void { + for (const pending of this.pendingRequests.values()) { + pending.reject(error); + } + + this.pendingRequests.clear(); + } + + // Helper to convert an HTTP URL to a WebSocket URL + static httpToWsUrl(httpUrl: string): string { + const url = new URL(httpUrl); + + if (url.protocol === 'http:') { + url.protocol = 'ws:'; + } else if (url.protocol === 'https:') { + url.protocol = 'wss:'; + } else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`Unsupported sync server protocol: ${url.protocol}`); + } + + // The constructor accepts either the server root or the complete `/ws` URL. + const pathname = url.pathname.replace(/\/+$/, ''); + url.pathname = pathname.endsWith('/ws') ? pathname : `${pathname}/ws`; + + return url.toString(); + } +} diff --git a/src/ws/index.ts b/src/ws/index.ts new file mode 100644 index 0000000..37fd896 --- /dev/null +++ b/src/ws/index.ts @@ -0,0 +1 @@ +export * from './client.js'; diff --git a/src/ws/types.ts b/src/ws/types.ts new file mode 100644 index 0000000..7fc9ec5 --- /dev/null +++ b/src/ws/types.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +/** A normal, correlated reply to a WebSocket request. */ +export const WsSuccessResponseSchema = z.object({ + id: z.string().optional(), + type: z.literal('response'), + statusCode: z.number(), + body: z.unknown(), +}); +export type WsSuccessResponse = z.infer; + +/** A public error returned by the server for one request. */ +export const WsErrorResponseSchema = z.object({ + id: z.string().optional(), + type: z.literal('error'), + statusCode: z.number(), + error: z.string(), + details: z.unknown().optional(), +}); +export type WsErrorResponse = z.infer; + +export const WsResponseSchema = z.discriminatedUnion('type', [WsSuccessResponseSchema, WsErrorResponseSchema]); +export type WsResponse = z.infer; + +/** + * A server-pushed application event. + * + * Sync resource changes currently use the type `instance-changed`, but the + * client deliberately leaves `type` open so this demo does not need updating + * whenever the server adds another event. + */ +export const WsEventSchema = z.object({ + id: z.string().optional(), + type: z.string(), + data: z.unknown(), +}); +export type WsEvent = z.infer; + +export const WsMessageSchema = z.union([WsResponseSchema, WsEventSchema]); +export type WsMessage = z.infer; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..742f926 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": ["node"], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}