├── .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 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
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 |
4 |
5 |
6 |
7 |
8 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
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 |
--------------------------------------------------------------------------------