├── .husky ├── .gitignore └── commit-msg ├── examples ├── webpack │ ├── dist │ │ └── .gitkeep │ ├── .babelrc │ ├── index.html │ ├── webpack.config.js │ ├── package.json │ └── src │ │ └── index.js ├── node │ ├── package.json │ ├── node-example-produce.js │ └── node-example-subscribe.js └── web │ ├── web-example-produce.html │ ├── web-example-metamask.html │ └── web-example-subscribe.html ├── .editorconfig ├── src ├── shim │ ├── crypto.js │ ├── http-https.js │ ├── node-fetch.js │ └── ws.js ├── index-commonjs.js ├── types.ts ├── index-esm.mjs ├── stream │ ├── StorageNode.ts │ ├── StreamPart.ts │ └── encryption │ │ ├── BrowserPersistentStore.ts │ │ ├── KeyExchangeUtils.ts │ │ └── ServerPersistentStore.ts ├── index.ts ├── subscribe │ ├── messageStream.js │ ├── resendStream.js │ ├── Decrypt.js │ ├── Validator.js │ ├── OrderMessages.js │ ├── api.js │ └── pipeline.js ├── user │ └── index.ts ├── publish │ ├── Encrypt.ts │ └── Signer.ts ├── rest │ ├── LoginEndpoints.ts │ └── authFetch.ts ├── utils │ └── AggregatedError.ts ├── Ethereum.js └── Session.ts ├── readme-header-img.png ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── misc.xml ├── jsLibraryMappings.xml ├── streamr-client-javascript.iml ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml └── vcs.xml ├── .eslintignore ├── typedoc.js ├── tsconfig.test.json ├── test ├── browser │ ├── server.js │ ├── realtime.js │ ├── browser.html │ └── resend.js ├── exports │ ├── tsconfig.json │ ├── tests │ │ ├── commonjs.js │ │ ├── typescript.ts │ │ └── esm.mjs │ ├── package.json │ └── webpack.config.js ├── unit │ ├── StubbedStreamrClient.ts │ ├── Stream.test.ts │ ├── pUtils.test.js │ ├── Config.test.ts │ ├── StreamUtils.test.ts │ └── utils.test.ts ├── integration │ ├── authFetch.test.ts │ ├── dataunion │ │ ├── deploy.test.ts │ │ ├── calculate.test.ts │ │ ├── adminFee.test.ts │ │ ├── stats.test.ts │ │ ├── member.test.ts │ │ └── signature.test.ts │ ├── devEnvironment.ts │ ├── config.js │ ├── Session.test.ts │ ├── TokenBalance.test.ts │ ├── Stream.test.ts │ ├── LoginEndpoints.test.ts │ ├── ResendReconnect.test.ts │ ├── Subscription.test.ts │ └── SubscriberResendsSequential.test.ts ├── legacy │ └── CombinedSubscription.test.js ├── flakey │ └── EnvStressTest.test.js └── benchmarks │ ├── publish.js │ └── subscribe.js ├── copy-package.js ├── README.md ├── nightwatch.json ├── jest.setup.js ├── .github └── workflows │ ├── docs.yml │ └── test-code.yml ├── .npmignore ├── .babel.node.config.js ├── tsconfig.node.json ├── tsconfig.json ├── .gitignore ├── .babel.config.js ├── commitlint.config.js ├── .eslintrc.js └── CHANGELOG.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /examples/webpack/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | -------------------------------------------------------------------------------- /src/shim/crypto.js: -------------------------------------------------------------------------------- 1 | exports.Crypto = function() { 2 | return window.crypto 3 | } 4 | -------------------------------------------------------------------------------- /readme-header-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/streamr-dev/streamr-client-javascript/HEAD/readme-header-img.png -------------------------------------------------------------------------------- /examples/webpack/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env"] 4 | ], 5 | "plugins": [ 6 | "add-module-exports" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/shim/http-https.js: -------------------------------------------------------------------------------- 1 | // NB: THIS FILE MUST BE IN ES5 2 | 3 | // Browser native fetch implementation does not support the http(s) Agent like node-fetch does 4 | export class Agent {} 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /src/shim/node-fetch.js: -------------------------------------------------------------------------------- 1 | // NB: THIS FILE MUST BE IN ES5 2 | 3 | // In browsers, the node-fetch package is replaced with this to use native fetch 4 | export default typeof fetch !== 'undefined' ? fetch : window.fetch 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | examples/** 3 | coverage/** 4 | dist/** 5 | test/legacy/** 6 | src/shim/** 7 | test/unit/StubbedStreamrClient.js 8 | streamr-docker-dev/** 9 | vendor/** 10 | test/exports/** 11 | docs/** -------------------------------------------------------------------------------- /src/index-commonjs.js: -------------------------------------------------------------------------------- 1 | const Client = require('./index') 2 | 3 | // required to get require('streamr-client') instead of require('streamr-client').default 4 | module.exports = Client.default 5 | Object.assign(Client.default, Client) 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { F } from 'ts-toolbelt' 2 | 3 | export type EthereumAddress = string 4 | 5 | export type MaybeAsync = T | F.Promisify // Utility Type: make a function maybe async 6 | 7 | export type Todo = any 8 | -------------------------------------------------------------------------------- /examples/webpack/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "streamr-client-node-example", 3 | "version": "0.0.1", 4 | "description": "Using streamr-client in node", 5 | "main": "node.js", 6 | "dependencies": { 7 | "streamr-client": "^4.1.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | BRANCH_NAME=$(git symbolic-ref --short -q HEAD) 3 | 4 | if [ $BRANCH_NAME != '(no branch)' ] 5 | then 6 | # don't run hook when rebasing 7 | . "$(dirname "$0")/_/husky.sh" 8 | 9 | npx --no-install commitlint --edit 10 | fi 11 | -------------------------------------------------------------------------------- /src/shim/ws.js: -------------------------------------------------------------------------------- 1 | // NB: THIS FILE MUST BE IN ES5 2 | 3 | // In browsers, the ws package is replaced with this to use native websockets 4 | export default typeof WebSocket !== 'undefined' ? WebSocket : function WebsocketWrap(url) { 5 | return new window.WebSocket(url) 6 | } 7 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entryPoints: [ 3 | 'src/dataunion/DataUnion.ts', 4 | 'src/Config.ts', 5 | 'src/StreamrClient.ts' 6 | ], 7 | tsconfig: 'tsconfig.node.json', 8 | readme: false, 9 | excludeInternal: true, 10 | includeVersion: true, 11 | } 12 | -------------------------------------------------------------------------------- /.idea/streamr-client-javascript.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/index-esm.mjs: -------------------------------------------------------------------------------- 1 | import StreamrClient from './index.js' 2 | // required to get import { DataUnion } from 'streamr-client' to work 3 | export * from './index.js' 4 | // required to get import StreamrClient from 'streamr-client' to work 5 | export default StreamrClient.default 6 | // note this file is manually copied as-is into dist/src since we don't want tsc to compile it to commonjs 7 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.node.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "jest", 6 | "node" 7 | ] 8 | }, 9 | "include": [ 10 | "src/**/*", 11 | "vendor/**/*", 12 | "contracts/**/*", 13 | "test/**/*" 14 | ], 15 | "exclude": ["node_modules", "dist", "test/legacy/*"] 16 | } 17 | -------------------------------------------------------------------------------- /test/browser/server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const express = require('express') 4 | 5 | const app = express() 6 | 7 | // viewed at http://localhost:8880 8 | app.use('/static', express.static(path.join(__dirname, '/../../dist'))) 9 | 10 | app.get('/', (req, res) => { 11 | res.sendFile(path.join(__dirname, 'browser.html')) 12 | }) 13 | 14 | app.listen(8880) 15 | -------------------------------------------------------------------------------- /copy-package.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | // eslint-disable-next-line 3 | const pkg = Object.assign({}, require('./package.json')) 4 | 5 | delete pkg.scripts 6 | delete pkg.private 7 | 8 | try { 9 | fs.mkdirSync('./dist/') 10 | } catch (err) { 11 | if (err.code !== 'EEXIST') { 12 | throw err 13 | } 14 | } 15 | 16 | fs.writeFileSync('./dist/package.json', JSON.stringify(pkg, null, 2)) 17 | -------------------------------------------------------------------------------- /test/exports/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "declaration": true, 6 | "outDir": "dist", 7 | "strict": true, 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "module": "commonjs" 11 | }, 12 | "include": [ 13 | "tests/typescript.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/exports/tests/commonjs.js: -------------------------------------------------------------------------------- 1 | // checks that require works 2 | const StreamrClient = require('streamr-client') 3 | 4 | console.info('const StreamrClient = require(\'streamr-client\'):', { StreamrClient }) 5 | 6 | const auth = StreamrClient.generateEthereumAccount() 7 | const client = new StreamrClient({ 8 | auth, 9 | }) 10 | 11 | client.connect().then(() => { 12 | console.info('success') 13 | return client.disconnect() 14 | }) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Streamr 4 | 5 |

6 |

7 | Streamr JavaScript Client 8 |

