diff --git a/.gitattributes b/.gitattributes index c5264e8..563b0d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.woff2 binary +patches/*.patch text eol=lf diff --git a/package-lock.json b/package-lock.json index 66d78ac..3e425c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pombo-p2p-chat", "version": "1.0.0", "dependencies": { - "@streamr/sdk": "^103.3.1", + "@streamr/sdk": "103.3.1", "dompurify": "^3.4.14", "ethers": "^6.9.0", "qrcode": "^1.5.4" @@ -20,6 +20,7 @@ "@vitest/ui": "^4.0.18", "happy-dom": "^20.8.3", "jsdom": "^28.1.0", + "patch-package": "^8.0.1", "tailwindcss": "^3.4.1", "vitest": "^4.0.18", "webpack": "^5.90.0", @@ -2083,6 +2084,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2843,6 +2851,36 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2900,6 +2938,22 @@ "node": ">=6.0" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cipher-base": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", @@ -3885,6 +3939,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -3923,6 +3987,21 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -4528,6 +4607,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4618,6 +4713,19 @@ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "license": "MIT" }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -4837,6 +4945,49 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/k-bucket": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/k-bucket/-/k-bucket-5.1.0.tgz", @@ -4856,6 +5007,16 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5561,6 +5722,16 @@ "node": ">= 6" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5590,6 +5761,23 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-defer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", @@ -5788,6 +5976,36 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6918,6 +7136,16 @@ "node": ">=18" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -7559,6 +7787,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -7751,6 +7989,16 @@ "imurmurhash": "^0.1.4" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8416,6 +8664,22 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/package.json b/package.json index 8f9f7bd..8a11476 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", - "vectors": "node tests/vectors/regen.mjs" + "vectors": "node tests/vectors/regen.mjs", + "postinstall": "patch-package" }, "dependencies": { - "@streamr/sdk": "^103.3.1", + "@streamr/sdk": "103.3.1", "dompurify": "^3.4.14", "ethers": "^6.9.0", "qrcode": "^1.5.4" @@ -31,6 +32,7 @@ "@vitest/ui": "^4.0.18", "happy-dom": "^20.8.3", "jsdom": "^28.1.0", + "patch-package": "^8.0.1", "tailwindcss": "^3.4.1", "vitest": "^4.0.18", "webpack": "^5.90.0", diff --git a/patches/@streamr+sdk+103.3.1.patch b/patches/@streamr+sdk+103.3.1.patch new file mode 100644 index 0000000..9a5c08f --- /dev/null +++ b/patches/@streamr+sdk+103.3.1.patch @@ -0,0 +1,224 @@ +diff --git a/node_modules/@streamr/sdk/dist/exports-browser.cjs b/node_modules/@streamr/sdk/dist/exports-browser.cjs +index 6a6e48a..bac92d7 100644 +--- a/node_modules/@streamr/sdk/dist/exports-browser.cjs ++++ b/node_modules/@streamr/sdk/dist/exports-browser.cjs +@@ -462,6 +462,7 @@ var __param$g = (globalThis && globalThis.__param) || function (paramIndex, deco + */ + let StorageNodeRegistry = class StorageNodeRegistry { + nodeRegistryContract; ++ nodeMetadataCache; + nodeRegistryContractReadonly; + contractFactory; + rpcProviderSource; +@@ -473,6 +474,10 @@ let StorageNodeRegistry = class StorageNodeRegistry { + this.config = config; + this.identity = identity; + this.nodeRegistryContractReadonly = this.contractFactory.createReadContract(utils.toEthereumAddress(this.config.contracts.storageNodeRegistryChainAddress), networkContracts.NodeRegistryABI, rpcProviderSource.getProvider(), 'storageNodeRegistry'); ++ this.nodeMetadataCache = createCacheMap({ ++ valueFactory: (nodeAddress) => this.getStorageNodeMetadata_nonCached(nodeAddress), ++ ...config.cache ++ }); + } + async connectToContract() { + if (this.nodeRegistryContract === undefined) { +@@ -489,8 +494,15 @@ let StorageNodeRegistry = class StorageNodeRegistry { + else { + await waitForTx(this.nodeRegistryContract.removeNodeSelf(ethersOverrides)); + } ++ this.nodeMetadataCache.invalidate(() => true); + } + async getStorageNodeMetadata(nodeAddress) { ++ return this.nodeMetadataCache.get(nodeAddress); ++ } ++ invalidateStorageNodeMetadata(nodeAddress) { ++ this.nodeMetadataCache.invalidate((key) => key === nodeAddress); ++ } ++ async getStorageNodeMetadata_nonCached(nodeAddress) { + const [resultNodeAddress, metadata] = await this.nodeRegistryContractReadonly.getNode(nodeAddress); + const NODE_NOT_FOUND = '0x0000000000000000000000000000000000000000'; + if (resultNodeAddress !== NODE_NOT_FOUND) { +@@ -6767,7 +6779,15 @@ let Resends = class Resends { + getStorageNodes: async () => without(nodeAddresses, nodeAddress), + config: (nodeAddresses.length === 1) ? { ...this.config, orderMessages: false } : this.config + }); +- const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), getHttpErrorTransform()); ++ const httpErrorTransform = getHttpErrorTransform(); ++ const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), (err) => { ++ // A node that did not answer, or answered 5xx, may have moved: read its URLs again next time. ++ const answered = (err instanceof FetchHttpStreamResponseError) && (err.response.status < 500); ++ if (!answered && (err?.name !== 'AbortError')) { ++ this.storageNodeRegistry.invalidateStorageNodeMetadata(nodeAddress); ++ } ++ return httpErrorTransform(err); ++ }); + setImmediate(async () => { + let count = 0; + const messages = map(lines, (bytes) => convertBytesToStreamMessage(bytes)); +diff --git a/node_modules/@streamr/sdk/dist/exports-browser.js b/node_modules/@streamr/sdk/dist/exports-browser.js +index c4d7b91..a92c799 100644 +--- a/node_modules/@streamr/sdk/dist/exports-browser.js ++++ b/node_modules/@streamr/sdk/dist/exports-browser.js +@@ -460,6 +460,7 @@ var __param$g = (globalThis && globalThis.__param) || function (paramIndex, deco + */ + let StorageNodeRegistry = class StorageNodeRegistry { + nodeRegistryContract; ++ nodeMetadataCache; + nodeRegistryContractReadonly; + contractFactory; + rpcProviderSource; +@@ -471,6 +472,10 @@ let StorageNodeRegistry = class StorageNodeRegistry { + this.config = config; + this.identity = identity; + this.nodeRegistryContractReadonly = this.contractFactory.createReadContract(toEthereumAddress(this.config.contracts.storageNodeRegistryChainAddress), NodeRegistryABI, rpcProviderSource.getProvider(), 'storageNodeRegistry'); ++ this.nodeMetadataCache = createCacheMap({ ++ valueFactory: (nodeAddress) => this.getStorageNodeMetadata_nonCached(nodeAddress), ++ ...config.cache ++ }); + } + async connectToContract() { + if (this.nodeRegistryContract === undefined) { +@@ -487,8 +492,15 @@ let StorageNodeRegistry = class StorageNodeRegistry { + else { + await waitForTx(this.nodeRegistryContract.removeNodeSelf(ethersOverrides)); + } ++ this.nodeMetadataCache.invalidate(() => true); + } + async getStorageNodeMetadata(nodeAddress) { ++ return this.nodeMetadataCache.get(nodeAddress); ++ } ++ invalidateStorageNodeMetadata(nodeAddress) { ++ this.nodeMetadataCache.invalidate((key) => key === nodeAddress); ++ } ++ async getStorageNodeMetadata_nonCached(nodeAddress) { + const [resultNodeAddress, metadata] = await this.nodeRegistryContractReadonly.getNode(nodeAddress); + const NODE_NOT_FOUND = '0x0000000000000000000000000000000000000000'; + if (resultNodeAddress !== NODE_NOT_FOUND) { +@@ -6765,7 +6777,15 @@ let Resends = class Resends { + getStorageNodes: async () => without(nodeAddresses, nodeAddress), + config: (nodeAddresses.length === 1) ? { ...this.config, orderMessages: false } : this.config + }); +- const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), getHttpErrorTransform()); ++ const httpErrorTransform = getHttpErrorTransform(); ++ const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), (err) => { ++ // A node that did not answer, or answered 5xx, may have moved: read its URLs again next time. ++ const answered = (err instanceof FetchHttpStreamResponseError) && (err.response.status < 500); ++ if (!answered && (err?.name !== 'AbortError')) { ++ this.storageNodeRegistry.invalidateStorageNodeMetadata(nodeAddress); ++ } ++ return httpErrorTransform(err); ++ }); + setImmediate(async () => { + let count = 0; + const messages = map(lines, (bytes) => convertBytesToStreamMessage(bytes)); +diff --git a/node_modules/@streamr/sdk/dist/exports-nodejs.cjs b/node_modules/@streamr/sdk/dist/exports-nodejs.cjs +index 6f9cbce..5e65fc9 100644 +--- a/node_modules/@streamr/sdk/dist/exports-nodejs.cjs ++++ b/node_modules/@streamr/sdk/dist/exports-nodejs.cjs +@@ -469,6 +469,7 @@ var __param$g = (globalThis && globalThis.__param) || function (paramIndex, deco + */ + let StorageNodeRegistry = class StorageNodeRegistry { + nodeRegistryContract; ++ nodeMetadataCache; + nodeRegistryContractReadonly; + contractFactory; + rpcProviderSource; +@@ -480,6 +481,10 @@ let StorageNodeRegistry = class StorageNodeRegistry { + this.config = config; + this.identity = identity; + this.nodeRegistryContractReadonly = this.contractFactory.createReadContract(utils.toEthereumAddress(this.config.contracts.storageNodeRegistryChainAddress), networkContracts.NodeRegistryABI, rpcProviderSource.getProvider(), 'storageNodeRegistry'); ++ this.nodeMetadataCache = createCacheMap({ ++ valueFactory: (nodeAddress) => this.getStorageNodeMetadata_nonCached(nodeAddress), ++ ...config.cache ++ }); + } + async connectToContract() { + if (this.nodeRegistryContract === undefined) { +@@ -496,8 +501,15 @@ let StorageNodeRegistry = class StorageNodeRegistry { + else { + await waitForTx(this.nodeRegistryContract.removeNodeSelf(ethersOverrides)); + } ++ this.nodeMetadataCache.invalidate(() => true); + } + async getStorageNodeMetadata(nodeAddress) { ++ return this.nodeMetadataCache.get(nodeAddress); ++ } ++ invalidateStorageNodeMetadata(nodeAddress) { ++ this.nodeMetadataCache.invalidate((key) => key === nodeAddress); ++ } ++ async getStorageNodeMetadata_nonCached(nodeAddress) { + const [resultNodeAddress, metadata] = await this.nodeRegistryContractReadonly.getNode(nodeAddress); + const NODE_NOT_FOUND = '0x0000000000000000000000000000000000000000'; + if (resultNodeAddress !== NODE_NOT_FOUND) { +@@ -6849,7 +6861,15 @@ let Resends = class Resends { + getStorageNodes: async () => without(nodeAddresses, nodeAddress), + config: (nodeAddresses.length === 1) ? { ...this.config, orderMessages: false } : this.config + }); +- const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), getHttpErrorTransform()); ++ const httpErrorTransform = getHttpErrorTransform(); ++ const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), (err) => { ++ // A node that did not answer, or answered 5xx, may have moved: read its URLs again next time. ++ const answered = (err instanceof FetchHttpStreamResponseError) && (err.response.status < 500); ++ if (!answered && (err?.name !== 'AbortError')) { ++ this.storageNodeRegistry.invalidateStorageNodeMetadata(nodeAddress); ++ } ++ return httpErrorTransform(err); ++ }); + setImmediate(async () => { + let count = 0; + const messages = map(lines, (bytes) => convertBytesToStreamMessage(bytes)); +diff --git a/node_modules/@streamr/sdk/dist/exports-nodejs.js b/node_modules/@streamr/sdk/dist/exports-nodejs.js +index cccbc90..d57c922 100644 +--- a/node_modules/@streamr/sdk/dist/exports-nodejs.js ++++ b/node_modules/@streamr/sdk/dist/exports-nodejs.js +@@ -467,6 +467,7 @@ var __param$g = (globalThis && globalThis.__param) || function (paramIndex, deco + */ + let StorageNodeRegistry = class StorageNodeRegistry { + nodeRegistryContract; ++ nodeMetadataCache; + nodeRegistryContractReadonly; + contractFactory; + rpcProviderSource; +@@ -478,6 +479,10 @@ let StorageNodeRegistry = class StorageNodeRegistry { + this.config = config; + this.identity = identity; + this.nodeRegistryContractReadonly = this.contractFactory.createReadContract(toEthereumAddress(this.config.contracts.storageNodeRegistryChainAddress), NodeRegistryABI, rpcProviderSource.getProvider(), 'storageNodeRegistry'); ++ this.nodeMetadataCache = createCacheMap({ ++ valueFactory: (nodeAddress) => this.getStorageNodeMetadata_nonCached(nodeAddress), ++ ...config.cache ++ }); + } + async connectToContract() { + if (this.nodeRegistryContract === undefined) { +@@ -494,8 +499,15 @@ let StorageNodeRegistry = class StorageNodeRegistry { + else { + await waitForTx(this.nodeRegistryContract.removeNodeSelf(ethersOverrides)); + } ++ this.nodeMetadataCache.invalidate(() => true); + } + async getStorageNodeMetadata(nodeAddress) { ++ return this.nodeMetadataCache.get(nodeAddress); ++ } ++ invalidateStorageNodeMetadata(nodeAddress) { ++ this.nodeMetadataCache.invalidate((key) => key === nodeAddress); ++ } ++ async getStorageNodeMetadata_nonCached(nodeAddress) { + const [resultNodeAddress, metadata] = await this.nodeRegistryContractReadonly.getNode(nodeAddress); + const NODE_NOT_FOUND = '0x0000000000000000000000000000000000000000'; + if (resultNodeAddress !== NODE_NOT_FOUND) { +@@ -6847,7 +6859,15 @@ let Resends = class Resends { + getStorageNodes: async () => without(nodeAddresses, nodeAddress), + config: (nodeAddresses.length === 1) ? { ...this.config, orderMessages: false } : this.config + }); +- const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), getHttpErrorTransform()); ++ const httpErrorTransform = getHttpErrorTransform(); ++ const lines = transformError(fetchLengthPrefixedFrameHttpBinaryStream(url, abortSignal), (err) => { ++ // A node that did not answer, or answered 5xx, may have moved: read its URLs again next time. ++ const answered = (err instanceof FetchHttpStreamResponseError) && (err.response.status < 500); ++ if (!answered && (err?.name !== 'AbortError')) { ++ this.storageNodeRegistry.invalidateStorageNodeMetadata(nodeAddress); ++ } ++ return httpErrorTransform(err); ++ }); + setImmediate(async () => { + let count = 0; + const messages = map(lines, (bytes) => convertBytesToStreamMessage(bytes)); diff --git a/tests/unit/sdkNodeMetadataCache.test.js b/tests/unit/sdkNodeMetadataCache.test.js new file mode 100644 index 0000000..28eda11 --- /dev/null +++ b/tests/unit/sdkNodeMetadataCache.test.js @@ -0,0 +1,98 @@ +// @vitest-environment node +// +// patches/@streamr+sdk+103.3.1.patch caches the storage node registry read the +// SDK makes on every resend. These tests run against the installed SDK, so a +// postinstall that did not run, or a patch that applied in the wrong place, +// fails here rather than in production. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'http'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { StreamrClient } = require('@streamr/sdk'); +const { AbiCoder } = require('ethers'); + +const NODE = '0xae340e799e8151f6a4999d245e466197aa217667'; +const GET_NODE = '0x9d209048'; +// The package exports no subpaths; its main entry lives in dist/ too. +const dist = (file) => readFileSync(join(dirname(require.resolve('@streamr/sdk')), file), 'utf8'); + +describe('the SDK storage node metadata cache (patch-package)', () => { + describe.each(['exports-browser.js', 'exports-browser.cjs'])('the bundled build %s', (file) => { + const src = dist(file); + + it('reads node metadata through the cache', () => { + expect(src).toContain( + ' async getStorageNodeMetadata(nodeAddress) {\n return this.nodeMetadataCache.get(nodeAddress);\n }'); + expect(src).toContain('valueFactory: (nodeAddress) => this.getStorageNodeMetadata_nonCached(nodeAddress)'); + }); + + it('drops a node entry when a resend from it fails without an answer', () => { + const resend = src.slice(src.indexOf('async fetchStream('), src.indexOf('Resends = __decorate')); + expect(resend).toContain('this.storageNodeRegistry.invalidateStorageNodeMetadata(nodeAddress)'); + expect(resend).toContain('(err.response.status < 500)'); + }); + }); + + describe('the installed client', () => { + let server; + let url; + let getNodeCalls = 0; + let client; + + beforeAll(async () => { + const node = AbiCoder.defaultAbiCoder().encode( + ['tuple(address,string,uint256)'], + [[NODE, JSON.stringify({ urls: ['https://storage.example'] }), 0]] + ); + const answer = (r) => { + if (r.method === 'eth_chainId') return { result: '0x89' }; + if (r.method === 'net_version') return { result: '137' }; + if (r.method === 'eth_blockNumber') return { result: '0x10' }; + if (r.method === 'eth_call' && String(r.params?.[0]?.data).startsWith(GET_NODE)) { + getNodeCalls++; + return { result: node }; + } + return { error: { code: -32601, message: 'not here' } }; + }; + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + const json = JSON.parse(body); + const reply = (r) => ({ jsonrpc: '2.0', id: r.id, ...answer(r) }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(Array.isArray(json) ? json.map(reply) : reply(json))); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + url = `http://127.0.0.1:${server.address().port}`; + client = new StreamrClient({ + metrics: false, + contracts: { rpcs: [{ url }], rpcQuorum: 1 } + }); + }); + + afterAll(async () => { + await client?.destroy(); + await new Promise((resolve) => server.close(resolve)); + }); + + it('reads the chain once for repeated lookups of the same node', async () => { + const first = await client.getStorageNodeMetadata(NODE); + const second = await client.getStorageNodeMetadata(NODE); + expect(first.urls).toEqual(['https://storage.example']); + expect(second).toEqual(first); + expect(getNodeCalls).toBe(1); + }); + + it('reads it again once the entry is dropped', async () => { + const before = getNodeCalls; + client.storageNodeRegistry.invalidateStorageNodeMetadata(NODE); + await client.getStorageNodeMetadata(NODE); + expect(getNodeCalls).toBe(before + 1); + }); + }); +});