From 4f33bd0ec8e8d91cd82108ff15e4acdbd8839b52 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 31 Oct 2024 16:48:19 -0500 Subject: [PATCH 1/2] feat: adding self-hosted servers (#952) This adds support for adding self-hosted servers. It adds the following functions: - `project.$member.addServerPeer()` - `project.$sync.connectServers()` - `project.$sync.disconnectServers()` This change doesn't include end-to-end tests for the server. That's deliberate! We can't (easily) add those tests without the server being released, but we can't (easily) release the server without this change. Once this change is released, we can release the server, and then we should be able to add end-to-end tests. See [#886](https://github.com/digidem/comapeo-core/pull/886) for more. Co-Authored-By: Gregor MacLennan --- package-lock.json | 521 +++++++++-------------------- package.json | 2 + src/index.js | 10 + src/lib/error.js | 47 +++ src/lib/get-own.js | 10 + src/lib/is-hostname-ip-address.js | 26 ++ src/lib/ws-core-replicator.js | 47 +++ src/mapeo-manager.js | 2 +- src/mapeo-project.js | 63 +++- src/member-api.js | 249 +++++++++++++- src/sync/peer-sync-controller.js | 1 + src/sync/sync-api.js | 147 +++++++- test-e2e/server.js | 205 ++++++++++++ test/lib/error.js | 66 ++++ test/lib/get-own.js | 23 ++ test/lib/is-hostname-ip-address.js | 29 ++ 16 files changed, 1081 insertions(+), 367 deletions(-) create mode 100644 src/lib/error.js create mode 100644 src/lib/get-own.js create mode 100644 src/lib/is-hostname-ip-address.js create mode 100644 src/lib/ws-core-replicator.js create mode 100644 test-e2e/server.js create mode 100644 test/lib/error.js create mode 100644 test/lib/get-own.js create mode 100644 test/lib/is-hostname-ip-address.js diff --git a/package-lock.json b/package-lock.json index 96dea9fc5..37ef0fc3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "type-fest": "^4.5.0", "undici": "^6.13.0", "varint": "^6.0.0", + "ws": "^8.18.0", "yauzl-promise": "^4.0.0" }, "devDependencies": { @@ -77,6 +78,7 @@ "@types/sub-encoder": "^2.1.0", "@types/throttle-debounce": "^5.0.0", "@types/varint": "^6.0.1", + "@types/ws": "^8.5.12", "@types/yauzl-promise": "^4.0.0", "@types/yazl": "^2.4.5", "bitfield": "^4.2.0", @@ -354,34 +356,6 @@ "superjson": "^2.2.1" } }, - "node_modules/@emnapi/core": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.2.0.tgz", - "integrity": "sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", - "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", - "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild-kit/core-utils": { "version": "3.1.0", "dev": true, @@ -547,33 +521,6 @@ "node": ">=14" } }, - "node_modules/@fastify/ajv-compiler": { - "version": "3.5.0", - "license": "MIT", - "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.12.0", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/@fastify/deepmerge": { "version": "1.3.0", "license": "MIT" @@ -583,13 +530,6 @@ "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==" }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^5.7.0" - } - }, "node_modules/@fastify/send": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", @@ -914,17 +854,6 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", - "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.1.0", - "@emnapi/runtime": "^1.1.0", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@node-rs/crc32": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.3.tgz", @@ -953,126 +882,6 @@ "@node-rs/crc32-win32-x64-msvc": "1.10.3" } }, - "node_modules/@node-rs/crc32-android-arm-eabi": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.3.tgz", - "integrity": "sha512-V9iNJd5ux9I415qOldmxZIHrazYMJNsQ6v+Kq/t9FTQyYqiEeHvRc1FzBh9MT6Uc24InwMhBeC1WVw0BL4VaxQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-android-arm64": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.3.tgz", - "integrity": "sha512-d6xLAhbk5FDGpltAKTFs7hZO/PWpHeihZ/ZCKx2LEVz8jXQEshpo2/ojnfb5FAw6oNzU2H+S/RI5GeCr7paa1Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-darwin-arm64": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.3.tgz", - "integrity": "sha512-IoX6HC4dlKc9BONe7632DADBtiHUiIVD7Bibuj3bGrvOBllN8hvBL9+dDC+/iDdOeuiBKgb0hgL5h2nPIybpzA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-darwin-x64": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.3.tgz", - "integrity": "sha512-JUDGAX/0W4A9ok9p6yuy4fAsBDrq8Db0sUjKLMZ/+P3NHB+Qk+OsZUsEDxP3yhBJxhPq97JpN4bBzgMnkDajpw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-freebsd-x64": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.3.tgz", - "integrity": "sha512-mbpVcrF9cRJm9ksv2vVaWc/yRsLJErdb90Kusc6I8CgsBxpS6/wI637i0khSl1l10iWrALXjfh6osihixANYhQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.3.tgz", - "integrity": "sha512-9MZohdtKzdnb16xRKU76t1UTEJu80dFO8f2/N0geJYNobnT1E6p/+5pqB/G1/H6OnPvjqMuFuLVL4BJVvO4GYQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-linux-arm64-gnu": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.3.tgz", - "integrity": "sha512-t1+9ik4awZF+luQp94HsUH8M1lSw8jWjvQiLaHyxMzrM0NY0/oIkhjqdOswXL11Wybkc63eunNwVqGKWfJEi4Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-linux-arm64-musl": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.3.tgz", - "integrity": "sha512-fsxOk9CpFzyon+vktvCICwhGk0b+tnfEZfPOXa3QDrkyZD7R7cHmpEHGim1BYgJZIJSTBfal5eM11hzBGjJbxw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/crc32-linux-x64-gnu": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.3.tgz", @@ -1103,66 +912,6 @@ "node": ">= 10" } }, - "node_modules/@node-rs/crc32-wasm32-wasi": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.3.tgz", - "integrity": "sha512-oT2V4r0lGZqZHkFLHeXu5Z8C8SutIvBVV0Ws3unz4/KhwmlMcOZYRmSelUSSILbjNLrg4FihCe20tC1VbmaNxA==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/crc32-win32-arm64-msvc": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.3.tgz", - "integrity": "sha512-IwP/TjDoQycv3ZCbAHV3qS9oH8pmBo7h9RC0chOvKY0g9+RxRl0nXhxcAcmZvJugKdJd+eCOR9fJrWzcwQOgFg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-win32-ia32-msvc": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.3.tgz", - "integrity": "sha512-YK0qYTHUFqriqAkHyXfe3IpDFfpG5fc2yuNl7MXn4ejklLLyNQPOCSawvPU7ouOBgtQDaAH60yZhFhsXZfwSfQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/crc32-win32-x64-msvc": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.3.tgz", - "integrity": "sha512-VI9jd8ECiij4YADsfzVuDnhk/UZ5op4RYHyN40yZzwhzcOQ8DDluOeHv91FPHSyMYJEsVsqbr3cqtD6R47xYjw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1308,7 +1057,8 @@ }, "node_modules/@sinclair/typebox": { "version": "0.29.6", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.29.6.tgz", + "integrity": "sha512-aX5IFYWlMa7tQ8xZr3b2gtVReCvg7f3LEhjir/JAjX2bJCMVJA5tIPv30wTD4KDfcwMd7DDYY3hFDeGmOgtrZQ==" }, "node_modules/@sinonjs/commons": { "version": "2.0.0", @@ -1364,15 +1114,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/b4a": { "version": "1.6.0", "license": "MIT", @@ -1514,6 +1255,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/yauzl-promise/-/yauzl-promise-4.0.0.tgz", @@ -2559,6 +2309,14 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/codecs": { "version": "3.1.0", "license": "MIT", @@ -3093,14 +2851,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3889,29 +3639,6 @@ "node": ">=0.8.x" } }, - "node_modules/execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^3.0.7", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": "^14.18.0 || ^16.14.0 || >=18.0.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/expand-template": { "version": "2.0.3", "license": "(MIT OR WTFPL)", @@ -4089,6 +3816,49 @@ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==" }, + "node_modules/fastify/node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/fastify/node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/fastify/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fastify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", + "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==" + }, + "node_modules/fastify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/fastify/node_modules/process-warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", @@ -4362,18 +4132,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.0.0", "dev": true, @@ -4724,15 +4482,6 @@ "node": ">= 0.8" } }, - "node_modules/human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", - "dev": true, - "engines": { - "node": ">=14.18.0" - } - }, "node_modules/husky": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", @@ -5676,6 +5425,77 @@ "node": ">=16" } }, + "node_modules/lint-staged/node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lint-staged/node_modules/pidtree": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", @@ -5688,6 +5508,18 @@ "node": ">=0.10" } }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lint-staged/node_modules/yaml": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", @@ -6727,33 +6559,6 @@ "which": "bin/which" } }, - "node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/object-inspect": { "version": "1.12.3", "dev": true, @@ -8879,18 +8684,6 @@ "node": ">=4" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "4.0.0", "dev": true, @@ -9753,6 +9546,26 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xache": { "version": "1.1.0", "license": "MIT" diff --git a/package.json b/package.json index af6a5b827..fee337a09 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "@types/sub-encoder": "^2.1.0", "@types/throttle-debounce": "^5.0.0", "@types/varint": "^6.0.1", + "@types/ws": "^8.5.12", "@types/yauzl-promise": "^4.0.0", "@types/yazl": "^2.4.5", "bitfield": "^4.2.0", @@ -200,6 +201,7 @@ "type-fest": "^4.5.0", "undici": "^6.13.0", "varint": "^6.0.0", + "ws": "^8.18.0", "yauzl-promise": "^4.0.0" } } diff --git a/src/index.js b/src/index.js index 795ab8471..5460e26f1 100644 --- a/src/index.js +++ b/src/index.js @@ -3,9 +3,19 @@ import { COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, } from './roles.js' +import { kProjectReplicate } from './mapeo-project.js' export { plugin as CoMapeoMapsFastifyPlugin } from './fastify-plugins/maps.js' export { FastifyController } from './fastify-controller.js' export { MapeoManager } from './mapeo-manager.js' +/** @import { MapeoProject } from './mapeo-project.js' */ + +/** + * @param {MapeoProject} project + * @param {Parameters} args + * @returns {ReturnType} + */ +export const replicateProject = (project, ...args) => + project[kProjectReplicate](...args) export const roles = /** @type {const} */ ({ CREATOR_ROLE_ID, diff --git a/src/lib/error.js b/src/lib/error.js new file mode 100644 index 000000000..41cbe5544 --- /dev/null +++ b/src/lib/error.js @@ -0,0 +1,47 @@ +/** + * Create an `Error` with a `code` property. + * + * @example + * const err = new ErrorWithCode('INVALID_DATA', 'data was invalid') + * err.message + * // => 'data was invalid' + * err.code + * // => 'INVALID_DATA' + */ +export class ErrorWithCode extends Error { + /** + * @param {string} code + * @param {string} message + * @param {object} [options] + * @param {unknown} [options.cause] + */ + constructor(code, message, options) { + super(message, options) + /** @readonly */ this.code = code + } +} + +/** + * Get the error message from an object if possible. + * Otherwise, stringify the argument. + * + * @param {unknown} maybeError + * @returns {string} + * @example + * try { + * // do something + * } catch (err) { + * console.error(getErrorMessage(err)) + * } + */ +export function getErrorMessage(maybeError) { + if (maybeError && typeof maybeError === 'object' && 'message' in maybeError) { + try { + const { message } = maybeError + if (typeof message === 'string') return message + } catch (_err) { + // Ignored + } + } + return 'unknown error' +} diff --git a/src/lib/get-own.js b/src/lib/get-own.js new file mode 100644 index 000000000..02c07f04b --- /dev/null +++ b/src/lib/get-own.js @@ -0,0 +1,10 @@ +/** + * @template {object} T + * @template {keyof T} K + * @param {T} obj + * @param {K} key + * @returns {undefined | T[K]} + */ +export function getOwn(obj, key) { + return Object.hasOwn(obj, key) ? obj[key] : undefined +} diff --git a/src/lib/is-hostname-ip-address.js b/src/lib/is-hostname-ip-address.js new file mode 100644 index 000000000..3e9acf86b --- /dev/null +++ b/src/lib/is-hostname-ip-address.js @@ -0,0 +1,26 @@ +import { isIPv4, isIPv6 } from 'node:net' + +/** + * Is this hostname an IP address? + * + * @param {string} hostname + * @returns {boolean} + * @example + * isHostnameIpAddress('100.64.0.42') + * // => false + * + * isHostnameIpAddress('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]') + * // => true + * + * isHostnameIpAddress('example.com') + * // => false + */ +export function isHostnameIpAddress(hostname) { + if (isIPv4(hostname)) return true + + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return isIPv6(hostname.slice(1, -1)) + } + + return false +} diff --git a/src/lib/ws-core-replicator.js b/src/lib/ws-core-replicator.js new file mode 100644 index 000000000..2ca1f8909 --- /dev/null +++ b/src/lib/ws-core-replicator.js @@ -0,0 +1,47 @@ +import { pipeline } from 'node:stream/promises' +import { Transform } from 'node:stream' +import { createWebSocketStream } from 'ws' +/** @import { WebSocket } from 'ws' */ +/** @import { ReplicationStream } from '../types.js' */ + +/** + * @param {WebSocket} ws + * @param {ReplicationStream} replicationStream + * @returns {Promise} + */ +export function wsCoreReplicator(ws, replicationStream) { + // This is purely to satisfy typescript at its worst. `pipeline` expects a + // NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex + // stream. The difference is that streamx does not implement the + // `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline` + // function does not depend on any of these methods (I have read through the + // NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely + // cast the stream to a NodeJS ReadWriteStream. + const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ ( + /** @type {unknown} */ (replicationStream) + ) + return pipeline( + _replicationStream, + wsSafetyTransform(ws), + createWebSocketStream(ws), + _replicationStream + ) +} + +/** + * Avoid writing data to a closing or closed websocket, which would result in an + * error. Instead we drop the data and wait for the stream close/end events to + * propagate and close the streams cleanly. + * + * @param {WebSocket} ws + */ +function wsSafetyTransform(ws) { + return new Transform({ + transform(chunk, encoding, callback) { + if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + return callback() + } + callback(null, chunk) + }, + }) +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 94e451f7e..71fb26843 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -556,7 +556,7 @@ export class MapeoManager extends TypedEmitter { * downloaded their proof of project membership and the project config. * * @param {Pick & { projectName: string }} projectJoinDetails - * @param {{ waitForSync?: boolean }} [opts] For internal use in tests, set opts.waitForSync = false to not wait for sync during addProject() + * @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject() * @returns {Promise} */ addProject = async ( diff --git a/src/mapeo-project.js b/src/mapeo-project.js index dadf7611c..118cbd34e 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -47,7 +47,11 @@ import { import { migrate } from './lib/drizzle-helpers.js' import { omit } from './lib/omit.js' import { MemberApi } from './member-api.js' -import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js' +import { + SyncApi, + kHandleDiscoveryKey, + kWaitForInitialSyncWithPeer, +} from './sync/sync-api.js' import { Logger } from './logger.js' import { IconApi } from './icon-api.js' import { readConfig } from './config-import.js' @@ -77,8 +81,9 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({}) * @extends {TypedEmitter<{ close: () => void }>} */ export class MapeoProject extends TypedEmitter { - #projectId + #projectKey #deviceId + #identityKeypair #coreManager #indexWriter #dataStores @@ -135,10 +140,12 @@ export class MapeoProject extends TypedEmitter { this.#l = Logger.create('project', logger) this.#deviceId = getDeviceId(keyManager) - this.#projectId = projectKeyToId(projectKey) + this.#projectKey = projectKey this.#loadingConfig = false this.#isArchiveDevice = isArchiveDevice + const getReplicationStream = this[kProjectReplicate].bind(this, true) + ///////// 1. Setup database this.#sqlite = new Database(dbPath) @@ -317,7 +324,7 @@ export class MapeoProject extends TypedEmitter { }, }), } - const identityKeypair = keyManager.getIdentityKeypair() + this.#identityKeypair = keyManager.getIdentityKeypair() const coreKeypairs = getCoreKeypairs({ projectKey, projectSecretKey, @@ -326,14 +333,14 @@ export class MapeoProject extends TypedEmitter { this.#coreOwnership = new CoreOwnership({ dataType: this.#dataTypes.coreOwnership, coreKeypairs, - identityKeypair, + identityKeypair: this.#identityKeypair, }) this.#roles = new Roles({ dataType: this.#dataTypes.role, coreOwnership: this.#coreOwnership, coreManager: this.#coreManager, projectKey: projectKey, - deviceKey: keyManager.getIdentityKeypair().publicKey, + deviceKey: this.#identityKeypair.publicKey, }) this.#memberApi = new MemberApi({ @@ -341,16 +348,18 @@ export class MapeoProject extends TypedEmitter { roles: this.#roles, coreOwnership: this.#coreOwnership, encryptionKeys, + getProjectName: this.#getProjectName.bind(this), projectKey, rpc: localPeers, + getReplicationStream, + waitForInitialSyncWithPeer: (deviceId, abortSignal) => + this.$sync[kWaitForInitialSyncWithPeer](deviceId, abortSignal), dataTypes: { deviceInfo: this.#dataTypes.deviceInfo, project: this.#dataTypes.projectSettings, }, }) - const projectPublicId = projectKeyToPublicId(projectKey) - this.#blobStore = new BlobStore({ coreManager: this.#coreManager, }) @@ -362,7 +371,7 @@ export class MapeoProject extends TypedEmitter { if (!base.endsWith('/')) { base += '/' } - return base + projectPublicId + return base + this.#projectPublicId }, }) @@ -374,7 +383,7 @@ export class MapeoProject extends TypedEmitter { if (!base.endsWith('/')) { base += '/' } - return base + projectPublicId + return base + this.#projectPublicId }, }) @@ -384,6 +393,24 @@ export class MapeoProject extends TypedEmitter { roles: this.#roles, blobDownloadFilter: null, logger: this.#l, + getServerWebsocketUrls: async () => { + const members = await this.#memberApi.getMany() + /** @type {string[]} */ + const serverWebsocketUrls = [] + for (const member of members) { + if ( + member.deviceType === 'selfHostedServer' && + member.selfHostedServerDetails + ) { + const { baseUrl } = member.selfHostedServerDetails + const wsUrl = new URL(`/sync/${this.#projectPublicId}`, baseUrl) + wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:' + serverWebsocketUrls.push(wsUrl.href) + } + } + return serverWebsocketUrls + }, + getReplicationStream, }) this.#translationApi = new TranslationApi({ @@ -458,6 +485,14 @@ export class MapeoProject extends TypedEmitter { return this.#deviceId } + get #projectId() { + return projectKeyToId(this.#projectKey) + } + + get #projectPublicId() { + return projectKeyToPublicId(this.#projectKey) + } + /** * Resolves when hypercores have all loaded * @@ -603,6 +638,13 @@ export class MapeoProject extends TypedEmitter { } } + /** + * @returns {Promise} + */ + async #getProjectName() { + return (await this.$getProjectSettings()).name + } + async $getOwnRole() { return this.#roles.getRole(this.#deviceId) } @@ -640,6 +682,7 @@ export class MapeoProject extends TypedEmitter { * Hypercore types need updating. * @type {any} */ ({ + keyPair: this.#identityKeypair, /** @param {Buffer} discoveryKey */ ondiscoverykey: async (discoveryKey) => { const protomux = diff --git a/src/member-api.js b/src/member-api.js index 3af048a27..92157ab5b 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -1,4 +1,6 @@ +import * as b4a from 'b4a' import * as crypto from 'node:crypto' +import WebSocket from 'ws' import { TypedEmitter } from 'tiny-typed-emitter' import { pEvent } from 'p-event' import { InviteResponse_Decision } from './generated/rpc.js' @@ -8,11 +10,15 @@ import { ExhaustivenessError, projectKeyToId, projectKeyToProjectInviteId, + projectKeyToPublicId, } from './utils.js' import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from 'string-timing-safe-equal' -import { ROLES, isRoleIdForNewInvite } from './roles.js' +import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' +import { ErrorWithCode, getErrorMessage } from './lib/error.js' +import { wsCoreReplicator } from './lib/ws-core-replicator.js' +import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' /** * @import { * DeviceInfo, @@ -21,11 +27,13 @@ import { ROLES, isRoleIdForNewInvite } from './roles.js' * ProjectSettingsValue * } from '@comapeo/schema' */ +/** @import { Promisable } from 'type-fest' */ /** @import { Invite, InviteResponse } from './generated/rpc.js' */ /** @import { DataType } from './datatype/index.js' */ /** @import { DataStore } from './datastore/index.js' */ /** @import { deviceInfoTable } from './schema/project.js' */ /** @import { projectSettingsTable } from './schema/client.js' */ +/** @import { ReplicationStream } from './types.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ @@ -45,8 +53,11 @@ export class MemberApi extends TypedEmitter { #roles #coreOwnership #encryptionKeys + #getProjectName #projectKey #rpc + #getReplicationStream + #waitForInitialSyncWithPeer #dataTypes /** @type {Map} */ @@ -58,8 +69,11 @@ export class MemberApi extends TypedEmitter { * @param {import('./roles.js').Roles} opts.roles * @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys + * @param {() => Promisable} opts.getProjectName * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc + * @param {() => ReplicationStream} opts.getReplicationStream + * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer * @param {Object} opts.dataTypes * @param {Pick} opts.dataTypes.deviceInfo * @param {Pick} opts.dataTypes.project @@ -69,8 +83,11 @@ export class MemberApi extends TypedEmitter { roles, coreOwnership, encryptionKeys, + getProjectName, projectKey, rpc, + getReplicationStream, + waitForInitialSyncWithPeer, dataTypes, }) { super() @@ -78,8 +95,11 @@ export class MemberApi extends TypedEmitter { this.#roles = roles this.#coreOwnership = coreOwnership this.#encryptionKeys = encryptionKeys + this.#getProjectName = getProjectName this.#projectKey = projectKey this.#rpc = rpc + this.#getReplicationStream = getReplicationStream + this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer this.#dataTypes = dataTypes } @@ -247,6 +267,181 @@ export class MemberApi extends TypedEmitter { this.#outboundInvitesByDevice.get(deviceId)?.abortController.abort() } + /** + * Add a server peer. + * + * Can reject with any of the following error codes (accessed via `err.code`): + * + * - `INVALID_URL`: the base URL is invalid, likely due to user error. + * - `MISSING_DATA`: some required data is missing in order to add the server + * peer. For example, the project must have a name. + * - `NETWORK_ERROR`: there was an issue connecting to the server. Is the + * device online? Is the server online? + * - `INVALID_SERVER_RESPONSE`: we connected to the server but it returned + * an unexpected response. Is the server running a compatible version of + * CoMapeo Cloud? + * + * If `err.code` is not specified, that indicates a bug in this module. + * + * @param {string} baseUrl + * @param {object} [options] + * @param {boolean} [options.dangerouslyAllowInsecureConnections] Allow insecure network connections. Should only be used in tests. + * @returns {Promise} + */ + async addServerPeer( + baseUrl, + { dangerouslyAllowInsecureConnections = false } = {} + ) { + if ( + !isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections }) + ) { + throw new ErrorWithCode('INVALID_URL', 'Server base URL is invalid') + } + + const { serverDeviceId } = await this.#addServerToProject(baseUrl) + + await this.#roles.assignRole(serverDeviceId, MEMBER_ROLE_ID) + + await this.#waitForInitialSyncWithServer({ + baseUrl, + serverDeviceId, + dangerouslyAllowInsecureConnections, + }) + } + + /** + * @param {string} baseUrl Server base URL. Should already be validated. + * @returns {Promise<{ serverDeviceId: string }>} + */ + async #addServerToProject(baseUrl) { + const projectName = await this.#getProjectName() + if (!projectName) { + throw new ErrorWithCode( + 'MISSING_DATA', + 'Project must have name to add server peer' + ) + } + + const requestUrl = new URL('projects', baseUrl) + const requestBody = { + projectName, + projectKey: encodeBufferForServer(this.#projectKey), + encryptionKeys: { + auth: encodeBufferForServer(this.#encryptionKeys.auth), + data: encodeBufferForServer(this.#encryptionKeys.data), + config: encodeBufferForServer(this.#encryptionKeys.config), + blobIndex: encodeBufferForServer(this.#encryptionKeys.blobIndex), + blob: encodeBufferForServer(this.#encryptionKeys.blob), + }, + } + + /** @type {Response} */ let response + try { + response = await fetch(requestUrl, { + method: 'PUT', + body: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json' }, + }) + } catch (err) { + throw new ErrorWithCode( + 'NETWORK_ERROR', + `Failed to add server peer due to network error: ${getErrorMessage( + err + )}` + ) + } + + if (response.status !== 200 && response.status !== 201) { + throw new ErrorWithCode( + 'INVALID_SERVER_RESPONSE', + `Failed to add server peer due to HTTP status code ${response.status}` + ) + } + + try { + const responseBody = await response.json() + assert( + responseBody && + typeof responseBody === 'object' && + 'data' in responseBody && + responseBody.data && + typeof responseBody.data === 'object' && + 'deviceId' in responseBody.data && + typeof responseBody.data.deviceId === 'string', + 'Response body is valid' + ) + return { serverDeviceId: responseBody.data.deviceId } + } catch (err) { + throw new ErrorWithCode( + 'INVALID_SERVER_RESPONSE', + "Failed to add server peer because we couldn't parse the response" + ) + } + } + + /** + * @param {object} options + * @param {string} options.baseUrl + * @param {string} options.serverDeviceId + * @param {boolean} options.dangerouslyAllowInsecureConnections + * @returns {Promise} + */ + async #waitForInitialSyncWithServer({ + baseUrl, + serverDeviceId, + dangerouslyAllowInsecureConnections, + }) { + const projectPublicId = projectKeyToPublicId(this.#projectKey) + const websocketUrl = new URL('sync/' + projectPublicId, baseUrl) + websocketUrl.protocol = + dangerouslyAllowInsecureConnections && websocketUrl.protocol === 'http:' + ? 'ws:' + : 'wss:' + + const websocket = new WebSocket(websocketUrl) + + try { + await pEvent(websocket, 'open', { rejectionEvents: ['error'] }) + } catch (rejectionEvent) { + throw new ErrorWithCode( + // It's difficult for us to reliably disambiguate between "network error" + // and "invalid response from server" here, so we just say it was an + // invalid server response. + 'INVALID_SERVER_RESPONSE', + 'Failed to open the socket', + rejectionEvent && + typeof rejectionEvent === 'object' && + 'error' in rejectionEvent + ? { cause: rejectionEvent.error } + : { cause: rejectionEvent } + ) + } + + const onErrorPromise = pEvent(websocket, 'error') + + const replicationStream = this.#getReplicationStream() + wsCoreReplicator(websocket, replicationStream) + + const syncAbortController = new AbortController() + const syncPromise = this.#waitForInitialSyncWithPeer( + serverDeviceId, + syncAbortController.signal + ) + + const errorEvent = await Promise.race([onErrorPromise, syncPromise]) + + if (errorEvent) { + syncAbortController.abort() + websocket.close() + throw errorEvent.error + } else { + const onClosePromise = pEvent(websocket, 'close') + onErrorPromise.cancel() + websocket.close() + await onClosePromise + } + } + /** * @param {string} deviceId * @returns {Promise} @@ -328,3 +523,55 @@ export class MemberApi extends TypedEmitter { return this.#roles.assignRole(deviceId, roleId) } } + +/** + * @param {string} baseUrl + * @param {object} options + * @param {boolean} options.dangerouslyAllowInsecureConnections + * @returns {boolean} + */ +function isValidServerBaseUrl( + baseUrl, + { dangerouslyAllowInsecureConnections } +) { + if (baseUrl.length > 2000) return false + + /** @type {URL} */ let url + try { + url = new URL(baseUrl) + } catch (_err) { + return false + } + + const isProtocolValid = + url.protocol === 'https:' || + (dangerouslyAllowInsecureConnections && url.protocol === 'http:') + if (!isProtocolValid) return false + + if (url.username) return false + if (url.password) return false + if (url.search) return false + if (url.hash) return false + + // We may want to support this someday. See . + if (url.pathname !== '/') return false + + if ( + !isHostnameIpAddress(url.hostname) && + !dangerouslyAllowInsecureConnections + ) { + const parts = url.hostname.split('.') + const isDomainValid = parts.length >= 2 && parts.every(Boolean) + if (!isDomainValid) return false + } + + return true +} + +/** + * @param {undefined | Uint8Array} buffer + * @returns {undefined | string} + */ +function encodeBufferForServer(buffer) { + return buffer ? b4a.toString(buffer, 'hex') : undefined +} diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index c523f1d0c..c78267039 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -152,6 +152,7 @@ export class PeerSyncController { if (didUpdate.auth) { try { + this.#log('reading role for %h', this.peerId) const cap = await this.#roles.getRole(this.peerId) this.#syncCapability = cap.sync } catch (e) { diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 88e21a49e..ea5ed77c4 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -1,4 +1,5 @@ import { TypedEmitter } from 'tiny-typed-emitter' +import WebSocket from 'ws' import { SyncState } from './sync-state.js' import { PeerSyncController } from './peer-sync-controller.js' import { Logger } from '../logger.js' @@ -8,15 +9,22 @@ import { PRESYNC_NAMESPACES, } from '../constants.js' import { ExhaustivenessError, assert, keyToId, noop } from '../utils.js' +import { getOwn } from '../lib/get-own.js' +import { wsCoreReplicator } from '../lib/ws-core-replicator.js' import { NO_ROLE_ID } from '../roles.js' /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ +/** @import * as http from 'node:http' */ /** @import { CoreOwnership } from '../core-ownership.js' */ /** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */ +/** @import { ReplicationStream } from '../types.js' */ export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') export const kRequestFullStop = Symbol('background') export const kRescindFullStopRequest = Symbol('foreground') +export const kWaitForInitialSyncWithPeer = Symbol( + 'wait for initial sync with peer' +) export const kSetBlobDownloadFilter = Symbol('set isArchiveDevice') /** @@ -66,6 +74,7 @@ export class SyncApi extends TypedEmitter { /** @type {Map} */ #pscByPeerId = new Map() #wantsToSyncData = false + #wantsToConnectToServers = false #hasRequestedFullStop = false /** @type {SyncEnabledState} */ #previousSyncEnabledState = 'none' @@ -78,14 +87,19 @@ export class SyncApi extends TypedEmitter { /** @type {Map>} */ #pendingDiscoveryKeys = new Map() #l + #getServerWebsocketUrls + #getReplicationStream + /** @type {Map} */ + #serverWebsockets = new Map() #blobDownloadFilter /** - * * @param {object} opts * @param {import('../core-manager/index.js').CoreManager} opts.coreManager * @param {CoreOwnership} opts.coreOwnership * @param {import('../roles.js').Roles} opts.roles + * @param {() => Promise>} opts.getServerWebsocketUrls + * @param {() => ReplicationStream} opts.getReplicationStream * @param {import('../types.js').BlobFilter | null} opts.blobDownloadFilter * @param {number} [opts.throttleMs] * @param {Logger} [opts.logger] @@ -94,6 +108,8 @@ export class SyncApi extends TypedEmitter { coreManager, throttleMs = 200, roles, + getServerWebsocketUrls, + getReplicationStream, logger, coreOwnership, blobDownloadFilter, @@ -104,6 +120,8 @@ export class SyncApi extends TypedEmitter { this.#coreManager = coreManager this.#coreOwnership = coreOwnership this.#roles = roles + this.#getServerWebsocketUrls = getServerWebsocketUrls + this.#getReplicationStream = getReplicationStream this[kSyncState] = new SyncState({ coreManager, throttleMs, @@ -294,6 +312,74 @@ export class SyncApi extends TypedEmitter { this.emit('sync-state', this.#getState(namespaceSyncState)) } + /** + * @returns {void} + */ + connectServers() { + this.#wantsToConnectToServers = true + + this.#getServerWebsocketUrls() + .then((urls) => { + const hasDisconnectedSinceWebsocketUrlsRequestFinished = + !this.#wantsToConnectToServers + if (hasDisconnectedSinceWebsocketUrlsRequestFinished) return + + for (const url of urls) { + const existingWebsocket = this.#serverWebsockets.get(url) + if ( + existingWebsocket && + (existingWebsocket.readyState === WebSocket.OPEN || + existingWebsocket.readyState === WebSocket.CONNECTING) + ) { + continue + } + + const websocket = new WebSocket(url) + + /** @param {Error} err */ + const onWebsocketError = (err) => { + this.#l.log('Ignoring WebSocket error to %s: %o', url, err) + } + websocket.on('error', onWebsocketError) + + /** + * @param {unknown} _req + * @param {http.IncomingMessage} res + */ + const onWebsocketUnexpectedResponse = (_req, res) => { + this.#l.log( + 'Ignoring unexpected %d WebSocket response to %s', + res.statusCode, + url + ) + } + websocket.on('unexpected-response', onWebsocketUnexpectedResponse) + + const replicationStream = this.#getReplicationStream() + wsCoreReplicator(websocket, replicationStream) + + this.#serverWebsockets.set(url, websocket) + websocket.once('close', () => { + websocket.off('error', onWebsocketError) + websocket.off('unexpected-response', onWebsocketUnexpectedResponse) + this.#serverWebsockets.delete(url) + }) + } + }) + .catch(noop) + } + + /** + * @returns {void} + */ + disconnectServers() { + for (const websocket of this.#serverWebsockets.values()) { + websocket.close() + } + this.#serverWebsockets.clear() + this.#wantsToConnectToServers = false + } + /** * Start syncing data cores. * @@ -368,6 +454,40 @@ export class SyncApi extends TypedEmitter { }) } + /** + * @param {string} deviceId + * @param {AbortSignal} abortSignal + * @returns {Promise} + */ + async [kWaitForInitialSyncWithPeer](deviceId, abortSignal) { + abortSignal.throwIfAborted() + + const state = this[kSyncState].getState() + if (isInitiallySyncedWithPeer(state, deviceId)) return + + return new Promise((resolve, reject) => { + /** @param {import('./sync-state.js').State} state */ + const onState = (state) => { + if (isInitiallySyncedWithPeer(state, deviceId)) { + cleanup() + resolve() + } + } + const onAbort = () => { + cleanup() + reject(abortSignal.reason) + } + + const cleanup = () => { + this[kSyncState].off('state', onState) + abortSignal.removeEventListener('abort', onAbort) + } + + this[kSyncState].on('state', onState) + abortSignal.addEventListener('abort', onAbort) + }) + } + #clearAutostopDataSyncTimeoutIfExists() { if (this.#autostopDataSyncTimeout) { clearTimeout(this.#autostopDataSyncTimeout) @@ -547,6 +667,31 @@ function isSynced(state, type, peerSyncControllers) { return true } +/** + * @param {import('./sync-state.js').State} state + * @param {string} peerId + */ +function isInitiallySyncedWithPeer(state, peerId) { + for (const ns of PRESYNC_NAMESPACES) { + const remoteDeviceSyncState = getOwn(state[ns].remoteStates, peerId) + if (!remoteDeviceSyncState) return false + + switch (remoteDeviceSyncState.status) { + case 'starting': + return false + case 'started': + case 'stopped': { + const { want, wanted } = remoteDeviceSyncState + if (want || wanted) return false + break + } + default: + throw new ExhaustivenessError(remoteDeviceSyncState.status) + } + } + return true +} + /** * @param {import('./sync-state.js').State} namespaceSyncState * @param {Iterable} peerSyncControllers diff --git a/test-e2e/server.js b/test-e2e/server.js new file mode 100644 index 000000000..bf740d5ad --- /dev/null +++ b/test-e2e/server.js @@ -0,0 +1,205 @@ +import createFastify from 'fastify' +import assert from 'node:assert/strict' +import test from 'node:test' +import { createManager } from './utils.js' +/** @import { MapeoProject } from '../src/mapeo-project.js' */ +/** @import { MemberInfo } from '../src/member-api.js' */ + +test('invalid base URLs', async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + const invalidUrls = [ + '', + 'no-protocol.example', + 'ftp://invalid-protocol.example', + 'http://invalid-protocol.example', + 'https:', + 'https://', + 'https://.', + 'https://..', + 'https://https://', + 'https://https://double-protocol.example', + 'https://bare-domain', + 'https://bare-domain:1234', + 'https://empty-part.', + 'https://.empty-part', + 'https://spaces .in-part', + 'https://spaces.in part', + 'https://bad-port.example:-1', + 'https://username@has-auth.example', + 'https://username:password@has-auth.example', + 'https://has-query.example/?foo=bar', + 'https://has-hash.example/#hash', + `https://${'x'.repeat(2000)}.example`, + // We may want to support this someday. See . + 'https://has-pathname.example/p', + ] + await Promise.all( + invalidUrls.map((url) => + assert.rejects( + () => project.$member.addServerPeer(url), + { + code: 'INVALID_URL', + message: /base url is invalid/i, + }, + `${url} should be invalid` + ) + ) + ) + + assert(!(await findServerPeer(project)), 'no server peers should be added') +}) + +test('project with no name', async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + await assert.rejects( + () => + project.$member.addServerPeer('http://localhost:9999', { + dangerouslyAllowInsecureConnections: true, + }), + { + code: 'MISSING_DATA', + message: /name/, + } + ) +}) + +test("fails if we can't connect to the server", async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject({ name: 'foo' }) + const project = await manager.getProject(projectId) + + const serverBaseUrl = 'http://localhost:9999' + await assert.rejects( + () => + project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }), + { + code: 'NETWORK_ERROR', + message: /Failed to add server peer due to network error/, + } + ) +}) + +test( + "fails if server doesn't return a 200", + { concurrency: true }, + async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject({ name: 'foo' }) + const project = await manager.getProject(projectId) + + await Promise.all( + [204, 302, 400, 500].map((statusCode) => + t.test(`when returning a ${statusCode}`, async (t) => { + const fastify = createFastify() + fastify.put('/projects', (_req, reply) => { + reply.status(statusCode).send() + }) + const serverBaseUrl = await fastify.listen() + t.after(() => fastify.close()) + + await assert.rejects( + () => + project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }), + { + code: 'INVALID_SERVER_RESPONSE', + message: `Failed to add server peer due to HTTP status code ${statusCode}`, + } + ) + }) + ) + ) + } +) + +test( + "fails if server doesn't return data in the right format", + { concurrency: true }, + async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject({ name: 'foo' }) + const project = await manager.getProject(projectId) + + await Promise.all( + [ + '', + '{bad_json', + JSON.stringify({ data: {} }), + JSON.stringify({ data: { deviceId: 123 } }), + JSON.stringify({ deviceId: 'not under "data"' }), + ].map((responseData) => + t.test(`when returning ${responseData}`, async (t) => { + const fastify = createFastify() + fastify.put('/projects', (_req, reply) => { + reply.header('Content-Type', 'application/json').send(responseData) + }) + const serverBaseUrl = await fastify.listen() + t.after(() => fastify.close()) + + await assert.rejects( + () => + project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }), + { + code: 'INVALID_SERVER_RESPONSE', + message: + "Failed to add server peer because we couldn't parse the response", + } + ) + }) + ) + ) + } +) + +test("fails if first request succeeds but sync doesn't", async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject({ name: 'foo' }) + const project = await manager.getProject(projectId) + + const fastify = createFastify() + fastify.put('/projects', (_req, reply) => { + reply.send({ data: { deviceId: 'abc123' } }) + }) + const serverBaseUrl = await fastify.listen() + t.after(() => fastify.close()) + + await assert.rejects( + () => + project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }), + (err) => { + assert(err instanceof Error, 'receives an error') + assert('code' in err, 'gets an error code') + assert.equal( + err.code, + 'INVALID_SERVER_RESPONSE', + 'gets the correct error code' + ) + assert(err.cause instanceof Error, 'error has a cause') + assert(err.cause.message.includes('404'), 'error cause is an HTTP 404') + return true + } + ) +}) + +/** + * @param {MapeoProject} project + * @returns {Promise} + */ +async function findServerPeer(project) { + return (await project.$member.getMany()).find( + (member) => member.deviceType === 'selfHostedServer' + ) +} diff --git a/test/lib/error.js b/test/lib/error.js new file mode 100644 index 000000000..c7de6ea98 --- /dev/null +++ b/test/lib/error.js @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test, { describe } from 'node:test' +import { ErrorWithCode, getErrorMessage } from '../../src/lib/error.js' + +describe('ErrorWithCode', () => { + test('ErrorWithCode with two arguments', () => { + const err = new ErrorWithCode('MY_CODE', 'my message') + assert.equal(err.code, 'MY_CODE') + assert.equal(err.message, 'my message') + assert(err instanceof Error) + }) + + test('ErrorWithCode with three arguments', () => { + const otherError = new Error('hello') + const err = new ErrorWithCode('MY_CODE', 'my message', { + cause: otherError, + }) + assert.equal(err.code, 'MY_CODE') + assert.equal(err.message, 'my message') + assert.equal(err.cause, otherError) + assert(err instanceof Error) + }) +}) + +describe('getErrorMessage', () => { + test('from objects without a string message', () => { + const testCases = [ + undefined, + null, + ['ignored'], + { message: 123 }, + { + get message() { + throw new Error('this should not crash') + }, + }, + ] + + for (const testCase of testCases) { + assert.equal(getErrorMessage(testCase), 'unknown error') + } + }) + + test('from objects with a string message', () => { + class WithInheritedMessage { + get message() { + return 'foo' + } + } + + const testCases = [ + { message: 'foo' }, + new Error('foo'), + { + get message() { + return 'foo' + }, + }, + new WithInheritedMessage(), + ] + + for (const testCase of testCases) { + assert.equal(getErrorMessage(testCase), 'foo') + } + }) +}) diff --git a/test/lib/get-own.js b/test/lib/get-own.js new file mode 100644 index 000000000..40a83b900 --- /dev/null +++ b/test/lib/get-own.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { getOwn } from '../../src/lib/get-own.js' + +test('getOwn', () => { + class Foo { + ownProperty = 123 + inheritedProperty() { + return 789 + } + } + const foo = new Foo() + assert.equal(getOwn(foo, 'ownProperty'), 123) + assert.equal(getOwn(foo, 'inheritedProperty'), undefined) + assert.equal(getOwn(foo, /** @type {any} */ ('hasOwnProperty')), undefined) + assert.equal(getOwn(foo, /** @type {any} */ ('garbage')), undefined) + + const nullProto = Object.create(null) + nullProto.foo = 123 + assert.equal(getOwn(nullProto, 'foo'), 123) + assert.equal(getOwn(nullProto, 'garbage'), undefined) + assert.equal(getOwn(nullProto, 'hasOwnProperty'), undefined) +}) diff --git a/test/lib/is-hostname-ip-address.js b/test/lib/is-hostname-ip-address.js new file mode 100644 index 000000000..9af96b075 --- /dev/null +++ b/test/lib/is-hostname-ip-address.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isHostnameIpAddress } from '../../src/lib/is-hostname-ip-address.js' + +test('IPv4', () => { + const ips = ['0.0.0.0', '127.0.0.1', '100.64.0.42'] + for (const ip of ips) { + assert(isHostnameIpAddress(ip)) + } +}) + +test('IPv6', () => { + const ips = [ + '::', + '2001:0db8:0000:0000:0000:0000:0000:0000', + '0:0:0:0:0:ffff:6440:002a', + ] + for (const ip of ips) { + assert(!isHostnameIpAddress(ip)) + assert(isHostnameIpAddress('[' + ip + ']')) + } +}) + +test('non-IP addresses', () => { + const hostnames = ['example', 'example.com', '123.example.com'] + for (const hostname of hostnames) { + assert(!isHostnameIpAddress(hostname)) + } +}) From a4dabd27545de8186b564cb6998d45bce8b3d20f Mon Sep 17 00:00:00 2001 From: "optic-release-automation[bot]" <94357573+optic-release-automation[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:54:41 -0500 Subject: [PATCH 2/2] [OPTIC-RELEASE-AUTOMATION] release/v2.1.0 (#953) Release v2.1.0 Co-authored-by: Evan Hahn --- docs/api/md/-internal-/README.md | 2 +- docs/api/md/-internal-/classes/BlobStore.md | 4 +- docs/api/md/-internal-/classes/CoreManager.md | 16 +++++ docs/api/md/-internal-/classes/DataStore.md | 22 ++---- .../api/md/-internal-/classes/MapeoProject.md | 65 ++++++++++++++---- docs/api/md/-internal-/classes/MemberApi.md | 41 +++++++++++ docs/api/md/-internal-/classes/SyncApi.md | 56 +++++++++++++++ .../md/-internal-/interfaces/MemberInfo.md | 6 ++ docs/api/md/-internal-/interfaces/Role.md | 2 +- .../Hyperdrive/interfaces/HyperdriveEntry.md | 2 +- .../README.md | 2 +- ...iceInfoTable.md => deviceSettingsTable.md} | 6 +- .../README.md | 2 + .../remoteDetectionAlertBacklinkTable.md | 9 +++ .../variables/remoteDetectionAlertTable.md | 9 +++ .../type-aliases/ArrayAtLeastOne.md | 13 ---- .../md/-internal-/type-aliases/BlobFilter.md | 2 +- .../type-aliases/GenericBlobFilter.md | 11 +++ .../-internal-/variables/NAMESPACE_SCHEMAS.md | 2 +- docs/api/md/README.md | 1 + docs/api/md/classes/MapeoManager.md | 68 ++++++++++--------- docs/api/md/functions/replicateProject.md | 19 ++++++ package-lock.json | 4 +- package.json | 2 +- 24 files changed, 278 insertions(+), 88 deletions(-) rename docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/{localDeviceInfoTable.md => deviceSettingsTable.md} (54%) create mode 100644 docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertBacklinkTable.md create mode 100644 docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertTable.md delete mode 100644 docs/api/md/-internal-/type-aliases/ArrayAtLeastOne.md create mode 100644 docs/api/md/-internal-/type-aliases/GenericBlobFilter.md create mode 100644 docs/api/md/functions/replicateProject.md diff --git a/docs/api/md/-internal-/README.md b/docs/api/md/-internal-/README.md index 78149f5ad..28766977c 100644 --- a/docs/api/md/-internal-/README.md +++ b/docs/api/md/-internal-/README.md @@ -91,7 +91,6 @@ ### Type Aliases -- [ArrayAtLeastOne](type-aliases/ArrayAtLeastOne.md) - [BitField](type-aliases/BitField.md) - [BlobDownloadStateError](type-aliases/BlobDownloadStateError.md) - [BlobFilter](type-aliases/BlobFilter.md) @@ -109,6 +108,7 @@ - [DeviceInfoParam](type-aliases/DeviceInfoParam.md) - [EditableProjectSettings](type-aliases/EditableProjectSettings.md) - [ElementOf](type-aliases/ElementOf.md) +- [GenericBlobFilter](type-aliases/GenericBlobFilter.md) - [GetMapeoDocTables](type-aliases/GetMapeoDocTables.md) - [HypercorePeer](type-aliases/HypercorePeer.md) - [HypercoreRemoteBitfield](type-aliases/HypercoreRemoteBitfield.md) diff --git a/docs/api/md/-internal-/classes/BlobStore.md b/docs/api/md/-internal-/classes/BlobStore.md index 38b8b3681..710f53dd2 100644 --- a/docs/api/md/-internal-/classes/BlobStore.md +++ b/docs/api/md/-internal-/classes/BlobStore.md @@ -117,7 +117,7 @@ Set to `true` to wait for a blob to download, otherwise will throw if blob is no • **options?** -• **options.metadata?**: `undefined` \| `object` +• **options.metadata?**: `undefined` \| `JsonObject` Metadata to store with the blob @@ -244,7 +244,7 @@ Hyperdrive entry • **options?** -• **options.metadata?**: `undefined` \| `object` +• **options.metadata?**: `undefined` \| `JsonObject` Metadata to store with the blob diff --git a/docs/api/md/-internal-/classes/CoreManager.md b/docs/api/md/-internal-/classes/CoreManager.md index 999f18a34..6a7f1627d 100644 --- a/docs/api/md/-internal-/classes/CoreManager.md +++ b/docs/api/md/-internal-/classes/CoreManager.md @@ -240,3 +240,19 @@ Resolves when all cores have finished loading #### Returns `Promise`\<`void`\> + +*** + +### sendDownloadIntents() + +> **sendDownloadIntents**(`blobFilter`, `peer`): `void` + +#### Parameters + +• **blobFilter**: [`BlobFilter`](../type-aliases/BlobFilter.md) + +• **peer**: [`HypercorePeer`](../type-aliases/HypercorePeer.md) + +#### Returns + +`void` diff --git a/docs/api/md/-internal-/classes/DataStore.md b/docs/api/md/-internal-/classes/DataStore.md index bb64ca4a6..3e495aafc 100644 --- a/docs/api/md/-internal-/classes/DataStore.md +++ b/docs/api/md/-internal-/classes/DataStore.md @@ -32,6 +32,8 @@ • **opts.namespace**: `TNamespace` +• **opts.reindex**: `boolean` + • **opts.storage**: `StorageParam` #### Returns @@ -66,11 +68,11 @@ ### schemas -> `get` **schemas**(): (`"observation"` \| `"track"`)[] \| (`"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"`)[] \| (`"coreOwnership"` \| `"role"`)[] +> `get` **schemas**(): (`"observation"` \| `"track"` \| `"remoteDetectionAlert"`)[] \| (`"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"`)[] \| (`"coreOwnership"` \| `"role"`)[] #### Returns -(`"observation"` \| `"track"`)[] \| (`"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"`)[] \| (`"coreOwnership"` \| `"role"`)[] +(`"observation"` \| `"track"` \| `"remoteDetectionAlert"`)[] \| (`"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"`)[] \| (`"coreOwnership"` \| `"role"`)[] *** @@ -94,16 +96,6 @@ *** -### getIndexState() - -> **getIndexState**(): `IndexState` - -#### Returns - -`IndexState` - -*** - ### read() > **read**(`versionId`): `Promise`\<`MapeoDoc`\> @@ -146,7 +138,7 @@ Unlink all index files. This should only be called after `close()` has resolved. ### write() -> **write**\<`TDoc`\>(`doc`): `Promise`\<`Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\>\> +> **write**\<`TDoc`\>(`doc`): `Promise`\<`Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\>\> UNSAFE: Does not check links: [] refer to a valid doc - should only be used internally. @@ -156,7 +148,7 @@ this DataStore. #### Type Parameters -• **TDoc** *extends* `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object` & `CoreOwnershipSignatures`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object` & `CoreOwnershipSignatures`, `"versionId"`\> +• **TDoc** *extends* `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object` & `CoreOwnershipSignatures`, `"versionId"` \| `"originalVersionId"` \| `"links"`\> & `object` \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object`, `"versionId"`\> \| `object` & `Omit`\<`object` & `CoreOwnershipSignatures`, `"versionId"`\> #### Parameters @@ -164,7 +156,7 @@ this DataStore. #### Returns -`Promise`\<`Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\>\> +`Promise`\<`Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\> \| `Extract`\<`object`, `TDoc`\>\> *** diff --git a/docs/api/md/-internal-/classes/MapeoProject.md b/docs/api/md/-internal-/classes/MapeoProject.md index e9768a583..a314c8515 100644 --- a/docs/api/md/-internal-/classes/MapeoProject.md +++ b/docs/api/md/-internal-/classes/MapeoProject.md @@ -34,6 +34,10 @@ Encryption keys for each namespace • **opts.getMediaBaseUrl** +• **opts.isArchiveDevice**: `boolean` + +Whether this device is an archive device + • **opts.keyManager**: `KeyManager` mapeo/crypto KeyManager instance @@ -142,7 +146,7 @@ DataTypes object mappings, used for tests ##### observation -> **observation**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"`\>, `SQLiteTableWithColumns`\<`object`\>, `"observation"`, `object`, `object`\> +> **observation**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"observation"`, `object`, `object`\> ##### preset @@ -152,13 +156,17 @@ DataTypes object mappings, used for tests > **projectSettings**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"config"`, `"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"`\>, `SQLiteTableWithColumns`\<`object`\>, `"projectSettings"`, `object`, `object`\> +##### remoteDetectionAlert + +> **remoteDetectionAlert**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"remoteDetectionAlert"`, `object`, `object`\> + ##### role > **role**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"auth"`, `"coreOwnership"` \| `"role"`\>, `SQLiteTableWithColumns`\<`object`\>, `"role"`, `object`, `object`\> ##### track -> **track**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"`\>, `SQLiteTableWithColumns`\<`object`\>, `"track"`, `object`, `object`\> +> **track**: [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"track"`, `object`, `object`\> ##### translation @@ -166,6 +174,16 @@ DataTypes object mappings, used for tests *** +### \[kIsArchiveDevice\] + +> `get` **\[kIsArchiveDevice\]**(): `boolean` + +#### Returns + +`boolean` + +*** + ### $icons > `get` **$icons**(): [`IconApi`](IconApi.md) @@ -228,11 +246,11 @@ DataTypes object mappings, used for tests ### observation -> `get` **observation**(): [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"`\>, `SQLiteTableWithColumns`\<`object`\>, `"observation"`, `object`, `object`\> +> `get` **observation**(): [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"observation"`, `object`, `object`\> #### Returns -[`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"`\>, `SQLiteTableWithColumns`\<`object`\>, `"observation"`, `object`, `object`\> +[`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"observation"`, `object`, `object`\> *** @@ -246,13 +264,23 @@ DataTypes object mappings, used for tests *** +### remoteDetectionAlert + +> `get` **remoteDetectionAlert**(): [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"remoteDetectionAlert"`, `object`, `object`\> + +#### Returns + +[`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"remoteDetectionAlert"`, `object`, `object`\> + +*** + ### track -> `get` **track**(): [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"`\>, `SQLiteTableWithColumns`\<`object`\>, `"track"`, `object`, `object`\> +> `get` **track**(): [`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"track"`, `object`, `object`\> #### Returns -[`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"`\>, `SQLiteTableWithColumns`\<`object`\>, `"track"`, `object`, `object`\> +[`DataType`](DataType.md)\<[`DataStore`](DataStore.md)\<`"data"`, `"observation"` \| `"track"` \| `"remoteDetectionAlert"`\>, `SQLiteTableWithColumns`\<`object`\>, `"track"`, `object`, `object`\> ## Methods @@ -280,22 +308,33 @@ Clear data if we've left the project. No-op if you're still in the project. ### \[kProjectReplicate\]() -> **\[kProjectReplicate\]**(`stream`): `Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\> & `object` & [`Protomux`](Protomux.md)\<`Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\>\> +> **\[kProjectReplicate\]**(`isInitiatorOrStream`): [`ReplicationStream`](../type-aliases/ReplicationStream.md) Replicate a project to a @hyperswarm/secret-stream. Invites will not function because the RPC channel is not connected for project replication, -and only this project will replicate (to replicate multiple projects you -need to replicate the manager instance via manager[kManagerReplicate]) +and only this project will replicate. #### Parameters -• **stream**: [`Protomux`](Protomux.md)\<`Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\>\> +• **isInitiatorOrStream**: `boolean` \| `Duplex` \| `Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\> + +#### Returns + +[`ReplicationStream`](../type-aliases/ReplicationStream.md) -A duplex stream, a @hyperswarm/secret-stream, or a Protomux instance +*** + +### \[kSetIsArchiveDevice\]() + +> **\[kSetIsArchiveDevice\]**(`isArchiveDevice`): `Promise`\<`void`\> + +#### Parameters + +• **isArchiveDevice**: `boolean` #### Returns -`Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\> & `object` & [`Protomux`](Protomux.md)\<`Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\>\> +`Promise`\<`void`\> *** @@ -305,7 +344,7 @@ A duplex stream, a @hyperswarm/secret-stream, or a Protomux instance #### Parameters -• **value**: `Pick`\<`object`, `"name"` \| `"deviceType"`\> +• **value**: `Pick`\<`object`, `"name"` \| `"deviceType"` \| `"selfHostedServerDetails"`\> #### Returns diff --git a/docs/api/md/-internal-/classes/MemberApi.md b/docs/api/md/-internal-/classes/MemberApi.md index 841614c83..9c1791910 100644 --- a/docs/api/md/-internal-/classes/MemberApi.md +++ b/docs/api/md/-internal-/classes/MemberApi.md @@ -34,12 +34,18 @@ public key of this device as hex string • **opts.encryptionKeys**: `EncryptionKeys` +• **opts.getProjectName** + +• **opts.getReplicationStream** + • **opts.projectKey**: `Buffer` • **opts.roles**: [`Roles`](Roles.md) • **opts.rpc**: [`LocalPeers`](LocalPeers.md) +• **opts.waitForInitialSyncWithPeer** + #### Returns [`MemberApi`](MemberApi.md) @@ -50,6 +56,41 @@ public key of this device as hex string ## Methods +### addServerPeer() + +> **addServerPeer**(`baseUrl`, `options`?): `Promise`\<`void`\> + +Add a server peer. + +Can reject with any of the following error codes (accessed via `err.code`): + +- `INVALID_URL`: the base URL is invalid, likely due to user error. +- `MISSING_DATA`: some required data is missing in order to add the server + peer. For example, the project must have a name. +- `NETWORK_ERROR`: there was an issue connecting to the server. Is the + device online? Is the server online? +- `INVALID_SERVER_RESPONSE`: we connected to the server but it returned + an unexpected response. Is the server running a compatible version of + CoMapeo Cloud? + +If `err.code` is not specified, that indicates a bug in this module. + +#### Parameters + +• **baseUrl**: `string` + +• **options?** = `{}` + +• **options.dangerouslyAllowInsecureConnections?**: `undefined` \| `boolean` = `false` + +Allow insecure network connections. Should only be used in tests. + +#### Returns + +`Promise`\<`void`\> + +*** + ### assignRole() > **assignRole**(`deviceId`, `roleId`): `Promise`\<`void`\> diff --git a/docs/api/md/-internal-/classes/SyncApi.md b/docs/api/md/-internal-/classes/SyncApi.md index 016ff9dfd..082f2fbce 100644 --- a/docs/api/md/-internal-/classes/SyncApi.md +++ b/docs/api/md/-internal-/classes/SyncApi.md @@ -20,10 +20,16 @@ • **opts** +• **opts.blobDownloadFilter**: `null` \| [`BlobFilter`](../type-aliases/BlobFilter.md) + • **opts.coreManager**: [`CoreManager`](CoreManager.md) • **opts.coreOwnership**: [`CoreOwnership`](CoreOwnership.md) +• **opts.getReplicationStream** + +• **opts.getServerWebsocketUrls** + • **opts.logger**: `undefined` \| [`Logger`](Logger.md) • **opts.roles**: [`Roles`](Roles.md) @@ -86,6 +92,56 @@ Rescind any requests for a full stop. *** +### \[kSetBlobDownloadFilter\]() + +> **\[kSetBlobDownloadFilter\]**(`blobDownloadFilter`): `void` + +#### Parameters + +• **blobDownloadFilter**: `null` \| [`BlobFilter`](../type-aliases/BlobFilter.md) + +#### Returns + +`void` + +*** + +### \[kWaitForInitialSyncWithPeer\]() + +> **\[kWaitForInitialSyncWithPeer\]**(`deviceId`, `abortSignal`): `Promise`\<`void`\> + +#### Parameters + +• **deviceId**: `string` + +• **abortSignal**: `AbortSignal` + +#### Returns + +`Promise`\<`void`\> + +*** + +### connectServers() + +> **connectServers**(): `void` + +#### Returns + +`void` + +*** + +### disconnectServers() + +> **disconnectServers**(): `void` + +#### Returns + +`void` + +*** + ### getState() > **getState**(): [`State`](../interfaces/State.md) diff --git a/docs/api/md/-internal-/interfaces/MemberInfo.md b/docs/api/md/-internal-/interfaces/MemberInfo.md index 2f19c3213..63dbc99b6 100644 --- a/docs/api/md/-internal-/interfaces/MemberInfo.md +++ b/docs/api/md/-internal-/interfaces/MemberInfo.md @@ -35,3 +35,9 @@ ### role > **role**: [`Role`](Role.md)\<`"a12a6702b93bd7ff"` \| `"f7c150f5a3a9a855"` \| `"012fd2d431c0bf60"` \| `"9e6d29263cba36c9"` \| `"8ced989b1904606b"` \| `"08e4251e36f6e7ed"`\> + +*** + +### selfHostedServerDetails + +> **selfHostedServerDetails**: `undefined` \| `object` diff --git a/docs/api/md/-internal-/interfaces/Role.md b/docs/api/md/-internal-/interfaces/Role.md index c56a2f617..216ac2713 100644 --- a/docs/api/md/-internal-/interfaces/Role.md +++ b/docs/api/md/-internal-/interfaces/Role.md @@ -14,7 +14,7 @@ ### docs -> **docs**: `Record`\<`"observation"` \| `"track"` \| `"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"` \| `"coreOwnership"` \| `"role"`, [`DocCapability`](DocCapability.md)\> +> **docs**: `Record`\<`"observation"` \| `"track"` \| `"remoteDetectionAlert"` \| `"translation"` \| `"preset"` \| `"field"` \| `"projectSettings"` \| `"deviceInfo"` \| `"icon"` \| `"coreOwnership"` \| `"role"`, [`DocCapability`](DocCapability.md)\> *** diff --git a/docs/api/md/-internal-/namespaces/Hyperdrive/interfaces/HyperdriveEntry.md b/docs/api/md/-internal-/namespaces/Hyperdrive/interfaces/HyperdriveEntry.md index a9eacc431..0c37b65db 100644 --- a/docs/api/md/-internal-/namespaces/Hyperdrive/interfaces/HyperdriveEntry.md +++ b/docs/api/md/-internal-/namespaces/Hyperdrive/interfaces/HyperdriveEntry.md @@ -38,4 +38,4 @@ #### metadata -> **metadata**: `null` \| `object` +> **metadata**: `JsonValue` diff --git a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/README.md b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/README.md index 9d4a5f05d..e3c08cf00 100644 --- a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/README.md +++ b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/README.md @@ -14,7 +14,7 @@ ### Variables -- [localDeviceInfoTable](variables/localDeviceInfoTable.md) +- [deviceSettingsTable](variables/deviceSettingsTable.md) - [projectBacklinkTable](variables/projectBacklinkTable.md) - [projectKeysTable](variables/projectKeysTable.md) - [projectSettingsTable](variables/projectSettingsTable.md) diff --git a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/localDeviceInfoTable.md b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/deviceSettingsTable.md similarity index 54% rename from docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/localDeviceInfoTable.md rename to docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/deviceSettingsTable.md index eeaa1bb35..dd95cb77f 100644 --- a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/localDeviceInfoTable.md +++ b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_client/variables/deviceSettingsTable.md @@ -2,8 +2,8 @@ *** -[API](../../../../README.md) / [\](../../../README.md) / ["/home/runner/work/comapeo-core/comapeo-core/src/schema/client"](../README.md) / localDeviceInfoTable +[API](../../../../README.md) / [\](../../../README.md) / ["/home/runner/work/comapeo-core/comapeo-core/src/schema/client"](../README.md) / deviceSettingsTable -# Variable: localDeviceInfoTable +# Variable: deviceSettingsTable -> `const` **localDeviceInfoTable**: `SQLiteTableWithColumns`\<`object`\> +> `const` **deviceSettingsTable**: `SQLiteTableWithColumns`\<`object`\> diff --git a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/README.md b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/README.md index 9997edccc..fd892afd4 100644 --- a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/README.md +++ b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/README.md @@ -23,6 +23,8 @@ - [observationTable](variables/observationTable.md) - [presetBacklinkTable](variables/presetBacklinkTable.md) - [presetTable](variables/presetTable.md) +- [remoteDetectionAlertBacklinkTable](variables/remoteDetectionAlertBacklinkTable.md) +- [remoteDetectionAlertTable](variables/remoteDetectionAlertTable.md) - [roleBacklinkTable](variables/roleBacklinkTable.md) - [roleTable](variables/roleTable.md) - [trackBacklinkTable](variables/trackBacklinkTable.md) diff --git a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertBacklinkTable.md b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertBacklinkTable.md new file mode 100644 index 000000000..0502c6d83 --- /dev/null +++ b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertBacklinkTable.md @@ -0,0 +1,9 @@ +[**API**](../../../../README.md) • **Docs** + +*** + +[API](../../../../README.md) / [\](../../../README.md) / ["/home/runner/work/comapeo-core/comapeo-core/src/schema/project"](../README.md) / remoteDetectionAlertBacklinkTable + +# Variable: remoteDetectionAlertBacklinkTable + +> `const` **remoteDetectionAlertBacklinkTable**: `SQLiteTableWithColumns`\<`object`\> diff --git a/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertTable.md b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertTable.md new file mode 100644 index 000000000..9b55b46a4 --- /dev/null +++ b/docs/api/md/-internal-/namespaces/home_runner_work_comapeo-core_comapeo-core_src_schema_project/variables/remoteDetectionAlertTable.md @@ -0,0 +1,9 @@ +[**API**](../../../../README.md) • **Docs** + +*** + +[API](../../../../README.md) / [\](../../../README.md) / ["/home/runner/work/comapeo-core/comapeo-core/src/schema/project"](../README.md) / remoteDetectionAlertTable + +# Variable: remoteDetectionAlertTable + +> `const` **remoteDetectionAlertTable**: `SQLiteTableWithColumns`\<`object`\> diff --git a/docs/api/md/-internal-/type-aliases/ArrayAtLeastOne.md b/docs/api/md/-internal-/type-aliases/ArrayAtLeastOne.md deleted file mode 100644 index 22c34b59f..000000000 --- a/docs/api/md/-internal-/type-aliases/ArrayAtLeastOne.md +++ /dev/null @@ -1,13 +0,0 @@ -[**API**](../../README.md) • **Docs** - -*** - -[API](../../README.md) / [\](../README.md) / ArrayAtLeastOne - -# Type Alias: ArrayAtLeastOne\ - -> **ArrayAtLeastOne**\<`T`\>: [`T`, `...T[]`] - -## Type Parameters - -• **T** diff --git a/docs/api/md/-internal-/type-aliases/BlobFilter.md b/docs/api/md/-internal-/type-aliases/BlobFilter.md index 41260e255..07b115fb0 100644 --- a/docs/api/md/-internal-/type-aliases/BlobFilter.md +++ b/docs/api/md/-internal-/type-aliases/BlobFilter.md @@ -6,4 +6,4 @@ # Type Alias: BlobFilter -> **BlobFilter**: `RequireAtLeastOne`\<`{ [KeyType in BlobType]: ArrayAtLeastOne> }`\> +> **BlobFilter**: `RequireAtLeastOne`\<`{ [KeyType in BlobType]: BlobVariant[] }`\> diff --git a/docs/api/md/-internal-/type-aliases/GenericBlobFilter.md b/docs/api/md/-internal-/type-aliases/GenericBlobFilter.md new file mode 100644 index 000000000..9a058fca0 --- /dev/null +++ b/docs/api/md/-internal-/type-aliases/GenericBlobFilter.md @@ -0,0 +1,11 @@ +[**API**](../../README.md) • **Docs** + +*** + +[API](../../README.md) / [\](../README.md) / GenericBlobFilter + +# Type Alias: GenericBlobFilter + +> **GenericBlobFilter**: `Record`\<`string`, `string`[]\> + +Map of blob types to array of blob variants diff --git a/docs/api/md/-internal-/variables/NAMESPACE_SCHEMAS.md b/docs/api/md/-internal-/variables/NAMESPACE_SCHEMAS.md index 056e3d985..73892a062 100644 --- a/docs/api/md/-internal-/variables/NAMESPACE_SCHEMAS.md +++ b/docs/api/md/-internal-/variables/NAMESPACE_SCHEMAS.md @@ -20,4 +20,4 @@ ### data -> `readonly` **data**: readonly [`"observation"`, `"track"`] +> `readonly` **data**: readonly [`"observation"`, `"track"`, `"remoteDetectionAlert"`] diff --git a/docs/api/md/README.md b/docs/api/md/README.md index 5591a5b56..9c634664d 100644 --- a/docs/api/md/README.md +++ b/docs/api/md/README.md @@ -20,3 +20,4 @@ ## Functions - [CoMapeoMapsFastifyPlugin](functions/CoMapeoMapsFastifyPlugin.md) +- [replicateProject](functions/replicateProject.md) diff --git a/docs/api/md/classes/MapeoManager.md b/docs/api/md/classes/MapeoManager.md index cdf2033b3..ead93d734 100644 --- a/docs/api/md/classes/MapeoManager.md +++ b/docs/api/md/classes/MapeoManager.md @@ -68,18 +68,6 @@ path for drizzle migrations folder for project database ## Accessors -### \[kRPC\] - -> `get` **\[kRPC\]**(): [`LocalPeers`](../-internal-/classes/LocalPeers.md) - -MapeoRPC instance, used for tests - -#### Returns - -[`LocalPeers`](../-internal-/classes/LocalPeers.md) - -*** - ### deviceId > `get` **deviceId**(): `string` @@ -100,26 +88,6 @@ MapeoRPC instance, used for tests ## Methods -### \[kManagerReplicate\]() - -> **\[kManagerReplicate\]**(`isInitiator`): [`ReplicationStream`](../-internal-/type-aliases/ReplicationStream.md) - -Create a Mapeo replication stream. This replication connects the Mapeo RPC -channel and allows invites. All active projects will sync automatically to -this replication stream. Only use for local (trusted) connections, because -the RPC channel key is public. To sync a specific project without -connecting RPC, use project[kProjectReplication]. - -#### Parameters - -• **isInitiator**: `boolean` - -#### Returns - -[`ReplicationStream`](../-internal-/type-aliases/ReplicationStream.md) - -*** - ### addProject() > **addProject**(`projectJoinDetails`, `opts`?): `Promise`\<`string`\> @@ -134,7 +102,7 @@ downloaded their proof of project membership and the project config. • **opts?** = `{}` -For internal use in tests, set opts.waitForSync = false to not wait for sync during addProject() +Set opts.waitForSync = false to not wait for sync during addProject() • **opts.waitForSync?**: `boolean` = `true` @@ -194,6 +162,22 @@ Project public id *** +### getIsArchiveDevice() + +> **getIsArchiveDevice**(): `boolean` + +Get whether this device is an archive device. Archive devices will download +all media during sync, where-as non-archive devices will not download media +original variants, and only download preview and thumbnail variants. + +#### Returns + +`boolean` + +isArchiveDevice + +*** + ### getMapStyleJsonUrl() > **getMapStyleJsonUrl**(): `Promise`\<`string`\> @@ -306,6 +290,24 @@ Will undo the effects of `onBackgrounded`. *** +### setIsArchiveDevice() + +> **setIsArchiveDevice**(`isArchiveDevice`): `void` + +Set whether this device is an archive device. Archive devices will download +all media during sync, where-as non-archive devices will not download media +original variants, and only download preview and thumbnail variants. + +#### Parameters + +• **isArchiveDevice**: `boolean` + +#### Returns + +`void` + +*** + ### startLocalPeerDiscoveryServer() > **startLocalPeerDiscoveryServer**(): `Promise`\<`object`\> diff --git a/docs/api/md/functions/replicateProject.md b/docs/api/md/functions/replicateProject.md new file mode 100644 index 000000000..4f1e78c9b --- /dev/null +++ b/docs/api/md/functions/replicateProject.md @@ -0,0 +1,19 @@ +[**API**](../README.md) • **Docs** + +*** + +[API](../README.md) / replicateProject + +# Function: replicateProject() + +> **replicateProject**(`project`, ...`args`): [`ReplicationStream`](../-internal-/type-aliases/ReplicationStream.md) + +## Parameters + +• **project**: [`MapeoProject`](../-internal-/classes/MapeoProject.md) + +• ...**args**: [`boolean` \| `Duplex` \| `Duplex`\<`any`, `any`, `any`, `any`, `true`, `true`, `DuplexEvents`\<`any`, `any`\>\>] + +## Returns + +[`ReplicationStream`](../-internal-/type-aliases/ReplicationStream.md) diff --git a/package-lock.json b/package-lock.json index 37ef0fc3d..6389b75bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@comapeo/core", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@comapeo/core", - "version": "2.0.1", + "version": "2.1.0", "license": "MIT", "dependencies": { "@comapeo/fallback-smp": "^1.0.0", diff --git a/package.json b/package.json index fee337a09..2384cbe32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comapeo/core", - "version": "2.0.1", + "version": "2.1.0", "description": "Offline p2p mapping library", "main": "src/index.js", "types": "dist/index.d.ts",