9 | 10 | ## This repository is deprecated, Streamr Javascript Client development has moved into the [network-monorepo](https://github.com/streamr-dev/network-monorepo) 11 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | -------------------------------------------------------------------------------- /src/stream/StorageNode.ts: -------------------------------------------------------------------------------- 1 | import { EthereumAddress } from '../types' 2 | 3 | export class StorageNode { 4 | 5 | static STREAMR_GERMANY = new StorageNode('0x31546eEA76F2B2b3C5cC06B1c93601dc35c9D916') 6 | static STREAMR_DOCKER_DEV = new StorageNode('0xde1112f631486CfC759A50196853011528bC5FA0') 7 | 8 | private _address: EthereumAddress 9 | 10 | constructor(address: EthereumAddress) { 11 | this._address = address 12 | } 13 | 14 | getAddress() { 15 | return this._address 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /nightwatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "webdriver": { 3 | "start_process": true, 4 | "server_path": "node_modules/.bin/chromedriver", 5 | "cli_args": [ 6 | "--verbose" 7 | ], 8 | "port": 9515 9 | }, 10 | 11 | "test_settings" : { 12 | "default" : { 13 | "desiredCapabilities" : { 14 | "browserName" : "chrome", 15 | "chromeOptions": { 16 | "args" : [ 17 | "--no-sandbox", 18 | "--headless", 19 | "--disable-dev-shm-usage" 20 | ] 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/exports/tests/typescript.ts: -------------------------------------------------------------------------------- 1 | // check ts esm works via tsc 2 | 3 | import DefaultExport, * as NamedExports from 'streamr-client' 4 | 5 | console.info('import DefaultExport, * as NamedExports from \'streamr-client\':', { DefaultExport, NamedExports }) 6 | 7 | const StreamrClient = DefaultExport 8 | 9 | const auth = StreamrClient.generateEthereumAccount() 10 | const client = new StreamrClient({ 11 | auth, 12 | }) 13 | 14 | console.assert(!!NamedExports.DataUnion, 'NamedExports should have DataUnion') 15 | 16 | client.connect().then(() => { 17 | console.info('success') 18 | return client.disconnect() 19 | }) 20 | -------------------------------------------------------------------------------- /test/exports/tests/esm.mjs: -------------------------------------------------------------------------------- 1 | // check esm works, as native and via webpack + babel. Also see typescript.ts 2 | import DefaultExport, * as NamedExports from 'streamr-client' 3 | 4 | console.info('import DefaultExport, * as NamedExports from \'streamr-client\':', { DefaultExport, NamedExports }) 5 | 6 | const StreamrClient = DefaultExport 7 | 8 | const auth = StreamrClient.generateEthereumAccount() 9 | const client = new StreamrClient({ 10 | auth, 11 | }) 12 | console.assert(!!NamedExports.DataUnion, 'NamedExports should have DataUnion') 13 | client.connect().then(() => { 14 | console.info('success') 15 | return client.disconnect() 16 | }) 17 | -------------------------------------------------------------------------------- /src/stream/StreamPart.ts: -------------------------------------------------------------------------------- 1 | export class StreamPart { 2 | 3 | _streamId: string 4 | _streamPartition: number 5 | 6 | constructor(streamId: string, streamPartition: number) { 7 | this._streamId = streamId 8 | this._streamPartition = streamPartition 9 | } 10 | 11 | static fromStream({ id, partitions }: { id: string, partitions: number }) { 12 | const result: StreamPart[] = [] 13 | for (let i = 0; i < partitions; i++) { 14 | result.push(new StreamPart(id, i)) 15 | } 16 | return result 17 | } 18 | 19 | getStreamId() { 20 | return this._streamId 21 | } 22 | 23 | getStreamPartition() { 24 | return this._streamPartition 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import { GitRevisionPlugin } from 'git-revision-webpack-plugin' 2 | 3 | const pkg = require('./package.json') 4 | 5 | export default async () => { 6 | if (!process.env.GIT_VERSION) { 7 | const gitRevisionPlugin = new GitRevisionPlugin() 8 | const [GIT_VERSION, GIT_COMMITHASH, GIT_BRANCH] = await Promise.all([ 9 | gitRevisionPlugin.version(), 10 | gitRevisionPlugin.commithash(), 11 | gitRevisionPlugin.branch(), 12 | ]) 13 | Object.assign(process.env, { 14 | version: pkg.version, 15 | GIT_VERSION, 16 | GIT_COMMITHASH, 17 | GIT_BRANCH, 18 | }, process.env) // don't override whatever is in process.env 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/unit/StubbedStreamrClient.ts: -------------------------------------------------------------------------------- 1 | import { StreamrClient } from '../../src/index' 2 | import { Stream } from '../../src/stream' 3 | 4 | export default class StubbedStreamrClient extends StreamrClient { 5 | // eslint-disable-next-line class-methods-use-this 6 | getUserInfo() { 7 | return Promise.resolve({ 8 | name: '', 9 | username: 'username', 10 | }) 11 | } 12 | 13 | async getStream(): Promise { 14 | return new Stream(this, { 15 | id: 'streamId', 16 | partitions: 1, 17 | }) 18 | } 19 | } 20 | // publisherId is the hash of 'username' 21 | // @ts-expect-error 22 | StubbedStreamrClient.hashedUsername = '0x16F78A7D6317F102BBD95FC9A4F3FF2E3249287690B8BDAD6B7810F82B34ACE3'.toLowerCase() 23 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Generate Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master # Set a branch name to trigger deployment 7 | 8 | jobs: 9 | build: 10 | name: Run build using Node 14.x 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: "14.x" 17 | - name: npm ci 18 | run: npm ci 19 | - name: npm run docs 20 | run: npm run docs 21 | - uses: actions/upload-artifact@v2 22 | with: 23 | name: docs 24 | path: docs 25 | - name: Deploy 26 | uses: peaceiris/actions-gh-pages@v3 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | publish_dir: ./docs 30 | -------------------------------------------------------------------------------- /examples/node/node-example-produce.js: -------------------------------------------------------------------------------- 1 | import { StreamrClient } from 'streamr-client'; 2 | 3 | // Create the client and supply either an API key or an Ethereum private key to authenticate 4 | const client = new StreamrClient({ 5 | auth: { 6 | privateKey: 'ETHEREUM-PRIVATE-KEY', 7 | }, 8 | }) 9 | 10 | // Create a stream for this example if it doesn't exist 11 | client.getOrCreateStream({ 12 | name: 'node-example-data', 13 | }).then((stream) => setInterval(() => { 14 | // Generate a message payload with a random number 15 | const msg = { 16 | random: Math.random(), 17 | } 18 | 19 | // Publish the message to the Stream 20 | stream.publish(msg) 21 | .then(() => console.log('Sent successfully: ', msg)) 22 | .catch((err) => console.error(err)) 23 | }, 1000)) 24 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | .idea 30 | *.iml 31 | .DS_Store 32 | 33 | # dist/* we want the dist folder on npm! 34 | dist/*.html 35 | -------------------------------------------------------------------------------- /test/exports/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-streamr-exports", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "commonjs.js", 6 | "private": true, 7 | "scripts": { 8 | "pretest": "rm -Rf dist", 9 | "test": "npm run test-commonjs && npm run test-esm && npm run test-ts && npm run webpack", 10 | "build-ts": "tsc --project ./tsconfig.json", 11 | "pretest-ts": "npm run build-ts", 12 | "test-ts": "node dist/typescript.js", 13 | "test-esm": "node tests/esm.mjs", 14 | "test-commonjs": "node tests/commonjs.js", 15 | "webpack": "../../node_modules/.bin/webpack --progress", 16 | "link": "mkdir -p node_modules && ln -fs ../../../dist/ node_modules/streamr-client" 17 | }, 18 | "author": "Tim Oxley ", 19 | "license": "ISC", 20 | "dependencies": { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/unit/Stream.test.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from '../../src/stream' 2 | import { Todo } from '../../src/types' 3 | 4 | describe('Stream', () => { 5 | let stream: Stream 6 | let clientMock: Todo 7 | 8 | beforeEach(() => { 9 | clientMock = { 10 | publish: jest.fn() 11 | } 12 | stream = new Stream(clientMock, { 13 | id: 'stream-id' 14 | }) 15 | }) 16 | 17 | describe('publish()', () => { 18 | it('should call client.publish(...)', () => { 19 | const msg = { 20 | foo: 'bar' 21 | } 22 | const ts = Date.now() 23 | const pk = 'my-partition-key' 24 | 25 | stream.publish(msg, ts, pk) 26 | 27 | expect(clientMock.publish).toHaveBeenCalledWith(stream.id, msg, ts, pk) 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /.babel.node.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | useBuiltIns: 'usage', 5 | modules: false, 6 | corejs: 3, 7 | loose: false, 8 | bugfixes: true, 9 | shippedProposals: true, 10 | targets: { 11 | node: true 12 | } 13 | }], 14 | ['@babel/preset-typescript'] 15 | ], 16 | plugins: [ 17 | 'add-module-exports', 18 | ['@babel/plugin-transform-runtime', { 19 | useESModules: false, 20 | corejs: 3, 21 | helpers: true, 22 | regenerator: false 23 | }], 24 | '@babel/plugin-transform-modules-commonjs', 25 | ['@babel/plugin-proposal-class-properties', { 26 | loose: false 27 | }] 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /examples/node/node-example-subscribe.js: -------------------------------------------------------------------------------- 1 | import { StreamrClient } from 'streamr-client'; 2 | 3 | // Create the client and supply either an API key or an Ethereum private key to authenticate 4 | const client = new StreamrClient({ 5 | auth: { 6 | privateKey: 'ETHEREUM-PRIVATE-KEY', 7 | }, 8 | }) 9 | 10 | // Create a stream for this example if it doesn't exist 11 | client.getOrCreateStream({ 12 | name: 'node-example-data', 13 | }).then((stream) => { 14 | client.subscribe( 15 | { 16 | stream: stream.id, 17 | // Resend the last 10 messages on connect 18 | resend: { 19 | last: 10, 20 | }, 21 | }, 22 | (message) => { 23 | // Do something with the messages as they are received 24 | console.log(JSON.stringify(message)) 25 | }, 26 | ) 27 | }) 28 | -------------------------------------------------------------------------------- /examples/webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-template */ 2 | /* eslint-disable prefer-destructuring */ 3 | 4 | const path = require('path') 5 | 6 | module.exports = { 7 | entry: path.join(__dirname, 'src', 'index.js'), 8 | target: 'web', 9 | devtool: 'source-map', 10 | output: { 11 | libraryTarget: 'umd2', 12 | path: path.join(__dirname, 'dist'), 13 | filename: 'webpack-example.js', 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /(\.jsx|\.js)$/, 19 | loader: 'babel-loader', 20 | exclude: /(node_modules|bower_components)/ 21 | }, 22 | ], 23 | }, 24 | resolve: { 25 | modules: [path.resolve('./node_modules'), path.resolve('./src')], 26 | extensions: ['.json', '.js'], 27 | }, 28 | plugins: [], 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "declaration": true, 6 | "declarationDir": "dist/types", 7 | "outDir": "dist", 8 | "lib": [ 9 | "ES5", 10 | "ES2015", 11 | "ES2016", 12 | "ES2017", 13 | "ES2018", 14 | "ES2019", 15 | "ES2020", 16 | "ESNext", 17 | "DOM" 18 | ], 19 | "strict": true, 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "module": "commonjs", 23 | "types": [ 24 | "node" 25 | ], 26 | "incremental": true, 27 | "sourceMap": true 28 | }, 29 | "include": [ 30 | "src/**/*", 31 | "vendor/**/*", 32 | "contracts/**/*" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "commonjs", 5 | "allowJs": true, 6 | "declaration": true, 7 | "declarationDir": "dist/types", 8 | "outDir": "dist", 9 | "lib": [ 10 | "ES5", 11 | "ES2015", 12 | "ES2016", 13 | "ES2017", 14 | "ES2018", 15 | "ES2019", 16 | "ES2020", 17 | "ESNext", 18 | "DOM" 19 | ], 20 | "strict": true, 21 | "esModuleInterop": true, 22 | "resolveJsonModule": true, 23 | "moduleResolution": "node", 24 | "types": [ 25 | "jest", 26 | "node" 27 | ], 28 | "sourceMap": true 29 | }, 30 | "include": [ 31 | "src/**/*", 32 | "vendor/**/*", 33 | "contracts/**/*" 34 | ], 35 | "exclude": ["node_modules", "dist"] 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | .DS_Store 30 | dist/* 31 | examples/webpack/dist/* 32 | 33 | # IntelliJ IDEA 34 | .idea/workspace.xml 35 | .idea/tasks.xml 36 | .idea/dictionaries 37 | .idea/httpRequests 38 | .idea/dataSources* 39 | 40 | reports 41 | tests_outputbenchmarks.txt 42 | tests_output 43 | test/exports/dist 44 | test/exports/package-lock.json 45 | vendor 46 | docs 47 | -------------------------------------------------------------------------------- /.babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | useBuiltIns: 'usage', 5 | corejs: 3, 6 | loose: false, 7 | bugfixes: true, 8 | shippedProposals: true, 9 | targets: { 10 | browsers: [ 11 | 'supports async-functions', 12 | 'supports cryptography', 13 | 'supports es6', 14 | 'supports es6-generators', 15 | 'not dead', 16 | 'not ie <= 11', 17 | 'not ie_mob <= 11' 18 | ] 19 | }, 20 | exclude: ['transform-regenerator', '@babel/plugin-transform-regenerator'] 21 | }], 22 | ['@babel/preset-typescript'] 23 | ], 24 | plugins: [ 25 | 'lodash', 26 | 'add-module-exports', 27 | ['@babel/plugin-transform-runtime', { 28 | corejs: 3, 29 | helpers: true, 30 | regenerator: false 31 | }], 32 | "@babel/plugin-transform-modules-commonjs", 33 | ['@babel/plugin-proposal-class-properties', { 34 | loose: false 35 | }] 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Streamr JavaScript Client 3 | * 4 | * @packageDocumentation 5 | * @module StreamrClient 6 | */ 7 | 8 | import { StreamrClient } from './StreamrClient' 9 | 10 | export * from './StreamrClient' 11 | export * from './Config' 12 | export * from './stream' 13 | export * from './stream/encryption/Encryption' 14 | export * from './stream/StreamPart' 15 | export * from './stream/StorageNode' 16 | export * from './subscribe' 17 | export * from './rest/LoginEndpoints' 18 | export * from './rest/StreamEndpoints' 19 | export * from './dataunion/DataUnion' 20 | export * from './rest/authFetch' 21 | export * from './types' 22 | 23 | // TODO should export these to support StreamMessageAsObject: 24 | // export { 25 | // StreamMessageType, ContentType, EncryptionType, SignatureType 26 | // } from 'streamr-client-protocol/dist/src/protocol/message_layer/StreamMessage' 27 | export { BigNumber } from '@ethersproject/bignumber' 28 | export { ConnectionInfo } from '@ethersproject/web' 29 | export { Contract } from '@ethersproject/contracts' 30 | export { BytesLike, Bytes } from '@ethersproject/bytes' 31 | export { ContractReceipt, ContractTransaction } from '@ethersproject/contracts' 32 | 33 | export default StreamrClient 34 | 35 | // Note awful export wrappers in index-commonjs.js & index-esm.mjs 36 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 24 | 25 | -------------------------------------------------------------------------------- /examples/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "streamr-client-webpack-example", 3 | "version": "0.0.0", 4 | "description": "Example of how streamr-client can be included in webpack projects", 5 | "scripts": { 6 | "build": "NODE_ENV=production webpack --mode=production --progress", 7 | "dev": "webpack --progress --colors --watch", 8 | "build-with-parent": "cp -Rpfv ../../dist ./node_modules/streamr-client/ && rm node_modules/streamr-client/package.json; cp ../../package.json ./node_modules/streamr-client/package.json && npm run build" 9 | }, 10 | "engines": { 11 | "node": ">= 8" 12 | }, 13 | "author": "Streamr", 14 | "license": "AGPL-3.0-or-later", 15 | "devDependencies": { 16 | "@babel/cli": "^7.7.7", 17 | "@babel/core": "^7.7.7", 18 | "@babel/plugin-proposal-class-properties": "^7.7.4", 19 | "@babel/plugin-transform-classes": "^7.7.4", 20 | "@babel/plugin-transform-modules-commonjs": "^7.7.5", 21 | "@babel/plugin-transform-runtime": "^7.7.6", 22 | "babel-loader": "^8.0.6", 23 | "babel-plugin-add-module-exports": "^1.0.2", 24 | "babel-plugin-transform-class-properties": "^6.24.1", 25 | "babel-plugin-transform-runtime": "^6.23.0", 26 | "core-js": "^2.6.11", 27 | "webpack": "^4.41.5", 28 | "webpack-cli": "^3.3.10" 29 | }, 30 | "#": "Update core-js -> 3.6.2, after releasing new version of client", 31 | "dependencies": { 32 | "@babel/runtime": "^7.11.2", 33 | "streamr-client": "^4.1.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/web/web-example-produce.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/stream/encryption/BrowserPersistentStore.ts: -------------------------------------------------------------------------------- 1 | import { PersistentStore } from './GroupKeyStore' 2 | import { get, set, del, clear, keys, createStore } from 'idb-keyval' 3 | 4 | export default class BrowserPersistentStore implements PersistentStore { 5 | readonly clientId: string 6 | readonly streamId: string 7 | private store?: any 8 | 9 | constructor({ clientId, streamId }: { clientId: string, streamId: string }) { 10 | this.streamId = encodeURIComponent(streamId) 11 | this.clientId = encodeURIComponent(clientId) 12 | this.store = createStore(`streamr-client::${clientId}::${streamId}`, 'GroupKeys') 13 | } 14 | 15 | async has(key: string) { 16 | const val = await this.get(key) 17 | return val == null 18 | } 19 | 20 | async get(key: string) { 21 | return get(key, this.store) 22 | } 23 | 24 | async set(key: string, value: string) { 25 | const had = await this.has(key) 26 | await set(key, value, this.store) 27 | return had 28 | } 29 | 30 | async delete(key: string) { 31 | if (!await this.has(key)) { 32 | return false 33 | } 34 | 35 | await del(key, this.store) 36 | return true 37 | } 38 | 39 | async clear() { 40 | const size = await this.size() 41 | await clear(this.store) 42 | return !!size 43 | } 44 | 45 | async size() { 46 | const allKeys = await keys(this.store) 47 | return allKeys.length 48 | } 49 | 50 | get [Symbol.toStringTag]() { 51 | return this.constructor.name 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/exports/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-template */ 2 | /* eslint-disable prefer-destructuring */ 3 | 4 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' // set a default NODE_ENV 5 | 6 | const path = require('path') 7 | 8 | module.exports = (env, argv) => { 9 | const isProduction = argv.mode === 'production' || process.env.NODE_ENV === 'production' 10 | 11 | return { 12 | mode: isProduction ? 'production' : 'development', 13 | target: 'web', 14 | entry: { 15 | commonjs: path.join(__dirname, 'tests/commonjs.js'), 16 | typescript: path.join(__dirname, 'tests/typescript.ts'), 17 | esm: path.join(__dirname, 'tests/esm.mjs'), 18 | }, 19 | devtool: false, 20 | output: { 21 | filename: '[name].webpacked.js', 22 | }, 23 | optimization: { 24 | minimize: false, 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /(\.jsx|\.js|\.ts)$/, 30 | exclude: /(node_modules|bower_components)/, 31 | use: { 32 | loader: 'babel-loader', 33 | options: { 34 | configFile: path.resolve(__dirname, '../../.babel.config.js'), 35 | babelrc: false, 36 | cacheDirectory: true, 37 | } 38 | } 39 | }, 40 | ], 41 | }, 42 | resolve: { 43 | modules: [path.resolve('./node_modules'), path.resolve('./tests/'), path.resolve('../../node_modules')], 44 | extensions: ['.json', '.js', '.ts', '.mjs'], 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | // don't care about line length 5 | // 200 seems generous, also just warn 6 | 'header-max-length': [1, 'always', 200], 7 | 'body-max-line-length': [1, 'always', 200], 8 | 'footer-max-line-length': [1, 'always', 200], 9 | // don't care about case 10 | 'header-case': [0, 'always', 'lower-case'], 11 | 'subject-case': [0, 'always', 'lower-case'], 12 | 'body-case': [0, 'always', 'lower-case'], 13 | // don't care about trailing full-stop. 14 | 'subject-full-stop': [0, 'never', '.'], 15 | 'header-full-stop': [0, 'never', '.'], 16 | // valid types + descriptions 17 | // feel free to add more types as necessary 18 | 'type-enum': [2, 'always', [ 19 | 'release', // Release commits. 20 | 'build', // Changes that affect the build system 21 | 'ci', // Changes to our CI configuration files and scripts. 22 | 'docs', // Documentation only changes 23 | 'feat', // A new feature 24 | 'fix', // A bug fix 25 | 'perf', // A code change that improves performance 26 | 'refactor', // A code change that neither fixes a bug nor adds a feature 27 | 'style', // Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 28 | 'test', // Adding missing tests or correcting existing tests 29 | 'revert', // git revert 30 | 'deps', // Changes that affect external dependencies e.g. refreshing package-lock, updating deps. 31 | 'deploy', // for gh-pages 32 | 'types', // for changes to types 33 | ]], 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/webpack/src/index.js: -------------------------------------------------------------------------------- 1 | import StreamrClient from 'streamr-client' 2 | 3 | const log = (msg) => { 4 | const elem = document.createElement('p') 5 | elem.innerHTML = msg 6 | document.body.appendChild(elem) 7 | } 8 | 9 | // Create the client with default options 10 | const client = new StreamrClient() 11 | 12 | document.getElementById('subscribe').addEventListener('click', () => { 13 | // Subscribe to a stream 14 | const subscription = client.subscribe({ 15 | stream: '7wa7APtlTq6EC5iTCBy6dw', 16 | // Resend the last 10 messages on connect 17 | resend: { 18 | last: 10, 19 | }, 20 | }, (message) => { 21 | // Handle the messages in this stream 22 | log(JSON.stringify(message)) 23 | }) 24 | 25 | // Event binding examples 26 | client.on('connected', () => { 27 | log('A connection has been established!') 28 | }) 29 | 30 | subscription.on('subscribed', () => { 31 | log(`Subscribed to ${subscription.streamId}`) 32 | }) 33 | 34 | subscription.on('resending', () => { 35 | log(`Resending from ${subscription.streamId}`) 36 | }) 37 | 38 | subscription.on('resent', () => { 39 | log(`Resend complete for ${subscription.streamId}`) 40 | }) 41 | 42 | subscription.on('no_resend', () => { 43 | log(`Nothing to resend for ${subscription.streamId}`) 44 | }) 45 | }) 46 | 47 | document.getElementById('publish').addEventListener('click', () => { 48 | // Here is the event we'll be sending 49 | const msg = { 50 | hello: 'world', 51 | random: Math.random(), 52 | } 53 | 54 | // Publish the event to the Stream 55 | client.publish('MY-STREAM-ID', msg, 'MY-API-KEY') 56 | .then(() => log('Sent successfully: ', msg)) 57 | .catch((err) => log(err)) 58 | }) 59 | -------------------------------------------------------------------------------- /src/subscribe/messageStream.js: -------------------------------------------------------------------------------- 1 | import { ControlLayer } from 'streamr-client-protocol' 2 | 3 | import PushQueue from '../utils/PushQueue' 4 | 5 | const { ControlMessage } = ControlLayer 6 | 7 | function getIsMatchingStreamMessage({ streamId, streamPartition = 0 }) { 8 | return function isMatchingStreamMessage({ streamMessage }) { 9 | const msgStreamId = streamMessage.getStreamId() 10 | if (streamId !== msgStreamId) { return false } 11 | const msgPartition = streamMessage.getStreamPartition() 12 | if (streamPartition !== msgPartition) { return false } 13 | return true 14 | } 15 | } 16 | 17 | /** 18 | * Listen for matching stream messages on connection. 19 | * Returns a PushQueue that will fill with messages. 20 | */ 21 | 22 | export default function messageStream(connection, { streamId, streamPartition, isUnicast, type }, onFinally = async () => {}) { 23 | if (!type) { 24 | // eslint-disable-next-line no-param-reassign 25 | type = isUnicast ? ControlMessage.TYPES.UnicastMessage : ControlMessage.TYPES.BroadcastMessage 26 | } 27 | 28 | const isMatchingStreamMessage = getIsMatchingStreamMessage({ 29 | streamId, 30 | streamPartition 31 | }) 32 | 33 | let msgStream 34 | // write matching messages to stream 35 | const onMessage = (msg) => { 36 | if (!isMatchingStreamMessage(msg)) { return } 37 | msgStream.push(msg) 38 | } 39 | 40 | // stream acts as buffer 41 | msgStream = new PushQueue([], { 42 | async onEnd(...args) { 43 | // remove onMessage handler & clean up 44 | connection.off(type, onMessage) 45 | await onFinally(...args) 46 | } 47 | }) 48 | 49 | Object.assign(msgStream, { 50 | streamId, 51 | streamPartition, 52 | }) 53 | 54 | connection.on(type, onMessage) 55 | 56 | return msgStream 57 | } 58 | -------------------------------------------------------------------------------- /test/integration/authFetch.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock('node-fetch') 2 | 3 | import fetch from 'node-fetch' 4 | 5 | import { StreamrClient } from '../../src/StreamrClient' 6 | import { fakePrivateKey } from '../utils' 7 | 8 | import { clientOptions } from './devEnvironment' 9 | 10 | describe('authFetch', () => { 11 | let client: StreamrClient 12 | afterEach(async () => { 13 | if (!client) { return } 14 | await client.ensureDisconnected() 15 | }) 16 | 17 | afterAll(() => { 18 | jest.restoreAllMocks() 19 | }) 20 | 21 | it('sends Streamr-Client header', async () => { 22 | const realFetch = jest.requireActual('node-fetch') 23 | // @ts-expect-error 24 | fetch.Response = realFetch.Response 25 | // @ts-expect-error 26 | fetch.Promise = realFetch.Promise 27 | // @ts-expect-error 28 | fetch.Request = realFetch.Request 29 | // @ts-expect-error 30 | fetch.Headers = realFetch.Headers 31 | // @ts-expect-error 32 | fetch.mockImplementation(realFetch) 33 | client = new StreamrClient({ 34 | ...clientOptions, 35 | autoConnect: false, 36 | autoDisconnect: false, 37 | auth: { 38 | privateKey: fakePrivateKey() 39 | }, 40 | }) 41 | await client.connect() 42 | expect(fetch).not.toHaveBeenCalled() // will get called in background though (questionable behaviour) 43 | await client.session.getSessionToken() // this ensures authentication completed 44 | expect(fetch).toHaveBeenCalled() 45 | // @ts-expect-error 46 | fetch.mock.calls.forEach(([url, opts]) => { 47 | expect(typeof url).toEqual('string') 48 | expect(opts).toMatchObject({ 49 | headers: { 50 | 'Streamr-Client': expect.stringMatching('streamr-client-javascript'), 51 | }, 52 | }) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/integration/dataunion/deploy.test.ts: -------------------------------------------------------------------------------- 1 | import { providers } from 'ethers' 2 | import debug from 'debug' 3 | 4 | import { StreamrClient } from '../../../src/StreamrClient' 5 | import { clientOptions } from '../devEnvironment' 6 | import { createMockAddress } from '../../utils' 7 | 8 | const log = debug('StreamrClient::DataUnion::integration-test-deploy') 9 | 10 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 11 | const providerMainnet = new providers.JsonRpcProvider(clientOptions.mainnet) 12 | 13 | describe('DataUnion deploy', () => { 14 | 15 | let adminClient: StreamrClient 16 | 17 | beforeAll(async () => { 18 | log('Connecting to Ethereum networks, clientOptions: %O', clientOptions) 19 | const network = await providerMainnet.getNetwork() 20 | log('Connected to "mainnet" network: ', JSON.stringify(network)) 21 | const network2 = await providerSidechain.getNetwork() 22 | log('Connected to sidechain network: ', JSON.stringify(network2)) 23 | adminClient = new StreamrClient(clientOptions as any) 24 | }, 60000) 25 | 26 | afterAll(() => { 27 | providerMainnet.removeAllListeners() 28 | providerSidechain.removeAllListeners() 29 | }) 30 | 31 | describe('owner', () => { 32 | 33 | it('not specified: defaults to deployer', async () => { 34 | const dataUnion = await adminClient.deployDataUnion() 35 | expect(await dataUnion.getAdminAddress()).toBe(await adminClient.getAddress()) 36 | }, 60000) 37 | 38 | it('specified', async () => { 39 | const owner = createMockAddress() 40 | const dataUnion = await adminClient.deployDataUnion({ owner }) 41 | expect(await dataUnion.getAdminAddress()).toBe(owner) 42 | }, 60000) 43 | 44 | it('invalid', () => { 45 | return expect(() => adminClient.deployDataUnion({ owner: 'foobar' })).rejects.toThrow('invalid address') 46 | }, 60000) 47 | 48 | }) 49 | }) 50 | 51 | -------------------------------------------------------------------------------- /src/user/index.ts: -------------------------------------------------------------------------------- 1 | import { computeAddress } from '@ethersproject/transactions' 2 | import { Web3Provider } from '@ethersproject/providers' 3 | import { hexlify } from '@ethersproject/bytes' 4 | import { sha256 } from '@ethersproject/sha2' 5 | import { StreamrClient } from '../StreamrClient' 6 | import { EthereumConfig } from '../Config' 7 | 8 | async function getUsername(client: StreamrClient) { 9 | const { options: { auth = {} } = {} } = client 10 | if (auth.username) { return auth.username } 11 | 12 | const { username, id } = await client.cached.getUserInfo() 13 | return ( 14 | username 15 | // edge case: if auth.apiKey is an anonymous key, userInfo.id is that anonymous key 16 | // update: not sure if still needed now that apiKey auth has been disabled 17 | || id 18 | ) 19 | } 20 | 21 | export async function getAddressFromOptions({ ethereum, privateKey }: { ethereum?: EthereumConfig, privateKey?: any} = {}) { 22 | if (privateKey) { 23 | return computeAddress(privateKey).toLowerCase() 24 | } 25 | 26 | if (ethereum) { 27 | const provider = new Web3Provider(ethereum) 28 | const address = await provider.getSigner().getAddress() 29 | return address.toLowerCase() 30 | } 31 | 32 | throw new Error('Need either "privateKey" or "ethereum".') 33 | } 34 | 35 | export async function getUserId(client: StreamrClient) { 36 | if (client.session.isUnauthenticated()) { 37 | throw new Error('Need to be authenticated to getUserId.') 38 | } 39 | 40 | const { options: { auth = {} } = {} } = client 41 | if (auth.ethereum || auth.privateKey) { 42 | return getAddressFromOptions(auth) 43 | } 44 | 45 | const username = await getUsername(client) 46 | 47 | if (username != null) { 48 | const hexString = hexlify(Buffer.from(username, 'utf8')) 49 | return sha256(hexString) 50 | } 51 | 52 | throw new Error('Need either "privateKey", "ethereum" or "sessionToken" to derive the publisher Id.') 53 | } 54 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: [ 4 | '@typescript-eslint' 5 | ], 6 | extends: [ 7 | 'streamr-nodejs' 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 2020, 11 | ecmaFeatures: { 12 | modules: true 13 | } 14 | }, 15 | env: { 16 | browser: true, 17 | es6: true 18 | }, 19 | rules: { 20 | 'max-len': ['warn', { 21 | code: 150 22 | }], 23 | 'no-plusplus': ['error', { 24 | allowForLoopAfterthoughts: true 25 | }], 26 | 'no-underscore-dangle': ['error', { 27 | allowAfterThis: true 28 | }], 29 | 'padding-line-between-statements': [ 30 | 'error', 31 | { 32 | blankLine: 'always', prev: 'if', next: 'if' 33 | } 34 | ], 35 | 'prefer-destructuring': 'warn', 36 | 'object-curly-newline': 'off', 37 | 'no-continue': 'off', 38 | 'max-classes-per-file': 'off', // javascript is not java 39 | // TODO check all errors/warnings and create separate PR 40 | 'promise/always-return': 'warn', 41 | 'promise/catch-or-return': 'warn', 42 | 'require-atomic-updates': 'warn', 43 | 'promise/param-names': 'warn', 44 | 'no-restricted-syntax': [ 45 | 'error', 'ForInStatement', 'LabeledStatement', 'WithStatement' 46 | ], 47 | 'import/extensions': ['error', 'never', { json: 'always' }], 48 | 'lines-between-class-members': 'off', 49 | 'padded-blocks': 'off', 50 | 'no-use-before-define': 'off', 51 | 'import/order': 'off', 52 | 'no-shadow': 'off', 53 | '@typescript-eslint/no-shadow': 'error', 54 | 'no-unused-vars': 'off', 55 | '@typescript-eslint/no-unused-vars': ['error'], 56 | }, 57 | settings: { 58 | 'import/resolver': { 59 | node: { 60 | extensions: ['.js', '.ts'] 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/integration/devEnvironment.ts: -------------------------------------------------------------------------------- 1 | import { Wallet } from 'ethers' 2 | import { JsonRpcProvider } from '@ethersproject/providers' 3 | import { id } from 'ethers/lib/utils' 4 | import clientOptionsConfig from './config' 5 | 6 | export const clientOptions = clientOptionsConfig 7 | 8 | export const tokenMediatorAddress = '0xedD2aa644a6843F2e5133Fe3d6BD3F4080d97D9F' 9 | 10 | // can mint mainnet DATA tokens 11 | export const tokenAdminPrivateKey = '0x5e98cce00cff5dea6b454889f359a4ec06b9fa6b88e9d69b86de8e1c81887da0' 12 | 13 | export const providerSidechain = new JsonRpcProvider(clientOptions.sidechain) 14 | export const providerMainnet = new JsonRpcProvider(clientOptions.mainnet) 15 | 16 | export function getTestWallet(index: number, provider: JsonRpcProvider) { 17 | // TODO: change to 'streamr-client-javascript' once https://github.com/streamr-dev/smart-contracts-init/pull/36 is in docker 18 | const hash = id(`marketplace-contracts${index}`) 19 | return new Wallet(hash, provider) 20 | } 21 | 22 | export function getMainnetTestWallet(index: number) { 23 | return getTestWallet(index, providerMainnet) 24 | } 25 | 26 | export function getSidechainTestWallet(index: number) { 27 | return getTestWallet(index, providerSidechain) 28 | } 29 | 30 | export const relayTokensAbi = [ 31 | { 32 | inputs: [ 33 | { 34 | internalType: 'address', 35 | name: 'token', 36 | type: 'address' 37 | }, 38 | { 39 | internalType: 'address', 40 | name: '_receiver', 41 | type: 'address' 42 | }, 43 | { 44 | internalType: 'uint256', 45 | name: '_value', 46 | type: 'uint256' 47 | }, 48 | { 49 | internalType: 'bytes', 50 | name: '_data', 51 | type: 'bytes' 52 | } 53 | ], 54 | name: 'relayTokensAndCall', 55 | outputs: [], 56 | stateMutability: 'nonpayable', 57 | type: 'function' 58 | } 59 | ] 60 | -------------------------------------------------------------------------------- /test/integration/config.js: -------------------------------------------------------------------------------- 1 | const toNumber = (value) => { 2 | return (value !== undefined) ? Number(value) : undefined 3 | } 4 | 5 | /** 6 | * Streamr client constructor options that work in the test environment 7 | */ 8 | module.exports = { 9 | // ganache 1: 0x4178baBE9E5148c6D5fd431cD72884B07Ad855a0 10 | auth: { 11 | privateKey: process.env.ETHEREUM_PRIVATE_KEY || '0xe5af7834455b7239881b85be89d905d6881dcb4751063897f12be1b0dd546bdb', 12 | }, 13 | url: process.env.WEBSOCKET_URL || `ws://${process.env.STREAMR_DOCKER_DEV_HOST || 'localhost'}/api/v1/ws`, 14 | restUrl: process.env.REST_URL || `http://${process.env.STREAMR_DOCKER_DEV_HOST || 'localhost'}/api/v1`, 15 | streamrNodeAddress: '0xFCAd0B19bB29D4674531d6f115237E16AfCE377c', 16 | tokenAddress: process.env.TOKEN_ADDRESS || '0xbAA81A0179015bE47Ad439566374F2Bae098686F', 17 | tokenSidechainAddress: process.env.TOKEN_ADDRESS_SIDECHAIN || '0x73Be21733CC5D08e1a14Ea9a399fb27DB3BEf8fF', 18 | dataUnion: { 19 | factoryMainnetAddress: process.env.DU_FACTORY_MAINNET || '0x4bbcBeFBEC587f6C4AF9AF9B48847caEa1Fe81dA', 20 | factorySidechainAddress: process.env.DU_FACTORY_SIDECHAIN || '0x4A4c4759eb3b7ABee079f832850cD3D0dC48D927', 21 | templateMainnetAddress: process.env.DU_TEMPLATE_MAINNET || '0x7bFBAe10AE5b5eF45e2aC396E0E605F6658eF3Bc', 22 | templateSidechainAddress: process.env.DU_TEMPLATE_SIDECHAIN || '0x36afc8c9283CC866b8EB6a61C6e6862a83cd6ee8', 23 | }, 24 | storageNode: { 25 | address: '0xde1112f631486CfC759A50196853011528bC5FA0', 26 | url: `http://${process.env.STREAMR_DOCKER_DEV_HOST || '10.200.10.1'}:8891` 27 | }, 28 | sidechain: { 29 | url: process.env.SIDECHAIN_URL || `http://${process.env.STREAMR_DOCKER_DEV_HOST || '10.200.10.1'}:8546`, 30 | timeout: toNumber(process.env.TEST_TIMEOUT), 31 | }, 32 | mainnet: { 33 | url: process.env.ETHEREUM_SERVER_URL || `http://${process.env.STREAMR_DOCKER_DEV_HOST || '10.200.10.1'}:8545`, 34 | timeout: toNumber(process.env.TEST_TIMEOUT), 35 | }, 36 | autoConnect: false, 37 | autoDisconnect: false, 38 | } 39 | -------------------------------------------------------------------------------- /test/unit/pUtils.test.js: -------------------------------------------------------------------------------- 1 | import { wait } from 'streamr-test-utils' 2 | 3 | import { pOrderedResolve, CacheAsyncFn } from '../../src/utils' 4 | 5 | describe('pOrderedResolve', () => { 6 | it('Execute functions concurrently, resolving in order they were executed', async () => { 7 | let count = 0 8 | let active = 0 9 | const orderedFn = pOrderedResolve(async (index) => { 10 | try { 11 | active += 1 12 | if (index === 1) { 13 | await wait(50) // delay first call 14 | } else { 15 | expect(active).toBeGreaterThan(1) // ensure concurrent 16 | await wait(1) 17 | } 18 | return index 19 | } finally { 20 | active -= 1 // eslint-disable-line require-atomic-updates 21 | } 22 | }) 23 | 24 | const results = [] 25 | const fn = async () => { 26 | count += 1 27 | const v = await orderedFn(count) 28 | results.push(v) 29 | return v 30 | } 31 | 32 | await Promise.all([fn(), fn(), fn()]) 33 | 34 | expect(results).toEqual([1, 2, 3]) 35 | 36 | expect(active).toBe(0) 37 | }) 38 | }) 39 | 40 | describe('CacheAsyncFn', () => { 41 | it('caches & be cleared', async () => { 42 | const fn = jest.fn() 43 | const cachedFn = CacheAsyncFn(fn) 44 | await cachedFn() 45 | expect(fn).toHaveBeenCalledTimes(1) 46 | await cachedFn() 47 | expect(fn).toHaveBeenCalledTimes(1) 48 | await cachedFn(1) 49 | expect(fn).toHaveBeenCalledTimes(2) 50 | await cachedFn(1) 51 | expect(fn).toHaveBeenCalledTimes(2) 52 | await cachedFn(2) 53 | expect(fn).toHaveBeenCalledTimes(3) 54 | cachedFn.clear() 55 | await cachedFn(1) 56 | expect(fn).toHaveBeenCalledTimes(4) 57 | await cachedFn(2) 58 | expect(fn).toHaveBeenCalledTimes(5) 59 | await cachedFn.clearMatching((v) => v === 1) 60 | await cachedFn(1) 61 | expect(fn).toHaveBeenCalledTimes(6) 62 | await cachedFn.clearMatching((v) => v === 1) 63 | await cachedFn(1) 64 | expect(fn).toHaveBeenCalledTimes(7) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/subscribe/resendStream.js: -------------------------------------------------------------------------------- 1 | import { ControlLayer } from 'streamr-client-protocol' 2 | 3 | import { counterId } from '../utils' 4 | import { validateOptions, waitForResponse } from '../stream/utils' 5 | 6 | import { resend } from './api' 7 | import messageStream from './messageStream' 8 | 9 | const { ControlMessage } = ControlLayer 10 | 11 | /** 12 | * Stream of resent messages. 13 | * Sends resend request, handles responses. 14 | */ 15 | 16 | export default function resendStream(client, opts = {}, onFinally = async () => {}) { 17 | const options = validateOptions(opts) 18 | const { connection } = client 19 | const requestId = counterId(`${options.key}-resend`) 20 | const msgStream = messageStream(client.connection, { 21 | ...options, 22 | isUnicast: true, 23 | }, async (...args) => { 24 | try { 25 | await connection.removeHandle(requestId) 26 | } finally { 27 | await onFinally(...args) 28 | } 29 | }) 30 | 31 | const onResendDone = waitForResponse({ // eslint-disable-line promise/catch-or-return 32 | requestId, 33 | connection: client.connection, 34 | types: [ 35 | ControlMessage.TYPES.ResendResponseResent, 36 | ControlMessage.TYPES.ResendResponseNoResend, 37 | ], 38 | }).then(() => ( 39 | msgStream.end() 40 | ), async (err) => { 41 | await msgStream.cancel(err) 42 | throw err 43 | }) 44 | 45 | // wait for resend complete message or resend request done 46 | return Object.assign(msgStream, { 47 | async subscribe() { 48 | await connection.addHandle(requestId) 49 | // wait for resend complete message or resend request done 50 | let error 51 | await Promise.race([ 52 | resend(client, { 53 | requestId, 54 | ...options, 55 | }).catch((err) => { 56 | error = err 57 | }), 58 | onResendDone.catch((err) => { 59 | error = err 60 | }) 61 | ]) 62 | if (error) { 63 | await msgStream.cancel(error) 64 | throw error 65 | } 66 | return this 67 | }, 68 | async unsubscribe() { 69 | return this.cancel() 70 | } 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /test/unit/Config.test.ts: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set' 2 | import { arrayify, BytesLike } from '@ethersproject/bytes' 3 | 4 | import { StreamrClient } from '../../src/StreamrClient' 5 | 6 | describe('Config', () => { 7 | describe('validate ethereum addresses', () => { 8 | const createClient = (propertyPaths: string, value: string|undefined|null) => { 9 | const opts: any = {} 10 | set(opts, propertyPaths, value) 11 | return new StreamrClient(opts) 12 | } 13 | const propertyPaths: string[] = [ 14 | 'streamrNodeAddress', 15 | 'tokenAddress', 16 | 'tokenSidechainAddress', 17 | 'dataUnion.factoryMainnetAddress', 18 | 'dataUnion.factorySidechainAddress', 19 | 'dataUnion.templateMainnetAddress', 20 | 'dataUnion.templateSidechainAddress', 21 | 'storageNode.address' 22 | ] 23 | for (const propertyPath of propertyPaths) { 24 | it(propertyPath, () => { 25 | const errorMessage = `${propertyPath} is not a valid Ethereum address` 26 | expect(() => createClient(propertyPath, 'invalid-address')).toThrow(errorMessage) 27 | expect(() => createClient(propertyPath, undefined)).toThrow(errorMessage) 28 | expect(() => createClient(propertyPath, null)).toThrow(errorMessage) 29 | expect(() => createClient(propertyPath, '0x1234567890123456789012345678901234567890')).not.toThrow() 30 | }) 31 | } 32 | }) 33 | 34 | describe('private key', () => { 35 | const createAuthenticatedClient = (privateKey: BytesLike) => { 36 | return new StreamrClient({ 37 | auth: { 38 | privateKey 39 | } 40 | }) 41 | } 42 | it('string', async () => { 43 | const client = createAuthenticatedClient('0x0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF') 44 | expect(await client.getAddress()).toBe('0xFCAd0B19bB29D4674531d6f115237E16AfCE377c') 45 | }) 46 | it('byteslike', async () => { 47 | const client = createAuthenticatedClient(arrayify('0x0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF')) 48 | expect(await client.getAddress()).toBe('0xFCAd0B19bB29D4674531d6f115237E16AfCE377c') 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Unreleased](https://github.com/streamr-dev/streamr-client/compare/v5.3.0-beta.0...c98b04415cdf558b483f70a838e58b2a5321ffed) (2021-05-01) 2 | 3 | ### Bug Fixes 4 | 5 | * Don't use waitForCondition outside test, doesn't wait for async, uses browser-incompatible setImmediate. ([c98b044](https://github.com/streamr-dev/streamr-client/commit/c98b04415cdf558b483f70a838e58b2a5321ffed)) 6 | * **login:** Remove apiKey login support, no longer supported by core-api. ([f37bac5](https://github.com/streamr-dev/streamr-client/commit/f37bac53972ed9dc429ffdbd1567172d6e502801)) 7 | 8 | ### Features 9 | 10 | * Don't clear message chains on disconnect, allows continued publishing to same chain. ([df64089](https://github.com/streamr-dev/streamr-client/commit/df6408985d0001a88d118d9712c2cc92f595748d)) 11 | 12 | 13 | ## [5.2.1](https://github.com/streamr-dev/streamr-client/compare/v5.1.0...v5.2.1) (2021-03-29) 14 | 15 | This release fixes a subtle but serious memleak in all previous 5.x 16 | versions. Upgrading immediately is highly recommended. 17 | 18 | ### Bug Fixes 19 | 20 | * **dataunions:** Withdraw amount can be a string ([bccebcb](https://github.com/streamr-dev/streamr-client/commit/bccebcb580e91f55a68a0aae864dcc48cf370bfa)) 21 | * **keyexchange:** Remove bad call to cancelTask. ([cb576fd](https://github.com/streamr-dev/streamr-client/commit/cb576fdccbbf2600011c89a151cb15a3870a258a)) 22 | * **pipeline:** Fix memleak in pipeline. ([86fcb83](https://github.com/streamr-dev/streamr-client/commit/86fcb833d74df267ad538fc37cf76c4f95c9462c)) 23 | * **pushqueue:** Empty pending promises array in cleanup, prevents memleak. ([1e4892c](https://github.com/streamr-dev/streamr-client/commit/1e4892ccabd6853c59648fc025bc8be1fd630e55)) 24 | * **pushqueue:** Transform was doing something unusual. ([27dbb05](https://github.com/streamr-dev/streamr-client/commit/27dbb05612a2d838e4ce399fb643d6ca00c1af74)) 25 | * **subscribe:** Clean up type errors. ([1841741](https://github.com/streamr-dev/streamr-client/commit/184174127835a3d11160acbe4804b621e4480a86)) 26 | * **util:** Clean up dangling Defers in LimitAsyncFnByKey. ([1eaa55a](https://github.com/streamr-dev/streamr-client/commit/1eaa55a9b51f44e055148ba80fa51e1d63fe2a77)) 27 | * **util/defer:** Rejig exposed functions so resolve/reject can be gc'ed. ([2638f2b](https://github.com/streamr-dev/streamr-client/commit/2638f2b164455b6c45f3c6e9d3d6b297669ebc7a)) 28 | * **validator:** Only keep a small message validator cache. ([de7b689](https://github.com/streamr-dev/streamr-client/commit/de7b68953dd52f8fbdf4bee4dff2ba6b3bd02545)) 29 | -------------------------------------------------------------------------------- /test/integration/Session.test.ts: -------------------------------------------------------------------------------- 1 | import { StreamrClient } from '../../src/StreamrClient' 2 | import { fakePrivateKey } from '../utils' 3 | 4 | import { clientOptions } from './devEnvironment' 5 | 6 | describe('Session', () => { 7 | const createClient = (opts = {}) => new StreamrClient({ 8 | ...clientOptions, 9 | ...opts, 10 | autoConnect: false, 11 | autoDisconnect: false, 12 | }) 13 | 14 | describe('Token retrievals', () => { 15 | it('fails if trying to use apiKey', async () => { 16 | expect.assertions(1) 17 | await expect(() => createClient({ 18 | auth: { 19 | apiKey: 'tester1-api-key', 20 | }, 21 | }).session.getSessionToken()).rejects.toThrow('no longer supported') 22 | }) 23 | 24 | it('gets the token using private key', async () => { 25 | expect.assertions(1) 26 | await expect(createClient({ 27 | auth: { 28 | privateKey: fakePrivateKey(), 29 | }, 30 | }).session.getSessionToken()).resolves.toBeTruthy() 31 | }) 32 | 33 | it('can handle multiple client instances', async () => { 34 | expect.assertions(1) 35 | const client1 = createClient({ 36 | auth: { 37 | privateKey: fakePrivateKey(), 38 | }, 39 | }) 40 | const client2 = createClient({ 41 | auth: { 42 | privateKey: fakePrivateKey(), 43 | }, 44 | }) 45 | const token1 = await client1.session.getSessionToken() 46 | const token2 = await client2.session.getSessionToken() 47 | expect(token1).not.toEqual(token2) 48 | }) 49 | 50 | it('fails if trying to get the token using username and password', async () => { 51 | expect.assertions(1) 52 | await expect(() => createClient({ 53 | auth: { 54 | username: 'tester2@streamr.com', 55 | password: 'tester2', 56 | }, 57 | }).session.getSessionToken()).rejects.toThrow('no longer supported') 58 | }) 59 | 60 | it('gets no token (undefined) when the auth object is empty', async () => { 61 | expect.assertions(1) 62 | await expect(createClient({ 63 | auth: {}, 64 | }).session.getSessionToken()).resolves.toBeUndefined() 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /examples/web/web-example-metamask.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 48 | 49 | 50 | 51 | 54 | 55 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/stream/encryption/KeyExchangeUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StreamMessage, Errors 3 | } from 'streamr-client-protocol' 4 | 5 | import { GroupKey, GroupKeyish } from './Encryption' 6 | import { StreamrClient } from '../../StreamrClient' 7 | 8 | const KEY_EXCHANGE_STREAM_PREFIX = 'SYSTEM/keyexchange' 9 | 10 | export const { ValidationError } = Errors 11 | 12 | export function isKeyExchangeStream(id = '') { 13 | return id.startsWith(KEY_EXCHANGE_STREAM_PREFIX) 14 | } 15 | 16 | /* 17 | class InvalidGroupKeyResponseError extends Error { 18 | constructor(...args) { 19 | super(...args) 20 | this.code = 'INVALID_GROUP_KEY_RESPONSE' 21 | if (Error.captureStackTrace) { 22 | Error.captureStackTrace(this, this.constructor) 23 | } 24 | } 25 | } 26 | 27 | class InvalidContentTypeError extends Error { 28 | constructor(...args) { 29 | super(...args) 30 | this.code = 'INVALID_MESSAGE_TYPE' 31 | if (Error.captureStackTrace) { 32 | Error.captureStackTrace(this, this.constructor) 33 | } 34 | } 35 | } 36 | */ 37 | 38 | type Address = string 39 | export type GroupKeyId = string 40 | 41 | export function getKeyExchangeStreamId(address: Address) { 42 | if (isKeyExchangeStream(address)) { 43 | return address // prevent ever double-handling 44 | } 45 | return `${KEY_EXCHANGE_STREAM_PREFIX}/${address.toLowerCase()}` 46 | } 47 | 48 | export type GroupKeysSerialized = Record 49 | 50 | export function parseGroupKeys(groupKeys: GroupKeysSerialized = {}): Map { 51 | return new Map(Object.entries(groupKeys || {}).map(([key, value]) => { 52 | if (!value || !key) { return null } 53 | return [key, GroupKey.from(value)] 54 | }).filter(Boolean) as []) 55 | } 56 | 57 | export async function subscribeToKeyExchangeStream(client: StreamrClient, onKeyExchangeMessage: (msg: any, streamMessage: StreamMessage) => void) { 58 | const { options } = client 59 | if ((!options.auth!.privateKey && !options.auth!.ethereum) || !options.keyExchange) { 60 | return Promise.resolve() 61 | } 62 | 63 | await client.session.getSessionToken() // trigger auth errors if any 64 | // subscribing to own keyexchange stream 65 | const publisherId = await client.getUserId() 66 | const streamId = getKeyExchangeStreamId(publisherId) 67 | const sub = await client.subscribe(streamId, onKeyExchangeMessage) 68 | sub.on('error', () => {}) // errors should not shut down subscription 69 | return sub 70 | } 71 | 72 | export type KeyExhangeOptions = { 73 | groupKeys?: Record 74 | } 75 | -------------------------------------------------------------------------------- /test/browser/realtime.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { v4: uuidv4 } = require('uuid') 3 | 4 | describe('StreamrClient Realtime', () => { 5 | const streamName = uuidv4() 6 | 7 | before((browser) => { 8 | // optionally forward url env vars as query params 9 | const url = process.env.WEBSOCKET_URL ? `&WEBSOCKET_URL=${encodeURIComponent(process.env.WEBSOCKET_URL)}` : '' 10 | const restUrl = process.env.REST_URL ? `&REST_URL=${encodeURIComponent(process.env.REST_URL)}` : '' 11 | const browserUrl = `http://localhost:8880?streamName=${streamName}${url}${restUrl}` 12 | // eslint-disable-next-line no-console 13 | console.info(browserUrl) 14 | return browser.url(browserUrl) 15 | }) 16 | 17 | test('Test StreamrClient in Chrome Browser', (browser) => { 18 | browser 19 | .waitForElementVisible('body') 20 | .assert.titleContains('Test StreamrClient in Chrome Browser') 21 | .click('button[id=connect]') 22 | .assert.containsText('#result', 'connected') 23 | .click('button[id=create]') 24 | .pause(6000) 25 | .assert.containsText('#result', streamName) 26 | .click('button[id=subscribe]') 27 | .assert.containsText('#result', 'subscribed') 28 | .click('button[id=publish]') 29 | .pause(6000) 30 | .verify.containsText('#result', '{"msg":0}') 31 | .verify.containsText('#result', '{"msg":1}') 32 | .verify.containsText('#result', '{"msg":2}') 33 | .verify.containsText('#result', '{"msg":3}') 34 | .verify.containsText('#result', '{"msg":4}') 35 | .verify.containsText('#result', '{"msg":5}') 36 | .verify.containsText('#result', '{"msg":6}') 37 | .verify.containsText('#result', '{"msg":7}') 38 | .verify.containsText('#result', '{"msg":8}') 39 | .verify.containsText('#result', '{"msg":9}') 40 | .assert.containsText('#result', '[{"msg":0},{"msg":1},{"msg":2},{"msg":3},{"msg":4},{"msg":5},{"msg":6},{"msg":7},{"msg":8},{"msg":9}]') 41 | .click('button[id=disconnect]') 42 | .assert.containsText('#result', 'disconnected') 43 | }) 44 | 45 | after(async (browser) => { 46 | browser.getLog('browser', (logs) => { 47 | logs.forEach((log) => { 48 | // eslint-disable-next-line no-console 49 | const logger = console[String(log.level).toLowerCase()] || console.log 50 | logger('[%s]: ', log.timestamp, log.message) 51 | }) 52 | }) 53 | return browser.end() 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/legacy/CombinedSubscription.test.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon' 2 | import { ControlLayer, MessageLayer } from 'streamr-client-protocol' 3 | 4 | import CombinedSubscription from '../../src/CombinedSubscription' 5 | 6 | const { StreamMessage, MessageIDStrict, MessageRef } = MessageLayer 7 | 8 | const createMsg = ( 9 | timestamp = 1, sequenceNumber = 0, prevTimestamp = null, 10 | prevSequenceNumber = 0, content = {}, publisherId = 'publisherId', msgChainId = '1', 11 | encryptionType = StreamMessage.ENCRYPTION_TYPES.NONE, 12 | ) => { 13 | const prevMsgRef = prevTimestamp ? new MessageRef(prevTimestamp, prevSequenceNumber) : null 14 | return new StreamMessage({ 15 | messageId: new MessageIDStrict('streamId', 0, timestamp, sequenceNumber, publisherId, msgChainId), 16 | prevMsgRef, 17 | content, 18 | messageType: StreamMessage.MESSAGE_TYPES.MESSAGE, 19 | encryptionType, 20 | signatureType: StreamMessage.SIGNATURE_TYPES.NONE, 21 | signature: '', 22 | }) 23 | } 24 | 25 | const msg1 = createMsg() 26 | 27 | describe('CombinedSubscription', () => { 28 | it('handles real time gap that occurred during initial resend', (done) => { 29 | const msg4 = createMsg(4, undefined, 3) 30 | const sub = new CombinedSubscription({ 31 | streamId: msg1.getStreamId(), 32 | streamPartition: msg1.getStreamPartition(), 33 | callback: sinon.stub(), 34 | options: { 35 | last: 1 36 | }, 37 | propagationTimeout: 100, 38 | resendTimeout: 100, 39 | }) 40 | sub.on('error', done) 41 | sub.addPendingResendRequestId('requestId') 42 | sub.on('gap', (from, to, publisherId) => { 43 | expect(from.timestamp).toEqual(1) 44 | expect(from.sequenceNumber).toEqual(1) 45 | expect(to.timestamp).toEqual(3) 46 | expect(to.sequenceNumber).toEqual(0) 47 | expect(publisherId).toEqual('publisherId') 48 | setTimeout(() => { 49 | sub.stop() 50 | done() 51 | }, 100) 52 | }) 53 | sub.handleResending(new ControlLayer.ResendResponseResending({ 54 | streamId: 'streamId', 55 | streamPartition: 0, 56 | requestId: 'requestId', 57 | })) 58 | sub.handleResentMessage(msg1, 'requestId', sinon.stub().resolves(true)) 59 | sub.handleBroadcastMessage(msg4, sinon.stub().resolves(true)) 60 | sub.handleResent(new ControlLayer.ResendResponseNoResend({ 61 | streamId: 'streamId', 62 | streamPartition: 0, 63 | requestId: 'requestId', 64 | })) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/subscribe/Decrypt.js: -------------------------------------------------------------------------------- 1 | import { MessageLayer } from 'streamr-client-protocol' 2 | 3 | import EncryptionUtil, { UnableToDecryptError } from '../stream/encryption/Encryption' 4 | import { SubscriberKeyExchange } from '../stream/encryption/KeyExchangeSubscriber' 5 | 6 | const { StreamMessage } = MessageLayer 7 | 8 | export default function Decrypt(client, options = {}) { 9 | if (!client.options.keyExchange) { 10 | // noop unless message encrypted 11 | return (streamMessage) => { 12 | if (streamMessage.groupKeyId) { 13 | throw new UnableToDecryptError('No keyExchange configured, cannot decrypt any message.', streamMessage) 14 | } 15 | 16 | return streamMessage 17 | } 18 | } 19 | 20 | const keyExchange = new SubscriberKeyExchange(client, { 21 | ...options, 22 | groupKeys: { 23 | ...client.options.groupKeys, 24 | ...options.groupKeys, 25 | } 26 | }) 27 | 28 | async function* decrypt(src, onError = async () => {}) { 29 | for await (const streamMessage of src) { 30 | if (!streamMessage.groupKeyId) { 31 | yield streamMessage 32 | continue 33 | } 34 | 35 | if (streamMessage.encryptionType !== StreamMessage.ENCRYPTION_TYPES.AES) { 36 | yield streamMessage 37 | continue 38 | } 39 | 40 | try { 41 | const groupKey = await keyExchange.getGroupKey(streamMessage).catch((err) => { 42 | throw new UnableToDecryptError(`Could not get GroupKey: ${streamMessage.groupKeyId} – ${err.stack}`, streamMessage) 43 | }) 44 | 45 | if (!groupKey) { 46 | throw new UnableToDecryptError([ 47 | `Could not get GroupKey: ${streamMessage.groupKeyId}`, 48 | 'Publisher is offline, key does not exist or no permission to access key.', 49 | ].join(' '), streamMessage) 50 | } 51 | 52 | await EncryptionUtil.decryptStreamMessage(streamMessage, groupKey) 53 | await keyExchange.addNewKey(streamMessage) 54 | } catch (err) { 55 | // clear cached permissions if cannot decrypt, likely permissions need updating 56 | client.cached.clearStream(streamMessage.getStreamId()) 57 | await onError(err, streamMessage) 58 | } finally { 59 | yield streamMessage 60 | } 61 | } 62 | } 63 | 64 | return Object.assign(decrypt, { 65 | async stop() { 66 | return keyExchange.stop() 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /test/integration/dataunion/calculate.test.ts: -------------------------------------------------------------------------------- 1 | import { providers, Wallet } from 'ethers' 2 | import debug from 'debug' 3 | 4 | import { StreamrClient } from '../../../src/StreamrClient' 5 | import { clientOptions } from '../devEnvironment' 6 | import { createClient, expectInvalidAddress } from '../../utils' 7 | 8 | const log = debug('StreamrClient::DataUnion::integration-test-calculate') 9 | 10 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 11 | const providerMainnet = new providers.JsonRpcProvider(clientOptions.mainnet) 12 | const adminWalletMainnet = new Wallet(clientOptions.auth.privateKey, providerMainnet) 13 | 14 | // This test will fail when new docker images are pushed with updated DU smart contracts 15 | // -> generate new codehashes for getDataUnionMainnetAddress() and getDataUnionSidechainAddress() 16 | 17 | describe('DataUnion calculate', () => { 18 | 19 | afterAll(() => { 20 | providerMainnet.removeAllListeners() 21 | providerSidechain.removeAllListeners() 22 | }) 23 | 24 | it('calculate DU address before deployment', async () => { 25 | log('Connecting to Ethereum networks, clientOptions: %O', clientOptions) 26 | const network = await providerMainnet.getNetwork() 27 | log('Connected to "mainnet" network: ', JSON.stringify(network)) 28 | const network2 = await providerSidechain.getNetwork() 29 | log('Connected to sidechain network: ', JSON.stringify(network2)) 30 | 31 | const adminClient = new StreamrClient(clientOptions as any) 32 | 33 | const dataUnionName = 'test-' + Date.now() 34 | // eslint-disable-next-line no-underscore-dangle 35 | const dataUnionPredicted = adminClient._getDataUnionFromName({ dataUnionName, deployerAddress: adminWalletMainnet.address }) 36 | 37 | const dataUnionDeployed = await adminClient.deployDataUnion({ dataUnionName }) 38 | const version = await dataUnionDeployed.getVersion() 39 | 40 | expect(dataUnionPredicted.getAddress()).toBe(dataUnionDeployed.getAddress()) 41 | expect(dataUnionPredicted.getSidechainAddress()).toBe(dataUnionDeployed.getSidechainAddress()) 42 | expect(version).toBe(2) 43 | }, 60000) 44 | 45 | it('get DataUnion: invalid address', () => { 46 | const client = createClient(providerSidechain) 47 | return expectInvalidAddress(async () => client.getDataUnion('invalid-address')) 48 | }) 49 | 50 | it('safeGetDataUnion fails for bad addresses', async () => { 51 | const client = createClient(providerSidechain) 52 | await expectInvalidAddress(async () => client.safeGetDataUnion('invalid-address')) 53 | return expect(client.safeGetDataUnion('0x2222222222222222222222222222222222222222')) 54 | .rejects 55 | .toThrow('0x2222222222222222222222222222222222222222 is not a Data Union!') 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/publish/Encrypt.ts: -------------------------------------------------------------------------------- 1 | import { MessageLayer } from 'streamr-client-protocol' 2 | 3 | import EncryptionUtil from '../stream/encryption/Encryption' 4 | import { Stream } from '../stream' 5 | import { StreamrClient } from '../StreamrClient' 6 | import { PublisherKeyExhange } from '../stream/encryption/KeyExchangePublisher' 7 | 8 | const { StreamMessage } = MessageLayer 9 | 10 | export default function Encrypt(client: StreamrClient) { 11 | let publisherKeyExchange: PublisherKeyExhange 12 | 13 | function getPublisherKeyExchange() { 14 | if (!publisherKeyExchange) { 15 | publisherKeyExchange = new PublisherKeyExhange(client, { 16 | groupKeys: { 17 | ...client.options.groupKeys, 18 | } 19 | }) 20 | } 21 | return publisherKeyExchange 22 | } 23 | 24 | async function encrypt(streamMessage: MessageLayer.StreamMessage, stream: Stream) { 25 | if (!client.canEncrypt()) { 26 | return 27 | } 28 | 29 | const { messageType } = streamMessage 30 | if ( 31 | messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE 32 | || messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_REQUEST 33 | || messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_ERROR_RESPONSE 34 | ) { 35 | // never encrypt 36 | return 37 | } 38 | 39 | if ( 40 | !stream.requireEncryptedData 41 | && !(await (getPublisherKeyExchange().hasAnyGroupKey(stream.id))) 42 | ) { 43 | // not needed 44 | return 45 | } 46 | 47 | if (streamMessage.messageType !== StreamMessage.MESSAGE_TYPES.MESSAGE) { 48 | return 49 | } 50 | 51 | const [groupKey, nextGroupKey] = await getPublisherKeyExchange().useGroupKey(stream.id) 52 | if (!groupKey) { 53 | throw new Error(`Tried to use group key but no group key found for stream: ${stream.id}`) 54 | } 55 | 56 | await EncryptionUtil.encryptStreamMessage(streamMessage, groupKey, nextGroupKey) 57 | } 58 | 59 | return Object.assign(encrypt, { 60 | setNextGroupKey(...args: Parameters) { 61 | return getPublisherKeyExchange().setNextGroupKey(...args) 62 | }, 63 | rotateGroupKey(...args: Parameters) { 64 | return getPublisherKeyExchange().rotateGroupKey(...args) 65 | }, 66 | rekey(...args: Parameters) { 67 | return getPublisherKeyExchange().rekey(...args) 68 | }, 69 | start() { 70 | return getPublisherKeyExchange().start() 71 | }, 72 | stop() { 73 | if (!publisherKeyExchange) { return Promise.resolve() } 74 | return getPublisherKeyExchange().stop() 75 | } 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /test/flakey/EnvStressTest.test.js: -------------------------------------------------------------------------------- 1 | import { pTimeout } from '../../src/utils' 2 | import { StreamrClient } from '../../src/StreamrClient' 3 | 4 | import { fakePrivateKey, uid } from '../utils' 5 | 6 | const clientOptions = require('../integration/config') 7 | 8 | const TEST_REPEATS = 6 9 | const MAX_CONCURRENCY = 24 10 | const TEST_TIMEOUT = 5000 11 | const INC_FACTOR = 1.5 12 | 13 | /* eslint-disable require-atomic-updates, no-loop-func */ 14 | 15 | describe('EnvStressTest', () => { 16 | let client 17 | 18 | describe('Stream Creation + Deletion', () => { 19 | const nextConcurrency = (j) => { 20 | if (j === MAX_CONCURRENCY) { 21 | return j + 1 22 | } 23 | 24 | return Math.min(MAX_CONCURRENCY, Math.round(j * INC_FACTOR)) 25 | } 26 | 27 | for (let j = 1; j <= MAX_CONCURRENCY; j = nextConcurrency(j)) { 28 | describe(`Create ${j} streams`, () => { 29 | let errors = [] 30 | beforeAll(() => { 31 | errors = [] 32 | }) 33 | 34 | afterAll(() => { 35 | expect(errors).toEqual([]) 36 | }) 37 | 38 | for (let i = 0; i < TEST_REPEATS; i++) { 39 | test(`Test ${i + 1} of ${TEST_REPEATS}`, async () => { 40 | const testDesc = `with concurrency ${j} for test ${i + 1}` 41 | client = new StreamrClient({ 42 | ...clientOptions, 43 | auth: { 44 | privateKey: fakePrivateKey(), 45 | }, 46 | }) 47 | 48 | await pTimeout(client.session.getSessionToken(), TEST_TIMEOUT, `Timeout getting session token ${testDesc}`) 49 | 50 | const names = [] 51 | for (let k = 0; k < j; k++) { 52 | names.push(uid(`stream ${k + 1} . `)) 53 | } 54 | 55 | const streams = await Promise.all(names.map((name, index) => ( 56 | pTimeout(client.createStream({ 57 | name, 58 | requireSignedData: true, 59 | requireEncryptedData: false, 60 | }), TEST_TIMEOUT * j * 0.2, `Timeout creating stream ${index + 1} ${testDesc}`) 61 | ))) 62 | 63 | streams.forEach((createdStream, index) => { 64 | try { 65 | expect(createdStream.id).toBeTruthy() 66 | expect(createdStream.name).toBe(names[index]) 67 | expect(createdStream.requireSignedData).toBe(true) 68 | } catch (err) { 69 | errors.push(`Error with stream ${index + 1} in ${testDesc}: ${err.message}`) 70 | throw err 71 | } 72 | }) 73 | 74 | await Promise.all(streams.map((s, index) => ( 75 | pTimeout(s.delete(), TEST_TIMEOUT * j * 0.2, `Timeout deleting stream ${index + 1} ${testDesc}`) 76 | ))) 77 | }, TEST_TIMEOUT * j * 1.2) 78 | } 79 | }) 80 | } 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/publish/Signer.ts: -------------------------------------------------------------------------------- 1 | import { MessageLayer, Utils } from 'streamr-client-protocol' 2 | import { Web3Provider } from '@ethersproject/providers' 3 | 4 | import { pLimitFn, sleep } from '../utils' 5 | import type { EthereumConfig } from '../Config' 6 | 7 | const { StreamMessage } = MessageLayer 8 | const { SigningUtil } = Utils 9 | const { SIGNATURE_TYPES } = StreamMessage 10 | 11 | type AuthOption = { 12 | ethereum?: never 13 | privateKey: string | Uint8Array 14 | } | { 15 | privateKey?: never 16 | ethereum: EthereumConfig 17 | } | { 18 | ethereum?: never 19 | privateKey?: never 20 | } 21 | 22 | function getSigningFunction({ 23 | privateKey, 24 | ethereum, 25 | }: AuthOption) { 26 | if (privateKey) { 27 | const key = (typeof privateKey === 'string' && privateKey.startsWith('0x')) 28 | ? privateKey.slice(2) // strip leading 0x 29 | : privateKey 30 | return async (d: string) => SigningUtil.sign(d, key.toString()) 31 | } 32 | 33 | if (ethereum) { 34 | const web3Provider = new Web3Provider(ethereum) 35 | const signer = web3Provider.getSigner() 36 | // sign one at a time & wait a moment before asking for next signature 37 | // otherwise metamask extension may not show the prompt window 38 | return pLimitFn(async (d) => { 39 | const sig = await signer.signMessage(d) 40 | await sleep(50) 41 | return sig 42 | }, 1) 43 | } 44 | 45 | throw new Error('Need either "privateKey" or "ethereum".') 46 | } 47 | 48 | export default function Signer(options: AuthOption, publishWithSignature = 'auto') { 49 | const { privateKey, ethereum } = options 50 | const noSignStreamMessage = (streamMessage: MessageLayer.StreamMessage) => streamMessage 51 | 52 | if (publishWithSignature === 'never') { 53 | return noSignStreamMessage 54 | } 55 | 56 | if (publishWithSignature === 'auto' && !privateKey && !ethereum) { 57 | return noSignStreamMessage 58 | } 59 | 60 | if (publishWithSignature !== 'auto' && publishWithSignature !== 'always') { 61 | throw new Error(`Unknown parameter value: ${publishWithSignature}`) 62 | } 63 | 64 | const sign = getSigningFunction(options) 65 | 66 | async function signStreamMessage( 67 | streamMessage: MessageLayer.StreamMessage, 68 | signatureType: MessageLayer.StreamMessage['signatureType'] = SIGNATURE_TYPES.ETH 69 | ) { 70 | if (!streamMessage) { 71 | throw new Error('streamMessage required as part of the data to sign.') 72 | } 73 | 74 | if (typeof streamMessage.getTimestamp !== 'function' || !streamMessage.getTimestamp()) { 75 | throw new Error('Timestamp is required as part of the data to sign.') 76 | } 77 | 78 | if (signatureType !== SIGNATURE_TYPES.ETH_LEGACY && signatureType !== SIGNATURE_TYPES.ETH) { 79 | throw new Error(`Unrecognized signature type: ${signatureType}`) 80 | } 81 | 82 | // set signature so getting of payload works correctly 83 | // (publisherId should already be set) 84 | streamMessage.signatureType = signatureType // eslint-disable-line no-param-reassign 85 | const signature = await sign(streamMessage.getPayloadToSign()) 86 | return Object.assign(streamMessage, { 87 | signature, 88 | }) 89 | } 90 | 91 | return Object.assign(signStreamMessage, { 92 | signData: sign, // this mainly for tests 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /src/subscribe/Validator.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | import { MessageLayer, Utils, Errors } from 'streamr-client-protocol' 4 | 5 | import { pOrderedResolve, CacheAsyncFn } from '../utils' 6 | import { validateOptions } from '../stream/utils' 7 | 8 | const { StreamMessageValidator, SigningUtil } = Utils 9 | const { ValidationError } = Errors 10 | const { StreamMessage, GroupKeyErrorResponse } = MessageLayer 11 | 12 | const EMPTY_MESSAGE = { 13 | serialize() {} 14 | } 15 | 16 | export class SignatureRequiredError extends ValidationError { 17 | constructor(streamMessage = EMPTY_MESSAGE) { 18 | super(`Client requires data to be signed. Message: ${inspect(streamMessage)}`) 19 | this.streamMessage = streamMessage 20 | if (Error.captureStackTrace) { 21 | Error.captureStackTrace(this, this.constructor) 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * Wrap StreamMessageValidator in a way that ensures it can validate in parallel but 28 | * validation is guaranteed to resolve in the same order they were called 29 | * Handles caching remote calls 30 | */ 31 | 32 | export default function Validator(client, opts) { 33 | const options = validateOptions(opts) 34 | const validator = new StreamMessageValidator({ 35 | getStream: client.cached.getStream.bind(client.cached), 36 | async isPublisher(publisherId, _streamId) { 37 | return client.cached.isStreamPublisher(_streamId, publisherId) 38 | }, 39 | async isSubscriber(ethAddress, _streamId) { 40 | return client.cached.isStreamSubscriber(_streamId, ethAddress) 41 | }, 42 | verify: CacheAsyncFn(SigningUtil.verify.bind(SigningUtil), { 43 | ...client.options.cache, 44 | // forcibly use small cache otherwise keeps n serialized messages in memory 45 | maxSize: 100, 46 | maxAge: 10000, 47 | cachePromiseRejection: true, 48 | cacheKey: (args) => args.join('|'), 49 | }) 50 | }) 51 | 52 | const validate = pOrderedResolve(async (msg) => { 53 | if (msg.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_ERROR_RESPONSE) { 54 | const res = GroupKeyErrorResponse.fromArray(msg.getParsedContent()) 55 | const err = new ValidationError(`${client.id} GroupKeyErrorResponse: ${res.errorMessage}`, msg) 56 | err.streamMessage = msg 57 | err.code = res.errorCode 58 | throw err 59 | } 60 | 61 | // Check special cases controlled by the verifySignatures policy 62 | if (client.options.verifySignatures === 'never' && msg.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { 63 | return msg // no validation required 64 | } 65 | 66 | if (options.verifySignatures === 'always' && !msg.signature) { 67 | throw new SignatureRequiredError(msg) 68 | } 69 | 70 | // In all other cases validate using the validator 71 | // will throw with appropriate validation failure 72 | await validator.validate(msg).catch((err) => { 73 | if (!err.streamMessage) { 74 | err.streamMessage = msg // eslint-disable-line no-param-reassign 75 | } 76 | throw err 77 | }) 78 | 79 | return msg 80 | }) 81 | 82 | // return validation function that resolves in call order 83 | return Object.assign(validate, { 84 | clear(key) { 85 | if (!key) { 86 | validate.clear() 87 | } 88 | } 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /test/browser/browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test StreamrClient in Chrome Browser 4 | 5 | 6 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 113 | 114 | -------------------------------------------------------------------------------- /test/browser/resend.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const { v4: uuidv4 } = require('uuid') 3 | 4 | describe('StreamrClient Resend', () => { 5 | const streamName = uuidv4() 6 | 7 | before((browser) => { 8 | // optionally forward url env vars as query params 9 | const url = process.env.WEBSOCKET_URL ? `&WEBSOCKET_URL=${encodeURIComponent(process.env.WEBSOCKET_URL)}` : '' 10 | const restUrl = process.env.REST_URL ? `&REST_URL=${encodeURIComponent(process.env.REST_URL)}` : '' 11 | const browserUrl = `http://localhost:8880?streamName=${streamName}${url}${restUrl}` 12 | // eslint-disable-next-line no-console 13 | console.info(browserUrl) 14 | return browser.url(browserUrl) 15 | }) 16 | 17 | test('Test StreamrClient in Chrome Browser', (browser) => { 18 | browser 19 | .waitForElementVisible('body') 20 | .assert.titleContains('Test StreamrClient in Chrome Browser') 21 | .click('button[id=connect]') 22 | .assert.containsText('#result', 'connected') 23 | .click('button[id=create]') 24 | .pause(6000) 25 | .assert.containsText('#result', streamName) 26 | .click('button[id=subscribe]') 27 | .assert.containsText('#result', 'subscribed') 28 | .click('button[id=publish]') 29 | .pause(6000) 30 | .verify.containsText('#result', '{"msg":0}') 31 | .verify.containsText('#result', '{"msg":1}') 32 | .verify.containsText('#result', '{"msg":2}') 33 | .verify.containsText('#result', '{"msg":3}') 34 | .verify.containsText('#result', '{"msg":4}') 35 | .verify.containsText('#result', '{"msg":5}') 36 | .verify.containsText('#result', '{"msg":6}') 37 | .verify.containsText('#result', '{"msg":7}') 38 | .verify.containsText('#result', '{"msg":8}') 39 | .verify.containsText('#result', '{"msg":9}') 40 | .assert.containsText('#result', '[{"msg":0},{"msg":1},{"msg":2},{"msg":3},{"msg":4},{"msg":5},{"msg":6},{"msg":7},{"msg":8},{"msg":9}]') 41 | .pause(10000) 42 | .click('button[id=resend]') 43 | .pause(10000) 44 | .verify.containsText('#result', '{"msg":0}') 45 | .verify.containsText('#result', '{"msg":1}') 46 | .verify.containsText('#result', '{"msg":2}') 47 | .verify.containsText('#result', '{"msg":3}') 48 | .verify.containsText('#result', '{"msg":4}') 49 | .verify.containsText('#result', '{"msg":5}') 50 | .verify.containsText('#result', '{"msg":6}') 51 | .verify.containsText('#result', '{"msg":7}') 52 | .verify.containsText('#result', '{"msg":8}') 53 | .verify.containsText('#result', '{"msg":9}') 54 | .assert.containsText( 55 | '#result', 56 | 'Resend: [{"msg":0},{"msg":1},{"msg":2},{"msg":3},{"msg":4},{"msg":5},{"msg":6},{"msg":7},{"msg":8},{"msg":9}]', 57 | ) 58 | .click('button[id=disconnect]') 59 | .assert.containsText('#result', 'disconnected') 60 | }) 61 | 62 | after(async (browser) => { 63 | browser.getLog('browser', (logs) => { 64 | logs.forEach((log) => { 65 | // eslint-disable-next-line no-console 66 | const logger = console[String(log.level).toLowerCase()] || console.log 67 | logger('[%s]: ', log.timestamp, log.message) 68 | }) 69 | }) 70 | return browser.end() 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/rest/LoginEndpoints.ts: -------------------------------------------------------------------------------- 1 | import { StreamrClient } from '../StreamrClient' 2 | import { getEndpointUrl } from '../utils' 3 | 4 | import authFetch, { AuthFetchError } from './authFetch' 5 | 6 | export interface UserDetails { 7 | name: string 8 | username: string 9 | imageUrlSmall?: string 10 | imageUrlLarge?: string 11 | lastLogin?: string 12 | } 13 | 14 | async function getSessionToken(url: string, props: any) { 15 | return authFetch<{ token: string }>( 16 | url, 17 | undefined, 18 | { 19 | method: 'POST', 20 | body: JSON.stringify(props), 21 | headers: { 22 | 'Content-Type': 'application/json', 23 | }, 24 | }, 25 | ) 26 | } 27 | 28 | /** TODO the class should be annotated with at-internal, but adding the annotation hides the methods */ 29 | export class LoginEndpoints { 30 | 31 | /** @internal */ 32 | client: StreamrClient 33 | 34 | constructor(client: StreamrClient) { 35 | this.client = client 36 | } 37 | 38 | /** @internal */ 39 | async getChallenge(address: string) { 40 | this.client.debug('getChallenge %o', { 41 | address, 42 | }) 43 | const url = getEndpointUrl(this.client.options.restUrl, 'login', 'challenge', address) 44 | return authFetch<{ challenge: string }>( 45 | url, 46 | undefined, 47 | { 48 | method: 'POST', 49 | }, 50 | ) 51 | } 52 | 53 | /** @internal */ 54 | async sendChallengeResponse(challenge: { challenge: string }, signature: string, address: string) { 55 | this.client.debug('sendChallengeResponse %o', { 56 | challenge, 57 | signature, 58 | address, 59 | }) 60 | const url = getEndpointUrl(this.client.options.restUrl, 'login', 'response') 61 | const props = { 62 | challenge, 63 | signature, 64 | address, 65 | } 66 | return getSessionToken(url, props) 67 | } 68 | 69 | /** @internal */ 70 | async loginWithChallengeResponse(signingFunction: (challenge: string) => Promise, address: string) { 71 | this.client.debug('loginWithChallengeResponse %o', { 72 | address, 73 | }) 74 | const challenge = await this.getChallenge(address) 75 | const signature = await signingFunction(challenge.challenge) 76 | return this.sendChallengeResponse(challenge, signature, address) 77 | } 78 | 79 | /** @internal */ 80 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this 81 | async loginWithApiKey(_apiKey: string): Promise { 82 | const message = 'apiKey auth is no longer supported. Please create an ethereum identity.' 83 | throw new AuthFetchError(message) 84 | } 85 | 86 | /** @internal */ 87 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this 88 | async loginWithUsernamePassword(_username: string, _password: string): Promise { 89 | const message = 'username/password auth is no longer supported. Please create an ethereum identity.' 90 | throw new AuthFetchError(message) 91 | } 92 | 93 | async getUserInfo() { 94 | this.client.debug('getUserInfo') 95 | return authFetch(`${this.client.options.restUrl}/users/me`, this.client.session) 96 | } 97 | 98 | /** @internal */ 99 | async logoutEndpoint(): Promise { 100 | this.client.debug('logoutEndpoint') 101 | await authFetch(`${this.client.options.restUrl}/logout`, this.client.session, { 102 | method: 'POST', 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /test/integration/dataunion/adminFee.test.ts: -------------------------------------------------------------------------------- 1 | import { Contract, providers, Wallet } from 'ethers' 2 | import { parseEther, formatEther } from 'ethers/lib/utils' 3 | import debug from 'debug' 4 | 5 | import { StreamrClient } from '../../../src/StreamrClient' 6 | import * as Token from '../../../contracts/TestToken.json' 7 | import { clientOptions, tokenAdminPrivateKey } from '../devEnvironment' 8 | 9 | const log = debug('StreamrClient::DataUnion::integration-test-adminFee') 10 | 11 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 12 | const providerMainnet = new providers.JsonRpcProvider(clientOptions.mainnet) 13 | const adminWalletMainnet = new Wallet(clientOptions.auth.privateKey, providerMainnet) 14 | 15 | describe('DataUnion admin fee', () => { 16 | let adminClient: StreamrClient 17 | 18 | const tokenAdminWallet = new Wallet(tokenAdminPrivateKey, providerMainnet) 19 | const tokenMainnet = new Contract(clientOptions.tokenAddress, Token.abi, tokenAdminWallet) 20 | 21 | beforeAll(async () => { 22 | log('Connecting to Ethereum networks, clientOptions: %O', clientOptions) 23 | const network = await providerMainnet.getNetwork() 24 | log('Connected to "mainnet" network: ', JSON.stringify(network)) 25 | const network2 = await providerSidechain.getNetwork() 26 | log('Connected to sidechain network: ', JSON.stringify(network2)) 27 | log(`Minting 100 tokens to ${adminWalletMainnet.address}`) 28 | const tx1 = await tokenMainnet.mint(adminWalletMainnet.address, parseEther('100')) 29 | await tx1.wait() 30 | adminClient = new StreamrClient(clientOptions as any) 31 | }, 10000) 32 | 33 | afterAll(() => { 34 | providerMainnet.removeAllListeners() 35 | providerSidechain.removeAllListeners() 36 | }) 37 | 38 | it('can set admin fee', async () => { 39 | const dataUnion = await adminClient.deployDataUnion() 40 | const oldFee = await dataUnion.getAdminFee() 41 | log(`DU owner: ${await dataUnion.getAdminAddress()}`) 42 | log(`Sending tx from ${await adminClient.getAddress()}`) 43 | const tr = await dataUnion.setAdminFee(0.1) 44 | log(`Transaction receipt: ${JSON.stringify(tr)}`) 45 | const newFee = await dataUnion.getAdminFee() 46 | expect(oldFee).toEqual(0) 47 | expect(newFee).toEqual(0.1) 48 | }, 150000) 49 | 50 | it('receives admin fees', async () => { 51 | const dataUnion = await adminClient.deployDataUnion() 52 | const tr = await dataUnion.setAdminFee(0.1) 53 | log(`Transaction receipt: ${JSON.stringify(tr)}`) 54 | 55 | const amount = parseEther('2') 56 | // eslint-disable-next-line no-underscore-dangle 57 | const contract = await dataUnion._getContract() 58 | const tokenAddress = await contract.token() 59 | const adminTokenMainnet = new Contract(tokenAddress, Token.abi, adminWalletMainnet) 60 | 61 | log(`Transferring ${amount} token-wei ${adminWalletMainnet.address}->${dataUnion.getAddress()}`) 62 | const txTokenToDU = await adminTokenMainnet.transfer(dataUnion.getAddress(), amount) 63 | await txTokenToDU.wait() 64 | 65 | const balance1 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) 66 | log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance1)} (${balance1.toString()})`) 67 | 68 | log(`Transferred ${formatEther(amount)} tokens, next sending to bridge`) 69 | const tx2 = await contract.sendTokensToBridge() 70 | await tx2.wait() 71 | 72 | const balance2 = await adminTokenMainnet.balanceOf(adminWalletMainnet.address) 73 | log(`Token balance of ${adminWalletMainnet.address}: ${formatEther(balance2)} (${balance2.toString()})`) 74 | 75 | expect(formatEther(balance2.sub(balance1))).toEqual('0.2') 76 | }, 150000) 77 | 78 | }) 79 | -------------------------------------------------------------------------------- /test/unit/StreamUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from '../../src/stream' 2 | import { createStreamId, validateOptions } from '../../src/stream/utils' 3 | 4 | describe('Stream utils', () => { 5 | 6 | describe('validateOptions', () => { 7 | 8 | it('no definition', () => { 9 | expect(() => validateOptions(undefined as any)).toThrow() 10 | expect(() => validateOptions(null as any)).toThrow() 11 | expect(() => validateOptions({})).toThrow() 12 | }) 13 | 14 | it('string', () => { 15 | expect(validateOptions('foo')).toMatchObject({ 16 | streamId: 'foo', 17 | streamPartition: 0, 18 | key: 'foo::0' 19 | }) 20 | }) 21 | 22 | it('object', () => { 23 | expect(validateOptions({ streamId: 'foo' })).toMatchObject({ 24 | streamId: 'foo', 25 | streamPartition: 0, 26 | key: 'foo::0' 27 | }) 28 | expect(validateOptions({ streamId: 'foo', streamPartition: 123 })).toMatchObject({ 29 | streamId: 'foo', 30 | streamPartition: 123, 31 | key: 'foo::123' 32 | }) 33 | expect(validateOptions({ id: 'foo', partition: 123 })).toMatchObject({ 34 | streamId: 'foo', 35 | streamPartition: 123, 36 | key: 'foo::123' 37 | }) 38 | }) 39 | 40 | it('stream', () => { 41 | const stream = new Stream(undefined as any, { 42 | id: 'foo', 43 | name: 'bar' 44 | }) 45 | expect(validateOptions({ stream })).toMatchObject({ 46 | streamId: 'foo', 47 | streamPartition: 0, 48 | key: 'foo::0' 49 | }) 50 | }) 51 | 52 | }) 53 | 54 | describe('createStreamId', () => { 55 | const ownerProvider = () => Promise.resolve('0xaAAAaaaaAA123456789012345678901234567890') 56 | 57 | it('path', async () => { 58 | const path = '/foo/BAR' 59 | const actual = await createStreamId(path, ownerProvider) 60 | expect(actual).toBe('0xaaaaaaaaaa123456789012345678901234567890/foo/BAR') 61 | }) 62 | 63 | it('path: no owner', () => { 64 | const path = '/foo/BAR' 65 | return expect(createStreamId(path, async () => undefined)).rejects.toThrowError('Owner missing for stream id: /foo/BAR') 66 | }) 67 | 68 | it('path: no owner provider', () => { 69 | const path = '/foo/BAR' 70 | return expect(createStreamId(path, undefined)).rejects.toThrowError('Owner provider missing for stream id: /foo/BAR') 71 | }) 72 | 73 | it('full: ethereum address', async () => { 74 | const id = '0xbbbbbBbBbB123456789012345678901234567890/foo/BAR' 75 | const actual = await createStreamId(id) 76 | expect(actual).toBe(id) 77 | }) 78 | 79 | it('full: ENS domain', async () => { 80 | const id = 'example.eth/foo/BAR' 81 | const actual = await createStreamId(id) 82 | expect(actual).toBe(id) 83 | }) 84 | 85 | it('legacy', async () => { 86 | const id = 'abcdeFGHJI1234567890ab' 87 | const actual = await createStreamId(id) 88 | expect(actual).toBe(id) 89 | }) 90 | 91 | it('system', async () => { 92 | const id = 'SYSTEM/keyexchange/0xcccccccccc123456789012345678901234567890' 93 | const actual = await createStreamId(id) 94 | expect(actual).toBe(id) 95 | }) 96 | 97 | it('undefined', () => { 98 | return expect(createStreamId(undefined as any)).rejects.toThrowError('Missing stream id') 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/integration/TokenBalance.test.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from '@ethersproject/contracts' 2 | import { Wallet } from '@ethersproject/wallet' 3 | import { createClient, createMockAddress } from '../utils' 4 | 5 | import * as Token from '../../contracts/TestToken.json' 6 | import { clientOptions, tokenAdminPrivateKey, tokenMediatorAddress } from './devEnvironment' 7 | import { BigNumber, providers } from 'ethers' 8 | import { parseEther } from 'ethers/lib/utils' 9 | import { EthereumAddress } from '../../src/types' 10 | import { until } from '../../src/utils' 11 | import debug from 'debug' 12 | import StreamrClient from '../../src' 13 | 14 | const providerMainnet = new providers.JsonRpcProvider(clientOptions.mainnet) 15 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 16 | const tokenAdminMainnetWallet = new Wallet(tokenAdminPrivateKey, providerMainnet) 17 | const tokenAdminSidechainWallet = new Wallet(tokenAdminPrivateKey, providerSidechain) 18 | const tokenMainnet = new Contract(clientOptions.tokenAddress, Token.abi, tokenAdminMainnetWallet) 19 | const tokenSidechain = new Contract(clientOptions.tokenSidechainAddress, Token.abi, tokenAdminSidechainWallet) 20 | 21 | const log = debug('StreamrClient::test::token-balance') 22 | 23 | const sendTokensToSidechain = async (receiverAddress: EthereumAddress, amount: BigNumber) => { 24 | const relayTokensAbi = [ 25 | { 26 | inputs: [ 27 | { 28 | internalType: 'address', 29 | name: 'token', 30 | type: 'address' 31 | }, 32 | { 33 | internalType: 'address', 34 | name: '_receiver', 35 | type: 'address' 36 | }, 37 | { 38 | internalType: 'uint256', 39 | name: '_value', 40 | type: 'uint256' 41 | }, 42 | { 43 | internalType: 'bytes', 44 | name: '_data', 45 | type: 'bytes' 46 | } 47 | ], 48 | name: 'relayTokensAndCall', 49 | outputs: [], 50 | stateMutability: 'nonpayable', 51 | type: 'function' 52 | } 53 | ] 54 | const tokenMediator = new Contract(tokenMediatorAddress, relayTokensAbi, tokenAdminMainnetWallet) 55 | const tx1 = await tokenMainnet.approve(tokenMediator.address, amount) 56 | await tx1.wait() 57 | log('Approved') 58 | const tx2 = await tokenMediator.relayTokensAndCall(tokenMainnet.address, receiverAddress, amount, '0x1234') // dummy 0x1234 59 | await tx2.wait() 60 | log('Relayed tokens') 61 | await until(async () => !(await tokenSidechain.balanceOf(receiverAddress)).eq('0'), 300000, 3000) 62 | log('Sidechain balance changed') 63 | } 64 | 65 | describe('Token', () => { 66 | 67 | let client: StreamrClient 68 | 69 | beforeAll(async () => { 70 | client = createClient() 71 | }) 72 | 73 | it('getTokenBalance', async () => { 74 | const userWallet = new Wallet(createMockAddress()) 75 | const tx1 = await tokenMainnet.mint(userWallet.address, parseEther('123')) 76 | await tx1.wait() 77 | const balance = await client.getTokenBalance(userWallet.address) 78 | expect(balance.toString()).toBe('123000000000000000000') 79 | }) 80 | 81 | it('getSidechainBalance', async () => { 82 | const amount = parseEther('456') 83 | const tx1 = await tokenMainnet.mint(tokenAdminMainnetWallet.address, amount) 84 | await tx1.wait() 85 | const userWallet = new Wallet(createMockAddress(), providerSidechain) 86 | await sendTokensToSidechain(userWallet.address, amount) 87 | const balance = await client.getSidechainTokenBalance(userWallet.address) 88 | expect(balance.toString()).toBe('456000000000000000000') 89 | }, 60000) 90 | 91 | }) 92 | -------------------------------------------------------------------------------- /src/subscribe/OrderMessages.js: -------------------------------------------------------------------------------- 1 | import { Utils } from 'streamr-client-protocol' 2 | 3 | import { pipeline } from '../utils/iterators' 4 | import PushQueue from '../utils/PushQueue' 5 | import { validateOptions } from '../stream/utils' 6 | 7 | import resendStream from './resendStream' 8 | 9 | const { OrderingUtil } = Utils 10 | 11 | let ID = 0 12 | 13 | /** 14 | * Wraps OrderingUtil into a pipeline. 15 | * Implements gap filling 16 | */ 17 | 18 | export default function OrderMessages(client, options = {}) { 19 | const { gapFillTimeout, retryResendAfter, maxGapRequests } = client.options 20 | const { streamId, streamPartition, gapFill = true } = validateOptions(options) 21 | const debug = client.debug.extend(`OrderMessages::${ID}`) 22 | ID += 1 23 | 24 | // output buffer 25 | const outStream = new PushQueue([], { 26 | autoEnd: false, 27 | }) 28 | 29 | let done = false 30 | const resendStreams = new Set() // holds outstanding resends for cleanup 31 | 32 | const orderingUtil = new OrderingUtil(streamId, streamPartition, (orderedMessage) => { 33 | if (!outStream.isWritable() || done) { 34 | return 35 | } 36 | outStream.push(orderedMessage) 37 | }, async (from, to, publisherId, msgChainId) => { 38 | if (done || !gapFill) { return } 39 | debug('gap %o', { 40 | streamId, streamPartition, publisherId, msgChainId, from, to, 41 | }) 42 | 43 | // eslint-disable-next-line no-use-before-define 44 | const resendMessageStream = resendStream(client, { 45 | streamId, streamPartition, from, to, publisherId, msgChainId, 46 | }) 47 | 48 | try { 49 | if (done) { return } 50 | resendStreams.add(resendMessageStream) 51 | await resendMessageStream.subscribe() 52 | if (done) { return } 53 | 54 | for await (const { streamMessage } of resendMessageStream) { 55 | if (done) { return } 56 | orderingUtil.add(streamMessage) 57 | } 58 | } finally { 59 | resendStreams.delete(resendMessageStream) 60 | await resendMessageStream.cancel() 61 | } 62 | }, gapFillTimeout, retryResendAfter, gapFill ? maxGapRequests : 0) 63 | 64 | const markMessageExplicitly = orderingUtil.markMessageExplicitly.bind(orderingUtil) 65 | 66 | let inputClosed = false 67 | 68 | function maybeClose() { 69 | // we can close when: 70 | // input has closed (i.e. all messages sent) 71 | // AND 72 | // no gaps are pending 73 | // AND 74 | // gaps have been filled or failed 75 | // NOTE ordering util cannot have gaps if queue is empty 76 | if (inputClosed && orderingUtil.isEmpty()) { 77 | outStream.end() 78 | } 79 | } 80 | 81 | orderingUtil.on('drain', () => { 82 | maybeClose() 83 | }) 84 | 85 | orderingUtil.on('error', () => { 86 | // TODO: handle gapfill errors without closing stream or logging 87 | maybeClose() // probably noop 88 | }) 89 | 90 | return Object.assign(pipeline([ 91 | // eslint-disable-next-line require-yield 92 | async function* WriteToOrderingUtil(src) { 93 | for await (const msg of src) { 94 | orderingUtil.add(msg) 95 | // note no yield 96 | // orderingUtil writes to outStream itself 97 | } 98 | inputClosed = true 99 | maybeClose() 100 | }, 101 | outStream, // consumer gets outStream 102 | ], async (err) => { 103 | done = true 104 | orderingUtil.clearGaps() 105 | resendStreams.forEach((s) => s.cancel()) 106 | resendStreams.clear() 107 | await outStream.cancel(err) 108 | orderingUtil.clearGaps() 109 | }), { 110 | markMessageExplicitly, 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /test/integration/Stream.test.ts: -------------------------------------------------------------------------------- 1 | import { StreamrClient } from '../../src/StreamrClient' 2 | import { Stream } from '../../src/stream' 3 | import { uid, fakePrivateKey, getPublishTestMessages } from '../utils' 4 | import { StorageNode } from '../../src/stream/StorageNode' 5 | 6 | import { clientOptions } from './devEnvironment' 7 | 8 | const createClient = (opts = {}) => new StreamrClient({ 9 | ...clientOptions, 10 | auth: { 11 | privateKey: fakePrivateKey(), 12 | }, 13 | autoConnect: false, 14 | autoDisconnect: false, 15 | ...opts, 16 | }) 17 | 18 | describe('Stream', () => { 19 | let client: StreamrClient 20 | let stream: Stream 21 | 22 | beforeEach(async () => { 23 | client = createClient() 24 | await client.connect() 25 | 26 | stream = await client.createStream({ 27 | name: uid('stream-integration-test') 28 | }) 29 | await stream.addToStorageNode(StorageNode.STREAMR_DOCKER_DEV) 30 | }) 31 | 32 | afterEach(async () => { 33 | await client.disconnect() 34 | }) 35 | 36 | describe('detectFields()', () => { 37 | it('does detect primitive types', async () => { 38 | const msg = { 39 | number: 123, 40 | boolean: true, 41 | object: { 42 | k: 1, 43 | v: 2, 44 | }, 45 | array: [1, 2, 3], 46 | string: 'test', 47 | } 48 | const publishTestMessages = getPublishTestMessages(client, { 49 | streamId: stream.id, 50 | waitForLast: true, 51 | createMessage: () => msg, 52 | }) 53 | await publishTestMessages(1) 54 | 55 | expect(stream.config.fields).toEqual([]) 56 | await stream.detectFields() 57 | const expectedFields = [ 58 | { 59 | name: 'number', 60 | type: 'number', 61 | }, 62 | { 63 | name: 'boolean', 64 | type: 'boolean', 65 | }, 66 | { 67 | name: 'object', 68 | type: 'map', 69 | }, 70 | { 71 | name: 'array', 72 | type: 'list', 73 | }, 74 | { 75 | name: 'string', 76 | type: 'string', 77 | }, 78 | ] 79 | 80 | expect(stream.config.fields).toEqual(expectedFields) 81 | const loadedStream = await client.getStream(stream.id) 82 | expect(loadedStream.config.fields).toEqual(expectedFields) 83 | }) 84 | 85 | it('skips unsupported types', async () => { 86 | const msg = { 87 | null: null, 88 | empty: {}, 89 | func: () => null, 90 | nonexistent: undefined, 91 | symbol: Symbol('test'), 92 | // TODO: bigint: 10n, 93 | } 94 | const publishTestMessages = getPublishTestMessages(client, { 95 | streamId: stream.id, 96 | waitForLast: true, 97 | createMessage: () => msg, 98 | }) 99 | await publishTestMessages(1) 100 | 101 | expect(stream.config.fields).toEqual([]) 102 | await stream.detectFields() 103 | const expectedFields = [ 104 | { 105 | name: 'null', 106 | type: 'map', 107 | }, 108 | { 109 | name: 'empty', 110 | type: 'map', 111 | }, 112 | ] 113 | 114 | expect(stream.config.fields).toEqual(expectedFields) 115 | 116 | const loadedStream = await client.getStream(stream.id) 117 | expect(loadedStream.config.fields).toEqual(expectedFields) 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /src/utils/AggregatedError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An Error of Errors 3 | * Pass an array of errors + message to create 4 | * Single error without throwing away other errors 5 | * Specifically not using AggregateError name as this has slightly different API 6 | * 7 | * See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError 8 | */ 9 | 10 | function joinMessages(msgs: (string | undefined)[]): string { 11 | return msgs.filter(Boolean).join('\n') 12 | } 13 | 14 | function getStacks(err: Error | AggregatedError) { 15 | if (err instanceof AggregatedError) { 16 | return [ 17 | err.ownStack, 18 | ...[...err.errors].map(({ stack }) => stack) 19 | ] 20 | } 21 | 22 | return [err.stack] 23 | } 24 | 25 | function joinStackTraces(errs: Error[]): string { 26 | return errs.flatMap((err) => getStacks(err)).filter(Boolean).join('\n') 27 | } 28 | 29 | export default class AggregatedError extends Error { 30 | errors: Set 31 | ownMessage: string 32 | ownStack?: string 33 | constructor(errors: Error[] = [], errorMessage = '') { 34 | const message = joinMessages([ 35 | errorMessage, 36 | ...errors.map((err) => err.message) 37 | ]) 38 | super(message) 39 | errors.forEach((err) => { 40 | Object.assign(this, err) 41 | }) 42 | this.message = message 43 | this.ownMessage = errorMessage 44 | this.errors = new Set(errors) 45 | if (Error.captureStackTrace) { 46 | Error.captureStackTrace(this, this.constructor) 47 | } 48 | this.ownStack = this.stack 49 | this.stack = joinStackTraces([this, ...errors]) 50 | } 51 | 52 | /** 53 | * Combine any errors from Promise.allSettled into AggregatedError. 54 | */ 55 | static fromAllSettled(results = [], errorMessage = '') { 56 | const errs = results.map(({ reason }) => reason).filter(Boolean) 57 | if (!errs.length) { 58 | return undefined 59 | } 60 | 61 | return new this(errs, errorMessage) 62 | } 63 | 64 | /** 65 | * Combine any errors from Promise.allSettled into AggregatedError and throw it. 66 | */ 67 | static throwAllSettled(results = [], errorMessage = '') { 68 | const err = this.fromAllSettled(results, errorMessage) 69 | if (err) { 70 | throw err 71 | } 72 | } 73 | 74 | /** 75 | * Handles 'upgrading' an existing error to an AggregatedError when necesary. 76 | */ 77 | static from(oldErr?: Error | AggregatedError, newErr?: Error, msg?: string) { 78 | if (!newErr) { 79 | if (oldErr && msg) { 80 | // copy message 81 | oldErr.message = joinMessages([oldErr.message, msg]) // eslint-disable-line no-param-reassign 82 | } 83 | return oldErr 84 | } 85 | 86 | // When no oldErr, just return newErr 87 | if (!oldErr) { 88 | if (newErr && msg) { 89 | // copy message 90 | newErr.message = joinMessages([newErr.message, msg]) // eslint-disable-line no-param-reassign 91 | } 92 | return newErr 93 | } 94 | 95 | // When oldErr is an AggregatedError, extend it 96 | if (oldErr instanceof AggregatedError) { 97 | return oldErr.extend(newErr, msg, this) 98 | } 99 | 100 | // Otherwise create new AggregatedError from oldErr and newErr 101 | return new this([oldErr]).extend(newErr, msg) 102 | } 103 | 104 | /** 105 | * Create a new error that adds err to list of errors 106 | */ 107 | 108 | extend(err: Error, message = '', baseClass = this.constructor): AggregatedError { 109 | if (err === this || this.errors.has(err)) { 110 | return this 111 | } 112 | const errors = [err, ...this.errors] 113 | return new ( baseClass)(errors, joinMessages([message, this.ownMessage])) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /examples/web/web-example-subscribe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 47 | 48 | 49 | 50 |

