From af31aac19c1188bf8fc4fc2818d5c2cc35e3c0c3 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 8 Jun 2026 13:28:20 +0000 Subject: [PATCH] Initial Commit --- .gitignore | 16 + package-lock.json | 1243 +++++++++++++++++++++++++++++++ package.json | 32 + src/app.ts | 46 ++ src/routes/index.ts | 1 + src/routes/invitations.ts | 130 ++++ src/routes/types.ts | 113 +++ src/services/http-router.ts | 345 +++++++++ src/services/sse-broadcaster.ts | 274 +++++++ src/services/storage.ts | 149 ++++ src/utils/ext-json.ts | 124 +++ src/utils/invitation-parser.ts | 68 ++ tsconfig.json | 31 + 13 files changed, 2572 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/invitations.ts create mode 100644 src/routes/types.ts create mode 100644 src/services/http-router.ts create mode 100644 src/services/sse-broadcaster.ts create mode 100644 src/services/storage.ts create mode 100644 src/utils/ext-json.ts create mode 100644 src/utils/invitation-parser.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99b3320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/node_modules +/dist +/build +/coverage +/logs +/tmp +/temp +/test +/tests +/test-results +/test-results.xml +/test-results.json +/test-results.xml + +.env +xo-invitations* diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dd121a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1243 @@ +{ + "name": "hono-sync-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hono-sync-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@hono/node-server": "^2.0.4", + "better-sqlite3": "^12.10.0", + "debug": "^4.4.3", + "hono": "^4.12.24", + "msgpackr": "^2.0.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/debug": "^4.1.13", + "@types/node": "^25.9.2", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.4.tgz", + "integrity": "sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "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" + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "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/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "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/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.24", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.24.tgz", + "integrity": "sha512-I36D1s+HgQc55KbhEr4iybfxv/9o1zdpw+XEM6dJa91LqQD0HCoSGdxpRJCZE+aavs87j4V3Ls2OJzq8C/U4iw==", + "license": "MIT", + "engines": { + "node": ">=16.9.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" + }, + "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" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "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/msgpackr": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.2.tgz", + "integrity": "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "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", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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" + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "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", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "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/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "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" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "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..2d4966d --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "hono-sync-server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "start": "tsx src/app.ts", + "dev": "tsx watch src/app.ts", + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "@hono/node-server": "^2.0.4", + "better-sqlite3": "^12.10.0", + "debug": "^4.4.3", + "hono": "^4.12.24", + "msgpackr": "^2.0.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/debug": "^4.1.13", + "@types/node": "^25.9.2", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..fa4ba46 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,46 @@ +import { HTTPService } from './services/http-router.js'; +import { InvitationsRoute } from './routes/invitations.js'; +import { StorageSQLite } from './services/storage.js'; +import { SSEBroadcaster } from './services/sse-broadcaster.js'; + +import type { InvitationSchema } from './utils/invitation-parser.js'; + +export class App { + static async create() { + // TODO: Make this configurable + const invitationStoragePath = "./xo-invitations.db"; + + // Create the invitation store (this is a in-memory store for now) + const storage = await StorageSQLite.createOrOpen(invitationStoragePath); + const invitationStore = await storage.createOrGetStore("invitations"); + + // Create the SSE Broadcaster + const sseBroadcaster = new SSEBroadcaster(); + + // Create the Invitation route, passing in the invitation store and sse broadcaster + const invitationsRoute = new InvitationsRoute(invitationStore, sseBroadcaster); + + // Create the HTTP service, passing in the invitation route + const http = new HTTPService([ + invitationsRoute, + ]); + + // Create the app instance, passing in the HTTP service + return new App(http); + } + + /** + * Create a new instance of App. + * @param http - The HTTP service instance. + */ + constructor(private readonly http: HTTPService) {} + + async start() { + // Start the HTTP service + await this.http.start(); + } +} + +// Create the app instance and start it +const app = await App.create(); +await app.start(); diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..5ebfa5c --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1 @@ +export * from './invitations.js'; \ No newline at end of file diff --git a/src/routes/invitations.ts b/src/routes/invitations.ts new file mode 100644 index 0000000..143a47d --- /dev/null +++ b/src/routes/invitations.ts @@ -0,0 +1,130 @@ +import type { AnyRouteOptions, RouteEventHandlers, RouteRequest, RouteResponse } from './types.js'; + +type InvitationRouteResponse = RouteResponse>; + +import type { SSEBroadcaster } from '../services/sse-broadcaster.js'; +import type { StoreSQLite } from '../services/storage.js'; +import { parseInvitation } from '../utils/invitation-parser.js'; + +import Z from 'zod'; +export class InvitationsRoute { + constructor( + private readonly invitationStore: StoreSQLite>, + private readonly sseBroadcaster: SSEBroadcaster, + ) {} + + async getRoutes(): Promise> { + return [ + { + method: 'GET', + url: '/invitations', + handler: this.getInvitation.bind(this), + }, + { + method: 'POST', + url: '/invitations', + handler: this.updateInvitation.bind(this), + } + ]; + } + + /** + * Get an invitation, and if the text/event-stream header is present, subscribe the client to the SSE stream. + * @param request - The request. + * @param reply - The reply. + * @returns The invitation. + */ + async getInvitation(request: RouteRequest, reply: InvitationRouteResponse): Promise { + // Get the invitation identifier from the query + const { invitationIdentifier } = request.query as { invitationIdentifier?: string }; + + // If the invitation identifier is not provided, return an error. + if (!invitationIdentifier) { + reply.status = 400; + reply.body = { error: 'Invitation Identifier is required' }; + return reply; + } + + // Get the invitation from the store + const storedInvitation = await this.invitationStore.get(invitationIdentifier); + + // If the client is not subscribing to the SSE stream, return the invitation. + if (request.headers['accept'] !== 'text/event-stream') { + reply.body = storedInvitation || {}; + return reply; + } + + // Its an SSE request, so we need to subscribe the client to the SSE stream. + await this.sseBroadcaster.subscribe(request, reply); + + // If the invitation doesn't exist, don't send anything. + if (!storedInvitation) { + reply.status = 204; + reply.body = {}; + return reply; + } + + // Send the invitation to the client as if it was a get request. + this.sseBroadcaster.sendEvent(reply, 'invitation-updated', storedInvitation); + + return reply; + } + + /** + * Update the invitation. + * @param request - The request. + * @param reply - The reply. + * @returns The merged invitation. + */ + async updateInvitation(request: RouteRequest, reply: InvitationRouteResponse): Promise { + // Parse the invitation + const invitation = parseInvitation.parse(request.body); + + // Get the existing invitation + const existingInvitation = await this.invitationStore.get(invitation.invitationIdentifier); + + // Merge the existing invitation with the new invitation + const mergedInvitation = InvitationsRoute.mergeInvitations(invitation, existingInvitation); + + // Store the merged invitation + await this.invitationStore.set(invitation.invitationIdentifier, mergedInvitation); + + // Broadcast the invitation update (We send down the whole invitation. Clients will have to compare commitIds) + await this.sseBroadcaster.broadcast(invitation.invitationIdentifier, 'invitation-updated', invitation); + + reply.status = 200; + reply.body = mergedInvitation; + + // Return the reply. + return reply; + } + + /** + * Merge two invitations by commit identifiers. + * This wont work in an actual commit merging situation since the invitations will be encrypted. + * + * @param invitation1 - The first invitation. + * @param invitation2 - The second invitation. + * @returns The merged invitation. + */ + static mergeInvitations(invitation1: Z.infer, invitation2: Z.infer | undefined): Z.infer { + // Initialize the result with the first invitation. + const result = invitation1; + + // Loop over the commits in the second invitation. + for(const commit of invitation2?.commits ?? []) { + // If the commit already exists in the result, skip it. + if(result.commits.some(c => c.commitIdentifier === commit.commitIdentifier)) { + continue; + } + + // Add the commit to the result. + result.commits.push(commit); + } + + // Return the merged invitation. + return result; + } + + static parseInvitation = parseInvitation +} \ No newline at end of file diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 0000000..8a59193 --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,113 @@ +/** + * Canonical shape for route-level response event handlers. + * + * Router implementations define what they can provide by extending this map. + */ +export type RouteEventHandlers = Record void>; + +/** + * Utility type for a route that does not require any response events. + */ +export type EmptyRouteEventHandlers = Record; + +/** + * Routes that use any {@link RouteResponse} implementation. + */ +export type AnyRouteOptions = RouteOptions>; + +/** + * RouteOptions defines a route configuration. + * + * The generic parameter `RequiredEvents` specifies what events the route handler requires + * from the response object. Routes that don't need any events should use the default (empty object). + * + * @template RequiredEvents - The events this route requires from the response (default: none) + */ +export type RouteOptions = EmptyRouteEventHandlers> = { + url: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'; + handler: (req: RouteRequest, res: RouteResponse) => Promise>; +} + +/** + * Generic HTTP Request class that can be extended for specific HTTP routers, eg. express, fastify, elysia + * + * NOTE: This is likely incomplete. Some special actions may require additional properties or methods. + * TODO: Add to this as we find more properties that are needed for specific actions. + */ +export abstract class RouteRequest { + /** The body of the request */ + abstract body: Record; + + /** The query parameters of the request */ + abstract query: Record; + + /** The path parameters of the request */ + abstract params: Record; + + /** The headers of the request */ + abstract headers: Record; + + /** The cookies of the request */ + abstract cookies: Record; + + /** The raw request object from the HTTP router */ + abstract raw: { + /** Set the timeout for the request */ + setTimeout: (timeout: number) => void; + } +} + +/** + * Generic HTTP Response class that can be extended for specific HTTP routers. + * + * The generic parameter `EventHandlers` defines what events this response provides. + * Implementations should specify all events they support. + * + * NOTE: This is likely incomplete. Some special actions may require additional properties or methods. + * TODO: Add to this as we find more properties that are needed for specific actions. + * + * @template EventHandlers - The events this response implementation provides (default: none) + */ +export abstract class RouteResponse = EmptyRouteEventHandlers> { + /** + * Internal compile-time brand carrying the concrete event map. + * This is intentionally optional and has no runtime effect. + */ + readonly __eventHandlersBrand?: EventHandlers; + + /** The status code of the response */ + abstract status: number; + + /** The body of the response */ + abstract body: Record | unknown; + + /** The headers of the response */ + abstract headers: Record; + + /** The raw response object from the HTTP router */ + abstract raw: { + /** Set a header of the response */ + setHeader: (key: string, value: string) => void; + + /** Write the headers of the response */ + writeHead: (status: number, headers: Record) => void; + + /** Write the body of the response */ + write: (data: string) => void; + + /** Flush the headers of the response */ + flushHeaders: () => void; + + /** End the response */ + end: () => void; + + /** Add an event listener to the response */ + on: (event: K, callback: EventHandlers[K]) => void; + + /** Remove an event listener from the response */ + off: (event: K, callback: EventHandlers[K]) => void; + } + + abstract ignore: boolean; +} diff --git a/src/services/http-router.ts b/src/services/http-router.ts new file mode 100644 index 0000000..52abe2c --- /dev/null +++ b/src/services/http-router.ts @@ -0,0 +1,345 @@ +import debug from "debug"; + +import { type Context, Hono } from "hono"; +import { type HttpBindings, serve } from "@hono/node-server"; +import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status"; +import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; +import { cors } from "hono/cors"; +import { getCookie } from "hono/cookie"; + +import { z } from "zod"; + +import { + decodeExtendedJsonObject, + encodeExtendedJson, +} from "../utils/ext-json.js"; + +import { + type AnyRouteOptions, + type RouteEventHandlers, + type RouteOptions, + RouteRequest, + RouteResponse, +} from "../routes/types.js"; + +/** Context variable key for ExtJSON-decoded request bodies. */ +const PARSED_BODY_KEY = "parsedBody"; + +/** + * Hono environment bindings used by this service. + * Includes Node's raw request/response objects for SSE streaming. + */ +type AppEnv = { + Bindings: HttpBindings; + Variables: { + parsedBody?: Record; + }; +}; + +// Interface to add to our route classes so that we can register them. +// NOTE: I hate this pattern. But ExpressJS is odd in that it is structured as a singleton that still needs registration. +export interface APIRoutes { + getRoutes(): Promise>; +} + +export class HTTPService { + private debug: debug.Debugger; + private server: Hono; + + constructor( + private routes: Array = [], + private port: number = 3000, + private host: string = "0.0.0.0", + ) { + this.debug = debug("xo:http-router"); + + this.server = new Hono(); + } + + async start(): Promise { + this.debug(`Starting on http://${this.host}:${this.port}`); + + // Setup ExtJSON handling. + this.handleExtJSON(); + + // Setup Error Handling (to give more verbose Zod errors) + this.handleErrors(); + + // Allow CORS requests. This allows requests from any origin/domain. + // Capacitor apps (like XO Wallet) use localhost:3000 as the origin, making it difficult to meaningfully restrict requests by origin for security. + this.server.use("*", cors()); + + // Register your routes here before starting the server + this.server.get("/health", async (c) => { + return c.json({ status: "ok" }); + }); + + // Register each route. + for (const routes of this.routes) { + for (const routeOptions of await routes.getRoutes()) { + const method = routeOptions.method.toLowerCase() as Lowercase< + RouteOptions["method"] + >; + const register = this.server[method].bind(this.server) as ( + path: string, + handler: (c: Context) => Promise, + ) => void; + + register(routeOptions.url, async (c) => { + const req = await HonoRequest.fromContext(c); + const res = new HonoResponse(c); + const result = await routeOptions.handler(req, res); + return HonoResponse.finalize(c, result); + }); + } + } + + serve({ + fetch: this.server.fetch, + port: this.port, + hostname: this.host, + }); + + this.debug(`Started on http://${this.host}:${this.port}`); + } + + // Helper method to access the server instance + getInstance(): Hono { + return this.server; + } + + private handleErrors() { + // Customize our error handler to give better errors. + // NOTE: This will nicely format the Zod validation errors. + this.server.onError((error: Error, c) => { + if (error instanceof z.ZodError) { + const formattedErrors = error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); + + this.debug(`Error: ${error}`); + + return c.json( + { + statusCode: 400, + error: "Validation Error", + details: formattedErrors, + }, + 400, + ); + } + + this.debug(`Error: ${error}`); + + // Handle other types of errors + return c.json({ error: "Internal Server Error" }, 500); + }); + } + + private handleExtJSON() { + this.server.use("*", async (c, next) => { + this.debug(`Request URL: ${c.req.method} ${c.req.url}`); + + const contentType = c.req.header("content-type"); + if (contentType?.includes("application/json")) { + try { + const rawBody = await c.req.json(); + this.debug(`Request: ${JSON.stringify(rawBody)}`); + c.set(PARSED_BODY_KEY, decodeExtendedJsonObject(rawBody)); + } catch (error) { + this.debug(`Failed to decode ExtJSON request body: ${error}`); + throw new Error("Invalid JSON in request body"); + } + } + + await next(); + + await encodeJsonResponse(c); + }); + } +} + +/** + * Hono adapter for the generic {@link RouteRequest}. + */ +export class HonoRequest extends RouteRequest { + body: Record; + query: Record; + params: Record; + headers: Record; + cookies: Record; + raw: { + setTimeout: (timeout: number) => void; + }; + + private constructor( + context: Context, + body: Record, + ) { + super(); + + this.body = body; + this.query = { ...context.req.query() }; + this.params = { ...context.req.param() }; + this.headers = { ...context.req.header() }; + this.cookies = { ...getCookie(context) }; + + const incoming = context.env.incoming; + this.raw = { + setTimeout: (timeout: number) => { + incoming.socket.setTimeout(timeout); + }, + }; + } + + /** + * Build a {@link RouteRequest} from the active Hono context. + * Request bodies are parsed once and prefer the ExtJSON-decoded value when present. + */ + static async fromContext(context: Context): Promise { + const parsedBody = context.get(PARSED_BODY_KEY); + if (parsedBody !== undefined) { + return new HonoRequest(context, parsedBody); + } + + const contentType = context.req.header("content-type"); + if (contentType?.includes("application/json")) { + const json = await context.req.json(); + const body = + json !== null && typeof json === "object" + ? (json as Record) + : {}; + return new HonoRequest(context, body); + } + + return new HonoRequest(context, {}); + } +} + +/** + * Hono adapter for the generic {@link RouteResponse}. + * + * When running on Node via `@hono/node-server`, `raw` delegates to the native + * `ServerResponse` so SSE routes can use the same streaming API as Fastify. + */ +export class HonoResponse< + EventHandlers extends Partial = Partial, +> extends RouteResponse { + status = 200; + body: Record | unknown; + headers: Record = {}; + ignore = false; + raw: RouteResponse["raw"]; + + /** Whether the handler already wrote directly to the Node response. */ + rawResponseSent = false; + + constructor(private readonly context: Context) { + super(); + + const outgoing = context.env.outgoing; + this.raw = { + setHeader: (key, value) => { + outgoing.setHeader(key, value); + this.headers[key.toLowerCase()] = value; + }, + writeHead: (status, responseHeaders) => { + this.status = status; + this.rawResponseSent = true; + outgoing.writeHead(status, responseHeaders); + for (const [key, value] of Object.entries(responseHeaders)) { + this.headers[key.toLowerCase()] = value; + } + }, + write: (data) => { + this.rawResponseSent = true; + outgoing.write(data); + }, + flushHeaders: () => { + outgoing.flushHeaders(); + }, + end: () => { + this.rawResponseSent = true; + outgoing.end(); + }, + on: (event, callback) => { + outgoing.on(String(event), callback as (...args: unknown[]) => void); + }, + off: (event, callback) => { + outgoing.off(String(event), callback as (...args: unknown[]) => void); + }, + }; + } + + /** + * Read a response header, matching Fastify's `reply.getHeader` behavior. + */ + getHeader(name: string): string | undefined { + const value = this.context.env.outgoing.getHeader(name); + if (value === undefined) { + return this.headers[name.toLowerCase()]; + } + + return Array.isArray(value) ? value.join(", ") : String(value); + } + + /** + * Convert a route handler result into a value Hono can return. + */ + static finalize( + context: Context, + response: RouteResponse, + ): Response | typeof RESPONSE_ALREADY_SENT { + const honoResponse = response as HonoResponse; + + if (response.ignore || honoResponse.rawResponseSent) { + return RESPONSE_ALREADY_SENT; + } + + const status = response.status as StatusCode; + + if (response.body === undefined) { + return context.body(null, status); + } + + const contentType = + response.headers["content-type"] ?? response.headers["Content-Type"]; + if (contentType?.includes("text/")) { + return context.text(String(response.body), status as ContentfulStatusCode, response.headers); + } + + return new Response(encodeExtendedJson(response.body), { + status, + headers: { + "content-type": "application/json", + ...response.headers, + }, + }); + } +} + +/** + * Encode a JSON response body to Extended JSON. + * Mirrors the Fastify `onSend` hook: parse string payloads first, then encode. + */ +async function encodeJsonResponse(context: Context): Promise { + const responseContentType = context.res.headers.get("content-type"); + if (!responseContentType?.includes("application/json")) { + return; + } + + const cloned = context.res.clone(); + const payload = await cloned.text(); + if (!payload) { + return; + } + + const data = JSON.parse(payload); + const encoded = encodeExtendedJson(data); + + context.res = new Response(encoded, { + status: context.res.status, + headers: context.res.headers, + }); +} diff --git a/src/services/sse-broadcaster.ts b/src/services/sse-broadcaster.ts new file mode 100644 index 0000000..e020fea --- /dev/null +++ b/src/services/sse-broadcaster.ts @@ -0,0 +1,274 @@ +import debug, { type Debugger } from "debug"; +import type { RouteEventHandlers, RouteRequest, RouteResponse } from "../routes/types.js"; + +/** SSE clients use the generic response stream event API. */ +type SSERouteResponse = RouteResponse>; + +import { encodeExtendedJson } from "../utils/ext-json.js"; + +/** + * Represents an event stored in the history buffer. + * Used for replaying missed events to reconnecting clients. + */ +interface HistoricalEvent { + /** The event topic/type (e.g., 'invitation-created', 'invitation-updated') */ + topic: string; + /** The event payload data */ + data: unknown; + /** Unix timestamp in milliseconds when the event was created */ + timestamp: number; +} + +/** + * Options for configuring the SSE service. + */ +interface SSEOptions { + /** Maximum age of events to keep in history (in milliseconds). Default: 5 minutes */ + maxHistoryAge?: number; + /** Maximum number of events to keep per user. Default: 1000 */ + maxHistorySize?: number; +} + +/** + * Server-Sent Events broadcaster with event history support. + * + * Maintains a per-user event history buffer that allows clients to replay + * missed events when reconnecting. This makes reconnections robust against + * network interruptions. + */ +export class SSEBroadcaster { + /** + * Factory method to create and start an SSE broadcaster. + * @param options - Configuration options for the SSE service + * @returns A started SSE instance + */ + static async from(options?: SSEOptions) { + const broadcaster = new SSEBroadcaster(options); + await broadcaster.start(); + return broadcaster; + } + + /** Map of Invitation IDs to their connected SSE response streams */ + private clients: Map> = new Map(); + + /** Map of Invitation IDs to their event history buffers */ + private eventHistory: Map = new Map(); + + /** Maximum age of events to keep in history (in milliseconds) */ + private maxHistoryAge: number; + + /** Maximum number of events to keep per user */ + private maxHistorySize: number; + + private debug: Debugger; + + constructor(options?: SSEOptions) { + this.clients = new Map(); + this.eventHistory = new Map(); + this.maxHistoryAge = options?.maxHistoryAge ?? 20 * 60 * 1000; // 20 minutes default + this.maxHistorySize = options?.maxHistorySize ?? 1000; // 1000 events default + this.debug = debug('xo:sse'); + } + + /** + * Starts the SSE broadcaster. + * @returns The SSE instance for chaining + */ + start() { + this.debug('SSE broadcaster is running (maxHistoryAge: %dms, maxHistorySize: %d)', + this.maxHistoryAge, this.maxHistorySize); + return this; + } + + /** + * Sends an event to a client. + * + * @param client - The client to send the event to + * @param topic - The event topic/type + * @param data - The event payload data + */ + static sendEvent(client: SSERouteResponse, topic: string, data: unknown) { + const timestamp = Date.now(); + client.raw.write(`id: ${timestamp}\n`); + client.raw.write(`event: ${topic}\n`); + client.raw.write(`data: ${encodeExtendedJson(data)}\n\n`); + } + + /** + * Sends an event to a client. + * + * @param client - The client to send the event to + * @param topic - The event topic/type + * @param data - The event payload data + */ + sendEvent(client: SSERouteResponse, topic: string, data: unknown) { + try { + SSEBroadcaster.sendEvent(client, topic, data); + } catch (error) { + this.debug('Error sending event to client', error); + } + } + + /** + * Broadcasts an event to all connected clients for a user and stores it in history. + * + * @param clientId - The user ID to broadcast to + * @param topic - The event topic/type + * @param data - The event payload data + */ + async broadcast(clientId: string, topic: string, data: unknown) { + const timestamp = Date.now(); + + // Store the event in history for potential replay + this.storeEvent(clientId, topic, data, timestamp); + + // Broadcast to all connected clients + this.clients.get(clientId)?.forEach((client: SSERouteResponse) => { + try { + this.sendEvent(client, topic, data); + } catch (error) { + this.debug('Error sending event to client', error); + } + }); + + this.debug('SSE broadcasted message', topic, data); + } + + /** + * Subscribes a client to receive SSE events. + * + * If lastEventTime is provided, all events that occurred after that timestamp + * will be replayed to the client before starting the live stream. + * + * @param req - The authenticated request containing the user ID + * @param res - The Express response object to use for SSE streaming + * @param lastEventTime - Optional timestamp to replay events from (in milliseconds) + */ + async subscribe(req: RouteRequest, res: SSERouteResponse, lastEventTime?: number) { + // Get the invitation ID from the request + const { invitationIdentifier } = req.query as { invitationIdentifier?: string }; + if (!invitationIdentifier) { + throw new Error('Invitation Identifier is required'); + } + + // Initialize client set for this user if needed + if (!this.clients.has(invitationIdentifier)) { + this.clients.set(invitationIdentifier, new Set()); + } + + // Manually include the CORS header since `writeHead` bypasses the auto-injection by the @fastify/cors plugin. + // This statement grabs the CORS header that the CORS plugin would have added to the response, configured in the HTTP service. + const corsHeader = res.headers["access-control-allow-origin"] ?? ""; + + // Disable timeout for the connection. Without this, the connection will drop and the client will have to reconnect. + req.raw.setTimeout(0); + + // Set up SSE headers + // Set headers for SSE + res.raw.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": corsHeader, + }); + + // Force the headers to be dispatched to the client. + // NOTE: This is very important: A `fetch` call will NOT resolve until it has received the headers. + // And Fastify, unless otherwise specified, will not send the headers until it sends the body. + res.raw.flushHeaders(); + + // Set retry interval for automatic reconnection + res.raw.write('retry: 3000\n\n'); + + // Replay missed events if a lastEventTime was provided + if (lastEventTime !== undefined) { + const missedEvents = this.getEventsAfter(invitationIdentifier, lastEventTime); + this.debug('SSE replaying %d missed events for invitation %s (since %d)', + missedEvents.length, invitationIdentifier, lastEventTime); + + for (const event of missedEvents) { + try { + await this.sendEvent(res, event.topic, event.data); + } catch (error) { + this.debug('Error sending event to client', error); + } + } + } + + // Add client to the set for live updates + this.clients.get(invitationIdentifier)?.add(res); + + this.debug('SSE subscribed to client (invitationId: %s, lastEventTime: %s)', + invitationIdentifier, lastEventTime ?? 'none'); + + // Clean up when client disconnects + res.raw.on('close', () => { + this.clients.get(invitationIdentifier)?.delete(res); + this.debug('SSE client disconnected (invitationIdentifier: %s)', invitationIdentifier); + }); + } + + /** + * Stores an event in the user's history buffer. + * Automatically prunes old events based on age and size limits. + * + * @param invitationId - The invitation ID to store the event for + * @param topic - The event topic/type + * @param data - The event payload data + * @param timestamp - The event timestamp + */ + private storeEvent(invitationId: string, topic: string, data: unknown, timestamp: number) { + // Initialize history array for this user if needed + if (!this.eventHistory.has(invitationId)) { + this.eventHistory.set(invitationId, []); + } + + const history = this.eventHistory.get(invitationId)!; + + // Add the new event + history.push({ topic, data, timestamp }); + + // Prune old events + this.pruneHistory(invitationId, timestamp); + } + + /** + * Removes old events from a invitation's history based on age and size limits. + * + * @param invitationId - The invitation ID whose history to prune + * @param currentTime - The current timestamp for age calculations + */ + private pruneHistory(invitationId: string, currentTime: number) { + const history = this.eventHistory.get(invitationId); + if (!history) return; + + const cutoffTime = currentTime - this.maxHistoryAge; + + // Remove events older than maxHistoryAge + const prunedByAge = history.filter(event => event.timestamp > cutoffTime); + + // If still over size limit, remove oldest events + const prunedBySize = prunedByAge.length > this.maxHistorySize + ? prunedByAge.slice(-this.maxHistorySize) + : prunedByAge; + + this.eventHistory.set(invitationId, prunedBySize); + } + + /** + * Retrieves all events for a user that occurred after a given timestamp. + * + * @param invitationId - The invitation ID to get events for + * @param afterTimestamp - The timestamp to get events after (exclusive) + * @returns Array of events that occurred after the timestamp + */ + private getEventsAfter(invitationId: string, afterTimestamp: number): HistoricalEvent[] { + const history = this.eventHistory.get(invitationId); + if (!history) return []; + + // First prune old events to ensure we don't return stale data + this.pruneHistory(invitationId, Date.now()); + + return history.filter(event => event.timestamp > afterTimestamp); + } +} \ No newline at end of file diff --git a/src/services/storage.ts b/src/services/storage.ts new file mode 100644 index 0000000..b9a6c31 --- /dev/null +++ b/src/services/storage.ts @@ -0,0 +1,149 @@ +import sqlite3, { type Database } from 'better-sqlite3'; +import { pack, unpack } from 'msgpackr'; +import { encodeExtendedJsonObject, decodeExtendedJsonObject } from '../utils/ext-json.js'; +import { binToHex, hexToBin } from '@bitauth/libauth'; + +export interface SQLiteOptions { + wal: boolean; +} + +/** + * Storage Adapter that uses SQLite as the storage backend. Everything is stored as a BLOB, to avoid type issues. + * + * @remarks This implementation is single threaded. It WILL block execution of the main thread. + */ +export class StorageSQLite { + /** + * Create or open a SQLite database. + * @param filepath - The path to the SQLite database file. + * @param options - The options for the SQLite database. + * @returns A new instance of StorageSQLite. + */ + static async createOrOpen( + filepath: string, + options: Partial = {}, + ) { + const db = sqlite3(filepath); + + const opts: SQLiteOptions = { + wal: true, + ...options, + }; + + if (opts.wal) { + db.pragma('journal_mode = WAL'); + } + + return new StorageSQLite(db); + } + + constructor(public db: Database) {} + + async listStores() { + const result = this.db + .prepare(`SELECT * FROM sqlite_master WHERE type='table'`) + .all() as { name: string }[]; + return result.map((row) => row.name); + } + + async createStore(storeName: string): Promise> { + // Create table with proper schema for key-value storage + this.db + .prepare( + ` + CREATE TABLE IF NOT EXISTS "${storeName}" ( + key TEXT PRIMARY KEY, + value BLOB + ) + `, + ) + .run(); + + return new StoreSQLite(this.db, storeName); + } + + async getStore(storeName: string): Promise | null> { + // Check if table exists + const tableExists = this.db + .prepare( + ` + SELECT name FROM sqlite_master + WHERE type='table' AND name=? + `, + ) + .get(storeName); + + if (!tableExists) { + return null; + } + + return new StoreSQLite(this.db, storeName); + } + + async createOrGetStore(storeName: string): Promise> { + return await this.createStore(storeName); + } + + async deleteStore(storeName: string) { + this.db.prepare(`DROP TABLE IF EXISTS "${storeName}"`).run(); + } + + async deleteDatabase() { + this.db.close(); + } +} + +export class StoreSQLite { + constructor( + protected db: Database, + protected storeName: string, + ) {} + + async keys() { + const result = this.db + .prepare(`SELECT key FROM "${this.storeName}"`) + .all() as { key: string }[]; + return result.map((row) => row.key); + } + + async get(key: string): Promise { + const result = this.db + .prepare(`SELECT value FROM "${this.storeName}" WHERE key = ?`) + .get(key) as { value: string } | undefined; + + if (!result) { + return undefined; + } + + const binValue = hexToBin(result.value); + + const unpackedValue = unpack(binValue); + + const decodedValue = decodeExtendedJsonObject(unpackedValue); + + return decodedValue as T; + } + + async set(key: string, value: T): Promise { + const encodedValue = encodeExtendedJsonObject(value); + + // Serialize using msgpackr for consistency with other implementations + const packedValue = pack(encodedValue); + + const serializedValue = binToHex(packedValue); + + this.db + .prepare( + `INSERT OR REPLACE INTO "${this.storeName}" (key, value) VALUES (?, ?)`, + ) + .run(key, serializedValue); + } + + async delete(key: string): Promise { + this.db.prepare(`DELETE FROM "${this.storeName}" WHERE key = ?`).run(key); + } + + async clear(): Promise { + this.db.prepare(`DELETE FROM "${this.storeName}"`).run(); + } +} diff --git a/src/utils/ext-json.ts b/src/utils/ext-json.ts new file mode 100644 index 0000000..4bdde89 --- /dev/null +++ b/src/utils/ext-json.ts @@ -0,0 +1,124 @@ +/** + * TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth. + * We are doing this so that we may better standardize with the rest of the BCH eco-system in future. + * See: https://github.com/bitauth/libauth/pull/108 + */ + +import { binToHex, hexToBin } from '@bitauth/libauth'; + +export const extendedJsonReplacer = function (value: any): any { + if (typeof value === 'bigint') { + return ``; + } else if (value instanceof Uint8Array) { + return ``; + } + + return value; +}; + +export const extendedJsonReviver = function (value: any): any { + // Define RegEx that matches our Extended JSON fields. + const bigIntRegex = /^[+-]?[0-9]*)n>$/; + const uint8ArrayRegex = /^[a-f0-9]*)>$/; + + // Only perform a check if the value is a string. + // NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string. + if (typeof value === 'string') { + // Check if this value matches an Extended JSON encoded bigint. + const bigintMatch = value.match(bigIntRegex); + if (bigintMatch) { + // Access the named group directly instead of using array indices + const { bigint } = bigintMatch.groups!; + + // Return the value casted to bigint. + return BigInt(bigint!); + } + + const uint8ArrayMatch = value.match(uint8ArrayRegex); + if (uint8ArrayMatch) { + // Access the named group directly instead of using array indices + const { hex } = uint8ArrayMatch.groups!; + + // Return the value casted to bigint. + return hexToBin(hex!); + } + } + + // Return the original value. + return value; +}; + +export const encodeExtendedJsonObject = function (value: any): any { + // If this is an object type (and it is not null - which is technically an "object")... + // ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object... + if ( + typeof value === 'object' && + value !== null && + !ArrayBuffer.isView(value) + ) { + // If this is an array, recursively call this function on each value. + if (Array.isArray(value)) { + return value.map(encodeExtendedJsonObject); + } + + // Declare object to store extended JSON entries. + const encodedObject: any = {}; + + // Iterate through each entry and encode it to extended JSON. + for (const [key, valueToEncode] of Object.entries(value)) { + encodedObject[key] = encodeExtendedJsonObject(valueToEncode); + } + + // Return the extended JSON encoded object. + return encodedObject; + } + + // Return the replaced value. + return extendedJsonReplacer(value); +}; + +export const decodeExtendedJsonObject = function (value: any): any { + // If this is an object type (and it is not null - which is technically an "object")... + // ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object... + if ( + typeof value === 'object' && + value !== null && + !ArrayBuffer.isView(value) + ) { + // If this is an array, recursively call this function on each value. + if (Array.isArray(value)) { + return value.map(decodeExtendedJsonObject); + } + + // Declare object to store decoded JSON entries. + const decodedObject: any = {}; + + // Iterate through each entry and decode it from extended JSON. + for (const [key, valueToEncode] of Object.entries(value)) { + decodedObject[key] = decodeExtendedJsonObject(valueToEncode); + } + + // Return the extended JSON encoded object. + return decodedObject; + } + + // Return the revived value. + return extendedJsonReviver(value); +}; + +export const encodeExtendedJson = function ( + value: any, + space: number | undefined = undefined, +): string { + const replacedObject = encodeExtendedJsonObject(value); + const stringifiedObject = JSON.stringify(replacedObject, null, space); + + return stringifiedObject; +}; + +export const decodeExtendedJson = function (json: string): any { + const parsedObject = JSON.parse(json); + const revivedObject = decodeExtendedJsonObject(parsedObject); + + return revivedObject; +}; diff --git a/src/utils/invitation-parser.ts b/src/utils/invitation-parser.ts new file mode 100644 index 0000000..6d2f1ff --- /dev/null +++ b/src/utils/invitation-parser.ts @@ -0,0 +1,68 @@ +import Z, { z } from 'zod'; + +/** + * Zod schemas for invitation validation. + * + * IMPORTANT: We use .passthrough() on all object schemas to preserve fields + * that aren't explicitly defined. This is critical because: + * 1. Invitations are signed based on stringify(commit.data) + * 2. If we strip fields, the signature verification will fail + * 3. The actual XOInvitation types have many more fields than we validate here + */ + +const variableSchema = z.object({ + variableIdentifier: z.string(), + roleIdentifier: z.string().optional(), + value: z.number().or(z.string()).or(z.boolean()).or(z.bigint()), +}).passthrough(); + +const mergesWithSchema = z.object({ + commitIdentifier: z.string(), + index: z.number(), +}).passthrough(); + +const inputSchema = z.object({ + inputIdentifier: z.string().optional(), + transactionIndex: z.number().optional(), + roleIdentifier: z.string().optional(), + mergesWith: mergesWithSchema.optional(), + // Additional fields preserved via passthrough: + // outpointTransactionHash, outpointIndex, sequenceNumber, unlockingBytecode, etc. +}).passthrough(); + +const outputSchema = z.object({ + outputIdentifier: z.string().optional(), + roleIdentifier: z.string().optional(), + secretIdentifier: z.string().optional(), + transactionIndex: z.number().optional(), + mergesWith: mergesWithSchema.optional(), + // Additional fields preserved via passthrough: + // valueSatoshis, lockingBytecode, token, etc. +}).passthrough(); + +const dataSchema = z.object({ + transactionVersion: z.number().optional(), + transactionLocktime: z.number().optional(), + variables: z.array(variableSchema).optional(), + inputs: z.array(inputSchema).optional(), + outputs: z.array(outputSchema).optional(), +}).passthrough(); + +const commitSchema = z.object({ + commitIdentifier: z.string(), + previousCommitIdentifier: z.string().or(z.undefined()), + entityIdentifier: z.string(), + data: dataSchema, + signature: z.string(), + expiresAtTimestamp: z.number(), +}).passthrough(); + +export const parseInvitation = z.object({ + invitationIdentifier: z.string(), + commits: z.array(commitSchema), + createdAtTimestamp: z.number(), + templateIdentifier: z.string(), + actionIdentifier: z.string(), +}).passthrough(); + +export type InvitationSchema = Z.infer; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fce47df --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + // 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, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}