Real-time telemetrics from trams running in Helsinki, Finland.

51 |

Provided by the local public transport authority (HSL) over MQTT protocol.

52 |
53 |
54 |
55 |
56 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/stream/encryption/ServerPersistentStore.ts: -------------------------------------------------------------------------------- 1 | import envPaths from 'env-paths' 2 | import { dirname, join } from 'path' 3 | import { promises as fs } from 'fs' 4 | import { open, Database } from 'sqlite' 5 | import sqlite3 from 'sqlite3' 6 | 7 | import { PersistentStore } from './GroupKeyStore' 8 | import { pOnce } from '../../utils' 9 | 10 | export default class ServerPersistentStore implements PersistentStore { 11 | readonly clientId: string 12 | readonly streamId: string 13 | readonly dbFilePath: string 14 | private store?: Database 15 | private error?: Error 16 | private readonly initialData 17 | 18 | constructor({ clientId, streamId, initialData = {} }: { clientId: string, streamId: string, initialData?: Record }) { 19 | this.streamId = encodeURIComponent(streamId) 20 | this.clientId = encodeURIComponent(clientId) 21 | this.initialData = initialData 22 | const paths = envPaths('streamr-client') 23 | const dbFilePath = join(paths.data, clientId, 'GroupKeys.db') 24 | this.dbFilePath = dbFilePath 25 | 26 | this.init = pOnce(this.init.bind(this)) 27 | this.init() 28 | } 29 | 30 | async init() { 31 | try { 32 | await fs.mkdir(dirname(this.dbFilePath), { recursive: true }) 33 | // open the database 34 | const store = await open({ 35 | filename: this.dbFilePath, 36 | driver: sqlite3.Database 37 | }) 38 | await store.exec(`CREATE TABLE IF NOT EXISTS GroupKeys ( 39 | id TEXT, 40 | groupKey TEXT, 41 | streamId TEXT 42 | )`) 43 | await store.exec('CREATE UNIQUE INDEX IF NOT EXISTS name ON GroupKeys (id)') 44 | this.store = store 45 | } catch (err) { 46 | if (!this.error) { 47 | this.error = err 48 | } 49 | } 50 | 51 | if (this.error) { 52 | throw this.error 53 | } 54 | 55 | await Promise.all(Object.entries(this.initialData).map(async ([key, value]) => { 56 | return this.setKeyValue(key, value) 57 | })) 58 | } 59 | 60 | async get(key: string) { 61 | await this.init() 62 | const value = await this.store!.get('SELECT groupKey FROM GroupKeys WHERE id = ? AND streamId = ?', key, this.streamId) 63 | return value?.groupKey 64 | } 65 | 66 | async has(key: string) { 67 | await this.init() 68 | const value = await this.store!.get('SELECT COUNT(*) FROM GroupKeys WHERE id = ? AND streamId = ?', key, this.streamId) 69 | return value && value['COUNT(*)'] != null && value['COUNT(*)'] !== 0 70 | } 71 | 72 | private async setKeyValue(key: string, value: string) { 73 | // set, but without init so init can insert initialData 74 | const result = await this.store!.run('INSERT INTO GroupKeys VALUES ($id, $groupKey, $streamId) ON CONFLICT DO NOTHING', { 75 | $id: key, 76 | $groupKey: value, 77 | $streamId: this.streamId, 78 | }) 79 | 80 | return !!result?.changes 81 | } 82 | 83 | async set(key: string, value: string) { 84 | await this.init() 85 | return this.setKeyValue(key, value) 86 | } 87 | 88 | async delete(key: string) { 89 | await this.init() 90 | const result = await this.store!.run('DELETE FROM GroupKeys WHERE id = ? AND streamId = ?', key, this.streamId) 91 | return !!result?.changes 92 | } 93 | 94 | async clear() { 95 | await this.init() 96 | const result = await this.store!.run('DELETE FROM GroupKeys WHERE streamId = ?', this.streamId) 97 | return !!result?.changes 98 | } 99 | 100 | async size() { 101 | await this.init() 102 | const size = await this.store!.get('SELECT COUNT(*) FROM GroupKeys WHERE streamId = ?;', this.streamId) 103 | return size && size['COUNT(*)'] 104 | } 105 | 106 | get [Symbol.toStringTag]() { 107 | return this.constructor.name 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/subscribe/api.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | import { ControlLayer, MessageLayer } from 'streamr-client-protocol' 4 | 5 | import { uuid } from '../utils' 6 | import { waitForRequestResponse } from '../stream/utils' 7 | 8 | const { 9 | SubscribeRequest, UnsubscribeRequest, 10 | ResendLastRequest, ResendFromRequest, ResendRangeRequest, 11 | } = ControlLayer 12 | 13 | const { MessageRef } = MessageLayer 14 | 15 | /** 16 | * Subscribe Request 17 | */ 18 | 19 | export async function subscribe(client, { streamId, streamPartition = 0 }) { 20 | const sessionToken = await client.session.getSessionToken() 21 | const request = new SubscribeRequest({ 22 | streamId, 23 | streamPartition, 24 | sessionToken, 25 | requestId: uuid('sub'), 26 | }) 27 | 28 | const onResponse = waitForRequestResponse(client, request) 29 | 30 | await client.send(request) 31 | return onResponse 32 | } 33 | 34 | /** 35 | * Unsubscribe Request 36 | */ 37 | 38 | export async function unsubscribe(client, { streamId, streamPartition = 0 }) { // eslint-disable-line no-underscore-dangle 39 | const { connection } = client 40 | 41 | // disconnection auto-unsubs 42 | // if already disconnected/disconnecting no need to send unsub 43 | const needsUnsubscribe = ( 44 | connection.isConnectionValid() 45 | && !connection.isDisconnected() 46 | && !connection.isDisconnecting() 47 | ) 48 | 49 | if (!needsUnsubscribe) { 50 | return Promise.resolve() 51 | } 52 | 53 | const sessionToken = await client.session.getSessionToken() 54 | const request = new UnsubscribeRequest({ 55 | streamId, 56 | streamPartition, 57 | sessionToken, 58 | requestId: uuid('unsub'), 59 | }) 60 | 61 | const onResponse = waitForRequestResponse(client, request).catch((err) => { 62 | // noop if unsubscribe failed because we are already unsubscribed 63 | if (err.message.contains('Not subscribed to stream')) { 64 | return 65 | } 66 | 67 | throw err 68 | }) 69 | 70 | await client.send(request) 71 | return onResponse 72 | } 73 | 74 | /** 75 | * Resend Request 76 | */ 77 | 78 | function createResendRequest(resendOptions) { 79 | const { 80 | requestId = uuid('rs'), 81 | streamId, 82 | streamPartition = 0, 83 | sessionToken, 84 | ...options 85 | } = resendOptions 86 | 87 | const { 88 | from, 89 | to, 90 | last, 91 | publisherId, 92 | msgChainId 93 | } = { 94 | ...options, 95 | ...options.resend 96 | } 97 | 98 | const commonOpts = { 99 | streamId, 100 | streamPartition, 101 | requestId, 102 | sessionToken, 103 | } 104 | 105 | let request 106 | 107 | if (last > 0) { 108 | request = new ResendLastRequest({ 109 | ...commonOpts, 110 | numberLast: last, 111 | }) 112 | } else if (from && !to) { 113 | request = new ResendFromRequest({ 114 | ...commonOpts, 115 | fromMsgRef: new MessageRef(from.timestamp, from.sequenceNumber), 116 | publisherId 117 | }) 118 | } else if (from && to) { 119 | request = new ResendRangeRequest({ 120 | ...commonOpts, 121 | fromMsgRef: new MessageRef(from.timestamp, from.sequenceNumber), 122 | toMsgRef: new MessageRef(to.timestamp, to.sequenceNumber), 123 | publisherId, 124 | msgChainId, 125 | }) 126 | } 127 | 128 | if (!request) { 129 | throw new Error(`Can't _requestResend without resend options. Got: ${inspect(resendOptions)}`) 130 | } 131 | 132 | return request 133 | } 134 | 135 | export async function resend(client, options) { 136 | const sessionToken = await client.session.getSessionToken() 137 | const request = createResendRequest({ 138 | ...options, 139 | sessionToken, 140 | }) 141 | 142 | const onResponse = waitForRequestResponse(client, request) 143 | 144 | await client.send(request) 145 | return onResponse 146 | } 147 | -------------------------------------------------------------------------------- /test/integration/LoginEndpoints.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | import { ethers } from 'ethers' 4 | 5 | import { StreamrClient } from '../../src/StreamrClient' 6 | 7 | import { clientOptions } from './devEnvironment' 8 | import { fakePrivateKey } from '../utils' 9 | 10 | describe('LoginEndpoints', () => { 11 | let client: StreamrClient 12 | 13 | const createClient = (opts = {}) => new StreamrClient({ 14 | ...clientOptions, 15 | auth: { 16 | privateKey: fakePrivateKey() 17 | }, 18 | autoConnect: false, 19 | autoDisconnect: false, 20 | ...opts, 21 | }) 22 | 23 | beforeAll(() => { 24 | client = createClient() 25 | }) 26 | 27 | afterAll(async () => { 28 | await client.disconnect() 29 | }) 30 | 31 | describe('Challenge generation', () => { 32 | it('should retrieve a challenge', async () => { 33 | const challenge = await client.getChallenge('some-address') 34 | assert(challenge) 35 | // @ts-expect-error 36 | assert(challenge.id) 37 | assert(challenge.challenge) 38 | // @ts-expect-error 39 | assert(challenge.expires) 40 | }) 41 | }) 42 | 43 | describe('Challenge response', () => { 44 | it('should fail to get a session token', async () => { 45 | await expect(async () => { 46 | await client.sendChallengeResponse({ 47 | // @ts-expect-error 48 | id: 'some-id', 49 | challenge: 'some-challenge', 50 | }, 'some-sig', 'some-address') 51 | }).rejects.toThrow() 52 | }) 53 | 54 | it('should get a session token', async () => { 55 | const wallet = ethers.Wallet.createRandom() 56 | const challenge = await client.getChallenge(wallet.address) 57 | assert(challenge.challenge) 58 | const signature = await wallet.signMessage(challenge.challenge) 59 | const sessionToken = await client.sendChallengeResponse(challenge, signature, wallet.address) 60 | assert(sessionToken) 61 | assert(sessionToken.token) 62 | // @ts-expect-error 63 | assert(sessionToken.expires) 64 | }) 65 | 66 | it('should get a session token with combined function', async () => { 67 | const wallet = ethers.Wallet.createRandom() 68 | const sessionToken = await client.loginWithChallengeResponse((d) => wallet.signMessage(d), wallet.address) 69 | assert(sessionToken) 70 | assert(sessionToken.token) 71 | // @ts-expect-error 72 | assert(sessionToken.expires) 73 | }) 74 | }) 75 | 76 | describe('API key login', () => { 77 | it('should fail', async () => { 78 | await expect(async () => { 79 | await client.loginWithApiKey('apikey') 80 | }).rejects.toThrow() 81 | }) 82 | }) 83 | 84 | describe('Username/password login', () => { 85 | it('should fail', async () => { 86 | await expect(async () => { 87 | await client.loginWithUsernamePassword('username', 'password') 88 | }).rejects.toThrow('no longer supported') 89 | }) 90 | }) 91 | 92 | describe('UserInfo', () => { 93 | it('should get user info', async () => { 94 | const userInfo = await client.getUserInfo() 95 | assert(userInfo.name) 96 | assert(userInfo.username) 97 | }) 98 | }) 99 | 100 | describe('logout', () => { 101 | it('should not be able to use the same session token after logout', async () => { 102 | await client.getUserInfo() // first fetches the session token, then requests the endpoint 103 | const sessionToken1 = client.session.options.sessionToken 104 | await client.logoutEndpoint() // invalidates the session token in core-api 105 | await client.getUserInfo() // requests the endpoint with sessionToken1, receives 401, fetches a new session token 106 | const sessionToken2 = client.session.options.sessionToken 107 | assert.notDeepStrictEqual(sessionToken1, sessionToken2) 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /test/integration/dataunion/stats.test.ts: -------------------------------------------------------------------------------- 1 | import { providers } from 'ethers' 2 | import debug from 'debug' 3 | 4 | import { StreamrClient } from '../../../src/StreamrClient' 5 | import { clientOptions } from '../devEnvironment' 6 | import { DataUnion, MemberStatus } from '../../../src/dataunion/DataUnion' 7 | import { createClient, createMockAddress, expectInvalidAddress } from '../../utils' 8 | import { BigNumber } from '@ethersproject/bignumber' 9 | 10 | const log = debug('StreamrClient::DataUnion::integration-test-stats') 11 | 12 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 13 | const providerMainnet = new providers.JsonRpcProvider(clientOptions.mainnet) 14 | 15 | describe('DataUnion stats', () => { 16 | 17 | let adminClient: StreamrClient 18 | let dataUnion: DataUnion 19 | let queryClient: StreamrClient 20 | const nonce = Date.now() 21 | const activeMemberAddressList = [ 22 | `0x100000000000000000000000000${nonce}`, 23 | `0x200000000000000000000000000${nonce}`, 24 | `0x300000000000000000000000000${nonce}`, 25 | ] 26 | const inactiveMember = createMockAddress() 27 | 28 | beforeAll(async () => { 29 | log('Connecting to Ethereum networks, clientOptions: %O', clientOptions) 30 | const network = await providerMainnet.getNetwork() 31 | log('Connected to "mainnet" network: ', JSON.stringify(network)) 32 | const network2 = await providerSidechain.getNetwork() 33 | log('Connected to sidechain network: ', JSON.stringify(network2)) 34 | adminClient = new StreamrClient(clientOptions as any) 35 | dataUnion = await adminClient.deployDataUnion() 36 | await dataUnion.addMembers(activeMemberAddressList.concat([inactiveMember])) 37 | await dataUnion.removeMembers([inactiveMember]) 38 | queryClient = createClient(providerSidechain) 39 | }, 60000) 40 | 41 | afterAll(() => { 42 | providerMainnet.removeAllListeners() 43 | providerSidechain.removeAllListeners() 44 | }) 45 | 46 | it('DataUnion stats', async () => { 47 | const stats = await queryClient.getDataUnion(dataUnion.getAddress()).getStats() 48 | expect(stats.activeMemberCount).toEqual(BigNumber.from(3)) 49 | expect(stats.inactiveMemberCount).toEqual(BigNumber.from(1)) 50 | expect(stats.joinPartAgentCount).toEqual(BigNumber.from(2)) 51 | expect(stats.totalEarnings).toEqual(BigNumber.from(0)) 52 | expect(stats.totalWithdrawable).toEqual(BigNumber.from(0)) 53 | expect(stats.lifetimeMemberEarnings).toEqual(BigNumber.from(0)) 54 | }, 150000) 55 | 56 | it('member stats', async () => { 57 | const memberStats = await Promise.all( 58 | activeMemberAddressList 59 | .concat([inactiveMember]) 60 | .map((m) => queryClient.getDataUnion(dataUnion.getAddress()).getMemberStats(m)) 61 | ) 62 | 63 | const ZERO = BigNumber.from(0) 64 | expect(memberStats).toMatchObject([{ 65 | status: MemberStatus.ACTIVE, 66 | earningsBeforeLastJoin: ZERO, 67 | totalEarnings: ZERO, 68 | withdrawableEarnings: ZERO, 69 | }, { 70 | status: MemberStatus.ACTIVE, 71 | earningsBeforeLastJoin: ZERO, 72 | totalEarnings: ZERO, 73 | withdrawableEarnings: ZERO, 74 | }, { 75 | status: MemberStatus.ACTIVE, 76 | earningsBeforeLastJoin: ZERO, 77 | totalEarnings: ZERO, 78 | withdrawableEarnings: ZERO, 79 | }, { 80 | status: MemberStatus.INACTIVE, 81 | earningsBeforeLastJoin: ZERO, 82 | totalEarnings: ZERO, 83 | withdrawableEarnings: ZERO, 84 | }]) 85 | }, 150000) 86 | 87 | it('member stats: no member', async () => { 88 | const memberStats = await queryClient.getDataUnion(dataUnion.getAddress()).getMemberStats(createMockAddress()) 89 | const ZERO = BigNumber.from(0) 90 | expect(memberStats).toMatchObject({ 91 | status: MemberStatus.NONE, 92 | earningsBeforeLastJoin: ZERO, 93 | totalEarnings: ZERO, 94 | withdrawableEarnings: ZERO 95 | }) 96 | }) 97 | 98 | it('member stats: invalid address', () => { 99 | return expectInvalidAddress(() => dataUnion.getMemberStats('invalid-address')) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/rest/authFetch.ts: -------------------------------------------------------------------------------- 1 | import fetch, { Response } from 'node-fetch' 2 | import Debug from 'debug' 3 | 4 | import { getVersionString } from '../utils' 5 | import Session from '../Session' 6 | 7 | export enum ErrorCode { 8 | NOT_FOUND = 'NOT_FOUND', 9 | VALIDATION_ERROR = 'VALIDATION_ERROR', 10 | UNKNOWN = 'UNKNOWN' 11 | } 12 | 13 | export const DEFAULT_HEADERS = { 14 | 'Streamr-Client': `streamr-client-javascript/${getVersionString()}`, 15 | } 16 | 17 | export class AuthFetchError extends Error { 18 | response?: Response 19 | body?: any 20 | errorCode: ErrorCode 21 | 22 | constructor(message: string, response?: Response, body?: any, errorCode?: ErrorCode) { 23 | const typePrefix = errorCode ? errorCode + ': ' : '' 24 | // add leading space if there is a body set 25 | const bodyMessage = body ? ` ${(typeof body === 'string' ? body : JSON.stringify(body).slice(0, 1024))}...` : '' 26 | super(typePrefix + message + bodyMessage) 27 | this.response = response 28 | this.body = body 29 | this.errorCode = errorCode || ErrorCode.UNKNOWN 30 | 31 | if (Error.captureStackTrace) { 32 | Error.captureStackTrace(this, this.constructor) 33 | } 34 | } 35 | } 36 | 37 | export class ValidationError extends AuthFetchError { 38 | constructor(message: string, response?: Response, body?: any) { 39 | super(message, response, body, ErrorCode.VALIDATION_ERROR) 40 | } 41 | } 42 | 43 | export class NotFoundError extends AuthFetchError { 44 | constructor(message: string, response?: Response, body?: any) { 45 | super(message, response, body, ErrorCode.NOT_FOUND) 46 | } 47 | } 48 | 49 | const ERROR_TYPES = new Map() 50 | ERROR_TYPES.set(ErrorCode.VALIDATION_ERROR, ValidationError) 51 | ERROR_TYPES.set(ErrorCode.NOT_FOUND, NotFoundError) 52 | ERROR_TYPES.set(ErrorCode.UNKNOWN, AuthFetchError) 53 | 54 | const parseErrorCode = (body: string) => { 55 | let json 56 | try { 57 | json = JSON.parse(body) 58 | } catch (err) { 59 | return ErrorCode.UNKNOWN 60 | } 61 | const { code } = json 62 | return code in ErrorCode ? code : ErrorCode.UNKNOWN 63 | } 64 | 65 | const debug = Debug('StreamrClient:utils:authfetch') // TODO: could use the debug instance from the client? (e.g. client.debug.extend('authFetch')) 66 | 67 | let ID = 0 68 | 69 | /** @internal */ 70 | export default async function authFetch(url: string, session?: Session, opts?: any, requireNewToken = false): Promise { 71 | ID += 1 72 | const timeStart = Date.now() 73 | const id = ID 74 | 75 | const options = { 76 | ...opts, 77 | headers: { 78 | ...DEFAULT_HEADERS, 79 | ...(opts && opts.headers), 80 | }, 81 | } 82 | // add default 'Content-Type: application/json' header for all POST and PUT requests 83 | if (!options.headers['Content-Type'] && (options.method === 'POST' || options.method === 'PUT')) { 84 | options.headers['Content-Type'] = 'application/json' 85 | } 86 | 87 | debug('%d %s >> %o', id, url, opts) 88 | 89 | const response: Response = await fetch(url, { 90 | ...opts, 91 | headers: { 92 | ...(session && !session.options.unauthenticated ? { 93 | Authorization: `Bearer ${await session.getSessionToken(requireNewToken)}`, 94 | } : {}), 95 | ...options.headers, 96 | }, 97 | }) 98 | const timeEnd = Date.now() 99 | // @ts-expect-error 100 | debug('%d %s << %d %s %s %s', id, url, response.status, response.statusText, Debug.humanize(timeEnd - timeStart)) 101 | 102 | const body = await response.text() 103 | 104 | if (response.ok) { 105 | try { 106 | return JSON.parse(body || '{}') 107 | } catch (e) { 108 | debug('%d %s – failed to parse body: %s', id, url, e.stack) 109 | throw new AuthFetchError(e.message, response, body) 110 | } 111 | } else if ([400, 401].includes(response.status) && !requireNewToken) { 112 | debug('%d %s – revalidating session') 113 | return authFetch(url, session, options, true) 114 | } else { 115 | debug('%d %s – failed', id, url) 116 | const errorCode = parseErrorCode(body) 117 | const ErrorClass = ERROR_TYPES.get(errorCode)! 118 | throw new ErrorClass(`Request ${id} to ${url} returned with error code ${response.status}.`, response, body, errorCode) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/integration/dataunion/member.test.ts: -------------------------------------------------------------------------------- 1 | import { providers, Wallet } from 'ethers' 2 | import debug from 'debug' 3 | 4 | import { StreamrClient } from '../../../src/StreamrClient' 5 | import { clientOptions } from '../devEnvironment' 6 | import { DataUnion, JoinRequestState } from '../../../src/dataunion/DataUnion' 7 | import { createMockAddress, expectInvalidAddress, fakePrivateKey } from '../../utils' 8 | import authFetch from '../../../src/rest/authFetch' 9 | import { getEndpointUrl } from '../../../src/utils' 10 | 11 | const log = debug('StreamrClient::DataUnion::integration-test-member') 12 | 13 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 14 | const providerMainnet = new providers.JsonRpcProvider(clientOptions.mainnet) 15 | 16 | const joinMember = async (memberWallet: Wallet, secret: string|undefined, dataUnionAddress: string) => { 17 | const memberClient = new StreamrClient({ 18 | ...clientOptions, 19 | auth: { 20 | privateKey: memberWallet.privateKey, 21 | } 22 | } as any) 23 | const du = await memberClient.safeGetDataUnion(dataUnionAddress) 24 | return du.join(secret) 25 | } 26 | 27 | describe('DataUnion member', () => { 28 | 29 | let dataUnion: DataUnion 30 | let secret: string 31 | 32 | beforeAll(async () => { 33 | log('Connecting to Ethereum networks, clientOptions: %O', clientOptions) 34 | const network = await providerMainnet.getNetwork() 35 | log('Connected to "mainnet" network: ', JSON.stringify(network)) 36 | const network2 = await providerSidechain.getNetwork() 37 | log('Connected to sidechain network: ', JSON.stringify(network2)) 38 | const adminClient = new StreamrClient(clientOptions as any) 39 | dataUnion = await adminClient.deployDataUnion() 40 | // product is needed for join requests to analyze the DU version 41 | const createProductUrl = getEndpointUrl(clientOptions.restUrl, 'products') 42 | await authFetch( 43 | createProductUrl, 44 | adminClient.session, 45 | { 46 | method: 'POST', 47 | body: JSON.stringify({ 48 | beneficiaryAddress: dataUnion.getAddress(), 49 | type: 'DATAUNION', 50 | dataUnionVersion: 2 51 | }) 52 | } 53 | ) 54 | secret = await dataUnion.createSecret() 55 | }, 60000) 56 | 57 | afterAll(() => { 58 | providerMainnet.removeAllListeners() 59 | providerSidechain.removeAllListeners() 60 | }) 61 | 62 | it('random user is not a member', async () => { 63 | const userAddress = createMockAddress() 64 | const isMember = await dataUnion.isMember(userAddress) 65 | expect(isMember).toBe(false) 66 | }, 60000) 67 | 68 | it('join with valid secret', async () => { 69 | const memberWallet = new Wallet(fakePrivateKey()) 70 | await joinMember(memberWallet, secret, dataUnion.getAddress()) 71 | const isMember = await dataUnion.isMember(memberWallet.address) 72 | expect(isMember).toBe(true) 73 | }, 60000) 74 | 75 | it('join with invalid secret', async () => { 76 | const memberWallet = new Wallet(fakePrivateKey()) 77 | return expect(() => joinMember(memberWallet, 'invalid-secret', dataUnion.getAddress())).rejects.toThrow('Incorrect data union secret') 78 | }, 60000) 79 | 80 | it('join without secret', async () => { 81 | const memberWallet = new Wallet(fakePrivateKey()) 82 | const response = await joinMember(memberWallet, undefined, dataUnion.getAddress()) 83 | expect(response.id).toBeDefined() 84 | expect(response.state).toBe(JoinRequestState.PENDING) 85 | }, 60000) 86 | 87 | it('add', async () => { 88 | const userAddress = createMockAddress() 89 | await dataUnion.addMembers([userAddress]) 90 | const isMember = await dataUnion.isMember(userAddress) 91 | expect(isMember).toBe(true) 92 | }, 60000) 93 | 94 | it('remove', async () => { 95 | const userAddress = createMockAddress() 96 | await dataUnion.addMembers([userAddress]) 97 | await dataUnion.removeMembers([userAddress]) 98 | const isMember = await dataUnion.isMember(userAddress) 99 | expect(isMember).toBe(false) 100 | }, 60000) 101 | 102 | it('invalid address', () => { 103 | return Promise.all([ 104 | expectInvalidAddress(() => dataUnion.addMembers(['invalid-address'])), 105 | expectInvalidAddress(() => dataUnion.removeMembers(['invalid-address'])), 106 | expectInvalidAddress(() => dataUnion.isMember('invalid-address')) 107 | ]) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/integration/ResendReconnect.test.ts: -------------------------------------------------------------------------------- 1 | import { wait, waitForCondition } from 'streamr-test-utils' 2 | 3 | import { uid, fakePrivateKey, getPublishTestMessages } from '../utils' 4 | import { StreamrClient } from '../../src/StreamrClient' 5 | import { Defer } from '../../src/utils' 6 | 7 | import { clientOptions } from './devEnvironment' 8 | import { Stream } from '../../src/stream' 9 | import { Subscription } from '../../src' 10 | import { PublishRequest } from 'streamr-client-protocol/dist/src/protocol/control_layer' 11 | import { StorageNode } from '../../src/stream/StorageNode' 12 | 13 | const createClient = (opts = {}) => new StreamrClient({ 14 | ...clientOptions, 15 | auth: { 16 | privateKey: fakePrivateKey(), 17 | }, 18 | autoConnect: false, 19 | autoDisconnect: false, 20 | ...opts, 21 | }) 22 | 23 | const MAX_MESSAGES = 3 24 | 25 | describe('resend/reconnect', () => { 26 | let client: StreamrClient 27 | let stream: Stream 28 | let publishedMessages: [message: any, request: PublishRequest][] 29 | let publishTestMessages: ReturnType 30 | 31 | beforeEach(async () => { 32 | client = createClient() 33 | await client.connect() 34 | 35 | stream = await client.createStream({ 36 | name: uid('resends') 37 | }) 38 | 39 | await stream.addToStorageNode(StorageNode.STREAMR_DOCKER_DEV) 40 | }, 10000) 41 | 42 | beforeEach(async () => { 43 | publishTestMessages = getPublishTestMessages(client, { 44 | streamId: stream.id, 45 | waitForLast: true, 46 | }) 47 | 48 | publishedMessages = await publishTestMessages(MAX_MESSAGES) 49 | }, 10000) 50 | 51 | afterEach(async () => { 52 | await client.disconnect() 53 | }) 54 | 55 | describe('reconnect with resend', () => { 56 | let shouldDisconnect = false 57 | let sub: Subscription 58 | let messages: any[] = [] 59 | 60 | beforeEach(async () => { 61 | const done = Defer() 62 | messages = [] 63 | sub = await client.subscribe({ 64 | streamId: stream.id, 65 | resend: { 66 | last: MAX_MESSAGES, 67 | }, 68 | }, (message) => { 69 | messages.push(message) 70 | if (shouldDisconnect) { 71 | client.connection.socket.close() 72 | } 73 | }) 74 | 75 | sub.once('resent', done.resolve) 76 | await done 77 | expect(messages).toEqual(publishedMessages.slice(-MAX_MESSAGES)) 78 | }, 15000) 79 | 80 | it('can handle mixed resend/subscribe', async () => { 81 | const prevMessages = messages.slice() 82 | const newMessages = await publishTestMessages(3) 83 | expect(messages).toEqual([...prevMessages, ...newMessages]) 84 | }, 10000) 85 | 86 | it('can handle reconnection after unintentional disconnection 1', async () => { 87 | const onClose = Defer() 88 | 89 | client.connection.socket.once('close', onClose.resolve) 90 | client.connection.socket.close() 91 | await onClose 92 | // should reconnect and get new messages 93 | const prevMessages = messages.slice() 94 | const newMessages = await publishTestMessages(3) 95 | await wait(6000) 96 | expect(messages).toEqual([...prevMessages, ...newMessages]) 97 | }, 11000) 98 | 99 | it('can handle reconnection after unintentional disconnection 2', async () => { 100 | // should reconnect and get new messages 101 | const prevMessages = messages.slice() 102 | const newMessages = await publishTestMessages(3, { 103 | waitForLast: false, 104 | }) 105 | const onClose = Defer() 106 | 107 | client.connection.socket.once('close', onClose.resolve) 108 | client.connection.socket.close() 109 | await client.connection.nextConnection() 110 | 111 | await wait(6000) 112 | expect(messages).toEqual([...prevMessages, ...newMessages]) 113 | }, 11000) 114 | 115 | it('can handle reconnection after unintentional disconnection 3', async () => { 116 | shouldDisconnect = true 117 | const prevMessages = messages.slice() 118 | const newMessages = await publishTestMessages(MAX_MESSAGES, { 119 | waitForLast: false, 120 | }) 121 | await waitForCondition(() => messages.length === MAX_MESSAGES * 2, 10000) 122 | expect(messages).toEqual([...prevMessages, ...newMessages]) 123 | }, 21000) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /test/benchmarks/publish.js: -------------------------------------------------------------------------------- 1 | const { format } = require('util') 2 | const { Benchmark } = require('benchmark') 3 | 4 | // eslint-disable-next-line import/no-unresolved 5 | const StreamrClient = require('../../dist') 6 | const clientOptions = require('../integration/config') 7 | 8 | /* eslint-disable no-console */ 9 | 10 | let count = 100000 // pedantic: use large initial number so payload size is similar 11 | const Msg = () => { 12 | count += 1 13 | return { 14 | msg: `msg${count}` 15 | } 16 | } 17 | 18 | function createClient(opts) { 19 | return new StreamrClient({ 20 | ...clientOptions, 21 | ...opts, 22 | }) 23 | } 24 | 25 | async function setupClientAndStream(clientOpts, streamOpts) { 26 | const client = createClient(clientOpts) 27 | await client.connect() 28 | await client.session.getSessionToken() 29 | 30 | const stream = await client.createStream({ 31 | name: `test-stream.${client.id}`, 32 | ...streamOpts, 33 | }) 34 | return [client, stream] 35 | } 36 | 37 | const BATCH_SIZES = [ 38 | 1, 39 | 4, 40 | 16, 41 | 64 42 | ] 43 | 44 | const log = (...args) => process.stderr.write(format(...args) + '\n') 45 | 46 | async function run() { 47 | const account1 = StreamrClient.generateEthereumAccount() 48 | const [client1, stream1] = await setupClientAndStream({ 49 | auth: { 50 | privateKey: account1.privateKey, 51 | }, 52 | publishWithSignature: 'always', 53 | }) 54 | 55 | const account2 = StreamrClient.generateEthereumAccount() 56 | const [client2, stream2] = await setupClientAndStream({ 57 | auth: { 58 | privateKey: account2.privateKey, 59 | }, 60 | publishWithSignature: 'never', 61 | }) 62 | 63 | const account3 = StreamrClient.generateEthereumAccount() 64 | const [client3, stream3] = await setupClientAndStream({ 65 | auth: { 66 | privateKey: account3.privateKey, 67 | }, 68 | publishWithSignature: 'always', 69 | }, { 70 | requiresEncryption: true, 71 | }) 72 | 73 | const suite = new Benchmark.Suite() 74 | 75 | async function publish(stream, batchSize) { 76 | const tasks = [] 77 | for (let i = 0; i < batchSize; i++) { 78 | tasks.push(stream.publish(Msg())) 79 | } 80 | return Promise.all(tasks) 81 | } 82 | 83 | BATCH_SIZES.forEach((batchSize) => { 84 | suite.add(`client publishing in batches of ${batchSize} with signing`, { 85 | defer: true, 86 | fn(deferred) { 87 | this.BATCH_SIZE = batchSize 88 | // eslint-disable-next-line promise/catch-or-return 89 | return publish(stream1, batchSize).then(() => deferred.resolve(), () => deferred.resolve()) 90 | } 91 | }) 92 | 93 | suite.add(`client publishing in batches of ${batchSize} without signing`, { 94 | defer: true, 95 | fn(deferred) { 96 | this.BATCH_SIZE = batchSize 97 | // eslint-disable-next-line promise/catch-or-return 98 | return publish(stream2, batchSize).then(() => deferred.resolve(), () => deferred.resolve()) 99 | } 100 | }) 101 | 102 | suite.add(`client publishing in batches of ${batchSize} with encryption`, { 103 | defer: true, 104 | fn(deferred) { 105 | this.BATCH_SIZE = batchSize 106 | // eslint-disable-next-line promise/catch-or-return 107 | return publish(stream3, batchSize).then(() => deferred.resolve(), () => deferred.resolve()) 108 | } 109 | }) 110 | }) 111 | 112 | function toStringBench(bench) { 113 | const { error, id, stats } = bench 114 | let { hz } = bench 115 | hz *= bench.BATCH_SIZE // adjust hz by batch size 116 | const size = stats.sample.length 117 | const pm = '\xb1' 118 | let result = bench.name || (Number.isNaN(id) ? id : '') 119 | if (error) { 120 | return result + ' Error' 121 | } 122 | 123 | result += ' x ' + Benchmark.formatNumber(hz.toFixed(hz < 100 ? 2 : 0)) + ' ops/sec ' + pm 124 | + stats.rme.toFixed(2) + '% (' + size + ' run' + (size === 1 ? '' : 's') + ' sampled)' 125 | return result 126 | } 127 | 128 | suite.on('cycle', (event) => { 129 | log(toStringBench(event.target)) 130 | }) 131 | 132 | suite.on('complete', async () => { 133 | log('Disconnecting clients') 134 | await Promise.all([ 135 | client1.disconnect(), 136 | client2.disconnect(), 137 | client3.disconnect(), 138 | ]) 139 | log('Clients disconnected') 140 | }) 141 | 142 | suite.run() 143 | } 144 | 145 | run() 146 | -------------------------------------------------------------------------------- /src/Ethereum.js: -------------------------------------------------------------------------------- 1 | import { Wallet } from '@ethersproject/wallet' 2 | import { getDefaultProvider, JsonRpcProvider, Web3Provider } from '@ethersproject/providers' 3 | import { computeAddress } from '@ethersproject/transactions' 4 | import { getAddress } from '@ethersproject/address' 5 | 6 | export default class StreamrEthereum { 7 | static generateEthereumAccount() { 8 | const wallet = Wallet.createRandom() 9 | return { 10 | address: wallet.address, 11 | privateKey: wallet.privateKey, 12 | } 13 | } 14 | 15 | constructor(client) { 16 | this.client = client 17 | const { options } = client 18 | const { auth } = options 19 | if (auth.privateKey) { 20 | const key = auth.privateKey 21 | const address = getAddress(computeAddress(key)) 22 | this._getAddress = async () => address 23 | this._getSigner = () => new Wallet(key, this.getMainnetProvider()) 24 | this._getSidechainSigner = async () => new Wallet(key, this.getSidechainProvider()) 25 | } else if (auth.ethereum) { 26 | this._getAddress = async () => { 27 | try { 28 | const accounts = await auth.ethereum.request({ method: 'eth_requestAccounts' }) 29 | const account = getAddress(accounts[0]) // convert to checksum case 30 | return account 31 | } catch { 32 | throw new Error('no addresses connected+selected in Metamask') 33 | } 34 | } 35 | this._getSigner = () => { 36 | const metamaskProvider = new Web3Provider(auth.ethereum) 37 | const metamaskSigner = metamaskProvider.getSigner() 38 | return metamaskSigner 39 | } 40 | this._getSidechainSigner = async () => { 41 | if (!options.sidechain || !options.sidechain.chainId) { 42 | throw new Error('Streamr sidechain not configured (with chainId) in the StreamrClient options!') 43 | } 44 | 45 | const metamaskProvider = new Web3Provider(auth.ethereum) 46 | const { chainId } = await metamaskProvider.getNetwork() 47 | if (chainId !== options.sidechain.chainId) { 48 | throw new Error( 49 | `Please connect Metamask to Ethereum blockchain with chainId ${options.sidechain.chainId}: current chainId is ${chainId}` 50 | ) 51 | } 52 | const metamaskSigner = metamaskProvider.getSigner() 53 | return metamaskSigner 54 | } 55 | // TODO: handle events 56 | // ethereum.on('accountsChanged', (accounts) => { }) 57 | // https://docs.metamask.io/guide/ethereum-provider.html#events says: 58 | // "We recommend reloading the page unless you have a very good reason not to" 59 | // Of course we can't and won't do that, but if we need something chain-dependent... 60 | // ethereum.on('chainChanged', (chainId) => { window.location.reload() }); 61 | } 62 | } 63 | 64 | canEncrypt() { 65 | return !!(this._getAddress && this._getSigner) 66 | } 67 | 68 | async getAddress() { 69 | if (!this._getAddress) { 70 | // _getAddress is assigned in constructor 71 | throw new Error('StreamrClient is not authenticated with private key') 72 | } 73 | 74 | return this._getAddress() 75 | } 76 | 77 | getSigner() { 78 | if (!this._getSigner) { 79 | // _getSigner is assigned in constructor 80 | throw new Error("StreamrClient not authenticated! Can't send transactions or sign messages.") 81 | } 82 | 83 | return this._getSigner() 84 | } 85 | 86 | async getSidechainSigner() { 87 | if (!this._getSidechainSigner) { 88 | // _getSidechainSigner is assigned in constructor 89 | throw new Error("StreamrClient not authenticated! Can't send transactions or sign messages.") 90 | } 91 | 92 | return this._getSidechainSigner() 93 | } 94 | 95 | /** @returns Ethers.js Provider, a connection to the Ethereum network (mainnet) */ 96 | getMainnetProvider() { 97 | if (!this.client.options.mainnet) { 98 | return getDefaultProvider() 99 | } 100 | 101 | return new JsonRpcProvider(this.client.options.mainnet) 102 | } 103 | 104 | /** @returns Ethers.js Provider, a connection to the Streamr EVM sidechain */ 105 | getSidechainProvider() { 106 | if (!this.client.options.sidechain) { 107 | throw new Error('StreamrClient has no sidechain configuration.') 108 | } 109 | 110 | return new JsonRpcProvider(this.client.options.sidechain) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/integration/Subscription.test.ts: -------------------------------------------------------------------------------- 1 | import { wait, waitForEvent } from 'streamr-test-utils' 2 | 3 | import { uid, fakePrivateKey } from '../utils' 4 | import { StreamrClient } from '../../src/StreamrClient' 5 | 6 | import { clientOptions } from './devEnvironment' 7 | import { Stream } from '../../src/stream' 8 | import { Subscription } from '../../src/subscribe' 9 | import { StorageNode } from '../../src/stream/StorageNode' 10 | 11 | const createClient = (opts = {}) => new StreamrClient({ 12 | ...clientOptions, 13 | auth: { 14 | privateKey: fakePrivateKey(), 15 | }, 16 | autoConnect: false, 17 | autoDisconnect: false, 18 | ...opts, 19 | }) 20 | 21 | const RESEND_ALL = { 22 | from: { 23 | timestamp: 0, 24 | }, 25 | } 26 | 27 | describe('Subscription', () => { 28 | let stream: Stream 29 | let client: StreamrClient 30 | let subscription: Subscription 31 | let errors: any[] = [] 32 | let expectedErrors = 0 33 | 34 | function onError(err: any) { 35 | errors.push(err) 36 | } 37 | 38 | /** 39 | * Returns an array which will be filled with subscription events in the order they occur. 40 | * Needs to create subscription at same time in order to track message events. 41 | */ 42 | 43 | async function createMonitoredSubscription(opts = {}) { 44 | if (!client) { throw new Error('No client') } 45 | const events: any[] = [] 46 | subscription = await client.subscribe({ 47 | streamId: stream.id, 48 | resend: RESEND_ALL, 49 | ...opts, 50 | }, (message) => { 51 | events.push(message) 52 | }) 53 | subscription.on('subscribed', () => events.push('subscribed')) 54 | subscription.on('resent', () => events.push('resent')) 55 | subscription.on('unsubscribed', () => events.push('unsubscribed')) 56 | subscription.on('error', () => events.push('error')) 57 | return events 58 | } 59 | 60 | async function publishMessage() { 61 | const message = { 62 | message: uid('msg') 63 | } 64 | await stream.publish(message) 65 | return message 66 | } 67 | 68 | beforeEach(async () => { 69 | errors = [] 70 | expectedErrors = 0 71 | client = createClient() 72 | client.on('error', onError) 73 | stream = await client.createStream({ 74 | name: uid('stream') 75 | }) 76 | await stream.addToStorageNode(StorageNode.STREAMR_DOCKER_DEV) 77 | await client.connect() 78 | }) 79 | 80 | afterEach(async () => { 81 | expect(errors).toHaveLength(expectedErrors) 82 | }) 83 | 84 | afterEach(async () => { 85 | if (!client) { return } 86 | client.off('error', onError) 87 | client.debug('disconnecting after test') 88 | await client.disconnect() 89 | }) 90 | 91 | describe('subscribe/unsubscribe events', () => { 92 | it('fires events in correct order 1', async () => { 93 | const subscriptionEvents = await createMonitoredSubscription() 94 | await waitForEvent(subscription, 'resent') 95 | await client.unsubscribe(stream) 96 | expect(subscriptionEvents).toEqual([ 97 | 'resent', 98 | 'unsubscribed', 99 | ]) 100 | }) 101 | 102 | it('fires events in correct order 2', async () => { 103 | const subscriptionEvents = await createMonitoredSubscription({ 104 | resend: undefined, 105 | }) 106 | await client.unsubscribe(stream) 107 | expect(subscriptionEvents).toEqual([ 108 | 'unsubscribed', 109 | ]) 110 | }) 111 | }) 112 | 113 | describe('resending/no_resend events', () => { 114 | it('fires events in correct order 1', async () => { 115 | const subscriptionEvents = await createMonitoredSubscription() 116 | await waitForEvent(subscription, 'resent') 117 | expect(subscriptionEvents).toEqual([ 118 | 'resent', 119 | ]) 120 | }) 121 | }) 122 | 123 | describe('resending/resent events', () => { 124 | it('fires events in correct order 1', async () => { 125 | const message1 = await publishMessage() 126 | const message2 = await publishMessage() 127 | await wait(5000) // wait for messages to (probably) land in storage 128 | const subscriptionEvents = await createMonitoredSubscription() 129 | await waitForEvent(subscription, 'resent') 130 | await wait(500) // wait in case messages appear after resent event 131 | expect(subscriptionEvents).toEqual([ 132 | message1, 133 | message2, 134 | 'resent', 135 | ]) 136 | }, 20 * 1000) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /test/benchmarks/subscribe.js: -------------------------------------------------------------------------------- 1 | const { format } = require('util') 2 | const { Benchmark } = require('benchmark') 3 | 4 | // eslint-disable-next-line import/no-unresolved 5 | const StreamrClient = require('../../dist') 6 | const clientOptions = require('../integration/config') 7 | 8 | /* eslint-disable no-console */ 9 | 10 | let count = 100000 // pedantic: use large initial number so payload size is similar 11 | const Msg = () => { 12 | count += 1 13 | return { 14 | value: `msg${count}` 15 | } 16 | } 17 | 18 | function createClient(opts) { 19 | return new StreamrClient({ 20 | ...clientOptions, 21 | ...opts, 22 | }) 23 | } 24 | 25 | async function setupClientAndStream(clientOpts, streamOpts) { 26 | const client = createClient(clientOpts) 27 | await client.connect() 28 | await client.session.getSessionToken() 29 | 30 | const stream = await client.createStream({ 31 | name: `test-stream.${client.id}`, 32 | ...streamOpts, 33 | }) 34 | return [client, stream] 35 | } 36 | 37 | const BATCH_SIZES = [ 38 | 1, 39 | 32, 40 | 512, 41 | 1024, 42 | ] 43 | 44 | const log = (...args) => process.stderr.write(format(...args) + '\n') 45 | 46 | async function run() { 47 | const account1 = StreamrClient.generateEthereumAccount() 48 | const [client1, stream1] = await setupClientAndStream({ 49 | auth: { 50 | privateKey: account1.privateKey, 51 | }, 52 | publishWithSignature: 'always', 53 | }) 54 | 55 | const account2 = StreamrClient.generateEthereumAccount() 56 | const [client2, stream2] = await setupClientAndStream({ 57 | auth: { 58 | privateKey: account2.privateKey, 59 | }, 60 | publishWithSignature: 'never', 61 | }) 62 | 63 | const account3 = StreamrClient.generateEthereumAccount() 64 | const [client3, stream3] = await setupClientAndStream({ 65 | auth: { 66 | privateKey: account3.privateKey, 67 | }, 68 | publishWithSignature: 'always', 69 | }, { 70 | requiresEncryption: true, 71 | }) 72 | 73 | const suite = new Benchmark.Suite() 74 | 75 | async function publish(stream, batchSize) { 76 | const msgs = [] 77 | for (let i = 0; i < batchSize; i++) { 78 | msgs.push(Msg()) 79 | } 80 | 81 | await Promise.all(msgs.map((msg) => stream.publish(msg))) 82 | return msgs 83 | } 84 | 85 | function test(client, stream, batchSize) { 86 | return async function Fn(deferred) { 87 | this.BATCH_SIZE = batchSize 88 | const received = [] 89 | let msgs 90 | const sub = await client.subscribe(stream.id, (msg) => { 91 | received.push(msg) 92 | if (msgs && received.length === msgs.length) { 93 | // eslint-disable-next-line promise/catch-or-return 94 | sub.unsubscribe().then(() => deferred.resolve(), () => deferred.resolve()) 95 | } 96 | }) 97 | msgs = await publish(stream, batchSize) 98 | } 99 | } 100 | 101 | BATCH_SIZES.forEach((batchSize) => { 102 | suite.add(`client subscribing in batches of ${batchSize} with signing`, { 103 | defer: true, 104 | fn: test(client1, stream1, batchSize) 105 | }) 106 | 107 | suite.add(`client subscribing in batches of ${batchSize} without signing`, { 108 | defer: true, 109 | fn: test(client2, stream2, batchSize) 110 | }) 111 | 112 | suite.add(`client subscribing in batches of ${batchSize} with encryption`, { 113 | defer: true, 114 | fn: test(client3, stream3, batchSize) 115 | }) 116 | }) 117 | 118 | function toStringBench(bench) { 119 | const { error, id, stats } = bench 120 | let { hz } = bench 121 | hz *= bench.BATCH_SIZE // adjust hz by batch size 122 | const size = stats.sample.length 123 | const pm = '\xb1' 124 | let result = bench.name || (Number.isNaN(id) ? id : '') 125 | if (error) { 126 | return result + ' Error' 127 | } 128 | 129 | result += ' x ' + Benchmark.formatNumber(hz.toFixed(hz < 100 ? 2 : 0)) + ' ops/sec ' + pm 130 | + stats.rme.toFixed(2) + '% (' + size + ' run' + (size === 1 ? '' : 's') + ' sampled)' 131 | return result 132 | } 133 | 134 | suite.on('cycle', (event) => { 135 | log(toStringBench(event.target)) 136 | }) 137 | 138 | suite.on('complete', async () => { 139 | log('Disconnecting clients') 140 | await Promise.all([ 141 | client1.disconnect(), 142 | client2.disconnect(), 143 | client3.disconnect(), 144 | ]) 145 | log('Clients disconnected') 146 | }) 147 | 148 | suite.run() 149 | } 150 | 151 | run() 152 | -------------------------------------------------------------------------------- /src/Session.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3' 2 | import { Wallet } from '@ethersproject/wallet' 3 | import { ExternalProvider, JsonRpcFetchFunc, Web3Provider } from '@ethersproject/providers' 4 | import { StreamrClient } from './StreamrClient' 5 | import { BytesLike } from '@ethersproject/bytes' 6 | 7 | enum State { 8 | LOGGING_OUT = 'logging out', 9 | LOGGED_OUT = 'logged out', 10 | LOGGING_IN ='logging in', 11 | LOGGED_IN = 'logged in', 12 | } 13 | 14 | export interface SessionOptions { 15 | privateKey?: BytesLike 16 | ethereum?: ExternalProvider|JsonRpcFetchFunc 17 | apiKey?: string 18 | username?: string 19 | password?: string 20 | sessionToken?: string 21 | unauthenticated?: boolean 22 | } 23 | 24 | interface TokenObject { 25 | token: string 26 | } 27 | 28 | /** @internal */ 29 | export default class Session extends EventEmitter { 30 | 31 | _client: StreamrClient 32 | options: SessionOptions 33 | state: State 34 | loginFunction: () => Promise 35 | sessionTokenPromise?: Promise 36 | 37 | constructor(client: StreamrClient, options: SessionOptions = {}) { 38 | super() 39 | this._client = client 40 | this.options = { 41 | ...options 42 | } 43 | 44 | this.state = State.LOGGED_OUT 45 | 46 | // TODO: move loginFunction to StreamrClient constructor where "auth type" is checked 47 | if (typeof this.options.privateKey !== 'undefined') { 48 | const wallet = new Wallet(this.options.privateKey) 49 | this.loginFunction = async () => ( 50 | this._client.loginWithChallengeResponse((d: string) => wallet.signMessage(d), wallet.address) 51 | ) 52 | } else if (typeof this.options.ethereum !== 'undefined') { 53 | const provider = new Web3Provider(this.options.ethereum) 54 | const signer = provider.getSigner() 55 | this.loginFunction = async () => ( 56 | this._client.loginWithChallengeResponse((d: string) => signer.signMessage(d), await signer.getAddress()) 57 | ) 58 | } else if (typeof this.options.apiKey !== 'undefined') { 59 | this.loginFunction = async () => ( 60 | this._client.loginWithApiKey(this.options.apiKey!) 61 | ) 62 | } else if (typeof this.options.username !== 'undefined' && typeof this.options.password !== 'undefined') { 63 | this.loginFunction = async () => ( 64 | this._client.loginWithUsernamePassword(this.options.username!, this.options.password!) 65 | ) 66 | } else { 67 | if (!this.options.sessionToken) { 68 | this.options.unauthenticated = true 69 | } 70 | this.loginFunction = async () => { 71 | throw new Error('Need either "privateKey", "ethereum" or "sessionToken" to login.') 72 | } 73 | } 74 | } 75 | 76 | isUnauthenticated() { 77 | return this.options.unauthenticated 78 | } 79 | 80 | updateState(newState: State) { 81 | this.state = newState 82 | this.emit(newState) 83 | } 84 | 85 | async getSessionToken(requireNewToken = false) { 86 | if (this.options.sessionToken && !requireNewToken) { 87 | return this.options.sessionToken 88 | } 89 | 90 | if (this.options.unauthenticated) { 91 | return undefined 92 | } 93 | 94 | if (this.state !== State.LOGGING_IN) { 95 | if (this.state === State.LOGGING_OUT) { 96 | this.sessionTokenPromise = new Promise((resolve) => { 97 | this.once(State.LOGGED_OUT, () => resolve(this.getSessionToken(requireNewToken))) 98 | }) 99 | } else { 100 | this.updateState(State.LOGGING_IN) 101 | this.sessionTokenPromise = this.loginFunction().then((tokenObj: TokenObject) => { 102 | this.options.sessionToken = tokenObj.token 103 | this.updateState(State.LOGGED_IN) 104 | return tokenObj.token 105 | }, (err: Error) => { 106 | this.updateState(State.LOGGED_OUT) 107 | throw err 108 | }) 109 | } 110 | } 111 | return this.sessionTokenPromise 112 | } 113 | 114 | async logout() { 115 | if (this.state === State.LOGGED_OUT) { 116 | throw new Error('Already logged out!') 117 | } 118 | 119 | if (this.state === State.LOGGING_OUT) { 120 | throw new Error('Already logging out!') 121 | } 122 | 123 | if (this.state === State.LOGGING_IN) { 124 | await new Promise((resolve) => { 125 | this.once(State.LOGGED_IN, () => resolve(this.logout())) 126 | }) 127 | return 128 | } 129 | 130 | this.updateState(State.LOGGING_OUT) 131 | await this._client.logoutEndpoint() 132 | this.options.sessionToken = undefined 133 | this.updateState(State.LOGGED_OUT) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/subscribe/pipeline.js: -------------------------------------------------------------------------------- 1 | import { counterId } from '../utils' 2 | import { pipeline } from '../utils/iterators' 3 | import { validateOptions } from '../stream/utils' 4 | 5 | import Validator from './Validator' 6 | import messageStream from './messageStream' 7 | import OrderMessages from './OrderMessages' 8 | import Decrypt from './Decrypt' 9 | 10 | export { SignatureRequiredError } from './Validator' 11 | 12 | async function collect(src) { 13 | const msgs = [] 14 | for await (const msg of src) { 15 | msgs.push(msg.getParsedContent()) 16 | } 17 | 18 | return msgs 19 | } 20 | 21 | /** 22 | * Subscription message processing pipeline 23 | */ 24 | 25 | export default function MessagePipeline(client, opts = {}, onFinally = async (err) => { if (err) { throw err } }) { 26 | const options = validateOptions(opts) 27 | const { key, afterSteps = [], beforeSteps = [] } = options 28 | const id = counterId('MessagePipeline') + key 29 | 30 | /* eslint-disable object-curly-newline */ 31 | const { 32 | validate = Validator(client, options), 33 | msgStream = messageStream(client.connection, options), 34 | orderingUtil = OrderMessages(client, options), 35 | decrypt = Decrypt(client, options), 36 | } = options 37 | /* eslint-enable object-curly-newline */ 38 | 39 | const seenErrors = new WeakSet() 40 | const onErrorFn = options.onError ? options.onError : (error) => { throw error } 41 | const onError = async (err) => { 42 | // don't handle same error multiple times 43 | if (seenErrors.has(err)) { 44 | return 45 | } 46 | seenErrors.add(err) 47 | await onErrorFn(err) 48 | } 49 | 50 | // re-order messages (ignore gaps) 51 | const internalOrderingUtil = OrderMessages(client, { 52 | ...options, 53 | gapFill: false, 54 | }) 55 | 56 | // collect messages that fail validation/parsing, do not push out of pipeline 57 | // NOTE: we let failed messages be processed and only removed at end so they don't 58 | // end up acting as gaps that we repeatedly try to fill. 59 | const ignoreMessages = new WeakSet() 60 | 61 | const p = pipeline([ 62 | // take messages 63 | msgStream, 64 | // custom pipeline steps 65 | ...beforeSteps, 66 | // unpack stream message 67 | async function* getStreamMessage(src) { 68 | for await (const { streamMessage } of src) { 69 | yield streamMessage 70 | } 71 | }, 72 | // order messages (fill gaps) 73 | orderingUtil, 74 | // validate 75 | async function* ValidateMessages(src) { 76 | for await (const streamMessage of src) { 77 | try { 78 | await validate(streamMessage) 79 | } catch (err) { 80 | ignoreMessages.add(streamMessage) 81 | await onError(err) 82 | } 83 | yield streamMessage 84 | } 85 | }, 86 | // decrypt 87 | async function* DecryptMessages(src) { 88 | yield* decrypt(src, async (err, streamMessage) => { 89 | ignoreMessages.add(streamMessage) 90 | await onError(err) 91 | }) 92 | }, 93 | // parse content 94 | async function* ParseMessages(src) { 95 | for await (const streamMessage of src) { 96 | try { 97 | streamMessage.getParsedContent() 98 | } catch (err) { 99 | ignoreMessages.add(streamMessage) 100 | await onError(err) 101 | } 102 | yield streamMessage 103 | } 104 | }, 105 | // re-order messages (ignore gaps) 106 | internalOrderingUtil, 107 | // ignore any failed messages 108 | async function* IgnoreMessages(src) { 109 | for await (const streamMessage of src) { 110 | if (ignoreMessages.has(streamMessage)) { 111 | continue 112 | } 113 | yield streamMessage 114 | } 115 | }, 116 | // special handling for bye message 117 | async function* ByeMessageSpecialHandling(src) { 118 | for await (const orderedMessage of src) { 119 | yield orderedMessage 120 | try { 121 | if (orderedMessage.isByeMessage()) { 122 | break 123 | } 124 | } catch (err) { 125 | await onError(err) 126 | } 127 | } 128 | }, 129 | // custom pipeline steps 130 | ...afterSteps 131 | ], async (err, ...args) => { 132 | decrypt.stop() 133 | await msgStream.cancel(err) 134 | try { 135 | if (err) { 136 | await onError(err) 137 | } 138 | } finally { 139 | await onFinally(err, ...args) 140 | } 141 | }) 142 | 143 | return Object.assign(p, { 144 | id, 145 | msgStream, 146 | orderingUtil, 147 | validate, 148 | collect: collect.bind(null, p), 149 | end: msgStream.end, 150 | }) 151 | } 152 | -------------------------------------------------------------------------------- /test/integration/dataunion/signature.test.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber, Contract, providers, Wallet } from 'ethers' 2 | import { parseEther } from 'ethers/lib/utils' 3 | import debug from 'debug' 4 | 5 | import { getEndpointUrl } from '../../../src/utils' 6 | import { StreamrClient } from '../../../src/StreamrClient' 7 | import * as Token from '../../../contracts/TestToken.json' 8 | import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' 9 | import { clientOptions } from '../devEnvironment' 10 | import authFetch from '../../../src/rest/authFetch' 11 | 12 | const log = debug('StreamrClient::DataUnion::integration-test-signature') 13 | 14 | const providerSidechain = new providers.JsonRpcProvider(clientOptions.sidechain) 15 | const adminWalletSidechain = new Wallet(clientOptions.auth.privateKey, providerSidechain) 16 | 17 | describe('DataUnion signature', () => { 18 | 19 | afterAll(() => { 20 | providerSidechain.removeAllListeners() 21 | }) 22 | 23 | it('check validity', async () => { 24 | const adminClient = new StreamrClient(clientOptions as any) 25 | const dataUnion = await adminClient.deployDataUnion() 26 | const dataUnionAddress = dataUnion.getAddress() 27 | const secret = await dataUnion.createSecret('test secret') 28 | log(`DataUnion ${dataUnionAddress} is ready to roll`) 29 | 30 | const memberWallet = new Wallet(`0x100000000000000000000000000000000000000012300000001${Date.now()}`, providerSidechain) 31 | const member2Wallet = new Wallet(`0x100000000000000000000000000000000000000012300000002${Date.now()}`, providerSidechain) 32 | 33 | const memberClient = new StreamrClient({ 34 | ...clientOptions, 35 | auth: { 36 | privateKey: memberWallet.privateKey 37 | } 38 | } as any) 39 | const memberDataUnion = await memberClient.safeGetDataUnion(dataUnionAddress) 40 | 41 | // product is needed for join requests to analyze the DU version 42 | const createProductUrl = getEndpointUrl(clientOptions.restUrl, 'products') 43 | await authFetch(createProductUrl, adminClient.session, { 44 | method: 'POST', 45 | body: JSON.stringify({ 46 | beneficiaryAddress: dataUnionAddress, 47 | type: 'DATAUNION', 48 | dataUnionVersion: 2 49 | }) 50 | }) 51 | await memberDataUnion.join(secret) 52 | 53 | // eslint-disable-next-line no-underscore-dangle 54 | const contract = await dataUnion._getContract() 55 | const sidechainContract = new Contract(contract.sidechain.address, DataUnionSidechain.abi, adminWalletSidechain) 56 | const tokenSidechain = new Contract(clientOptions.tokenSidechainAddress, Token.abi, adminWalletSidechain) 57 | 58 | const signature = await memberDataUnion.signWithdrawAllTo(member2Wallet.address) 59 | const signature2 = await memberDataUnion.signWithdrawAmountTo(member2Wallet.address, parseEther('1')) 60 | const signature3 = await memberDataUnion.signWithdrawAmountTo(member2Wallet.address, 3000000000000000) // 0.003 tokens 61 | 62 | const isValid = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, '0', signature) // '0' = all earnings 63 | const isValid2 = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, parseEther('1'), signature2) 64 | const isValid3 = await sidechainContract.signatureIsValid(memberWallet.address, member2Wallet.address, '3000000000000000', signature3) 65 | log(`Signature for all tokens ${memberWallet.address} -> ${member2Wallet.address}: ${signature}, checked ${isValid ? 'OK' : '!!!BROKEN!!!'}`) 66 | log(`Signature for 1 token ${memberWallet.address} -> ${member2Wallet.address}: ${signature2}, checked ${isValid2 ? 'OK' : '!!!BROKEN!!!'}`) 67 | // eslint-disable-next-line max-len 68 | log(`Signature for 0.003 tokens ${memberWallet.address} -> ${member2Wallet.address}: ${signature3}, checked ${isValid3 ? 'OK' : '!!!BROKEN!!!'}`) 69 | log(`sidechainDU(${sidechainContract.address}) token bal ${await tokenSidechain.balanceOf(sidechainContract.address)}`) 70 | 71 | expect(isValid).toBe(true) 72 | expect(isValid2).toBe(true) 73 | expect(isValid3).toBe(true) 74 | }, 100000) 75 | 76 | it('create signature', async () => { 77 | const client = new StreamrClient({ 78 | auth: { 79 | privateKey: '0x1111111111111111111111111111111111111111111111111111111111111111' 80 | } 81 | }) 82 | const dataUnion = client.getDataUnion('0x2222222222222222222222222222222222222222') 83 | const to = '0x3333333333333333333333333333333333333333' 84 | const withdrawn = BigNumber.from('4000000000000000') 85 | const amounts = [5000000000000000, '5000000000000000', BigNumber.from('5000000000000000')] 86 | // eslint-disable-next-line no-underscore-dangle 87 | const signaturePromises = amounts.map((amount) => dataUnion._createWithdrawSignature(amount, to, withdrawn, client.ethereum.getSigner())) 88 | const actualSignatures = await Promise.all(signaturePromises) 89 | const expectedSignature = '0x5325ae62cdfd7d7c15101c611adcb159439217a48193c4e1d87ca5de698ec5233b1a68fd1302fdbd5450618d40739904295c88e88cf79d4241cf8736c2ec75731b' // eslint-disable-line max-len 90 | expect(actualSignatures.every((actual) => actual === expectedSignature)) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /.github/workflows/test-code.yml: -------------------------------------------------------------------------------- 1 | # Any tests that can run without building should go in here. 2 | name: Lint, Unit, Integration Tests 3 | 4 | # Be sure to update both workflow files if you edit any env or trigger config 5 | env: 6 | CI: true 7 | DEBUG_COLORS: true 8 | DEBUG: "Streamr*" 9 | on: 10 | push: 11 | tags: 12 | - "*" 13 | branches: 14 | - master 15 | paths-ignore: 16 | - 'README.md' 17 | - 'LICENSE' 18 | - '.editorconfig' 19 | - 'typedoc.js' 20 | pull_request: 21 | branches: 22 | - "*" 23 | paths-ignore: 24 | - 'README.md' 25 | - 'LICENSE' 26 | - '.editorconfig' 27 | - 'typedoc.js' 28 | schedule: 29 | # run every day at 00:00 30 | - cron: "0 0 * * *" 31 | # Be sure to update both workflow files if you edit any env or trigger config 32 | 33 | jobs: 34 | init: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Cancel Previous Runs 38 | uses: styfle/cancel-workflow-action@0.8.0 39 | with: 40 | access_token: ${{ github.token }} 41 | lint: 42 | name: Run linter using Node 14.x 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v2 46 | - uses: actions/setup-node@v2 47 | with: 48 | node-version: "14.x" 49 | - name: npm ci 50 | run: npm ci 51 | - name: npm run eslint 52 | run: npm run eslint 53 | 54 | test: 55 | name: Test Unit using Node ${{ matrix.node-version }} 56 | runs-on: ubuntu-latest 57 | strategy: 58 | matrix: 59 | node-version: [12.x, 14.x] 60 | env: 61 | TEST_REPEATS: 5 62 | steps: 63 | - uses: actions/checkout@v2 64 | - uses: actions/setup-node@v2 65 | with: 66 | node-version: ${{ matrix.node-version }} 67 | - name: npm ci 68 | run: npm ci 69 | - name: test-unit 70 | timeout-minutes: 7 71 | run: npm run test-unit 72 | 73 | integration: 74 | name: ${{ matrix.test-name }} ${{ matrix.websocket-url.name }} using Node ${{ matrix.node-version }} 75 | runs-on: ubuntu-latest 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | node-version: [12.x, 14.x] 80 | test-name: [ 81 | "test-integration-no-resend", 82 | "test-integration-resend", 83 | "test-integration-dataunions", 84 | ] 85 | websocket-url: 86 | - name: "default" 87 | url: "" 88 | - name: "single-node-only" 89 | url: "ws://localhost:8690/api/v1/ws" 90 | 91 | exclude: 92 | # no need to test different ws urls for dataunion tests 93 | - test-name: "test-integration-dataunions" 94 | websocket-url: 95 | - name: "single-node-only" 96 | - url: "ws://localhost:8690/api/v1/ws" 97 | env: 98 | TEST_NAME: ${{ matrix.test-name }} 99 | WEBSOCKET_URL: ${{ matrix.websocket-url.url}} 100 | TEST_REPEATS: 2 101 | 102 | steps: 103 | - uses: actions/checkout@v2 104 | - uses: actions/setup-node@v2 105 | with: 106 | node-version: ${{ matrix.node-version }} 107 | - name: npm ci 108 | run: npm ci 109 | - name: Start Streamr Docker Stack 110 | uses: streamr-dev/streamr-docker-dev-action@v1.0.0-alpha.3 111 | with: 112 | services-to-start: "mysql redis core-api cassandra parity-node0 parity-sidechain-node0 bridge brokers trackers nginx smtp" 113 | - name: Run Test 114 | run: npm run $TEST_NAME 115 | - name: Collect docker logs on failure 116 | if: failure() 117 | uses: jwalton/gh-docker-logs@v1 118 | with: 119 | dest: './logs' 120 | - name: Upload logs to GitHub 121 | if: failure() 122 | uses: actions/upload-artifact@master 123 | with: 124 | name: docker-logs-${{ matrix.test-name }}-${{ matrix.websocket-url.name }}-node${{ matrix.node-version }}--${{ github.run_number }}-${{ github.run_id }} 125 | path: ./logs 126 | 127 | flakey: 128 | name: Flakey Tests using Node ${{ matrix.node-version }} 129 | runs-on: ubuntu-latest 130 | strategy: 131 | fail-fast: false 132 | matrix: 133 | node-version: [12.x, 14.x] 134 | 135 | steps: 136 | - uses: actions/checkout@v2 137 | - uses: actions/setup-node@v2 138 | with: 139 | node-version: ${{ matrix.node-version }} 140 | - name: npm ci 141 | run: npm ci 142 | - name: Start Streamr Docker Stack 143 | uses: streamr-dev/streamr-docker-dev-action@v1.0.0-alpha.3 144 | with: 145 | services-to-start: "mysql redis core-api cassandra parity-node0 parity-sidechain-node0 bridge brokers trackers nginx smtp" 146 | - uses: nick-invision/retry@v2 147 | name: Run Test 148 | with: 149 | max_attempts: 2 150 | timeout_minutes: 15 151 | retry_on: error 152 | command: npm run test-flakey || echo "::warning::Flakey Tests Failed" 153 | 154 | memory: 155 | name: Memory Tests using Node ${{ matrix.node-version }} 156 | runs-on: ubuntu-latest 157 | if: ${{ false }} # temporarily disable memory test until production resends are stable 158 | strategy: 159 | fail-fast: false 160 | matrix: 161 | node-version: [12.x, 14.x] 162 | 163 | steps: 164 | - uses: actions/checkout@v2 165 | - uses: actions/setup-node@v2 166 | with: 167 | node-version: ${{ matrix.node-version }} 168 | - name: npm ci 169 | run: npm ci 170 | - uses: nick-invision/retry@v2 171 | name: Run Test 172 | with: 173 | max_attempts: 2 174 | timeout_minutes: 20 175 | retry_on: error 176 | command: npm run test-memory 177 | -------------------------------------------------------------------------------- /test/integration/SubscriberResendsSequential.test.ts: -------------------------------------------------------------------------------- 1 | import { wait } from 'streamr-test-utils' 2 | 3 | import { 4 | Msg, 5 | uid, 6 | collect, 7 | describeRepeats, 8 | fakePrivateKey, 9 | getWaitForStorage, 10 | getPublishTestMessages, 11 | } from '../utils' 12 | import { StreamrClient } from '../../src/StreamrClient' 13 | import Connection from '../../src/Connection' 14 | 15 | import { clientOptions } from './devEnvironment' 16 | import { Stream } from '../../src/stream' 17 | import { Subscriber } from '../../src/subscribe' 18 | import { StorageNode } from '../../src/stream/StorageNode' 19 | 20 | /* eslint-disable no-await-in-loop */ 21 | 22 | const WAIT_FOR_STORAGE_TIMEOUT = process.env.CI ? 12000 : 6000 23 | const MAX_MESSAGES = 5 24 | const ITERATIONS = 6 25 | 26 | describeRepeats('sequential resend subscribe', () => { 27 | let expectErrors = 0 // check no errors by default 28 | let onError = jest.fn() 29 | 30 | let client: StreamrClient 31 | let subscriber: Subscriber 32 | let stream: Stream 33 | 34 | let publishTestMessages: ReturnType 35 | let waitForStorage: (...args: any[]) => Promise 36 | 37 | let published: any[] // keeps track of stream message data so we can verify they were resent 38 | let publishedRequests: any[] // tracks publish requests so we can pass them to waitForStorage 39 | 40 | const createClient = (opts = {}) => { 41 | const c = new StreamrClient({ 42 | ...clientOptions, 43 | auth: { 44 | privateKey: fakePrivateKey(), 45 | }, 46 | // @ts-expect-error 47 | publishAutoDisconnectDelay: 1000, 48 | autoConnect: false, 49 | autoDisconnect: false, 50 | maxRetries: 2, 51 | ...opts, 52 | }) 53 | c.onError = jest.fn() 54 | c.on('error', onError) 55 | return c 56 | } 57 | 58 | beforeAll(async () => { 59 | client = createClient() 60 | subscriber = client.subscriber 61 | 62 | // eslint-disable-next-line require-atomic-updates 63 | await Promise.all([ 64 | client.connect(), 65 | client.session.getSessionToken(), 66 | ]) 67 | stream = await client.createStream({ 68 | name: uid('stream') 69 | }) 70 | await stream.addToStorageNode(StorageNode.STREAMR_DOCKER_DEV) 71 | 72 | publishTestMessages = getPublishTestMessages(client, { 73 | stream, 74 | }) 75 | 76 | waitForStorage = getWaitForStorage(client, { 77 | stream, 78 | timeout: WAIT_FOR_STORAGE_TIMEOUT, 79 | }) 80 | 81 | await client.connect() 82 | // initialize resend data by publishing some messages and waiting for 83 | // them to land in storage 84 | const results = await publishTestMessages.raw(MAX_MESSAGES, { 85 | waitForLast: true, 86 | timestamp: 111111, 87 | }) 88 | 89 | published = results.map(([msg]: any) => msg) 90 | publishedRequests = results.map(([, req]: any) => req) 91 | }, WAIT_FOR_STORAGE_TIMEOUT * 2) 92 | 93 | beforeEach(async () => { 94 | await client.connect() 95 | expectErrors = 0 96 | onError = jest.fn() 97 | }) 98 | 99 | afterEach(async () => { 100 | await client.connect() 101 | // ensure last message is in storage 102 | const lastRequest = publishedRequests[publishedRequests.length - 1] 103 | await waitForStorage(lastRequest) 104 | }) 105 | 106 | afterEach(async () => { 107 | await wait(0) 108 | // ensure no unexpected errors 109 | expect(onError).toHaveBeenCalledTimes(expectErrors) 110 | if (client) { 111 | expect(client.onError).toHaveBeenCalledTimes(expectErrors) 112 | } 113 | }) 114 | 115 | afterEach(async () => { 116 | if (client) { 117 | client.debug('disconnecting after test') 118 | await client.disconnect() 119 | } 120 | 121 | const openSockets = Connection.getOpen() 122 | if (openSockets !== 0) { 123 | await Connection.closeOpen() 124 | throw new Error(`sockets not closed: ${openSockets}`) 125 | } 126 | client.debug('\n\n\n\n') 127 | }) 128 | 129 | for (let i = 0; i < ITERATIONS; i++) { 130 | // keep track of which messages were published in previous tests 131 | // so we can check that they exist in resends of subsequent tests 132 | // publish messages with timestamps like 222222, 333333, etc so the 133 | // sequencing is clearly visible in logs 134 | const id = (i + 2) * 111111 // start at 222222 135 | // eslint-disable-next-line no-loop-func 136 | test(`test ${id}`, async () => { 137 | const sub = await subscriber.resendSubscribe({ 138 | streamId: stream.id, 139 | last: published.length, 140 | }) 141 | 142 | const onResent = jest.fn() 143 | sub.on('resent', onResent) 144 | 145 | const message = Msg() 146 | // eslint-disable-next-line no-await-in-loop 147 | const req = await client.publish(stream.id, message, id) // should be realtime 148 | // keep track of published messages so we can check they are resent in next test(s) 149 | published.push(message) 150 | publishedRequests.push(req) 151 | const receivedMsgs = await collect(sub, async ({ received }) => { 152 | if (received.length === published.length) { 153 | await sub.return() 154 | } 155 | }) 156 | 157 | const msgs = receivedMsgs 158 | expect(msgs).toHaveLength(published.length) 159 | expect(msgs).toEqual(published) 160 | }) 161 | } 162 | }) 163 | -------------------------------------------------------------------------------- /test/unit/utils.test.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon' 2 | import Debug from 'debug' 3 | import express, { Application } from 'express' 4 | 5 | import authFetch from '../../src/rest/authFetch' 6 | import * as utils from '../../src/utils' 7 | import { describeRepeats } from '../utils' 8 | import { Server } from 'http' 9 | 10 | const debug = Debug('StreamrClient::test::utils') 11 | 12 | interface TestResponse { 13 | test: string 14 | } 15 | 16 | describeRepeats('utils', () => { 17 | let session: any 18 | let expressApp: Application 19 | let server: Server 20 | const baseUrl = 'http://127.0.0.1:30000' 21 | const testUrl = '/some-test-url' 22 | 23 | beforeAll((done) => { 24 | session = sinon.stub() 25 | session.options = {} 26 | expressApp = express() 27 | 28 | function handle(req: any, res: any) { 29 | if (req.get('Authorization') !== 'Bearer session-token') { 30 | res.sendStatus(401) 31 | } else { 32 | res.status(200).send({ 33 | test: 'test', 34 | }) 35 | } 36 | } 37 | 38 | expressApp.get(testUrl, (req: any, res: any) => handle(req, res)) 39 | 40 | server = expressApp.listen(30000, () => { 41 | debug('Mock server started on port 30000\n') 42 | done() 43 | }) 44 | }) 45 | 46 | afterAll((done) => { 47 | server.close(done) 48 | }) 49 | 50 | describe('authFetch', () => { 51 | it('should return normally when valid session token is passed', async () => { 52 | session.getSessionToken = sinon.stub().resolves('session-token') 53 | const res = await authFetch(baseUrl + testUrl, session) 54 | expect(session.getSessionToken.calledOnce).toBeTruthy() 55 | expect(res.test).toBeTruthy() 56 | }) 57 | 58 | it('should return 401 error when invalid session token is passed twice', async () => { 59 | session.getSessionToken = sinon.stub().resolves('invalid token') 60 | const onCaught = jest.fn() 61 | await authFetch(baseUrl + testUrl, session).catch((err) => { 62 | onCaught() 63 | expect(session.getSessionToken.calledTwice).toBeTruthy() 64 | expect(err.toString()).toMatch( 65 | `${baseUrl + testUrl} returned with error code 401. Unauthorized` 66 | ) 67 | expect(err.body).toEqual('Unauthorized') 68 | }) 69 | expect(onCaught).toHaveBeenCalledTimes(1) 70 | }) 71 | 72 | it('should return normally when valid session token is passed after expired session token', async () => { 73 | session.getSessionToken = sinon.stub() 74 | session.getSessionToken.onCall(0).resolves('expired-session-token') 75 | session.getSessionToken.onCall(1).resolves('session-token') 76 | 77 | const res = await authFetch(baseUrl + testUrl, session) 78 | expect(session.getSessionToken.calledTwice).toBeTruthy() 79 | expect(res.test).toBeTruthy() 80 | }) 81 | }) 82 | 83 | describe('uuid', () => { 84 | it('generates different ids', () => { 85 | expect(utils.uuid('test')).not.toEqual(utils.uuid('test')) 86 | }) 87 | it('includes text', () => { 88 | expect(utils.uuid('test')).toContain('test') 89 | }) 90 | it('increments', () => { 91 | const uid = utils.uuid('test') // generate new text to ensure count starts at 1 92 | expect(utils.uuid(uid) < utils.uuid(uid)).toBeTruthy() 93 | }) 94 | }) 95 | 96 | describe('getEndpointUrl', () => { 97 | it('works', () => { 98 | const streamId = 'x/y' 99 | const url = utils.getEndpointUrl('http://example.com', 'abc', streamId, 'def') 100 | expect(url.toLowerCase()).toBe('http://example.com/abc/x%2fy/def') 101 | }) 102 | }) 103 | 104 | describe('until', () => { 105 | it('works with sync true', async () => { 106 | const condition = jest.fn(() => true) 107 | await utils.until(condition) 108 | expect(condition).toHaveBeenCalledTimes(1) 109 | }) 110 | 111 | it('works with async true', async () => { 112 | const condition = jest.fn(async () => true) 113 | await utils.until(condition) 114 | expect(condition).toHaveBeenCalledTimes(1) 115 | }) 116 | 117 | it('works with sync false -> true', async () => { 118 | let calls = 0 119 | const condition = jest.fn(() => { 120 | calls += 1 121 | return calls > 1 122 | }) 123 | await utils.until(condition) 124 | expect(condition).toHaveBeenCalledTimes(2) 125 | }) 126 | 127 | it('works with sync false -> true', async () => { 128 | let calls = 0 129 | const condition = jest.fn(async () => { 130 | calls += 1 131 | return calls > 1 132 | }) 133 | await utils.until(condition) 134 | expect(condition).toHaveBeenCalledTimes(2) 135 | }) 136 | 137 | it('can time out', async () => { 138 | const condition = jest.fn(() => false) 139 | await expect(async () => { 140 | await utils.until(condition, 100) 141 | }).rejects.toThrow('Timeout') 142 | expect(condition).toHaveBeenCalled() 143 | }) 144 | 145 | it('can set interval', async () => { 146 | const condition = jest.fn(() => false) 147 | await expect(async () => { 148 | await utils.until(condition, 100, 20) 149 | }).rejects.toThrow('Timeout') 150 | expect(condition).toHaveBeenCalledTimes(5) // exactly 5 151 | }) 152 | }) 153 | }) 154 | --------------------------------------------------------------------------------