diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c2b01f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Help + url: https://t.me/ru_dart + about: Ask a question about Spinify \ No newline at end of file diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 89720bb..ee9546b 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -3,6 +3,23 @@ name: Checkout on: workflow_dispatch: push: + branches: + - "main" + - "master" + #- "dev" + #- "develop" + #- "feature/**" + #- "bugfix/**" + #- "hotfix/**" + #- "support/**" + paths: + - "lib/**.dart" + - "test/**.dart" + - "example/**.dart" + - ".github/workflows/*.yml" + - "pubspec.yaml" + - "analysis_options.yaml" + pull_request: branches: - "main" - "master" @@ -16,8 +33,18 @@ on: - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - .github/workflows/*.yml + - ".github/workflows/*.yml" - "pubspec.yaml" + - "analysis_options.yaml" + +permissions: + contents: read + actions: read + checks: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: checkout: @@ -29,7 +56,7 @@ jobs: container: image: dart:stable env: - pub-cache-name: pub + pub-cache: pub PUB_CACHE: /github/home/.pub-cache timeout-minutes: 10 steps: @@ -51,8 +78,8 @@ jobs: uses: actions/cache/restore@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ runner.os }}-pub-${{ env.pub-cache }}-${{ hashFiles('pubspec.yaml') }} - name: ๐Ÿ‘ท Install Dependencies id: install-dependencies @@ -63,16 +90,18 @@ jobs: - name: ๐Ÿ“ฅ Save Pub modules id: cache-pub-save + if: steps.cache-pub-restore.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ steps.cache-pub-restore.outputs.cache-primary-key }} - - name: ๐Ÿ”Ž Check format + - name: ๐Ÿšฆ Check code format id: check-format timeout-minutes: 1 - run: dart format --set-exit-if-changed -l 80 -o none lib/ test/ + run: | + find lib test -name "*.dart" ! -name "*.*.dart" -print0 | xargs -0 dart format --set-exit-if-changed --line-length 80 -o none lib/ test/ - name: ๐Ÿ“ˆ Check analyzer id: check-analyzer @@ -99,5 +128,5 @@ jobs: timeout-minutes: 2 run: | dart test --color --platform=vm --concurrency=12 \ - --timeout=60s --reporter=github --file-reporter=json:coverage/tests.json \ + --timeout=60s --reporter=github --file-reporter=json:reports/tests.json \ --coverage=coverage -- test/unit_test.dart diff --git a/.github/workflows/tests-report.yml b/.github/workflows/tests-report.yml new file mode 100644 index 0000000..39a1cbc --- /dev/null +++ b/.github/workflows/tests-report.yml @@ -0,0 +1,27 @@ +name: "Tests Report" + +on: + workflow_run: + workflows: ["Tests"] # runs after "Tests" workflow + types: + - completed + +permissions: + contents: read + actions: read + checks: write + +jobs: + report: + name: "๐Ÿš› Tests report" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Test report + uses: dorny/test-reporter@v1 + with: + artifact: test-results + name: Test Report + path: "**/tests.json" + reporter: flutter-json + fail-on-error: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 075383d..aebd0cd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,9 @@ on: - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - .github/workflows/*.yml + - ".github/workflows/*.yml" - "pubspec.yaml" + - "analysis_options.yaml" pull_request: branches: - "main" @@ -32,78 +33,88 @@ on: - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - .github/workflows/*.yml + - ".github/workflows/*.yml" - "pubspec.yaml" + - "analysis_options.yaml" -jobs: - build-echo: - name: "Build Echo server" - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./ - container: - image: golang:1.22 - env: - echo-cache-name: echo - timeout-minutes: 5 - steps: - - name: ๐Ÿš‚ Get latest code - id: checkout - uses: actions/checkout@v4 - with: - sparse-checkout: | - .github - tool/echo - - - name: ๐Ÿ“ค Restore Go echo server - id: cache-echo-restore - uses: actions/cache/restore@v4 - with: - path: | - ~/build/bin/echo - key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} - - - name: ๐Ÿฆซ Build Go echo server - id: build-echo - if: steps.cache-echo-restore.outputs.cache-hit != 'true' - working-directory: ./tool/echo - run: | - mkdir -p ~/build/bin/ - go get - go build -o ~/build/bin/echo echo.go +permissions: + contents: read + actions: read + checks: write - - name: ๐Ÿ“ฅ Save Go echo server - id: cache-echo-save - if: steps.cache-echo-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - ~/build/bin/echo - key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true - - name: ๐Ÿ’พ Upload Go echo server - id: upload-echo-server - uses: actions/upload-artifact@v4 - with: - name: echo-server - path: ~/build/bin/echo - compression-level: 6 - overwrite: true - retention-days: 1 +jobs: + # build-echo: + # name: "Build Echo server" + # runs-on: ubuntu-latest + # defaults: + # run: + # working-directory: ./ + # container: + # image: golang:1.22 + # env: + # echo-cache-name: echo + # timeout-minutes: 5 + # steps: + # - name: ๐Ÿš‚ Get latest code + # id: checkout + # uses: actions/checkout@v4 + # with: + # sparse-checkout: | + # .github + # tool/echo + # + # - name: ๐Ÿ“ค Restore Go echo server + # id: cache-echo-restore + # uses: actions/cache/restore@v4 + # with: + # path: | + # ~/build/bin/echo + # key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} + # + # - name: ๐Ÿฆซ Build Go echo server + # id: build-echo + # if: steps.cache-echo-restore.outputs.cache-hit != 'true' + # working-directory: ./tool/echo + # run: | + # mkdir -p ~/build/bin/ + # go get + # go build -o ~/build/bin/echo echo.go + # + # - name: ๐Ÿ“ฅ Save Go echo server + # id: cache-echo-save + # if: steps.cache-echo-restore.outputs.cache-hit != 'true' + # uses: actions/cache/save@v4 + # with: + # path: | + # ~/build/bin/echo + # key: ${{ runner.os }}-spinify-${{ env.echo-cache-name }}-${{ hashFiles('tool/echo/echo.go') }} + # + # - name: ๐Ÿ’พ Upload Go echo server + # id: upload-echo-server + # uses: actions/upload-artifact@v4 + # with: + # name: echo-server + # path: ~/build/bin/echo + # compression-level: 6 + # overwrite: true + # retention-days: 1 tests: name: "Tests" runs-on: ubuntu-latest - needs: build-echo + #needs: build-echo defaults: run: working-directory: ./ container: image: dart:stable env: - pub-cache-name: pub threshold: 50 + pub-cache: pub PUB_CACHE: /github/home/.pub-cache timeout-minutes: 15 steps: @@ -117,21 +128,23 @@ jobs: lib test analysis_options.yaml + README.md + CHANGELOG.md - - name: ๐Ÿ“‚ Download Echo server - id: download-echo-server - uses: actions/download-artifact@v4 - with: - name: echo-server - path: ~/build/bin/ + #- name: ๐Ÿ“‚ Download Echo server + # id: download-echo-server + # uses: actions/download-artifact@v4 + # with: + # name: echo-server + # path: ~/build/bin/ - name: ๐Ÿ“ค Restore Pub modules id: cache-pub-restore uses: actions/cache/restore@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} + /home/runner/.pub-cache + key: ${{ runner.os }}-pub-${{ env.pub-cache }}-${{ hashFiles('pubspec.yaml') }} - name: ๐Ÿ‘ท Install Dependencies id: install-dependencies @@ -143,22 +156,23 @@ jobs: - name: ๐Ÿ“ฅ Save Pub modules id: cache-pub-save + if: steps.cache-pub-restore.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | - ${{ env.PUB_CACHE }} - key: ${{ runner.os }}-spinify-${{ env.pub-cache-name }}-${{ hashFiles('pubspec.yaml') }} - - - name: ๐Ÿ“ข Run Echo server - id: run-echo-server - timeout-minutes: 1 - run: | - test -f ~/build/bin/echo - chmod +x ~/build/bin/echo - nohup ~/build/bin/echo > echo.log 2>&1 & - echo $! > echo_pid.txt - env: - PORT: 8000 + /home/runner/.pub-cache + key: ${{ steps.cache-pub-restore.outputs.cache-primary-key }} + + #- name: ๐Ÿ“ข Run Echo server + # id: run-echo-server + # timeout-minutes: 1 + # run: | + # test -f ~/build/bin/echo + # chmod +x ~/build/bin/echo + # nohup ~/build/bin/echo > echo.log 2>&1 & + # echo $! > echo_pid.txt + # env: + # PORT: 8000 - name: ๐Ÿงช Run tests id: run-tests @@ -167,29 +181,29 @@ jobs: dart pub global activate coverage dart pub global run coverage:test_with_coverage -fb -o coverage -- \ --platform vm --compiler=kernel --coverage=coverage \ - --reporter=github --file-reporter=json:coverage/tests.json \ + --reporter=github --file-reporter=json:reports/tests.json \ --timeout=10m --concurrency=12 --color \ - test/unit_test.dart test/smoke_test.dart - - - name: ๐Ÿงพ Upload echo logs - id: upload-echo-logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: server-logs - path: echo.log - compression-level: 9 - overwrite: true - retention-days: 1 - - - name: โœ‹ Stop echo server - id: stop-echo-server - timeout-minutes: 1 - if: always() - run: | - kill $(cat echo_pid.txt) || true - rm -f echo_pid.txt || true - rm -f echo.log || true + test/unit_test.dart + + #- name: ๐Ÿงพ Upload echo logs + # id: upload-echo-logs + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: server-logs + # path: echo.log + # compression-level: 9 + # overwrite: true + # retention-days: 1 + + #- name: โœ‹ Stop echo server + # id: stop-echo-server + # timeout-minutes: 1 + # if: always() + # run: | + # kill $(cat echo_pid.txt) || true + # rm -f echo_pid.txt || true + # rm -f echo.log || true - name: ๐Ÿ” Check coverage id: check-coverage @@ -216,3 +230,10 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + + - name: ๐Ÿ“ฅ Upload test report + uses: actions/upload-artifact@v4 + if: (success() || failure()) && ${{ github.actor != 'dependabot[bot]' }} + with: + name: test-results + path: reports/tests.json diff --git a/.gitignore b/.gitignore index cdce770..0f205e5 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,8 @@ coverage/ .coverage/ /test/**/*.json /test/.test_coverage.dart +reports/ +.reports/ # Centifuge centrifugo-config.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e2aea12..a2dafaa 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,8 @@ { "recommendations": [ "dart-code.dart-code", + "github.vscode-github-actions", + "golang.go", + "kangping.protobuf" ] } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 97a2b09..57b7ded 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,21 @@ { "version": "0.2.0", "configurations": [ + { + "name": "[Dart] Example (debug)", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "cwd": "${workspaceFolder}/example/echo", + "program": "main.dart", + "env": { + "ENVIRONMENT": "local" + }, + "console": "debugConsole", + "runTestsOnDevice": false, + "toolArgs": [], + "args": [] + }, { "name": "[Flutter] Benchmark (debug)", "request": "launch", diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a5516..816cc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.1.0-pre.1 + +- Large scale refactoring +- Test coverage increased to 88% + ## 0.0.4 - Update `SpinifyState$Disconnected` state and extend with `bool get temporary` diff --git a/Makefile b/Makefile index a1b5987..e807e91 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,15 @@ -ifeq ($(OS),Windows_NT) - SHELL = cmd - RM = del /Q - MKDIR = mkdir - PWD = $(shell $(PWD)) -else - SHELL = /bin/bash -e -o pipefail - RM = rm -f - MKDIR = mkdir -p - PWD = pwd -endif +SHELL :=/bin/bash -e -o pipefail +PWD :=$(shell pwd) .DEFAULT_GOAL := all .PHONY: all all: ## build pipeline all: generate format check test +.PHONY: ci +ci: ## CI build pipeline +ci: all + .PHONY: precommit precommit: ## validate the branch before commit precommit: all @@ -56,6 +51,14 @@ publish: generate ## Publish the package .PHONY: deploy deploy: publish +.PHONY: echo-go +echo-go: ## Start the echo server + @cd tool/echo && go run echo.go + +.PHONY: echo-dart +echo-dart: ## Start the echo client + @cd example/echo && dart run main.dart + .PHONY: echo-up echo-up: ## Start the echo server @dart run tool/echo_up.dart @@ -71,7 +74,7 @@ coverage: get ## Generate the coverage report --platform vm --compiler=kernel --coverage=coverage \ --reporter=expanded --file-reporter=json:coverage/tests.json \ --timeout=10m --concurrency=12 --color \ - test/unit_test.dart test/smoke_test.dart + test/unit_test.dart # @dart test --concurrency=6 --platform vm --coverage=coverage test/ # @dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib @mv coverage/lcov.info coverage/lcov.base.info @@ -106,6 +109,11 @@ gen: generate .PHONY: codegen codegen: generate +.PHONY: dart-version +dart-version: ## Show the Dart version + @dart --version + @which dart + .PHONY: diff diff: ## git diff $(call print-target) diff --git a/benchmark/encoding_benchmark.dart b/benchmark/encoding_benchmark.dart new file mode 100644 index 0000000..a0bc8d3 --- /dev/null +++ b/benchmark/encoding_benchmark.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:protobuf/protobuf.dart' as pb; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; + +void main() { + final command = pb.Command( + send: pb.SendRequest( + data: Uint16List.fromList([for (var i = 0; i < 256; i++) i]), + ), + ); + + final a = _EncdingBenchmark$Concatination(command)..report(); + final b = _EncdingBenchmark$Builder(command)..report(); + + if (a.bytes.length != b.bytes.length) { + throw StateError('Bytes length mismatch'); + } + for (var i = 0; i < a.bytes.length; i++) { + if (a.bytes[i] != b.bytes[i]) { + throw StateError('Bytes mismatch at index $i'); + } + } +} + +class _EncdingBenchmark$Concatination extends BenchmarkBase { + _EncdingBenchmark$Concatination(this.command) + : super('Encoding concatination'); + + final pb.Command command; + + List bytes = Uint8List(0); + + @override + void run() { + final commandData = command.writeToBuffer(); + final length = commandData.lengthInBytes; + final writer = pb.CodedBufferWriter()..writeInt32NoTag(length); + bytes = writer.toBuffer() + commandData; + } +} + +class _EncdingBenchmark$Builder extends BenchmarkBase { + _EncdingBenchmark$Builder(this.command) : super('Encoding builder'); + + final pb.Command command; + + List bytes = Uint8List(0); + + @override + void run() { + final commandData = command.writeToBuffer(); + final length = commandData.lengthInBytes; + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(length) + ..writeRawBytes(commandData); + bytes = writer.toBuffer(); + } +} diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart new file mode 100644 index 0000000..639d1b9 --- /dev/null +++ b/benchmark/mutex_benchmark.dart @@ -0,0 +1,350 @@ +/* + * Mutex benchmark + * https://gist.github.com/PlugFox/264d59a37d02dd06a7123ef19ee8537d + * https://dartpad.dev?id=264d59a37d02dd06a7123ef19ee8537d + * Mike Matiunin , 11 November 2024 + */ + +import 'dart:async'; +import 'dart:collection'; +import 'dart:isolate'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:meta/meta.dart'; + +void main() => Future(() async { + //final baseUs = await _WithoutMutex().measure(); + final benchmarks = [ + _WithoutMutex.new, + _MutexList.new, + _MutexQueue.new, + _MutexLinked.new, + _MutexLock.new, + _MutexLast.new, + _MutexWrap.new, + _MutexEncapsulated.new, + ]; + final results = + await Stream.fromIterable(benchmarks) + .asyncMap( + (constructor) async => await Isolate.run( + () async { + final benchmark = constructor(); + return ( + name: benchmark.name, + score: await benchmark.measure() + ); + }, + ), + ) + .toList(); + results.sort((a, b) => a.score.compareTo(b.score)); + final buffer = StringBuffer(); + for (final r in results) { + buffer.writeln('${r.name.padLeft(12)} |' + ' ${r.score.toStringAsPrecision(6).padRight(8)} us |' + ' ${1000000 ~/ r.score} FPS'); + } + print(buffer.toString()); // ignore: avoid_print + }); + +class _Base extends AsyncBenchmarkBase { + _Base(super.name); + + int _counter = 0; + + @override + @mustCallSuper + Future setup() async { + _counter = 0; + return super.setup(); + } + + /// Measures the score for this benchmark by executing it repeatedly until + /// time minimum has been reached. + static Future measureFor( + Future Function() f, int minimumMillis) async { + final futures = List>.filled(100, Future.value()); + final minimumMicros = minimumMillis * 1000; + final watch = Stopwatch()..start(); + var iter = 0; + var elapsed = 0; + while (elapsed < minimumMicros) { + for (var i = 0; i < 100; i++) { + futures[i] = f(); + iter++; + } + await Future.wait(futures); + elapsed = watch.elapsedMicroseconds; + } + return elapsed / iter; + } + + /// Measures the score for the benchmark and returns it. + @override + @mustCallSuper + Future measure() async { + await setup(); + try { + // Warmup for at least 100ms. Discard result. + await measureFor(warmup, 100); + } finally { + await teardown(); + } + await setup(); + try { + // Run the benchmark for at least 2000ms. + return await measureFor(exercise, 2000); + } finally { + await teardown(); + } + } + + @override + @mustCallSuper + Future teardown() async { + if (_counter == 0) throw StateError('Counter mismatch'); + return super.teardown(); + } +} + +class _WithoutMutex extends _Base { + _WithoutMutex() : super('Without'); + + @override + Future run() => Future.delayed(Duration.zero, () { + final value = _counter; + _counter = value + 1; + }); +} + +class _MutexList extends _Base { + _MutexList() : super('List'); + + final _list = >[Future.value()]; + + @override + Future run() async { + final last = _list.last; + final completer = Completer.sync(); + _list.add(completer.future); + await last; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + unawaited(_list.removeAt(0)); + completer.complete(); + } +} + +class _MutexLinked extends _Base { + _MutexLinked() : super('Linked'); + + _Mutex$Request? _node; + + @override + Future run() async { + final prev = _node; + final current = _node = _Mutex$Request.sync()..prev = prev; + await prev?.future; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + current.prev = null; + if (identical(_node, current)) _node = null; + current.release(); + } +} + +class _MutexQueue extends _Base { + _MutexQueue() : super('Queue'); + + final _queue = Queue>()..add(Future.value()); + + @override + Future run() async { + final last = _queue.last; + final completer = Completer.sync(); + _queue.add(completer.future); + await last; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + unawaited(_queue.removeFirst()); + completer.complete(); + } +} + +class _MutexLast extends _Base { + _MutexLast() : super('Last'); + + Future? _last; // The last running block + + @override + Future run() async { + final prev = _last; + final completer = Completer.sync(); + final current = _last = completer.future; + await prev; + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + if (identical(_last, current)) _last = null; + completer.complete(); + } +} + +class _MutexLock extends _Base { + _MutexLock() : super('Lock'); + + Future? _last; // The last running block + + Future _lock() async { + final prev = _last; + final completer = Completer.sync(); + final current = _last = completer.future; + await prev; + return () { + if (identical(_last, current)) _last = null; + completer.complete(); + }; + } + + @override + Future run() async { + final unlock = await _lock(); + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + unlock(); + } +} + +class _MutexWrap extends _Base { + _MutexWrap() : super('Wrap'); + + Future? _last; // The last running block + + Future _wrap(Future Function() fn) async { + final prev = _last; + final completer = Completer.sync(); + final current = _last = completer.future; + await prev; + await fn(); + if (identical(_last, current)) _last = null; + completer.complete(); + } + + @override + Future run() => _wrap(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + }); +} + +class _MutexEncapsulated extends _Base { + _MutexEncapsulated() : super('Encapsulated'); + + final _Mutex _m = _Mutex(); + + @override + Future run() async { + await _m.lock(); + try { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + //if (_counter < 50) print('$value -> $_counter'); + } finally { + _m.unlock(); + } + } + + @override + @mustCallSuper + Future teardown() async { + if (_m.locks != 0) throw StateError('Lock mismatch'); + return super.teardown(); + } +} + +/// A request for a mutex lock. +class _Mutex$Request { + /// Creates a new mutex request. + _Mutex$Request._(Completer completer) + : _completer = completer, + future = completer.future; + + /// Creates a new mutex request with a synchronous completer. + factory _Mutex$Request.sync() => _Mutex$Request._(Completer.sync()); + + final Completer _completer; // The completer for the request. + void release() => _completer.complete(); // Releases the lock. + final Future future; // The future for the request. + _Mutex$Request? prev; // The previous request in the chain. +} + +/// A mutual exclusion lock. +class _Mutex { + /// Creates a new mutex. + _Mutex(); + + _Mutex$Request? _last; // The last requested block + _Mutex$Request? _current; // The first and current running block + int _locks = 0; // The number of locks currently held + + /// The number of locks currently held. + int get locks => _locks; + + /// The list of pending locks. + List> get pending { + final pending = List>.filled(_locks, Future.value(), + growable: false); + for (var i = _locks - 1, request = _last; + i >= 0; + i--, request = request?.prev) { + final future = request?.future; + if (future != null) + pending[i] = future; + else + assert(false, 'Invalid lock state'); + } + return pending; + } + + /// Protects a callback with the mutex. + Future protect(Future Function() callback) async { + await lock(); + try { + return await callback(); + } finally { + unlock(); + } + } + + /// Locks the mutex. + Future lock() async { + _locks++; + final prev = _last; + final current = _last = _Mutex$Request.sync()..prev = prev; + // Wait for the previous lock to be released. + if (prev != null) await prev.future; + _current = current..prev = null; // Set the current lock. + } + + /// Unlocks the mutex. + void unlock() { + final current = _current; + if (current == null) return; + _locks--; + _current = null; + current.release(); + } +} diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..d14b29c --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,13 @@ +timeout: 1x + +platforms: + - vm + +file_reporters: + json: reports/tests.json + +tags: + unit: + timeout: 1x + smoke: + timeout: 2x diff --git a/example/echo/main.dart b/example/echo/main.dart index 696fcf6..dc34a91 100644 --- a/example/echo/main.dart +++ b/example/echo/main.dart @@ -1,3 +1,27 @@ +// ignore_for_file: avoid_print + +import 'dart:io' as io; + import 'package:spinify/spinify.dart'; -void main() => Spinify(); +void main(List args) { + var url = args.firstWhere((a) => a.startsWith('--url='), orElse: () => ''); + if (url.isNotEmpty) url = url.substring(6).trim(); + if (url.isEmpty) url = io.Platform.environment['URL'] ?? ''; + if (url.isEmpty) url = const String.fromEnvironment('URL', defaultValue: ''); + if (url.isEmpty) url = 'ws://localhost:8000/connection/websocket'; + + final client = Spinify( + config: SpinifyConfig( + logger: (level, event, message, context) => print('[$event] $message'), + ), + ); + + var prev = client.state; + client.states.listen((next) { + print('$prev -> $next'); + prev = next; + }); + + client.connect(url).ignore(); +} diff --git a/lib/spinify.dart b/lib/spinify.dart index ee59ec8..8bb4e72 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -4,6 +4,8 @@ export 'package:fixnum/fixnum.dart' show Int64; export 'src/model/channel_event.dart'; export 'src/model/client_info.dart'; +export 'src/model/codec.dart'; +export 'src/model/codes.dart'; export 'src/model/command.dart'; export 'src/model/config.dart'; export 'src/model/exception.dart'; @@ -19,7 +21,7 @@ export 'src/model/subscription_config.dart'; export 'src/model/subscription_state.dart'; export 'src/model/subscription_states.dart'; export 'src/model/transport_interface.dart'; -export 'src/spinify_impl.dart' show Spinify; +export 'src/protobuf/protobuf_codec.dart'; +export 'src/spinify.dart' show Spinify; export 'src/spinify_interface.dart'; export 'src/subscription_interface.dart'; -export 'src/transport_fake.dart'; diff --git a/lib/src/model/annotations.dart b/lib/src/model/annotations.dart index e92ccfe..797a0a4 100644 --- a/lib/src/model/annotations.dart +++ b/lib/src/model/annotations.dart @@ -8,12 +8,18 @@ const SpinifyAnnotation interactive = SpinifyAnnotation('interactive'); @internal const SpinifyAnnotation sideEffect = SpinifyAnnotation('sideEffect'); -// TODO(plugfox): add more annotations +/// Method that shouldn't throw an any exception. +@internal +const SpinifyAnnotation safe = SpinifyAnnotation('safe'); + +/// Method that can throw an exception. +@internal +const SpinifyAnnotation unsafe = SpinifyAnnotation('unsafe'); /// Annotation for Spinify library. @internal @immutable -final class SpinifyAnnotation { +class SpinifyAnnotation { @literal const SpinifyAnnotation( this.name, { @@ -26,3 +32,15 @@ final class SpinifyAnnotation { /// Annotation metadata. final Map meta; } + +/// Annotation for Spinify library that mark methods as possible to throw +/// exceptions of specified types. +@internal +@immutable +class Throws extends SpinifyAnnotation { + @literal + const Throws(this.exceptions) : super('throws'); + + /// List of exceptions that can be thrown. + final List exceptions; +} diff --git a/lib/src/model/channel_event.dart b/lib/src/model/channel_event.dart index eb07773..a879d8d 100644 --- a/lib/src/model/channel_event.dart +++ b/lib/src/model/channel_event.dart @@ -1,6 +1,8 @@ import 'package:fixnum/fixnum.dart' as fixnum; import 'package:meta/meta.dart'; +import '../util/list_equals.dart'; +import '../util/map_equals.dart'; import 'client_info.dart'; import 'stream_position.dart'; @@ -119,6 +121,9 @@ sealed class SpinifyChannelEvent implements Comparable { /// Whether this is a refresh event abstract final bool isRefresh; + /// Copy this event with a new channel. + SpinifyChannelEvent copyWith({String? channel}); + @override int compareTo(SpinifyChannelEvent other) => timestamp.compareTo(other.timestamp); @@ -161,17 +166,15 @@ final class SpinifyPublication extends SpinifyChannelEvent { final Map? tags; /// Copy this publication with a new channel. - SpinifyPublication copyWith({required String channel}) => - channel == this.channel - ? this - : SpinifyPublication( - timestamp: timestamp, - channel: channel, - data: data, - offset: offset, - info: info, - tags: tags, - ); + @override + SpinifyPublication copyWith({String? channel}) => SpinifyPublication( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + offset: offset, + info: info, + tags: tags, + ); @override bool get isConnect => false; @@ -196,6 +199,28 @@ final class SpinifyPublication extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + data, + offset, + info, + tags, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyPublication && + channel == other.channel && + timestamp == other.timestamp && + offset == other.offset && + info == other.info && + mapEquals(tags, other.tags) && + listEquals(data, other.data); + } } /// {@template channel_presence} @@ -284,11 +309,35 @@ final class SpinifyJoin extends SpinifyPresence { @override String get type => 'Join'; + /// Copy this event with a new channel. + @override + SpinifyJoin copyWith({String? channel}) => SpinifyJoin( + timestamp: timestamp, + channel: channel ?? this.channel, + info: info, + ); + @override bool get isJoin => true; @override bool get isLeave => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + info, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyJoin && + channel == other.channel && + timestamp == other.timestamp && + info == other.info; + } } /// Leave event @@ -308,11 +357,35 @@ final class SpinifyLeave extends SpinifyPresence { @override String get type => 'Leave'; + /// Copy this event with a new channel. + @override + SpinifyLeave copyWith({String? channel}) => SpinifyLeave( + timestamp: timestamp, + channel: channel ?? this.channel, + info: info, + ); + @override bool get isJoin => false; @override bool get isLeave => true; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + info, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyLeave && + channel == other.channel && + timestamp == other.timestamp && + info == other.info; + } } /// {@template unsubscribe} @@ -339,6 +412,15 @@ final class SpinifyUnsubscribe extends SpinifyChannelEvent { /// Reason of unsubscribe. final String reason; + /// Copy this event with a new channel. + @override + SpinifyUnsubscribe copyWith({String? channel}) => SpinifyUnsubscribe( + timestamp: timestamp, + channel: channel ?? this.channel, + code: code, + reason: reason, + ); + @override bool get isConnect => false; @@ -362,6 +444,24 @@ final class SpinifyUnsubscribe extends SpinifyChannelEvent { @override bool get isUnsubscribe => true; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + code, + reason, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyUnsubscribe && + channel == other.channel && + timestamp == other.timestamp && + code == other.code && + reason == other.reason; + } } /// {@template message} @@ -384,6 +484,14 @@ final class SpinifyMessage extends SpinifyChannelEvent { /// Payload of message. final List data; + /// Copy this event with a new channel. + @override + SpinifyMessage copyWith({String? channel}) => SpinifyMessage( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + ); + @override bool get isConnect => false; @@ -407,6 +515,22 @@ final class SpinifyMessage extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + data, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyMessage && + channel == other.channel && + timestamp == other.timestamp && + listEquals(data, other.data); + } } /// {@template subscribe} @@ -441,6 +565,17 @@ final class SpinifySubscribe extends SpinifyChannelEvent { /// Data attached to subscription. final List? data; + /// Copy this event with a new channel. + @override + SpinifySubscribe copyWith({String? channel}) => SpinifySubscribe( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + positioned: positioned, + recoverable: recoverable, + since: since, + ); + @override bool get isConnect => false; @@ -464,6 +599,28 @@ final class SpinifySubscribe extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + positioned, + recoverable, + since, + data, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifySubscribe && + channel == other.channel && + timestamp == other.timestamp && + positioned == positioned && + recoverable == recoverable && + since == since && + listEquals(data, other.data); + } } /// {@template connect} @@ -521,6 +678,22 @@ final class SpinifyConnect extends SpinifyChannelEvent { /// Payload of connected push. final List? data; + /// Copy this event with a new channel. + @override + SpinifyConnect copyWith({String? channel}) => SpinifyConnect( + timestamp: timestamp, + channel: channel ?? this.channel, + data: data, + client: client, + version: version, + expires: expires, + ttl: ttl, + pingInterval: pingInterval, + sendPong: sendPong, + session: session, + node: node, + ); + @override bool get isConnect => true; @@ -544,6 +717,38 @@ final class SpinifyConnect extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + client, + version, + expires, + ttl, + pingInterval, + sendPong, + session, + node, + data, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyConnect && + channel == other.channel && + timestamp == other.timestamp && + client == other.client && + version == other.version && + expires == other.expires && + ttl == other.ttl && + pingInterval == other.pingInterval && + sendPong == other.sendPong && + session == other.session && + node == other.node && + listEquals(data, other.data); + } } /// {@template disconnect} @@ -592,6 +797,16 @@ final class SpinifyDisconnect extends SpinifyChannelEvent { /// Reconnect flag. final bool reconnect; + /// Copy this event with a new channel. + @override + SpinifyDisconnect copyWith({String? channel}) => SpinifyDisconnect( + timestamp: timestamp, + channel: channel ?? this.channel, + code: code, + reason: reason, + reconnect: reconnect, + ); + @override bool get isConnect => false; @@ -615,6 +830,26 @@ final class SpinifyDisconnect extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + code, + reason, + reconnect, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyDisconnect && + channel == other.channel && + timestamp == other.timestamp && + code == other.code && + reason == other.reason && + reconnect == other.reconnect; + } } /// {@template refresh} @@ -641,6 +876,15 @@ final class SpinifyRefresh extends SpinifyChannelEvent { /// Time when connection will be expired final DateTime? ttl; + /// Copy this event with a new channel. + @override + SpinifyRefresh copyWith({String? channel}) => SpinifyRefresh( + timestamp: timestamp, + channel: channel ?? this.channel, + expires: expires, + ttl: ttl, + ); + @override bool get isConnect => false; @@ -664,4 +908,22 @@ final class SpinifyRefresh extends SpinifyChannelEvent { @override bool get isUnsubscribe => false; + + @override + int get hashCode => Object.hashAll([ + timestamp, + channel, + expires, + ttl, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SpinifyRefresh && + channel == other.channel && + timestamp == other.timestamp && + expires == other.expires && + ttl == other.ttl; + } } diff --git a/lib/src/model/client_info.dart b/lib/src/model/client_info.dart index 7433238..4ec8715 100644 --- a/lib/src/model/client_info.dart +++ b/lib/src/model/client_info.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import '../util/list_equals.dart'; + /// {@template client_info} /// Client information. /// {@endtemplate} @@ -39,13 +41,13 @@ final class SpinifyClientInfo { bool operator ==(Object other) => identical(this, other) || other is SpinifyClientInfo && - user == other.client && + user == other.user && client == other.client && - connectionInfo == other.connectionInfo && - channelInfo == other.channelInfo; + listEquals(connectionInfo, other.connectionInfo) && + listEquals(channelInfo, other.channelInfo); @override - String toString() => 'ClientInfo{' + String toString() => 'SpinifyClientInfo{' 'user: $user, ' 'client: $client' '}'; diff --git a/lib/src/model/codec.dart b/lib/src/model/codec.dart new file mode 100644 index 0000000..b1d5616 --- /dev/null +++ b/lib/src/model/codec.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; + +import 'command.dart'; +import 'reply.dart'; + +/// A codec for encoding and decoding Spinify commands and replies. +abstract interface class SpinifyCodec { + /// The protocol used by the codec. + /// e.g. 'centrifuge-protobuf' + abstract final String protocol; + + /// Decodes a Spinify replies from a list of bytes. + abstract final Converter, Iterable> decoder; + + /// Encodes a Spinify command to a list of bytes. + abstract final Converter> encoder; +} diff --git a/lib/src/model/codes.dart b/lib/src/model/codes.dart new file mode 100644 index 0000000..a85a9c1 --- /dev/null +++ b/lib/src/model/codes.dart @@ -0,0 +1,551 @@ +import 'package:meta/meta.dart'; + +/// The disconnect codes for the Spinify WebSocket connection. +/// +/// Codes have some rules which should be followed by a client +/// connector implementation. +/// These rules described below. +/// +/// Codes in range 0..2999 should not be used by a Centrifuge library user. +/// Those are reserved for the client-side and transport specific needs. +/// +/// Server may send custom disconnect codes to a client. +/// Custom disconnect codes must be in range 3000..4999. +/// +/// Codes in range >=5000 should not be used also. +/// Those are reserved by Centrifuge. +/// +/// Client should reconnect upon receiving code in range +/// 3000..3499, 4000..4499, >=5000. +/// For codes <3000 reconnect behavior can be adjusted for specific transport. +/// (Default reconnect is true in this implementation). +/// +/// Codes in range 3500..3999 and 4500..4999 are application terminal codes, +/// no automatic reconnect should be made by a client implementation. +/// +/// Library users supposed to use codes in range 4000..4999 for creating custom +/// disconnects. +extension type const SpinifyDisconnectCode(int code) implements int { + // --- 0..99 Internal client-side and transport specific codes --- // + + /// Disconnect called explicitly by the client. + @literal + const SpinifyDisconnectCode.disconnect() : code = 0; + + /// Disconnect due to omitted ping from the server. + @literal + const SpinifyDisconnectCode.noPingFromServer() : code = 99; + + // --- 0..99 Internal server-side transport specific codes --- // + + /// Error Internal means server error, + /// if returned this is a signal that something went wrong with the server + /// itself and client is most probably not guilty. + @literal + const SpinifyDisconnectCode.internalServerError() : code = 100; + + /// Unauthorized indicates that the request is unauthorized. + @literal + const SpinifyDisconnectCode.unauthorized() : code = 101; + + /// Unknown Channel means that the channel name does not exist. + /// Usually this is returned when client uses a channel with a namespace + /// that is not defined in the Centrifugo configuration. + @literal + const SpinifyDisconnectCode.unknownChannel() : code = 102; + + /// Permission Denied means access to the resource is not allowed. + @literal + const SpinifyDisconnectCode.permissionDenied() : code = 103; + + /// Method Not Found indicates that the requested method does not exist. + @literal + const SpinifyDisconnectCode.methodNotFound() : code = 104; + + /// Already Subscribed indicates that the client is already subscribed + /// to the specified channel. In Centrifugo, a client can only have one + /// subscription to a specific channel. + @literal + const SpinifyDisconnectCode.alreadySubscribed() : code = 105; + + /// Limit Exceeded indicates that a server-imposed + /// limit has been exceeded. + /// Server logs should provide more information. + @literal + const SpinifyDisconnectCode.limitExceeded() : code = 106; + + /// Bad Request means the server cannot process the received data + /// because it is malformed. Retrying the request does not make sense. + @literal + const SpinifyDisconnectCode.badRequest() : code = 107; + + /// Not Available indicates that the requested resource is not enabled. + /// This may occur, for example, when trying to access history or presence + /// in a channel that does not support these features. + @literal + const SpinifyDisconnectCode.notAvailable() : code = 108; + + /// Token Expired indicates that the connection token has expired. + /// This is generally handled by updating the token. + @literal + const SpinifyDisconnectCode.tokenExpired() : code = 109; + + /// Expired indicates that the connection has expired + /// (no token involved). + @literal + const SpinifyDisconnectCode.expired() : code = 110; + + /// Too Many Requests means that the server rejected the request + /// due to rate limiting. + @literal + const SpinifyDisconnectCode.tooManyRequests() : code = 111; + + /// Unrecoverable Position indicates that the stream does not contain + /// the required range of publications to fulfill a history query, possibly + /// due to an incorrect epoch being passed. + @literal + const SpinifyDisconnectCode.unrecoverablePosition() : code = 112; + + // --- Web socket closures --- // + + /// Normal closure. + @literal + const SpinifyDisconnectCode.normalClosure() : code = 1000; + + /// Abnormal closure. + @literal + const SpinifyDisconnectCode.abnormalClosure() : code = 1006; + + /* + /// Normalize disconnect code and reason. + @experimental + static ({SpinifyDisconnectCode code, String reason, bool reconnect}) + normalize([int? code, String? reason]) => switch (code ?? 1) { + // --- Client error codes --- // + + < 0 => ( + code: SpinifyDisconnectCode(code!), + reason: reason ?? 'client error', + reconnect: false, + ), + + /// Disconnect called explicitly by the client. + 0 => ( + code: const SpinifyDisconnectCode(0), + reason: reason ?? 'disconnect called', + reconnect: true, + ), + + /// Disconnect due to malformed protocol message sent by the client. + 2 => ( + code: const SpinifyDisconnectCode(2), + reason: reason ?? 'bad protocol', + reconnect: true, + ), + + /// Disconnect due to omitted ping from the server. + 99 => ( + code: const SpinifyDisconnectCode(99), + reason: reason ?? 'no ping from server', + reconnect: true, + ), + + /// Internal server error means server error, + /// if returned this is a signal that something went wrong with + /// the server itself and client is most probably not guilty. + 100 => ( + code: const SpinifyDisconnectCode(100), + reason: reason ?? 'internal server error', + reconnect: true, + ), + + /// Unauthorized indicates that the request is unauthorized. + 101 => ( + code: const SpinifyDisconnectCode(101), + reason: reason ?? 'unauthorized', + reconnect: true, + ), + + /// Unknown Channel means that the channel name does not exist. + /// Usually this is returned when the client uses a channel with a + /// namespace that is not defined in Centrifugo configuration. + 102 => ( + code: const SpinifyDisconnectCode(102), + reason: reason ?? 'unknown channel', + reconnect: true, + ), + + /// Permission Denied means access to the resource is not allowed. + 103 => ( + code: const SpinifyDisconnectCode(103), + reason: reason ?? 'permission denied', + reconnect: true, + ), + + /// Method Not Found indicates that + /// the requested method does not exist. + 104 => ( + code: const SpinifyDisconnectCode(104), + reason: reason ?? 'method not found', + reconnect: true, + ), + + /// Already Subscribed indicates that the client is + /// already subscribed to the specified channel. + /// In Centrifugo, a client can only have one + /// subscription to a specific channel. + 105 => ( + code: const SpinifyDisconnectCode(105), + reason: reason ?? 'already subscribed', + reconnect: true, + ), + + /// Limit Exceeded indicates that a server-imposed + /// limit has been exceeded. + /// Server logs should provide more information. + 106 => ( + code: const SpinifyDisconnectCode(106), + reason: reason ?? 'limit exceeded', + reconnect: true, + ), + + /// Bad Request means the server cannot process the received data + /// because it is malformed. + /// Retrying the request does not make sense. + 107 => ( + code: const SpinifyDisconnectCode(107), + reason: reason ?? 'bad request', + reconnect: true, + ), + + /// Not Available indicates that the requested + /// resource is not enabled. + /// This may occur, for example, + /// when trying to access history or presence + /// in a channel that does not support these features. + 108 => ( + code: const SpinifyDisconnectCode(108), + reason: reason ?? 'not available', + reconnect: true, + ), + + /// Token Expired indicates that the connection token has expired. + /// This is generally handled by updating the token. + 109 => ( + code: const SpinifyDisconnectCode(109), + reason: reason ?? 'token expired', + reconnect: true, + ), + + /// Expired indicates that the connection has expired + /// (no token involved). + 110 => ( + code: const SpinifyDisconnectCode(110), + reason: reason ?? 'expired', + reconnect: true, + ), + + /// Too Many Requests means that the server rejected the request + /// due to rate limiting. + 111 => ( + code: const SpinifyDisconnectCode(111), + reason: reason ?? 'too many requests', + reconnect: true, + ), + + /// Unrecoverable Position indicates that + /// the stream does not contain + /// the required range of publications to fulfill a history query, + /// possibly due to an incorrect epoch being passed. + 112 => ( + code: const SpinifyDisconnectCode(112), + reason: reason ?? 'unrecoverable position', + reconnect: true, + ), + + /// Normal closure. + 1000 => ( + code: const SpinifyDisconnectCode(1000), + reason: reason ?? 'normal closure', + reconnect: true, + ), + + /// Abnormal closure. + 1006 => ( + code: const SpinifyDisconnectCode(1006), + reason: reason ?? 'abnormal closure', + reconnect: true, + ), + + /// Message size limit exceeded. + 1009 => ( + code: const SpinifyDisconnectCode(1009), + reason: reason ?? 'message size limit exceeded', + reconnect: true, + ), + + /// Custom disconnect codes from server. + /// We expose codes defined by Centrifuge protocol, + /// hiding details about transport-specific error codes. + /// Reconnect is true by default. + < 3000 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + // --- Non-terminal disconnect codes --- // + + /// DisconnectConnectionClosed is a special Disconnect + /// object used when + /// client connection was closed without any advice + /// from a server side. + /// This can be a clean disconnect, + /// or temporary disconnect of the client + /// due to internet connection loss. + /// Server can not distinguish the actual reason of disconnect. + 3000 => ( + code: const SpinifyDisconnectCode(3000), + reason: reason ?? 'connection closed', + reconnect: true, + ), + + /// Shutdown code. + 3001 => ( + code: const SpinifyDisconnectCode(3001), + reason: reason ?? 'shutdown', + reconnect: true, + ), + + /// DisconnectServerError issued when + /// internal error occurred on server. + 3004 => ( + code: const SpinifyDisconnectCode(3004), + reason: reason ?? 'internal server error', + reconnect: true, + ), + + /// DisconnectExpired + 3005 => ( + code: const SpinifyDisconnectCode(3005), + reason: reason ?? 'connection expired', + reconnect: true, + ), + + /// DisconnectSubExpired issued when client subscription expired. + 3006 => ( + code: const SpinifyDisconnectCode(3006), + reason: reason ?? 'subscription expired', + reconnect: true, + ), + + /// DisconnectSlow issued when client + /// can't read messages fast enough. + 3008 => ( + code: const SpinifyDisconnectCode(3008), + reason: reason ?? 'slow', + reconnect: true, + ), + + /// DisconnectWriteError issued when an error occurred + /// while writing to client connection. + 3009 => ( + code: const SpinifyDisconnectCode(3009), + reason: reason ?? 'write error', + reconnect: true, + ), + + /// DisconnectInsufficientState issued when Centrifugo detects wrong + /// client position in a channel stream. + /// Disconnect allows client to restore missed + /// publications on reconnect. + /// + /// Insufficient state in channel only happens in channels + /// with positioning/recovery on โ€“ where Centrifugo detects message + /// loss and message order issues. + /// + /// Insufficient state in a stream means that Centrifugo + /// detected message loss from the broker. + /// Generally, rare cases of getting such disconnect code are OK, + /// but if there is an increase in the amount of such codes + /// โ€“ then this can be a signal of Centrifugo-to-Broker + /// communication issue. The root cause should be investigated โ€“ + /// it may be an unstable connection between Centrifugo and broker, + /// or Centrifugo can't keep up with a message stream in a channel, + /// or a broker skips messages for some reason. + 3010 => ( + code: const SpinifyDisconnectCode(3010), + reason: reason ?? 'insufficient state', + reconnect: true, + ), + + /// DisconnectForceReconnect issued when server disconnects + /// connection for some reason and whants it to reconnect. + 3011 => ( + code: const SpinifyDisconnectCode(3011), + reason: reason ?? 'force reconnect', + reconnect: true, + ), + + /// DisconnectNoPong may be issued when server disconnects + /// bidirectional connection due to no pong received to + /// application-level server-to-client pings in a configured time. + 3012 => ( + code: const SpinifyDisconnectCode(3012), + reason: reason ?? 'no pong', + reconnect: true, + ), + + /// DisconnectTooManyRequests may be issued when client sends + /// too many commands to a server. + 3013 => ( + code: const SpinifyDisconnectCode(3013), + reason: reason ?? 'too many requests', + reconnect: true, + ), + + /// Custom disconnect codes from server. + /// Reconnect is true by default. + <= 3499 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + // --- Terminal disconnect codes --- // + + /// DisconnectInvalidToken issued when client + /// came with invalid token. + 3500 => ( + code: const SpinifyDisconnectCode(3500), + reason: reason ?? 'invalid token', + reconnect: false, + ), + + /// DisconnectBadRequest issued when client + /// uses malformed protocol frames. + 3501 => ( + code: const SpinifyDisconnectCode(3501), + reason: reason ?? 'bad request', + reconnect: false, + ), + + /// DisconnectStale issued to close connection that did not become + /// authenticated in configured interval after dialing. + 3502 => ( + code: const SpinifyDisconnectCode(3502), + reason: reason ?? 'stale', + reconnect: false, + ), + + /// DisconnectForceNoReconnect issued when server + /// disconnects connection and asks it to not reconnect again. + 3503 => ( + code: const SpinifyDisconnectCode(3503), + reason: reason ?? 'force disconnect', + reconnect: false, + ), + + /// DisconnectConnectionLimit can be issued when client connection + /// exceeds a configured connection limit + /// (per user ID or due to other rule). + 3504 => ( + code: const SpinifyDisconnectCode(3504), + reason: reason ?? 'connection limit', + reconnect: false, + ), + + /// DisconnectChannelLimit can be issued when client + /// connection exceeds a configured channel limit. + 3505 => ( + code: const SpinifyDisconnectCode(3505), + reason: reason ?? 'channel limit', + reconnect: false, + ), + + /// DisconnectInappropriateProtocol can be issued when + /// client connection format can not handle incoming data. + /// For example, this happens when JSON-based clients receive + /// binary data in a channel. + /// This is usually an indicator of programmer error, + /// JSON clients can not handle binary. + 3506 => ( + code: const SpinifyDisconnectCode(3506), + reason: reason ?? 'inappropriate protocol', + reconnect: false, + ), + + /// DisconnectPermissionDenied may be issued when client + /// attempts accessing a server without enough permissions. + 3507 => ( + code: const SpinifyDisconnectCode(3507), + reason: reason ?? 'permission denied', + reconnect: false, + ), + + /// DisconnectNotAvailable may be issued when ErrorNotAvailable + /// does not fit message type, + /// for example we issue DisconnectNotAvailable + /// when client sends asynchronous message without MessageHandler + /// set on server side. + 3508 => ( + code: const SpinifyDisconnectCode(3508), + reason: reason ?? 'not available', + reconnect: false, + ), + + /// DisconnectTooManyErrors may be issued when client + /// generates too many errors. + 3509 => ( + code: const SpinifyDisconnectCode(3509), + reason: reason ?? 'too many errors', + reconnect: false, + ), + + /// Application terminal codes with no reconnect. + <= 3999 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'application terminal code', + reconnect: false, + ), + + /// Custom disconnect codes. Reconnect is true by default. + <= 4499 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + /// Application terminal codes with no reconnect. + <= 4999 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'application terminal code', + reconnect: false, + ), + + /// Internal and reserved by Centrifuge + /// Reconnect is true by default. + >= 5000 => ( + code: SpinifyDisconnectCode(code ?? 0), + reason: reason ?? 'transport closed', + reconnect: true, + ), + + /// Custom disconnect codes (unreachable). + // coverage:ignore-start + _ => throw ArgumentError('invalid disconnect code'), + // coverage:ignore-end + }; + */ + + /// Reconnect is needed due to specific transport close code. + bool get reconnect => switch (code) { + >= 0000 && <= 2999 => true, // Centrifuge library internal codes (true) + >= 3000 && <= 3499 => true, // Server non-terminal codes (true) + >= 3500 && <= 3999 => false, // Application terminal codes (false) + >= 4000 && <= 4499 => true, // Custom disconnect codes (true) + >= 4500 && <= 4999 => false, // Custom disconnect codes (false) + >= 5000 => true, // Reserved by Centrifuge (true) + _ => false, // Other cases (e.g. negative values) + }; +} diff --git a/lib/src/model/command.dart b/lib/src/model/command.dart index eb7b122..fc52680 100644 --- a/lib/src/model/command.dart +++ b/lib/src/model/command.dart @@ -25,6 +25,9 @@ sealed class SpinifyCommand implements Comparable { /// Command type. abstract final String type; + /// Check if command has ID. + abstract final bool hasId; + /// Timestamp of command. final DateTime timestamp; @@ -34,6 +37,7 @@ sealed class SpinifyCommand implements Comparable { 0 => id.compareTo(other.id), int result => result, }; + @override int get hashCode => id ^ type.hashCode ^ timestamp.microsecondsSinceEpoch; @@ -65,6 +69,9 @@ final class SpinifyConnectRequest extends SpinifyCommand { @override String get type => 'ConnectRequest'; + @override + bool get hasId => true; + /// Token to authenticate. final String? token; @@ -101,6 +108,9 @@ final class SpinifySubscribeRequest extends SpinifyCommand { @override String get type => 'SubscribeRequest'; + @override + bool get hasId => true; + /// Channel to subscribe. final String channel; @@ -146,6 +156,9 @@ final class SpinifyUnsubscribeRequest extends SpinifyCommand { @override String get type => 'UnsubscribeRequest'; + @override + bool get hasId => true; + /// Channel to unsubscribe. final String channel; } @@ -163,6 +176,9 @@ final class SpinifyPublishRequest extends SpinifyCommand { @override String get type => 'PublishRequest'; + @override + bool get hasId => true; + /// Channel to publish. final String channel; @@ -182,6 +198,9 @@ final class SpinifyPresenceRequest extends SpinifyCommand { @override String get type => 'PresenceRequest'; + @override + bool get hasId => true; + /// Channel to get presence. final String channel; } @@ -198,6 +217,9 @@ final class SpinifyPresenceStatsRequest extends SpinifyCommand { @override String get type => 'PresenceStatsRequest'; + @override + bool get hasId => true; + /// Channel to get presence stats. final String channel; } @@ -217,6 +239,9 @@ final class SpinifyHistoryRequest extends SpinifyCommand { @override String get type => 'HistoryRequest'; + @override + bool get hasId => true; + /// Channel to get history. final String? channel; @@ -239,6 +264,9 @@ final class SpinifyPingRequest extends SpinifyCommand { @override String get type => 'PingRequest'; + + @override + bool get hasId => false; } /// {@macro command} @@ -252,6 +280,9 @@ final class SpinifySendRequest extends SpinifyCommand { @override String get type => 'SendRequest'; + @override + bool get hasId => false; + /// Data to send. final List data; } @@ -269,6 +300,9 @@ final class SpinifyRPCRequest extends SpinifyCommand { @override String get type => 'RPCRequest'; + @override + bool get hasId => true; + /// Data to send. final List data; @@ -288,6 +322,9 @@ final class SpinifyRefreshRequest extends SpinifyCommand { @override String get type => 'RefreshRequest'; + @override + bool get hasId => true; + /// Token to refresh. /// Token should not be null or empty string. final String token; @@ -306,6 +343,9 @@ final class SpinifySubRefreshRequest extends SpinifyCommand { @override String get type => 'SubRefreshRequest'; + @override + bool get hasId => true; + /// Channel to refresh. final String channel; diff --git a/lib/src/model/config.dart b/lib/src/model/config.dart index a6f0e5d..c331504 100644 --- a/lib/src/model/config.dart +++ b/lib/src/model/config.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'codec.dart'; import 'pubspec.yaml.g.dart'; import 'transport_interface.dart'; @@ -264,6 +265,7 @@ final class SpinifyConfig { Map? headers, this.logger, this.transportBuilder, + this.codec, }) : headers = Map.unmodifiable( headers ?? const {}), client = client ?? @@ -341,6 +343,9 @@ final class SpinifyConfig { /// Callback to build Spinify transport. final SpinifyTransportBuilder? transportBuilder; + /// The Spinify codec to use for encoding and decoding messages. + final SpinifyCodec? codec; + @override String toString() => 'SpinifyConfig{}'; } diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart index faba366..b686dcc 100644 --- a/lib/src/model/exception.dart +++ b/lib/src/model/exception.dart @@ -22,8 +22,22 @@ sealed class SpinifyException implements Exception { /// Source error of exception if exists. final Object? error; + /// Visitor pattern for nested exceptions. + /// Callback for each nested exception, starting from the current one. + void visitor(void Function(Object error) fn) { + fn(this); + switch (error) { + case SpinifyException e: + e.visitor(fn); + case Object e: + fn(e); + case null: + break; + } + } + @override - int get hashCode => code.hashCode; + int get hashCode => Object.hash(code, message, error); @override bool operator ==(Object other) => identical(this, other); @@ -52,9 +66,11 @@ final class SpinifyReplyException extends SpinifyException { required this.replyCode, required String replyMessage, required this.temporary, + Object? error, }) : super( 'spinify_reply_exception', replyMessage, + error, ); /// Reply code. @@ -68,10 +84,10 @@ final class SpinifyReplyException extends SpinifyException { /// {@category Exception} final class SpinifyPingException extends SpinifyException { /// {@macro exception} - const SpinifyPingException([Object? error]) + const SpinifyPingException({String? message, Object? error}) : super( 'spinify_ping_exception', - 'Ping error', + message ?? 'Ping error', error, ); } @@ -108,6 +124,92 @@ final class SpinifySendException extends SpinifyException { ); } +/// {@macro exception} +/// {@category Exception} +final class SpinifyPublishException extends SpinifyException { + /// {@macro exception} + const SpinifyPublishException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_publish_exception', + message ?? 'Failed to publish message to channel', + error, + ); + + /// Publish channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyPresenceException extends SpinifyException { + /// {@macro exception} + const SpinifyPresenceException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_presence_exception', + message ?? 'Failed to get presence info for channel', + error, + ); + + /// Presence channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyPresenceStatsException extends SpinifyException { + /// {@macro exception} + const SpinifyPresenceStatsException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_presence_stats_exception', + message ?? 'Failed to get presence stats for channel', + error, + ); + + /// Presence channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyHistoryException extends SpinifyException { + /// {@macro exception} + const SpinifyHistoryException({ + required this.channel, + String? message, + Object? error, + }) : super( + 'spinify_history_exception', + message ?? 'Failed to get history for channel', + error, + ); + + /// Presence channel. + final String channel; +} + +/// {@macro exception} +/// {@category Exception} +final class SpinifyRPCException extends SpinifyException { + /// {@macro exception} + const SpinifyRPCException({ + String? message, + Object? error, + }) : super( + 'spinify_rpc_exception', + message ?? 'Failed to call remote procedure', + error, + ); +} + /// {@macro exception} /// {@category Exception} final class SpinifyFetchException extends SpinifyException { @@ -135,3 +237,23 @@ final class SpinifyRefreshException extends SpinifyException { error, ); } + +/// Problem relevant to transport layer, connection, +/// data transfer or encoding/decoding issues. +/// {@macro exception} +/// {@category Exception} +final class SpinifyTransportException extends SpinifyException { + /// {@macro exception} + const SpinifyTransportException({ + required String message, + Object? error, + this.data, + }) : super( + 'spinify_transport_exception', + message, + error, + ); + + /// Additional data related to the exception. + final Object? data; +} diff --git a/lib/src/model/history.dart b/lib/src/model/history.dart index 791ce90..42700e6 100644 --- a/lib/src/model/history.dart +++ b/lib/src/model/history.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; +import '../util/list_equals.dart'; import 'channel_event.dart'; import 'stream_position.dart'; @@ -21,6 +22,20 @@ final class SpinifyHistory { /// Offset and epoch of last publication in publications list final SpinifyStreamPosition since; + @override + int get hashCode => Object.hashAll([ + since.epoch, + since.offset, + publications, + ]); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpinifyHistory && + since == other.since && + listEquals(publications, other.publications); + @override String toString() => 'SpinifyHistory{}'; } diff --git a/lib/src/model/metric.dart b/lib/src/model/metric.dart index 2a0ffe8..88191d6 100644 --- a/lib/src/model/metric.dart +++ b/lib/src/model/metric.dart @@ -49,10 +49,22 @@ sealed class SpinifyMetrics implements Comparable { abstract final fixnum.Int64 bytesReceived; /// The total number of messages sent. - abstract final fixnum.Int64 messagesSent; + abstract final fixnum.Int64 chunksSent; + + /// The total number of messages sent. + fixnum.Int64 get messagesSent => chunksSent; + + /// The total number of bytes chunks received. + abstract final fixnum.Int64 chunksReceived; /// The total number of messages received. - abstract final fixnum.Int64 messagesReceived; + fixnum.Int64 get messagesReceived => chunksReceived; + + /// The total number of commands encoded. + abstract final fixnum.Int64 commandsEncoded; + + /// The total number of replies decoded. + abstract final fixnum.Int64 repliesDecoded; /* /// The number of subscriptions. @@ -112,8 +124,10 @@ sealed class SpinifyMetrics implements Comparable { 'state': state, 'bytesSent': bytesSent.toString(), 'bytesReceived': bytesReceived.toString(), - 'messagesSent': messagesSent.toString(), - 'messagesReceived': messagesReceived.toString(), + 'chunksSent': chunksSent.toString(), + 'chunksReceived': chunksReceived.toString(), + 'commandsEncoded': commandsEncoded.toString(), + 'repliesDecoded': repliesDecoded.toString(), 'connects': connects, 'lastConnectAt': lastConnectAt?.toUtc().toIso8601String(), 'reconnectUrl': reconnectUrl, @@ -211,8 +225,10 @@ final class SpinifyMetrics$Immutable extends SpinifyMetrics { required this.lastDisconnectAt, required this.bytesReceived, required this.bytesSent, - required this.messagesReceived, - required this.messagesSent, + required this.chunksReceived, + required this.commandsEncoded, + required this.repliesDecoded, + required this.chunksSent, required this.lastPingAt, required this.receivedPings, required this.channels, @@ -258,10 +274,16 @@ final class SpinifyMetrics$Immutable extends SpinifyMetrics { final fixnum.Int64 bytesSent; @override - final fixnum.Int64 messagesReceived; + final fixnum.Int64 chunksReceived; + + @override + final fixnum.Int64 commandsEncoded; @override - final fixnum.Int64 messagesSent; + final fixnum.Int64 repliesDecoded; + + @override + final fixnum.Int64 chunksSent; @override final DateTime? lastPingAt; @@ -365,10 +387,16 @@ final class SpinifyMetrics$Mutable extends SpinifyMetrics { fixnum.Int64 bytesSent = fixnum.Int64.ZERO; @override - fixnum.Int64 messagesReceived = fixnum.Int64.ZERO; + fixnum.Int64 chunksReceived = fixnum.Int64.ZERO; + + @override + fixnum.Int64 commandsEncoded = fixnum.Int64.ZERO; + + @override + fixnum.Int64 repliesDecoded = fixnum.Int64.ZERO; @override - fixnum.Int64 messagesSent = fixnum.Int64.ZERO; + fixnum.Int64 chunksSent = fixnum.Int64.ZERO; @override DateTime? lastPingAt; @@ -395,8 +423,10 @@ final class SpinifyMetrics$Mutable extends SpinifyMetrics { lastDisconnectAt: lastDisconnectAt, bytesReceived: bytesReceived, bytesSent: bytesSent, - messagesReceived: messagesReceived, - messagesSent: messagesSent, + chunksReceived: chunksReceived, + commandsEncoded: commandsEncoded, + repliesDecoded: repliesDecoded, + chunksSent: chunksSent, lastPingAt: lastPingAt, receivedPings: receivedPings, channels: Map.unmodifiable( diff --git a/lib/src/model/presence_stats.dart b/lib/src/model/presence_stats.dart index eb3bc04..42613ec 100644 --- a/lib/src/model/presence_stats.dart +++ b/lib/src/model/presence_stats.dart @@ -22,6 +22,21 @@ final class SpinifyPresenceStats { /// Users count final int users; + @override + int get hashCode => Object.hash( + channel, + clients, + users, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpinifyPresenceStats && + channel == other.channel && + clients == other.clients && + users == other.users; + @override String toString() => 'SpinifyPresenceStats{channel: $channel}'; } diff --git a/lib/src/model/pubspec.yaml.g.dart b/lib/src/model/pubspec.yaml.g.dart index ef217f5..1e0e9f1 100644 --- a/lib/src/model/pubspec.yaml.g.dart +++ b/lib/src/model/pubspec.yaml.g.dart @@ -93,13 +93,13 @@ sealed class Pubspec { static const PubspecVersion version = ( /// Non-canonical string representation of the version as provided /// in the pubspec.yaml file. - representation: r'0.0.4', + representation: r'0.1.0-pre.1', /// Returns a 'canonicalized' representation /// of the application version. /// This represents the version string in accordance with /// Semantic Versioning (SemVer) standards. - canonical: r'0.0.4', + canonical: r'0.1.0-pre.1', /// MAJOR version when you make incompatible API changes. /// The major version number: 1 in "1.2.3". @@ -108,14 +108,14 @@ sealed class Pubspec { /// MINOR version when you add functionality /// in a backward compatible manner. /// The minor version number: 2 in "1.2.3". - minor: 0, + minor: 1, /// PATCH version when you make backward compatible bug fixes. /// The patch version number: 3 in "1.2.3". - patch: 4, + patch: 0, /// The pre-release identifier: "foo" in "1.2.3-foo". - preRelease: [], + preRelease: [r'pre', r'1'], /// The build identifier: "foo" in "1.2.3+foo". build: [], @@ -125,12 +125,12 @@ sealed class Pubspec { static final DateTime timestamp = DateTime.utc( 2024, 10, - 15, - 17, - 34, 30, - 637, - 667, + 6, + 17, + 56, + 982, + 146, ); /// Name @@ -326,7 +326,9 @@ sealed class Pubspec { /// /// Current app [topics] /// - /// Package authors can use the topics field to categorize their package. Topics can be used to assist discoverability during search with filters on pub.dev. Pub.dev displays the topics on the package page as well as in the search results. + /// Package authors can use the topics field to categorize their package. + /// Topics can be used to assist discoverability during search with filters on pub.dev. + /// Pub.dev displays the topics on the package page as well as in the search results. /// /// The field consists of a list of names. For example: /// @@ -435,12 +437,13 @@ sealed class Pubspec { /// Developer dependencies static const Map devDependencies = { - 'build_runner': r'^2.4.6', - 'pubspec_generator': r'^4.0.0', + 'build_runner': r'^2.4.10', + 'pubspec_generator': r'^4.1.0-pre.1', 'benchmark_harness': r'^2.2.2', - 'lints': r'^5.0.0', - 'test': r'^1.24.4', - 'fake_async': r'^1.3.1', + 'lints': r'>=4.0.0 <6.0.0', + 'test': r'^1.25.8', + 'fake_async': r'^1.3.2', + 'mockito': r'^5.0.0', }; /// Dependency overrides diff --git a/lib/src/model/reply.dart b/lib/src/model/reply.dart index c696728..7676676 100644 --- a/lib/src/model/reply.dart +++ b/lib/src/model/reply.dart @@ -25,6 +25,9 @@ sealed class SpinifyReply implements Comparable { /// For pushes it will have zero value. final int id; + /// Check if reply has ID. + abstract final bool hasId; + /// Timestamp of reply. final DateTime timestamp; @@ -57,8 +60,8 @@ sealed class SpinifyReply implements Comparable { } /// Reply result of a command. -base mixin SpinifyReplyResult on SpinifyReply { - @override +base mixin SpinifyReplyResult { + /// This is a result of a command. bool get isResult => true; } @@ -76,6 +79,9 @@ final class SpinifyServerPing extends SpinifyReply { @override String get type => 'ServerPing'; + @override + bool get hasId => false; + @override bool get isResult => false; } @@ -96,6 +102,9 @@ final class SpinifyPush extends SpinifyReply { @override String get type => 'Push'; + @override + bool get hasId => false; + @override bool get isResult => false; @@ -128,6 +137,9 @@ final class SpinifyConnectResult extends SpinifyReply @override String get type => 'ConnectResult'; + @override + bool get hasId => true; + /// Unique client connection ID server issued to this connection final String client; @@ -183,6 +195,9 @@ final class SpinifySubscribeResult extends SpinifyReply @override String get type => 'SubscribeResult'; + @override + bool get hasId => true; + /* bool expires = 1; uint32 ttl = 2; @@ -236,6 +251,9 @@ final class SpinifyUnsubscribeResult extends SpinifyReply @override String get type => 'UnsubscribeResult'; + + @override + bool get hasId => true; } /// {@macro reply} @@ -249,6 +267,9 @@ final class SpinifyPublishResult extends SpinifyReply @override String get type => 'PublishResult'; + + @override + bool get hasId => true; } /// {@macro reply} @@ -264,6 +285,9 @@ final class SpinifyPresenceResult extends SpinifyReply @override String get type => 'PresenceResult'; + @override + bool get hasId => true; + /// Contains presence information - a map client IDs as keys /// and client information as values. final Map presence; @@ -283,6 +307,9 @@ final class SpinifyPresenceStatsResult extends SpinifyReply @override String get type => 'PresenceStatsResult'; + @override + bool get hasId => true; + /// Number of clients final int numClients; @@ -304,6 +331,9 @@ final class SpinifyHistoryResult extends SpinifyReply @override String get type => 'HistoryResult'; + @override + bool get hasId => true; + /// Offset final SpinifyStreamPosition since; @@ -322,6 +352,9 @@ final class SpinifyPingResult extends SpinifyReply @override String get type => 'PingResult'; + + @override + bool get hasId => true; } /// {@macro reply} @@ -337,6 +370,9 @@ final class SpinifyRPCResult extends SpinifyReply @override String get type => 'RPCResult'; + @override + bool get hasId => true; + /// Payload final List data; } @@ -359,6 +395,9 @@ final class SpinifyRefreshResult extends SpinifyReply @override String get type => 'RefreshResult'; + @override + bool get hasId => true; + /// Unique client connection ID server issued to this connection final String client; @@ -388,6 +427,9 @@ final class SpinifySubRefreshResult extends SpinifyReply @override String get type => 'SubRefreshResult'; + @override + bool get hasId => true; + /// Whether a server will expire subscription at some point final bool expires; @@ -413,6 +455,9 @@ final class SpinifyErrorResult extends SpinifyReply @override String get type => 'ErrorResult'; + @override + bool get hasId => true; + /// Error code. final int code; diff --git a/lib/src/model/state.dart b/lib/src/model/state.dart index 1d0142d..d0a3888 100644 --- a/lib/src/model/state.dart +++ b/lib/src/model/state.dart @@ -69,9 +69,6 @@ sealed class SpinifyState extends _$SpinifyStateBase { /// Permanently closed /// {@macro state} factory SpinifyState.closed({DateTime? timestamp}) = SpinifyState$Closed; - - @override - String toString() => type; } /// Disconnected @@ -132,6 +129,9 @@ final class SpinifyState$Disconnected extends SpinifyState { identical(this, other) || (other is SpinifyState$Disconnected && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => 'SpinifyState\$Disconnected{temporary: $temporary}'; } /// Connecting @@ -181,6 +181,9 @@ final class SpinifyState$Connecting extends SpinifyState { identical(this, other) || (other is SpinifyState$Connecting && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => 'SpinifyState\$Connecting{url: $url}'; } /// Connected @@ -271,6 +274,9 @@ final class SpinifyState$Connected extends SpinifyState { identical(this, other) || (other is SpinifyState$Connected && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => 'SpinifyState\$Connected{url: $url}'; } /// Permanently closed @@ -320,6 +326,9 @@ final class SpinifyState$Closed extends SpinifyState { identical(this, other) || (other is SpinifyState$Closed && other.timestamp.isAtSameMomentAs(timestamp)); + + @override + String toString() => r'SpinifyState$Closed{}'; } /// Pattern matching for [SpinifyState]. @@ -327,7 +336,8 @@ final class SpinifyState$Closed extends SpinifyState { typedef SpinifyStateMatch = R Function(S state); @immutable -abstract base class _$SpinifyStateBase { +abstract base class _$SpinifyStateBase + implements Comparable<_$SpinifyStateBase> { const _$SpinifyStateBase(this.timestamp); /// Represents the current state type. @@ -387,4 +397,8 @@ abstract base class _$SpinifyStateBase { connected: connected ?? (_) => null, closed: closed ?? (_) => null, ); + + @override + int compareTo(_$SpinifyStateBase other) => + timestamp.compareTo(other.timestamp); } diff --git a/lib/src/model/subscription_state.dart b/lib/src/model/subscription_state.dart index 4df305f..de732a4 100644 --- a/lib/src/model/subscription_state.dart +++ b/lib/src/model/subscription_state.dart @@ -34,12 +34,6 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { List? data, DateTime? timestamp, }) = SpinifySubscriptionState$Subscribed; - - /// Converts this state to JSON. - Map toJson(); - - @override - String toString() => type; } /// Unsubscribed state @@ -80,12 +74,6 @@ final class SpinifySubscriptionState$Unsubscribed }) => unsubscribed(this); - @override - Map toJson() => { - 'type': type, - 'timestamp': timestamp.toUtc().toIso8601String(), - }; - @override int get hashCode => 0 + timestamp.microsecondsSinceEpoch * 10; @@ -94,6 +82,9 @@ final class SpinifySubscriptionState$Unsubscribed identical(this, other) || other is SpinifySubscriptionState$Unsubscribed && other.timestamp.isAtSameMomentAs(timestamp); + + @override + String toString() => r'SpinifySubscriptionState$Unsubscribed{}'; } /// Subscribing state @@ -134,12 +125,6 @@ final class SpinifySubscriptionState$Subscribing }) => subscribing(this); - @override - Map toJson() => { - 'type': type, - 'timestamp': timestamp.toUtc().toIso8601String(), - }; - @override int get hashCode => 1 + timestamp.microsecondsSinceEpoch * 10; @@ -148,6 +133,9 @@ final class SpinifySubscriptionState$Subscribing identical(this, other) || other is SpinifySubscriptionState$Subscribing && other.timestamp.isAtSameMomentAs(timestamp); + + @override + String toString() => r'SpinifySubscriptionState$Subscribing{}'; } /// Subscribed state @@ -192,12 +180,6 @@ final class SpinifySubscriptionState$Subscribed }) => subscribed(this); - @override - Map toJson() => { - 'type': type, - 'timestamp': timestamp.toUtc().toIso8601String(), - }; - @override int get hashCode => 2 + timestamp.microsecondsSinceEpoch * 10; @@ -206,6 +188,9 @@ final class SpinifySubscriptionState$Subscribed identical(this, other) || other is SpinifySubscriptionState$Subscribed && other.timestamp.isAtSameMomentAs(timestamp); + + @override + String toString() => r'SpinifySubscriptionState$Subscribed{}'; } /// Pattern matching for [SpinifySubscriptionState]. @@ -214,7 +199,8 @@ typedef SpinifySubscriptionStateMatch = R Function(S state); @immutable -abstract base class _$SpinifySubscriptionStateBase { +abstract base class _$SpinifySubscriptionStateBase + implements Comparable<_$SpinifySubscriptionStateBase> { const _$SpinifySubscriptionStateBase({ required this.timestamp, }); @@ -277,4 +263,8 @@ abstract base class _$SpinifySubscriptionStateBase { subscribing: subscribing ?? (_) => null, subscribed: subscribed ?? (_) => null, ); + + @override + int compareTo(_$SpinifySubscriptionStateBase other) => + timestamp.compareTo(other.timestamp); } diff --git a/lib/src/model/subscription_states.dart b/lib/src/model/subscription_states.dart index c20ceb4..933b57f 100644 --- a/lib/src/model/subscription_states.dart +++ b/lib/src/model/subscription_states.dart @@ -9,18 +9,15 @@ import 'subscription_state.dart'; extension type SpinifySubscriptionStates( Stream _) implements Stream { /// Unsubscribed - SpinifySubscriptionStates unsubscribed( - {String? channel}) => - filter(); + SpinifySubscriptionStates + unsubscribed() => filter(); /// Subscribing - SpinifySubscriptionStates subscribing( - {String? channel}) => - filter(); + SpinifySubscriptionStates + subscribing() => filter(); /// Subscribed - SpinifySubscriptionStates subscribed( - {String? channel}) => + SpinifySubscriptionStates subscribed() => filter(); /// Filtered stream of [SpinifySubscriptionState]. diff --git a/lib/src/model/transport_interface.dart b/lib/src/model/transport_interface.dart index 9052150..a9e020d 100644 --- a/lib/src/model/transport_interface.dart +++ b/lib/src/model/transport_interface.dart @@ -1,33 +1,40 @@ -import 'command.dart'; -import 'config.dart'; -import 'metric.dart'; -import 'reply.dart'; +import 'dart:async'; -/// Create a Spinify transport -/// (e.g. WebSocket or gRPC with JSON or Protocol Buffers). -typedef SpinifyTransportBuilder = Future Function({ - /// URL for the connection - required String url, +import 'annotations.dart'; - /// Spinify client configuration - required SpinifyConfig config, +/// WebSocket interface. +abstract interface class WebSocket implements Sink> { + /// Stream of incoming messages. + abstract final Stream> stream; - /// Metrics - required SpinifyMetrics$Mutable metrics, + /// Close code. + /// May be `null` if connection still open. + int? get closeCode; - /// Callback for reply messages - required Future Function(SpinifyReply reply) onReply, + /// Close reason. + /// May be `null` if connection still open. + String? get closeReason; - /// Callback for disconnect event - required Future Function({required bool temporary}) onDisconnect, -}); + /// Is connection closed. + /// Returns `true` if connection closed. + /// After connection closed no more messages can be sent or received. + bool get isClosed; -/// Spinify transport interface. -abstract interface class ISpinifyTransport { - /// Send command to the server. - Future send(SpinifyCommand command); + /// Adds [data] to the sink. + /// Must not be called after a call to [close]. + @unsafe + @override + void add(List data); - /// Disconnect from the server. - /// Client if not needed anymore. - Future disconnect([int? code, String? reason]); + @safe + @override + void close([int? code, String? reason]); } + +/// Create a Spinify transport +/// (e.g. WebSocket or gRPC with JSON or Protocol Buffers). +typedef SpinifyTransportBuilder = Future Function({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}); diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index d76cde1..005abd9 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -1,21 +1,38 @@ -@internal import 'dart:convert'; -import 'package:meta/meta.dart'; +import 'package:protobuf/protobuf.dart' as pb; import '../model/channel_event.dart'; import '../model/client_info.dart'; +import '../model/codec.dart'; import '../model/command.dart'; import '../model/config.dart'; import '../model/reply.dart'; import '../model/stream_position.dart'; import 'client.pb.dart' as pb; -/// SpinifyCommand --> Protobuf Command encoder. -final class ProtobufCommandEncoder - extends Converter { +/// Default protobuf codec for Spinify. +final class SpinifyProtobufCodec implements SpinifyCodec { + /// Default protobuf codec for Spinify. + SpinifyProtobufCodec([SpinifyLogger? logger]) + : decoder = SpinifyProtobufReplyDecoder(logger), + encoder = SpinifyProtobufCommandEncoder(logger); + + @override + String get protocol => 'centrifuge-protobuf'; + + @override + final Converter, Iterable> decoder; + + @override + final Converter> encoder; +} + +/// SpinifyCommand --> List encoder. +final class SpinifyProtobufCommandEncoder + extends Converter> { /// SpinifyCommand --> List encoder. - const ProtobufCommandEncoder([this.logger]); + const SpinifyProtobufCommandEncoder([this.logger]); /// Logger function to use for logging. /// If not specified, the logger will be disabled. @@ -34,7 +51,7 @@ final class ProtobufCommandEncoder final SpinifyLogger? logger; @override - pb.Command convert(SpinifyCommand input) { + List convert(SpinifyCommand input) { final cmd = pb.Command(id: input.id); switch (input) { case SpinifySendRequest send: @@ -125,29 +142,24 @@ final class ProtobufCommandEncoder token: subRefresh.token, ); } - /* assert(() { - print('Command > ${cmd.toProto3Json()}'); - return true; - }()); */ - - /* final buffer = pb.CodedBufferWriter(); - pb.writeToCodedBufferWriter(buffer); - return buffer.toBuffer(); */ - - /* final commandData = cmd.writeToBuffer(); - final length = commandData.lengthInBytes; - final writer = pb.CodedBufferWriter() - ..writeInt32NoTag(length); //..writeRawBytes(commandData); - return writer.toBuffer() + commandData; */ - - return cmd; + final commandData = cmd.writeToBuffer(); + /* final writer = pb.CodedBufferWriter() + ..writeInt32NoTag( + commandData.lengthInBytes); //..writeRawBytes(commandData); + final bytes = writer.toBuffer() + commandData; + return bytes; */ + return (pb.CodedBufferWriter() + ..writeInt32NoTag(commandData.lengthInBytes) + ..writeRawBytes(commandData)) + .toBuffer(); } } -/// Protobuf Reply --> SpinifyReply decoder. -final class ProtobufReplyDecoder extends Converter { - /// List --> SpinifyCommand decoder. - const ProtobufReplyDecoder([this.logger]); +/// Protobuf List --> Iterable decoder. +final class SpinifyProtobufReplyDecoder + extends Converter, Iterable> { + /// List --> Iterable decoder. + const SpinifyProtobufReplyDecoder([this.logger]); /// Logger function to use for logging. /// If not specified, the logger will be disabled. @@ -166,38 +178,54 @@ final class ProtobufReplyDecoder extends Converter { final SpinifyLogger? logger; @override - SpinifyReply convert(pb.Reply input) { - //final reader = pb.CodedBufferReader(input); - //while (!reader.isAtEnd()) { - //final reply = pb.Reply(); - //reader.readMessage(reply, pb.ExtensionRegistry.EMPTY); - final reply = input; - - /* assert(() { - print('Reply < ${reply.toProto3Json()}'); - return true; - }()); */ - - if (reply.hasPush()) { - return _decodePush(reply.push); - } else if (reply.hasId() && reply.id > 0) { - return _decodeReply(reply); - } else if (reply.hasError()) { - final error = reply.error; - return SpinifyErrorResult( - id: reply.hasId() ? reply.id : 0, - timestamp: DateTime.now(), - code: error.code, - message: error.message, - temporary: error.temporary, - ); - } else { - return SpinifyServerPing( - timestamp: DateTime.now(), - ); + Iterable convert(List input) sync* { + if (input.isEmpty) return; + final reader = pb.CodedBufferReader(input); + while (!reader.isAtEnd()) { + try { + final message = pb.Reply(); + reader.readMessage(message, pb.ExtensionRegistry.EMPTY); + /* assert(() { + print('Reply < ${message.toProto3Json()}'); + return true; + }()); */ + if (message.hasPush()) { + yield _decodePush(message.push); + } else if (message.hasId() && message.id > 0) { + yield _decodeReply(message); + } else if (message.hasError()) { + // coverage:ignore-start + final error = message.error; + yield SpinifyErrorResult( + id: message.hasId() ? message.id : 0, + timestamp: DateTime.now(), + code: error.code, + message: error.message, + temporary: error.temporary, + ); + // coverage:ignore-end + } else { + yield SpinifyServerPing( + timestamp: DateTime.now(), + ); + } + } on Object catch (error, stackTrace) { + // coverage:ignore-start + logger?.call( + const SpinifyLogLevel.warning(), + 'protobuf_reply_decoder_error', + 'Error decoding reply', + { + 'error': error, + 'stackTrace': stackTrace, + 'input': input, + }, + ); + // coverage:ignore-end + } } - //} - //assert(reader.isAtEnd(), 'Data is not fully consumed'); + + assert(reader.isAtEnd(), 'Data is not fully consumed'); } /* @@ -432,6 +460,7 @@ final class ProtobufReplyDecoder extends Converter { pingInterval = Duration(seconds: ping); } else { assert(false, 'Ping interval is invalid'); // coverage:ignore-line + pingInterval = const Duration(seconds: 25); } return SpinifyConnectResult( id: id, diff --git a/lib/src/spinify.dart b/lib/src/spinify.dart new file mode 100644 index 0000000..6cd17f8 --- /dev/null +++ b/lib/src/spinify.dart @@ -0,0 +1,2787 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:fixnum/fixnum.dart' as fixnum; +import 'package:meta/meta.dart'; + +import 'model/annotations.dart'; +import 'model/channel_event.dart'; +import 'model/channel_events.dart'; +import 'model/client_info.dart'; +import 'model/codec.dart'; +import 'model/codes.dart'; +import 'model/command.dart'; +import 'model/config.dart'; +import 'model/constant.dart'; +import 'model/exception.dart'; +import 'model/history.dart'; +import 'model/metric.dart'; +import 'model/presence_stats.dart'; +import 'model/reply.dart'; +import 'model/state.dart'; +import 'model/states_stream.dart'; +import 'model/stream_position.dart'; +import 'model/subscription_config.dart'; +import 'model/subscription_state.dart'; +import 'model/subscription_states.dart'; +import 'model/transport_interface.dart'; +import 'protobuf/protobuf_codec.dart'; +import 'spinify_interface.dart'; +import 'subscription_interface.dart'; +import 'util/backoff.dart'; +import 'util/guarded.dart'; +import 'util/mutex.dart'; +import 'web_socket_stub.dart' + // ignore: uri_does_not_exist + if (dart.library.js_interop) 'web_socket_js.dart' + // ignore: uri_does_not_exist + if (dart.library.io) 'web_socket_vm.dart'; + +/// {@template spinify} +/// Spinify client for Centrifuge. +/// +/// Centrifugo SDKs use WebSocket as the main data transport and send/receive +/// messages encoded according to our bidirectional protocol. +/// That protocol is built on top of the Protobuf schema +/// (both JSON and binary Protobuf formats are supported). +/// It provides asynchronous communication, sending RPC, +/// multiplexing subscriptions to channels, etc. +/// +/// Client SDK wraps the protocol and exposes a set of APIs to developers. +/// +/// Client connection has 4 states: +/// - [SpinifyState$Disconnected] +/// - [SpinifyState$Connecting] +/// - [SpinifyState$Connected] +/// - [SpinifyState$Closed] +/// +/// {@endtemplate} +/// {@category Client} +final class Spinify implements ISpinify { + /// Create a new Spinify client. + /// + /// {@macro spinify} + @safe + Spinify({SpinifyConfig? config}) + : config = config ?? SpinifyConfig.byDefault(), + _codec = config?.codec ?? SpinifyProtobufCodec(), + _mutex = MutexImpl() /* MutexDisabled() */ { + /// Client initialization (from constructor). + _init(); + } + + /// Create client and connect. + /// + /// {@macro spinify} + @safe + factory Spinify.connect(String url, {SpinifyConfig? config}) => + Spinify(config: config)..connect(url).ignore(); + + /// Spinify config. + @safe + @override + final SpinifyConfig config; + + /// Mutex to protect client interactive operations. + final IMutex _mutex; + + @safe + @override + SpinifyMetrics get metrics => _metrics.freeze(); + + /// Codec to encode and decode messages for the [_transport]. + final SpinifyCodec _codec; + + /// Current WebSocket transport. + WebSocket? _transport; + StreamSubscription? _replySubscription; + + /// Internal mutable metrics. Also it's container for Spinify's state. + final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); + + @safe + @override + SpinifyState get state => _metrics.state; + + @safe + @override + bool get isClosed => _metrics.state.isClosed; + + @safe + @override + late final SpinifyStatesStream states = + SpinifyStatesStream(_statesController.stream); + + @safe + final StreamController _statesController = + StreamController.broadcast(sync: false); + + @override + late final SpinifyChannelEvents stream = + SpinifyChannelEvents(_eventController.stream); + + final StreamController _eventController = + StreamController.broadcast(sync: true); + + Completer? _readyCompleter; + Timer? _refreshTimer; + Timer? _reconnectTimer; + Timer? _healthTimer; + Timer? _pingTimer; + + /// Registry of client subscriptions. + final Map + _clientSubscriptionRegistry = {}; + + /// Registry of server subscriptions. + final Map + _serverSubscriptionRegistry = {}; + + @override + ({ + Map client, + Map server + }) get subscriptions => ( + client: UnmodifiableMapView( + _clientSubscriptionRegistry, + ), + server: UnmodifiableMapView( + _serverSubscriptionRegistry, + ), + ); + + /// Hash map of pending replies. + final Map _replies = {}; + + /// Log an event with the given [level], [event], [message] and [context]. + @safe + @protected + @nonVirtual + void _log( + SpinifyLogLevel level, + String event, + String message, + Map context, + ) { + try { + config.logger?.call(level, event, message, context); + } on Object {/* ignore */} + } + + /// Set a new state and notify listeners via [states]. + @safe + @protected + @nonVirtual + void _setState(SpinifyState state) { + if (isClosed) return; // Client is closed, do not notify about states. + final prev = _metrics.state, next = state; + // coverage:ignore-start + if (prev.type == next.type) { + // Should we notify about the same state? + switch ((prev, next)) { + case (SpinifyState$Connecting prev, SpinifyState$Connecting next): + if (prev.url == next.url) return; // The same + case (SpinifyState$Disconnected prev, SpinifyState$Disconnected next): + if (prev.temporary == next.temporary) return; // The same + case (SpinifyState$Closed _, SpinifyState$Closed _): + return; // Do not notify about closed states changes. + case (SpinifyState$Connected _, SpinifyState$Connected _): + break; // Always notify about connected states changes. + default: + break; // Notify about other states changes. + } + } + // coverage:ignore-end + _statesController.add(_metrics.state = next); + _log( + const SpinifyLogLevel.config(), + 'state_changed', + 'State changed from $prev to $next', + { + 'prev': prev, + 'next': next, + 'state': next, + }, + ); + } + + /// Counter for command messages. + @safe + @nonVirtual + int _getNextCommandId() { + if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; + return _metrics.commandId++; + } + + // --- Init --- // + + /// Initialization from constructor + @safe + @protected + @nonVirtual + void _init() { + _setUpHealthCheckTimer(); + _log( + const SpinifyLogLevel.info(), + 'init', + 'Spinify client initialized', + { + 'config': config, + }, + ); + } + + // --- Health checks --- // + + /// Set up health check timer. + @safe + @protected + @nonVirtual + void _setUpHealthCheckTimer() { + _tearDownHealthCheckTimer(); + // coverage:ignore-start + + void warning(String message) { + //debugger(); + _log( + const SpinifyLogLevel.warning(), + 'health_check_error', + message, + {}, + ); + } + + _healthTimer = Timer.periodic( + const Duration(seconds: 15), + (_) { + if (_statesController.isClosed) { + warning('Health check failed: states controller is closed'); + } + if (_eventController.isClosed) { + warning('Health check failed: event controller is closed'); + } + switch (state) { + case SpinifyState$Disconnected state: + if (state.temporary) { + if (_metrics.reconnectUrl == null) { + warning('Health check failed: no reconnect URL set'); + _setState(SpinifyState$Disconnected(temporary: false)); + } else if (_reconnectTimer == null) { + warning('Health check failed: no reconnect timer set'); + _setUpReconnectTimer(); + } + } + if (_refreshTimer != null) { + warning( + 'Health check failed: refresh timer set but not connected'); + } + if (_transport != null || _replySubscription != null) { + warning('Health check failed: transport is not closed'); + _internalDisconnect( + code: const SpinifyDisconnectCode.abnormalClosure(), + reason: 'abnormal closure', + reconnect: false, + ); + } + case SpinifyState$Connecting _: + if (_refreshTimer != null) { + warning('Health check failed: refresh timer set during connect'); + } + case SpinifyState$Connected _: + if (_refreshTimer == null) { + warning('Health check failed: no refresh timer set'); + _setUpRefreshConnection(); + } + case SpinifyState$Closed _: + warning('Health check failed: health check should be stopped'); + } + }, + ); + + // coverage:ignore-end + } + + /// Tear down health check timer. + @safe + @protected + @nonVirtual + void _tearDownHealthCheckTimer() { + _healthTimer?.cancel(); + _healthTimer = null; + } + + /// Set up refresh connection timer. + @safe + @protected + @nonVirtual + void _setUpRefreshConnection() { + _tearDownRefreshConnection(); + if (state + case SpinifyState$Connected( + :String url, + :bool expires, + :DateTime? ttl, + :String? node, + :Duration? pingInterval, + :bool? sendPong, + :String? session, + :List? data, + ) when expires && ttl != null) { + // coverage:ignore-start + final duration = ttl.difference(DateTime.now()) - config.timeout; + if (duration < Duration.zero) { + _log( + const SpinifyLogLevel.warning(), + 'refresh_connection_cancelled', + 'Spinify token TTL is too short for refresh connection', + { + 'url': url, + 'duration': duration, + 'ttl': ttl, + }, + ); + assert(false, 'Token TTL is too short'); + return; + } + // coverage:ignore-end + _refreshTimer = Timer(duration, () async { + if (!state.isConnected) return; + final token = await config.getToken?.call(); + if (token == null || token.isEmpty) { + _log( + const SpinifyLogLevel.warning(), + 'refresh_connection_cancelled', + 'Spinify token is null or empty for refresh connection', + { + 'url': url, + 'token': token, + }, + ); + return; + } + final request = SpinifyRefreshRequest( + id: _getNextCommandId(), + timestamp: DateTime.now(), + token: token, + ); + final SpinifyRefreshResult result; + try { + result = await _sendCommand(request); + _setState(SpinifyState$Connected( + url: url, + client: result.client, + version: result.version, + expires: result.expires, + ttl: result.ttl, + node: node, + pingInterval: pingInterval, + sendPong: sendPong, + session: session, + data: data, + )); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.error(), + 'refresh_connection_error', + 'Error refreshing connection', + { + 'url': url, + 'command': request, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + return; + } finally { + if (state.isConnected) _setUpRefreshConnection(); + } + _log( + const SpinifyLogLevel.config(), + 'refresh_connection_success', + 'Successfully refreshed connection to $url', + { + 'request': request, + 'result': result, + }, + ); + }); + } + } + + /// Tear down refresh connection timer. + @safe + @protected + @nonVirtual + void _tearDownRefreshConnection() { + _refreshTimer?.cancel(); + _refreshTimer = null; + } + + /// Set up reconnect timer. + @safe + @protected + @nonVirtual + void _setUpReconnectTimer() { + final lastUrl = _metrics.reconnectUrl; + _tearDownReconnectTimer(); + if (lastUrl == null) return; + final attempt = _metrics.reconnectAttempts ?? 0; + final delay = Backoff.nextDelay( + attempt, + config.connectionRetryInterval.min.inMilliseconds, + config.connectionRetryInterval.max.inMilliseconds, + ); + _metrics.nextReconnectAt = DateTime.now().add(delay); + _log( + const SpinifyLogLevel.debug(), + 'reconnect_delayed', + 'Setting up reconnect timer to $lastUrl ' + 'after ${delay.inMilliseconds} ms.', + { + 'url': lastUrl, + 'delay': delay, + 'attempt': attempt, + }, + ); + _reconnectTimer = Timer( + delay, + () { + //_nextReconnectionAttempt = null; + if (!state.isDisconnected) return; + _metrics.reconnectAttempts = attempt + 1; + _log( + const SpinifyLogLevel.config(), + 'reconnect_attempt', + 'Reconnecting to $lastUrl after ${delay.inMilliseconds} ms.', + { + 'url': lastUrl, + 'delay': delay, + }, + ); + try { + _internalReconnect(lastUrl); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.error(), + 'reconnect_error', + 'Error reconnecting to $lastUrl', + { + 'url': lastUrl, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + }, + ); + } + + /// Tear down reconnect timer. + @safe + @protected + @nonVirtual + void _tearDownReconnectTimer() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + + /// Start or restart keepalive timer, + /// you should restart it after each received ping message. + /// Or connection will be closed by timeout. + @safe + @protected + @nonVirtual + void _setUpPingTimer() { + _tearDownPingTimer(); + // coverage:ignore-start + if (isClosed || !state.isConnected) return; + // coverage:ignore-end + if (state case SpinifyState$Connected(:Duration? pingInterval) + when pingInterval != null && pingInterval > Duration.zero) { + _pingTimer = Timer( + pingInterval + config.serverPingDelay, + () async { + // Reconnect if no pong received. + if (state case SpinifyState$Connected(:String url)) { + _log( + const SpinifyLogLevel.warning(), + 'no_pong_reconnect', + 'No pong from server - reconnecting', + { + 'url': url, + 'pingInterval': pingInterval, + 'serverPingDelay': config.serverPingDelay, + }, + ); + try { + _internalDisconnect( + code: const SpinifyDisconnectCode.noPingFromServer(), + reason: 'no ping from server', + reconnect: true, + ); + } finally { + _internalReconnect(url).ignore(); + } + } + /* disconnect( + SpinifyConnectingCode.noPing, + 'No ping from server', + ); */ + }, + ); + } + } + + /// Tear down ping timer. + @safe + @protected + @nonVirtual + void _tearDownPingTimer() { + _pingTimer?.cancel(); + _pingTimer = null; + } + + // --- Ready --- // + + @unsafe + @override + @Throws([SpinifyConnectionException]) + Future ready() => asyncGuarded(() async { + const error = SpinifyConnectionException( + message: 'Connection is closed permanently', + ); + if (state.isConnected) return; + if (state.isClosed) throw error; + try { + await (_readyCompleter ??= Completer()).future; + } on SpinifyConnectionException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyConnectionException( + message: 'Failed to wait for connection', + error: error, + ), + stackTrace, + ); + } + }); + + /// Plan to do action when client is connected. + @unsafe + @nonVirtual + Future _doOnReady(Future Function() action) => switch (state) { + SpinifyState$Connected _ => action(), + SpinifyState$Connecting _ => ready().then((_) => action()), + SpinifyState$Disconnected _ => Future.error( + const SpinifyConnectionException(message: 'Disconnected'), + StackTrace.current, + ), + SpinifyState$Closed _ => Future.error( + const SpinifyConnectionException(message: 'Closed'), + StackTrace.current, + ), + }; + + // --- Connection --- // + + @unsafe + @protected + @nonVirtual + Future _webSocketConnect({ + required String url, + Map? headers, + Iterable? protocols, + }) => + (config.transportBuilder ?? $webSocketConnect)( + url: url, + headers: headers, + protocols: protocols, + ); + + @unsafe + @override + @nonVirtual + @Throws([SpinifyConnectionException]) + Future connect(String url) async { + try { + await _mutex.lock(); + await _interactiveConnect(url); + } on SpinifyConnectionException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyConnectionException( + message: 'Failed to connect to server', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + } + + /// User initiated connect. + @unsafe + @protected + @nonVirtual + Future _interactiveConnect(String url) async { + if (isClosed) + throw const SpinifyConnectionException( + message: 'Client is closed permanently', + ); + if (state.isConnected || state.isConnecting) await _interactiveDisconnect(); + _setUpReconnectTimer(); + await _internalReconnect(url); + } + + /// Library initiated connect. + @unsafe + @protected + @nonVirtual + Future _internalReconnect(String url) => asyncGuarded(() async { + if (state.isConnected || state.isConnecting) { + _internalDisconnect( + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', + reconnect: false, + ); + } + final readyCompleter = _readyCompleter = switch (_readyCompleter) { + Completer value when !value.isCompleted => value, + _ => Completer(), + }; + // We need this just to not receive any errors at zone + // if we are completeError before any future subscription. + // + // See more at [Completer.completeError] comments. + readyCompleter.future.ignore(); + try { + if (!state.isDisconnected) { + _log( + const SpinifyLogLevel.warning(), + 'reconnect_error', + 'Failed to reconnect: state is not disconnected', + { + 'state': state, + }, + ); + assert( + false, + 'State should be disconnected', + ); + return; + } + assert( + _transport == null, + 'Transport should be null', + ); + assert( + _replySubscription == null, + 'Reply subscription should be null', + ); + _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); + assert(state.isConnecting, 'State should be connecting'); + + void checkStillConnecting() { + // coverage:ignore-start + if (isClosed) { + _log( + const SpinifyLogLevel.warning(), + 'closed_during_connect_error', + 'Client is closed during connect', + {}, + ); + throw const SpinifyConnectionException( + message: 'Client is closed during connect', + ); + } else if (!state.isConnecting) { + _log( + const SpinifyLogLevel.warning(), + 'state_changed_during_connect_error', + 'State changed during connect', + { + 'state': state, + }, + ); + throw const SpinifyConnectionException( + message: 'State changed during connect', + ); + } else if (!identical(url, _metrics.reconnectUrl)) { + _log( + const SpinifyLogLevel.warning(), + 'url_changed_during_connect_error', + 'URL changed during connect', + { + 'url': url, + 'reconnectUrl': _metrics.reconnectUrl, + }, + ); + throw const SpinifyConnectionException( + message: 'URL changed during connect', + ); + } else if (readyCompleter.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'ready_completer_completed_error', + 'Ready completer is already completed', + { + 'readyCompleter': readyCompleter, + }, + ); + throw const SpinifyConnectionException( + message: 'Ready completer is already completed', + ); + } else if (!identical(_readyCompleter, readyCompleter)) { + _log( + const SpinifyLogLevel.warning(), + 'ready_completer_changed_error', + 'Ready completer changed during connect', + { + 'readyCompleter': _readyCompleter, + 'newReadyCompleter': readyCompleter, + }, + ); + throw const SpinifyConnectionException( + message: 'Ready completer changed during connect', + ); + } + // coverage:ignore-end + } + + checkStillConnecting(); + + // Prepare connect request. + final SpinifyConnectRequest request; + { + final token = await config.getToken?.call(); + final payload = await config.getPayload?.call(); + + checkStillConnecting(); + + final id = _getNextCommandId(); + final now = DateTime.now(); + request = SpinifyConnectRequest( + id: id, + timestamp: now, + token: token, + data: payload, + subs: { + for (final sub in _serverSubscriptionRegistry.values) + sub.channel: SpinifySubscribeRequest( + id: id, + timestamp: now, + channel: sub.channel, + recover: sub.recoverable, + epoch: sub.epoch, + offset: sub.offset, + token: null, + data: null, + positioned: null, + recoverable: null, + joinLeave: null, + ), + }, + name: config.client.name, + version: config.client.version, + ); + } + + checkStillConnecting(); + + // Create a new transport + final ws = _transport = await _webSocketConnect( + url: url, + headers: config.headers, + protocols: [_codec.protocol], + ); + + checkStillConnecting(); + + // Create handler for connect reply. + final connectResultCompleter = Completer(); + + SpinifyErrorResult? errorConnectResult; + // ignore: omit_local_variable_types + void Function(SpinifyReply reply) handleReply = (reply) { + if (connectResultCompleter.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'connect_result_error', + 'Connect result completer is already completed', + { + 'reply': reply, + }, + ); + } else if (reply is SpinifyConnectResult) { + connectResultCompleter.complete(reply); + } else if (reply is SpinifyErrorResult) { + connectResultCompleter.completeError(errorConnectResult = reply); + } else { + connectResultCompleter.completeError( + const SpinifyConnectionException( + message: 'Unexpected reply received', + ), + ); + } + }; + + void handleDone() { + assert(() { + if (!identical(ws, _transport)) { + _log( + const SpinifyLogLevel.warning(), + 'transport_closed_error', + 'Transport closed on different and not active transport', + { + 'transport': ws, + }, + ); + } + return true; + }(), '...'); + var WebSocket(:int? closeCode, :String? closeReason) = ws; + closeCode ??= 1000; + closeReason ??= 'no reason'; + final code = + SpinifyDisconnectCode(errorConnectResult?.code ?? closeCode); + final reason = errorConnectResult?.message ?? closeReason; + final reconnect = errorConnectResult?.temporary ?? code.reconnect; + _log( + const SpinifyLogLevel.transport(), + 'transport_disconnect', + 'Transport disconnected ' + '${reconnect ? 'temporarily' : 'permanently'} ' + 'with reason: $reason', + { + 'code': code, + 'reason': reason, + 'reconnect': reconnect, + }, + ); + _internalDisconnect( + code: code, + reason: reason, + reconnect: reconnect, + ); + } + + _replySubscription = ws.stream.transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + _metrics + ..bytesReceived += data.length + ..chunksReceived += 1; + for (final reply in _codec.decoder.convert(data)) { + _metrics.repliesDecoded += 1; + sink.add(reply); + } + }, + ), + ).listen( + (reply) { + assert(() { + if (!identical(ws, _transport)) { + _log( + const SpinifyLogLevel.warning(), + 'wrong_transport_error', + 'Reply received on different and not active transport', + { + 'transport': ws, + 'reply': reply, + }, + ); + } + return true; + }(), '...'); + + handleReply(reply); // Handle replies + }, + onDone: handleDone, + onError: (Object error, StackTrace stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'reply_error', + 'Error receiving reply', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + }, + cancelOnError: false, + ); + + await _sendCommandAsync(request); + + checkStillConnecting(); + + final result = await connectResultCompleter.future; + + checkStillConnecting(); + + if (!state.isConnecting) { + throw const SpinifyConnectionException( + message: 'Connection is not in connecting state', + ); + } else if (!identical(ws, _transport)) { + throw const SpinifyConnectionException( + message: 'Transport is not the same as created', + ); + } + + _setState(SpinifyState$Connected( + url: url, + client: result.client, + version: result.version, + expires: result.expires, + ttl: result.ttl, + node: result.node, + pingInterval: result.pingInterval, + sendPong: result.sendPong, + session: result.session, + data: result.data, + )); + + _onReply(result); // Handle connect reply + handleReply = _onReply; // Switch to normal reply handler + + _tearDownReconnectTimer(); // Cancel reconnect timer + _setUpRefreshConnection(); // Start refresh connection timer + _setUpPingTimer(); // Start expecting ping messages + + // Notify ready. + if (readyCompleter.isCompleted) { + throw const SpinifyConnectionException( + message: 'Ready completer is already completed. Why so?', + ); + } else { + readyCompleter.complete(); + _readyCompleter = null; + } + + _metrics.lastConnectAt = DateTime.now(); + _metrics.connects++; + + _log( + const SpinifyLogLevel.config(), + 'connected', + 'Connected to server with $url successfully', + { + 'url': url, + 'request': request, + 'result': result, + }, + ); + } on Object catch ($error, stackTrace) { + final SpinifyConnectionException error; + if ($error is SpinifyConnectionException) { + error = $error; + } else { + error = SpinifyConnectionException( + message: 'Error connecting to server $url', + error: $error, + ); + } + if (!readyCompleter.isCompleted) + readyCompleter.completeError(error, stackTrace); + _readyCompleter = null; + _log( + const SpinifyLogLevel.error(), + 'connect_error', + 'Error connecting to server $url', + { + 'url': url, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + + final transport = _transport; // Close transport + if (transport != null && !transport.isClosed) transport.close(); + _transport = null; + + switch ($error) { + case SpinifyErrorResult result: + if (result.code == 109) { + // Token expired error. + _setUpReconnectTimer(); // Retry resubscribe + } else if (result.temporary) { + // Temporary error. + _setUpReconnectTimer(); // Retry resubscribe + } else { + // Disable resubscribe timer on permanent errors. + _metrics.reconnectUrl = null; + _tearDownReconnectTimer(); + _setState(SpinifyState$Disconnected(temporary: false)); + } + case SpinifyConnectionException _: + _setUpReconnectTimer(); // Some spinify exception - resubscribe + default: + _setUpReconnectTimer(); // Unknown error - resubscribe + } + + Error.throwWithStackTrace(error, stackTrace); + } + }); + + // --- Disconnection --- // + + @safe + @override + @nonVirtual + Future disconnect({bool force = false}) async { + try { + if (!force) { + await _mutex.lock(); + if (_replies.isNotEmpty) { + try { + await Future.wait( + _replies.values.map>((e) => e.future), + ).timeout(config.timeout); + } on Object {/* ignore */} + } + } + await _interactiveDisconnect(); + } finally { + if (!force) _mutex.unlock(); + } + } + + /// User initiated disconnect. + @safe + @protected + @nonVirtual + Future _interactiveDisconnect() async { + try { + _tearDownReconnectTimer(); + _tearDownPingTimer(); + _metrics.reconnectUrl = null; + _internalDisconnect( + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', + reconnect: false, + ); + } on Object catch (error, stackTrace) { + // coverage:ignore-start + // Normally we should not get here. + _log( + const SpinifyLogLevel.warning(), + 'disconnect_error', + 'Error on disconnect', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + // coverage:ignore-end + } + } + + /// Library initiated disconnect. + @safe + @protected + @nonVirtual + void _internalDisconnect({ + required int code, + required String reason, + required bool reconnect, + }) => + guarded( + () { + try { + _tearDownRefreshConnection(); + + // Unsuscribe from reply messages. + // To ignore last messages and done event from transport. + _replySubscription?.cancel().ignore(); + _replySubscription = null; + + // Close transport. + _transport?.close(code, reason); + _transport = null; + + // Update metrics. + _metrics.lastDisconnectAt = DateTime.now(); + _metrics.disconnects++; + + // Close all pending replies with error. + const error = SpinifyReplyException( + replyCode: 0, + replyMessage: 'Disconnected', + temporary: true, + ); + const stackTrace = StackTrace.empty; + for (final completer in _replies.values) { + if (completer.isCompleted) continue; + completer.completeError(error, stackTrace); + _log( + const SpinifyLogLevel.warning(), + 'disconnected_reply_error', + 'Reply for command ' + '${completer.command.type}{id: ${completer.command.id}} ' + 'error on disconnect', + { + 'command': completer.command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + _replies.clear(); + + // Complete ready completer with error, + // if we still waiting for connection. + if (_readyCompleter case Completer c when !c.isCompleted) { + c.completeError( + const SpinifyConnectionException( + message: 'Disconnected during connection', + ), + stackTrace, + ); + } + + // Reconnect if [reconnect] is true and we have reconnect URL. + if (_metrics.reconnectUrl != null) { + if (reconnect) { + _setUpReconnectTimer(); + } else { + _metrics.reconnectUrl = null; + } + } + + // Unsuscribe from all subscriptions. + for (final sub in _clientSubscriptionRegistry.values) { + // Internal unsubscribe without sending message. + sub + ._unsubscribe( + code: code, + reason: reason, + sendUnsubscribe: false, + ) + .ignore(); + } + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'disconnected_error', + 'Error on disconnect', + { + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + _setState(SpinifyState$Disconnected(temporary: reconnect)); + _log( + const SpinifyLogLevel.config(), + 'disconnected', + 'Disconnected from server ' + '${reconnect ? 'temporarily' : 'permanent'}', + { + 'temporary': reconnect, + }, + ); + }, + ignore: true, + ); + + // --- Close --- // + + @safe + @override + @nonVirtual + Future close({bool force = false}) async { + if (state.isClosed) return; + try { + if (!force) { + await _mutex.lock(); + if (_replies.isNotEmpty) { + try { + await Future.wait( + _replies.values.map>((e) => e.future), + ).timeout(config.timeout); + } on Object {/* ignore */} + } + } + _tearDownHealthCheckTimer(); + _internalDisconnect( + code: const SpinifyDisconnectCode.normalClosure(), + reason: 'normal closure', + reconnect: false, + ); + + // Close all client subscriptions. + final clientSubs = + _clientSubscriptionRegistry.values.toList(growable: false); + for (final sub in clientSubs) { + sub.close(); + _clientSubscriptionRegistry.remove(sub.channel); + } + + // Close all server subscriptions. + final serverSubs = + _serverSubscriptionRegistry.values.toList(growable: false); + for (final sub in serverSubs) { + sub.close(); + _serverSubscriptionRegistry.remove(sub.channel); + } + + _setState(SpinifyState$Closed()); + } on Object {/* ignore */} finally { + if (!force) _mutex.unlock(); + _statesController.close().ignore(); + _eventController.close().ignore(); + _log( + const SpinifyLogLevel.info(), + 'closed', + 'Closed', + { + 'state': state, + }, + ); + } + } + + // --- Send --- // + + @unsafe + @protected + @nonVirtual + @Throws([SpinifySendException]) + Future _sendCommandAsync(SpinifyCommand command) async { + _log( + const SpinifyLogLevel.debug(), + 'send_command_async_begin', + 'Comand ${command.type}{id: ${command.id}} sent async begin', + { + 'command': command, + }, + ); + try { + // coverage:ignore-start + assert(command.id > -1, 'Command ID should be greater or equal to 0'); + assert(_transport != null, 'Transport is not connected'); + assert(!state.isClosed, 'State is closed'); + // coverage:ignore-end + final bytes = _codec.encoder.convert(command); + _metrics.commandsEncoded += 1; + if (_transport == null) + throw const SpinifySendException(message: 'Transport is not connected'); + _transport?.add(bytes); + _metrics + ..bytesSent += bytes.length + ..chunksSent += 1; + _log( + const SpinifyLogLevel.config(), + 'send_command_async_success', + 'Command sent ${command.type}{id: ${command.id}} async successfully', + { + 'command': command, + }, + ); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'send_command_async_error', + 'Error sending command ${command.type}{id: ${command.id}} async', + { + 'command': command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + if (error is SpinifySendException) + rethrow; + else + Error.throwWithStackTrace( + SpinifySendException( + message: + 'Failed to send command ${command.type}{id: ${command.id}}', + ), + stackTrace, + ); + } + } + + @unsafe + @protected + @nonVirtual + @Throws([SpinifySendException]) + Future _sendCommand(SpinifyCommand command) async { + _log( + const SpinifyLogLevel.debug(), + 'send_command_begin', + 'Command ${command.type}{id: ${command.id}} sent begin', + { + 'command': command, + }, + ); + try { + // coverage:ignore-start + assert(command.id > -1, 'Command ID should be greater or equal to 0'); + assert(_replies[command.id] == null, 'Command ID should be unique'); + assert(_transport != null, 'Transport is not connected'); + assert(!state.isClosed, 'State is closed'); + // coverage:ignore-end + final bytes = _codec.encoder.convert(command); + _metrics.commandsEncoded += 1; + final pr = _replies[command.id] = _PendingReply(command); + if (_transport == null) + throw const SpinifySendException(message: 'Transport is not connected'); + _transport?.add(bytes); + _metrics + ..bytesSent += bytes.length + ..chunksSent += 1; + final result = await pr.future.timeout(config.timeout); + _log( + const SpinifyLogLevel.config(), + 'send_command_success', + 'Command ${command.type}{id: ${command.id}} sent successfully', + { + 'command': command, + 'result': result, + }, + ); + return result; + } on Object catch (error, stackTrace) { + if (_replies.remove(command.id) case _PendingReply pr + when !pr.isCompleted) { + pr.completeError( + SpinifyReplyException( + replyCode: 0, + replyMessage: 'Failed to send command', + temporary: true, + error: error, + ), + stackTrace, + ); + } + _log( + const SpinifyLogLevel.warning(), + 'send_command_error', + 'Error sending command ${command.type}{id: ${command.id}}', + { + 'command': command, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + if (error is SpinifySendException) + rethrow; + else + Error.throwWithStackTrace( + SpinifySendException( + message: + 'Failed to send command ${command.type}{id: ${command.id}}', + error: error, + ), + stackTrace, + ); + } + } + + @unsafe + @override + @nonVirtual + @Throws([SpinifySendException]) + Future send(List data) async { + await _mutex.lock(); + Future result; + try { + result = _doOnReady(() => _sendCommandAsync( + SpinifySendRequest( + timestamp: DateTime.now(), + data: data, + ), + )); + } on SpinifySendException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifySendException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifySendException(error: error), stackTrace); + } + } + + // --- Remote Procedure Call --- // + + @unsafe + @override + @nonVirtual + @Throws([SpinifyRPCException]) + Future> rpc(String method, [List? data]) async { + await _mutex.lock(); + Future result; + try { + result = _doOnReady(() => _sendCommand( + SpinifyRPCRequest( + id: _getNextCommandId(), + timestamp: DateTime.now(), + method: method, + data: data ?? const [], + ), + )); + } on SpinifyRPCException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifyRPCException(error: error), stackTrace); + } finally { + _mutex.unlock(); + } + try { + return await result.then((value) => value.data); + } on SpinifyRPCException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace(SpinifyRPCException(error: error), stackTrace); + } + } + + // --- Subscriptions and Channels --- // + + @safe + @override + @nonVirtual + SpinifySubscription? getSubscription(String channel) => + _clientSubscriptionRegistry[channel] ?? + _serverSubscriptionRegistry[channel]; + + @safe + @override + @nonVirtual + SpinifyClientSubscription? getClientSubscription(String channel) => + _clientSubscriptionRegistry[channel]; + + @safe + @override + @nonVirtual + SpinifyServerSubscription? getServerSubscription(String channel) => + _serverSubscriptionRegistry[channel]; + + @safe + @override + @nonVirtual + SpinifyClientSubscription newSubscription( + String channel, { + SpinifySubscriptionConfig? config, + bool subscribe = false, + }) { + assert( + channel.isNotEmpty, + 'Channel should not be empty', + ); + assert( + channel.trim() == channel, + 'Channel should not have leading or trailing spaces', + ); + assert( + channel.length <= 255, + 'Channel should not be longer than 255 characters', + ); + assert( + channel.codeUnits.every((code) => code >= 0 && code <= 0x7f), + 'Channel should contain only ASCII characters', + ); + + final sub = _clientSubscriptionRegistry[channel] ?? + _serverSubscriptionRegistry[channel]; + if (sub != null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_exists_error', + 'Subscription already exists', + { + 'channel': channel, + 'subscription': sub, + }, + ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription already exists', + ); + } + final newSub = + _clientSubscriptionRegistry[channel] = _SpinifyClientSubscriptionImpl( + client: this, + channel: channel, + config: config ?? const SpinifySubscriptionConfig.byDefault(), + ); + if (subscribe) newSub.subscribe(); + return newSub; + } + + @unsafe + @override + @Throws([SpinifySubscriptionException]) + Future removeSubscription( + SpinifyClientSubscription subscription, + ) async { + await _mutex.lock(); + final subFromRegistry = + _clientSubscriptionRegistry.remove(subscription.channel); + try { + await subFromRegistry?.unsubscribe(); + // coverage:ignore-start + assert( + subFromRegistry != null, + 'Subscription not found in the registry', + ); + assert( + identical(subFromRegistry, subscription), + 'Subscription should be the same instance as in the registry', + ); + // coverage:ignore-end + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_remove_error', + 'Error removing subscription', + { + 'channel': subscription.channel, + 'subscription': subscription, + }, + ); + if (error is SpinifySubscriptionException) + rethrow; + else + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: subscription.channel, + message: 'Error while unsubscribing', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + subFromRegistry?.close(); + } + } + + // --- Publish --- // + + @unsafe + @override + @Throws([SpinifyPublishException]) + Future publish(String channel, List data) async { + await _mutex.lock(); + Future result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, + ); + throw SpinifyPublishException( + channel: channel, + message: 'Subscription not found', + ); + } + result = sub.publish(data); + } on SpinifyPublishException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPublishException( + channel: channel, + message: 'Failed to publish data', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyPublishException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPublishException( + channel: channel, + message: 'Failed to publish data', + error: error, + ), + stackTrace, + ); + } + } + + // --- Presence --- // + + @unsafe + @override + @nonVirtual + @Throws([SpinifyPresenceException]) + Future> presence(String channel) async { + await _mutex.lock(); + Future> result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, + ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ); + } + result = sub.presence(); + } on SpinifyPresenceException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceException( + channel: channel, + message: 'Failed to get presence data', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyPresenceException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceException( + channel: channel, + message: 'Failed to get presence data', + error: error, + ), + stackTrace, + ); + } + } + + @unsafe + @override + @nonVirtual + @Throws([SpinifyPresenceStatsException]) + Future presenceStats(String channel) async { + await _mutex.lock(); + Future result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, + ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ); + } + result = sub.presenceStats(); + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } + } + + // --- History --- // + + @unsafe + @override + @nonVirtual + @Throws([SpinifyHistoryException]) + Future history( + String channel, { + int? limit, + SpinifyStreamPosition? since, + bool? reverse, + }) async { + await _mutex.lock(); + Future result; + try { + final sub = getSubscription(channel); + if (sub == null) { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription not found', + { + 'channel': channel, + }, + ); + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription not found', + ); + } + result = sub.history( + limit: limit, + since: since, + reverse: reverse, + ); + } on SpinifyHistoryException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyHistoryException( + channel: channel, + message: 'Failed to get history data', + error: error, + ), + stackTrace, + ); + } finally { + _mutex.unlock(); + } + try { + return await result; + } on SpinifyHistoryException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyHistoryException( + channel: channel, + message: 'Failed to get history data', + error: error, + ), + stackTrace, + ); + } + } + + // --- Replies --- // + + @safe + @sideEffect + @nonVirtual + void _onEvent(SpinifyChannelEvent event) { + _eventController.add(event); // Add event to the broadcast stream. + _log( + const SpinifyLogLevel.debug(), + 'push_received', + 'Push ${event.type} received', + { + 'event': event, + }, + ); + switch (event) { + case SpinifyChannelEvent(channel: ''): + /* ignore push without channel */ + break; + case SpinifyDisconnect disconnect: + _internalDisconnect( + code: disconnect.code, + reason: disconnect.reason, + reconnect: disconnect.reconnect, + ); + case SpinifySubscribe _: + // Add server subscription to the registry on subscribe event. + _serverSubscriptionRegistry.putIfAbsent( + event.channel, + () => _SpinifyServerSubscriptionImpl( + client: this, + channel: event.channel, + recoverable: event.recoverable, + epoch: event.since.epoch, + offset: event.since.offset, + )) + ..onEvent(event) + .._setState(SpinifySubscriptionState.subscribed(data: event.data)); + case SpinifyUnsubscribe _: + // Remove server subscription from the registry. + _serverSubscriptionRegistry.remove(event.channel) + ?..onEvent(event) + .._setState(SpinifySubscriptionState.unsubscribed()); + // Unsubscribe client subscription on unsubscribe event. + if (_clientSubscriptionRegistry[event.channel] + case _SpinifyClientSubscriptionImpl subscription) { + subscription.onEvent(event); + if (event.code < 2500) { + // Unsubscribe client subscription on unsubscribe event. + subscription + ._unsubscribe( + code: event.code, + reason: event.reason, + sendUnsubscribe: false, + ) + .ignore(); + } else { + // Resubscribe client subscription on unsubscribe event. + subscription._resubscribe().ignore(); + } + } + default: + // Notify subscription about new event. + final sub = _serverSubscriptionRegistry[event.channel] ?? + _clientSubscriptionRegistry[event.channel]; + if (sub != null) { + sub.onEvent(event); + if (event is SpinifyPublication && sub.recoverable) { + // Update subscription offset on publication. + if (event.offset case fixnum.Int64 newOffset when newOffset > 0) + sub.offset = newOffset; + } + } else { + _log( + const SpinifyLogLevel.warning(), + 'subscription_not_found_error', + 'Subscription ${event.channel} not found for event', + { + 'channel': event.channel, + 'event': event, + }, + ); + } + } + } + + /// Called when [SpinifyReply] received from the server. + @safe + @sideEffect + @nonVirtual + void _onReply(SpinifyReply reply) { + try { + // coverage:ignore-start + if (reply.id < 0 || reply.id > _metrics.commandId) { + _log( + const SpinifyLogLevel.warning(), + 'reply_id_error', + 'Reply ID out of range', + { + 'reply': reply, + }, + ); + return; + } + // coverage:ignore-end + + // If reply is a result then find pending reply and complete it. + if (reply.isResult) { + if (reply.id case int id when id > 0) { + final completer = _replies.remove(id); + if (completer == null) { + // Thats okay, we can send some commands asynchronously + // and do not wait for reply. + // E.g. connection command or ping command. + } else if (completer.isCompleted) { + _log( + const SpinifyLogLevel.warning(), + 'reply_completer_error', + 'Reply completer already completed', + { + 'reply': reply, + }, + ); + return; + } else if (reply is SpinifyErrorResult) { + completer.completeError( + SpinifyReplyException( + replyCode: reply.code, + replyMessage: reply.message, + temporary: reply.temporary, + ), + StackTrace.current, + ); + } else { + completer.complete(reply); + } + } + } + + // Handle different types of replies. + switch (reply) { + case SpinifyPush push: + _onEvent(push.event); + case SpinifyServerPing _: + final command = SpinifyPingRequest(timestamp: DateTime.now()); + _metrics + ..lastPingAt = command.timestamp + ..receivedPings = _metrics.receivedPings + 1; + if (state case SpinifyState$Connected(:bool sendPong) when sendPong) { + // No need to handle error in a special way - + // if pong can't be sent but connection is closed anyway. + _sendCommandAsync(command).ignore(); + } + _log( + const SpinifyLogLevel.debug(), + 'server_ping_received', + 'Ping from server received, pong sent', + { + 'ping': reply, + 'pong': command, + }, + ); + _setUpPingTimer(); + case SpinifyConnectResult _: + // Update server subscriptions. + final newServerSubs = + reply.subs ?? {}; + for (final entry in newServerSubs.entries) { + final MapEntry( + key: channel, + value: value + ) = entry; + final sub = _serverSubscriptionRegistry.putIfAbsent( + channel, + () => _SpinifyServerSubscriptionImpl( + client: this, + channel: channel, + recoverable: value.recoverable, + epoch: value.since.epoch, + offset: value.since.offset, + )) + .._setState( + SpinifySubscriptionState.subscribed(data: value.data)); + + // Notify about new publications. + for (var publication in value.publications) { + // If publication has wrong channel, fix it. + // Thats a workaround because we do not have channel + // in the publication in this server SpinifyConnectResult reply. + if (publication.channel != channel) { + // coverage:ignore-start + assert( + publication.channel.isEmpty, + 'Publication contains wrong channel', + ); + // coverage:ignore-end + publication = publication.copyWith(channel: channel); + } + _eventController.add(publication); + sub.onEvent(publication); + // Update subscription offset on publication. + if (sub.recoverable) { + if (publication.offset case fixnum.Int64 newOffset + when newOffset > sub.offset) { + sub.offset = newOffset; + } + } + } + } + + // Remove server subscriptions that are not in the new list. + final currentServerSubs = _serverSubscriptionRegistry.keys.toSet(); + for (final key in currentServerSubs) { + if (newServerSubs.containsKey(key)) continue; + _serverSubscriptionRegistry.remove(key) + ?.._setState(SpinifySubscriptionState.unsubscribed()) + ..close(); + } + + // We should resubscribe client subscriptions here. + for (final subscription in _clientSubscriptionRegistry.values) + subscription._resubscribe().ignore(); + case SpinifyErrorResult _: + break; + case SpinifySubscribeResult _: + break; + case SpinifyUnsubscribeResult _: + break; + case SpinifyPublishResult _: + break; + case SpinifyPresenceResult _: + break; + case SpinifyPresenceStatsResult _: + break; + case SpinifyHistoryResult _: + break; + case SpinifyPingResult _: + break; + case SpinifyRPCResult _: + break; + case SpinifyRefreshResult _: + break; + case SpinifySubRefreshResult _: + break; + } + + _log( + const SpinifyLogLevel.debug(), + 'reply', + 'Reply ${reply.type}{id: ${reply.id}} received', + { + 'reply': reply, + }, + ); + } on Object catch (error, stackTrace) { + _log( + const SpinifyLogLevel.warning(), + 'reply_error', + 'Error processing reply', + { + 'reply': reply, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + } + } +} + +/// Pending reply. +class _PendingReply { + _PendingReply(this.command) : _completer = Completer(); + + final SpinifyCommand command; // Command that was sent. + + final Completer _completer; // Completer for the reply. + + Future get future => _completer.future; // Future for the reply. + + bool get isCompleted => _completer.isCompleted; // Is reply received. + + void complete(R reply) => _completer.complete(reply); // Complete reply. + + void completeError(SpinifyReplyException error, StackTrace stackTrace) => + _completer.completeError(error, stackTrace); // Complete with error. +} + +abstract base class _SpinifySubscriptionBase implements SpinifySubscription { + _SpinifySubscriptionBase({ + required Spinify client, + required this.channel, + required this.recoverable, + required this.epoch, + required this.offset, + }) : _client = client { + _metrics = _client._metrics.channels + .putIfAbsent(channel, SpinifyMetrics$Channel$Mutable.new); + } + + @override + final String channel; + + /// Spinify client + final Spinify _client; + + /// Spinify channel metrics. + late final SpinifyMetrics$Channel$Mutable _metrics; + + final StreamController _stateController = + StreamController.broadcast(sync: false); + + final StreamController _eventController = + StreamController.broadcast(sync: true); + + Future _sendCommand( + SpinifyCommand Function(int nextId) builder, + ) => + _client._doOnReady( + () => _client._sendCommand( + builder(_client._getNextCommandId()), + ), + ); + + @override + bool recoverable; + + @override + String epoch; + + @override + fixnum.Int64 offset; + + @override + SpinifySubscriptionState get state => _metrics.state; + + @override + SpinifySubscriptionStates get states => + SpinifySubscriptionStates(_stateController.stream); + + @override + SpinifyChannelEvents get stream => + SpinifyChannelEvents(_eventController.stream); + + /// Receives notification about new event from the client. + /// Available only for internal use. + @internal + @sideEffect + @mustCallSuper + void onEvent(SpinifyChannelEvent event) { + // coverage:ignore-start + assert( + event.channel == channel, + 'Subscription "$channel" received event for another channel', + ); + // coverage:ignore-end + _eventController.add(event); + _client._log( + const SpinifyLogLevel.debug(), + 'subscription_event_received', + 'Subscription "$channel" received ${event.type} event', + { + 'channel': channel, + 'subscription': this, + 'event': event, + if (event is SpinifyPublication) 'publication': event, + }, + ); + } + + @mustCallSuper + void _setState(SpinifySubscriptionState state) { + final previous = _metrics.state; + if (previous.type == state.type) return; + _stateController.add(_metrics.state = state); + _client._log( + const SpinifyLogLevel.config(), + 'subscription_state_changed', + 'Subscription "$channel" state changed to ${state.type}', + { + 'channel': channel, + 'subscription': this, + 'previous': previous, + 'state': state, + }, + ); + } + + @interactive + @mustCallSuper + void close() { + _setState(SpinifySubscriptionState$Unsubscribed()); + _stateController.close().ignore(); + _eventController.close().ignore(); + } + + @unsafe + @override + @interactive + Future ready() async { + if (_client.isClosed) + throw SpinifySubscriptionException( + channel: channel, + message: 'Client is closed', + ); + if (_metrics.state.isSubscribed) return; + if (_stateController.isClosed) + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription is closed permanently', + ); + final state = await _stateController.stream + .firstWhere((state) => !state.isSubscribing); + if (!state.isSubscribed) + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription failed to subscribe', + ); + } + + @unsafe + @override + @interactive + @Throws([SpinifyHistoryException]) + Future history({ + int? limit, + SpinifyStreamPosition? since, + bool? reverse, + }) async { + try { + final reply = await _sendCommand( + (id) => SpinifyHistoryRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + limit: limit, + since: since, + reverse: reverse, + ), + ); + return SpinifyHistory( + publications: List.unmodifiable( + reply.publications.map( + (pub) => + pub.channel != channel ? pub.copyWith(channel: channel) : pub, + ), + ), + since: reply.since, + ); + } on SpinifyHistoryException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyHistoryException( + channel: channel, + message: 'Failed to get history data', + error: error, + ), + stackTrace, + ); + } + } + + @unsafe + @override + @interactive + @Throws([SpinifyPresenceException]) + Future> presence() async { + try { + final reply = await _sendCommand( + (id) => SpinifyPresenceRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + ), + ); + return reply.presence; + } on SpinifyPresenceException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceException( + channel: channel, + message: 'Failed to get presence data', + error: error, + ), + stackTrace, + ); + } + } + + @unsafe + @override + @nonVirtual + @Throws([SpinifyPresenceStatsException]) + Future presenceStats() async { + try { + final reply = await _sendCommand( + (id) => SpinifyPresenceStatsRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + ), + ); + return SpinifyPresenceStats( + channel: channel, + clients: reply.numClients, + users: reply.numUsers, + ); + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } + } + + @override + @interactive + Future publish(List data) async { + try { + await _sendCommand( + (id) => SpinifyPublishRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + data: data, + ), + ); + } on SpinifyPresenceStatsException { + rethrow; + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + SpinifyPresenceStatsException( + channel: channel, + message: 'Failed to get presence stats', + error: error, + ), + stackTrace, + ); + } + } +} + +final class _SpinifyServerSubscriptionImpl extends _SpinifySubscriptionBase + implements SpinifyServerSubscription { + _SpinifyServerSubscriptionImpl({ + required super.client, + required super.channel, + required super.recoverable, + required super.epoch, + required super.offset, + }); +} + +final class _SpinifyClientSubscriptionImpl extends _SpinifySubscriptionBase + implements SpinifyClientSubscription { + _SpinifyClientSubscriptionImpl({ + required super.client, + required super.channel, + required this.config, + }) : super( + recoverable: config.recoverable, + epoch: config.since?.epoch ?? '', + offset: config.since?.offset ?? fixnum.Int64.ZERO, + ); + + @override + final SpinifySubscriptionConfig config; + + /// Whether the subscription should recover. + bool _recover = false; + + /// Interactively subscribes to the channel. + @override + @interactive + Future subscribe() async { + // Check if the client is connected + switch (_client.state) { + case SpinifyState$Connected _: + break; + case SpinifyState$Connecting _: + case SpinifyState$Disconnected _: + await _client.ready(); + case SpinifyState$Closed _: + throw SpinifySubscriptionException( + channel: channel, + message: 'Client is closed', + ); + } + + // Check if the subscription is already subscribed + switch (state) { + case SpinifySubscriptionState$Subscribed _: + return; + case SpinifySubscriptionState$Subscribing _: + await ready(); + case SpinifySubscriptionState$Unsubscribed _: + await _resubscribe(); + } + } + + /// Interactively unsubscribes from the channel. + @override + @interactive + Future unsubscribe([ + int code = 0, + String reason = 'unsubscribe called', + ]) => + _unsubscribe( + code: code, + reason: reason, + sendUnsubscribe: true, + ); + + /// Unsubscribes from the channel. + @unsafe + @Throws([SpinifySubscriptionException]) + Future _unsubscribe({ + required int code, + required String reason, + required bool sendUnsubscribe, + }) async { + final currentState = _metrics.state; + _tearDownResubscribeTimer(); + _tearDownRefreshSubscriptionTimer(); + if (currentState.isUnsubscribed) return; + _setState(SpinifySubscriptionState$Unsubscribed()); + _metrics.lastUnsubscribeAt = DateTime.now(); + _metrics.unsubscribes++; + try { + if (sendUnsubscribe && + currentState.isSubscribed && + _client.state.isConnected) { + await _sendCommand( + (id) => SpinifyUnsubscribeRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + ), + ); + } + } on Object catch (error, stackTrace) { + _client._log( + const SpinifyLogLevel.error(), + 'subscription_unsubscribe_error', + 'Subscription "$channel" failed to unsubscribe', + { + 'channel': channel, + 'subscription': this, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + _client._transport?.close(4, 'unsubscribe error'); + if (error is SpinifySubscriptionException) rethrow; + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: channel, + message: 'Error while unsubscribing', + error: error, + ), + stackTrace, + ); + } + } + + /// `SubscriptionImpl{}._resubscribe()` from `centrifuge` package + Future _resubscribe() async { + if (!_metrics.state.isUnsubscribed) return; + try { + _setState(SpinifySubscriptionState$Subscribing()); + + final token = await config.getToken?.call(); + // Token can be null if it is not required for subscription. + if (token != null && token.length <= 5) { + throw SpinifySubscriptionException( + channel: channel, + message: 'Subscription token is empty', + ); + } + + final data = await config.getPayload?.call(); + + final recover = + _recover && offset > fixnum.Int64.ZERO && epoch.isNotEmpty; + + final result = await _sendCommand( + (id) => SpinifySubscribeRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + token: token, + recoverable: recoverable, + recover: recover, + offset: recover ? offset : null, + epoch: recover ? epoch : null, + positioned: config.positioned, + joinLeave: config.joinLeave, + data: data, + ), + ); + + if (state.isUnsubscribed) { + _client._log( + const SpinifyLogLevel.debug(), + 'subscription_resubscribe_skipped', + 'Subscription "$channel" resubscribe skipped, ' + 'subscription is unsubscribed.', + { + 'channel': channel, + 'subscription': this, + }, + ); + await _unsubscribe( + code: 0, + reason: 'resubscribe skipped', + sendUnsubscribe: false, + ); + } + + // If subscription is recoverable and server sends recoverable flag + // then we should update epoch and offset values. + if (result.recoverable) { + _recover = true; + epoch = result.since.epoch; + offset = result.since.offset; + } + + _setState(SpinifySubscriptionState$Subscribed(data: result.data)); + + // Set up refresh subscription timer if needed. + if (result.expires) { + if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { + _setUpRefreshSubscriptionTimer(ttl: ttl); + } else { + // coverage:ignore-start + assert( + false, + 'Subscription "$channel" has invalid TTL: ${result.ttl}', + ); + // coverage:ignore-end + } + } + + // Handle received publications and update offset. + for (var pub in result.publications) { + if (pub.channel.isEmpty) pub = pub.copyWith(channel: channel); + _client._eventController.add(pub); + onEvent(pub); + if (pub.offset case fixnum.Int64 value when value > offset) { + offset = value; + } + } + + _onSubscribed(); // Successful subscription completed + + _client._log( + const SpinifyLogLevel.config(), + 'subscription_subscribed', + 'Subscription "$channel" subscribed', + { + 'channel': channel, + 'subscription': this, + }, + ); + } on Object catch (error, stackTrace) { + _client._log( + const SpinifyLogLevel.error(), + 'subscription_resubscribe_error', + 'Subscription "$channel" failed to resubscribe', + { + 'channel': channel, + 'subscription': this, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + switch (error) { + case SpinifyErrorResult result: + if (result.code == 109) { + _setUpResubscribeTimer(); // Token expired error, retry resubscribe + } else if (result.temporary) { + _setUpResubscribeTimer(); // Temporary error, retry resubscribe + } else { + // Disable resubscribe timer and unsubscribe + _unsubscribe( + code: result.code, + reason: result.message, + sendUnsubscribe: false, + ).ignore(); + } + case SpinifySubscriptionException _: + _setUpResubscribeTimer(); // Some spinify exception, retry resubscribe + rethrow; + default: + _setUpResubscribeTimer(); // Unknown error, retry resubscribe + } + Error.throwWithStackTrace( + SpinifySubscriptionException( + channel: channel, + message: 'Failed to resubscribe to "$channel"', + error: error, + ), + stackTrace, + ); + } + } + + /// Successful subscription completed. + void _onSubscribed() { + _tearDownResubscribeTimer(); + _metrics.lastSubscribeAt = DateTime.now(); + _metrics.subscribes++; + } + + /// Resubscribe timer. + Timer? _resubscribeTimer; + + /// Set up resubscribe timer. + void _setUpResubscribeTimer() { + _resubscribeTimer?.cancel(); + final attempt = _metrics.resubscribeAttempts ?? 0; + final delay = Backoff.nextDelay( + attempt, + _client.config.connectionRetryInterval.min.inMilliseconds, + _client.config.connectionRetryInterval.max.inMilliseconds, + ); + _metrics.resubscribeAttempts = attempt + 1; + if (delay <= Duration.zero) { + if (!state.isUnsubscribed) return; + _client._log( + const SpinifyLogLevel.config(), + 'subscription_resubscribe_attempt', + 'Resubscibing to $channel immediately.', + { + 'channel': channel, + 'delay': delay, + 'subscription': this, + 'attempts': attempt, + }, + ); + Future.sync(subscribe).ignore(); + return; + } + _client._log( + const SpinifyLogLevel.debug(), + 'subscription_resubscribe_delayed', + 'Setting up resubscribe timer for $channel ' + 'after ${delay.inMilliseconds} ms.', + { + 'channel': channel, + 'delay': delay, + 'subscription': this, + 'attempts': attempt, + }, + ); + _metrics.nextResubscribeAt = DateTime.now().add(delay); + _resubscribeTimer = Timer(delay, () { + if (!state.isUnsubscribed) return; + _client._log( + const SpinifyLogLevel.debug(), + 'subscription_resubscribe_attempt', + 'Resubscribing to $channel after ${delay.inMilliseconds} ms.', + { + 'channel': channel, + 'subscription': this, + 'attempts': attempt, + }, + ); + Future.sync(_resubscribe).ignore(); + }); + } + + /// Tear down resubscribe timer. + void _tearDownResubscribeTimer() { + _metrics + ..resubscribeAttempts = 0 + ..nextResubscribeAt = null; + _resubscribeTimer?.cancel(); + _resubscribeTimer = null; + } + + /// Refresh subscription timer. + Timer? _refreshTimer; + + /// Set up refresh subscription timer. + void _setUpRefreshSubscriptionTimer({required DateTime ttl}) { + _tearDownRefreshSubscriptionTimer(); + _metrics.ttl = ttl; + _refreshTimer = Timer(ttl.difference(DateTime.now()), _refreshToken); + } + + /// Tear down refresh subscription timer. + void _tearDownRefreshSubscriptionTimer() { + _refreshTimer?.cancel(); + _refreshTimer = null; + _metrics.ttl = null; + } + + /// Refresh subscription token. + void _refreshToken() => runZonedGuarded( + () async { + _tearDownRefreshSubscriptionTimer(); + if (!state.isSubscribed || !_client.state.isConnected) return; + final token = await config.getToken?.call(); + if (token == null || token.isEmpty) { + throw SpinifySubscriptionException( + channel: channel, + message: 'Token is empty', + ); + } + final result = await _sendCommand( + (id) => SpinifySubRefreshRequest( + id: id, + channel: channel, + timestamp: DateTime.now(), + token: token, + ), + ); + + DateTime? newTtl; + if (result.expires) { + if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { + newTtl = ttl; + _setUpRefreshSubscriptionTimer(ttl: ttl); + } else { + // coverage:ignore-start + assert( + false, + 'Subscription "$channel" has invalid TTL: ${result.ttl}', + ); + // coverage:ignore-end + } + } + + _client._log( + const SpinifyLogLevel.debug(), + 'subscription_refresh_token', + 'Subscription "$channel" token refreshed', + { + 'channel': channel, + 'subscription': this, + if (newTtl != null) 'ttl': newTtl, + }, + ); + }, + (error, stackTrace) { + _client._log( + const SpinifyLogLevel.error(), + 'subscription_refresh_token_error', + 'Subscription "$channel" failed to refresh token', + { + 'channel': channel, + 'subscription': this, + 'error': error, + 'stackTrace': stackTrace, + }, + ); + + // Calculate new TTL for refresh subscription timer + late final ttl = + DateTime.now().add(Backoff.nextDelay(0, 5 * 1000, 10 * 1000)); + switch (error) { + case SpinifyErrorResult result: + if (result.temporary) { + _setUpRefreshSubscriptionTimer(ttl: ttl); + } else { + // Disable refresh subscription timer and unsubscribe + _unsubscribe( + code: result.code, + reason: result.message, + sendUnsubscribe: true, + ).ignore(); + } + case SpinifySubscriptionException _: + _setUpRefreshSubscriptionTimer(ttl: ttl); + default: + _setUpRefreshSubscriptionTimer(ttl: ttl); + } + }, + ); +} diff --git a/lib/src/spinify_impl.dart b/lib/src/spinify_impl.dart deleted file mode 100644 index e59e361..0000000 --- a/lib/src/spinify_impl.dart +++ /dev/null @@ -1,1234 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:fixnum/fixnum.dart' as fixnum; -import 'package:meta/meta.dart'; - -import 'model/annotations.dart'; -import 'model/channel_event.dart'; -import 'model/channel_events.dart'; -import 'model/client_info.dart'; -import 'model/command.dart'; -import 'model/config.dart'; -import 'model/constant.dart'; -import 'model/exception.dart'; -import 'model/history.dart'; -import 'model/metric.dart'; -import 'model/presence_stats.dart'; -import 'model/reply.dart'; -import 'model/state.dart'; -import 'model/states_stream.dart'; -import 'model/stream_position.dart'; -import 'model/subscription_config.dart'; -import 'model/subscription_state.dart'; -import 'model/subscription_states.dart'; -import 'model/transport_interface.dart'; -import 'spinify_interface.dart'; -import 'subscription_interface.dart'; -import 'transport_ws_pb_stub.dart' - // ignore: uri_does_not_exist - if (dart.library.js_util) 'transport_ws_pb_js.dart' - // ignore: uri_does_not_exist - if (dart.library.io) 'transport_ws_pb_vm.dart'; -import 'util/backoff.dart'; - -part 'subscription_impl.dart'; - -/// Base class for Spinify client. -abstract base class SpinifyBase implements ISpinify { - /// Create a new Spinify client. - SpinifyBase({required this.config}) { - _init(); - } - - /// Counter for command messages. - int _getNextCommandId() { - if (_metrics.commandId == kMaxInt) _metrics.commandId = 1; - return _metrics.commandId++; - } - - @override - bool get isClosed => state.isClosed; - - /// Spinify config. - @override - @nonVirtual - final SpinifyConfig config; - - late final SpinifyTransportBuilder _createTransport; - ISpinifyTransport? _transport; - - final SpinifyMetrics$Mutable _metrics = SpinifyMetrics$Mutable(); - - /// Client initialization (from constructor). - @mustCallSuper - void _init() { - _createTransport = config.transportBuilder ?? $create$WS$PB$Transport; - config.logger?.call( - const SpinifyLogLevel.info(), - 'init', - 'Spinify client initialized', - { - 'config': config, - }, - ); - } - - /// On connect to the server. - @mustCallSuper - Future _onConnected() async {} - - @mustCallSuper - Future _onReply(SpinifyReply reply) async { - config.logger?.call( - const SpinifyLogLevel.debug(), - 'reply', - 'Reply ${reply.type}{id: ${reply.id}} received', - { - 'reply': reply, - }, - ); - } - - /// On disconnect from the server. - @mustCallSuper - Future _onDisconnected({required bool temporary}) async {} - - Future _doOnReady(Future Function() action) { - if (state.isConnected) return action(); - return ready().then((_) => action()); - } - - @override - Future close() async { - config.logger?.call( - const SpinifyLogLevel.info(), - 'closed', - 'Closed', - { - 'state': state, - }, - ); - } -} - -/// Base mixin for Spinify client state management. -base mixin SpinifyStateMixin on SpinifyBase { - @override - SpinifyState get state => _metrics.state; - - @override - late final SpinifyStatesStream states = - SpinifyStatesStream(_statesController.stream); - - @nonVirtual - final StreamController _statesController = - StreamController.broadcast(); - - @nonVirtual - void _setState(SpinifyState state) { - final previous = _metrics.state; - _statesController.add(_metrics.state = state); - config.logger?.call( - const SpinifyLogLevel.config(), - 'state_changed', - 'State changed from $previous to $state', - { - 'previous': previous, - 'state': state, - }, - ); - } - - @override - Future _onDisconnected({required bool temporary}) async { - await super._onDisconnected(temporary: temporary); - if (!state.isDisconnected) { - _setState(SpinifyState$Disconnected(temporary: temporary)); - config.logger?.call( - const SpinifyLogLevel.config(), - 'disconnected', - 'Disconnected from server', - {}, - ); - } - } - - @override - Future close() async { - await super.close(); - if (!state.isClosed) _setState(SpinifyState$Closed()); - await _statesController.close(); - } -} - -/// Base mixin for Spinify command sending. -base mixin SpinifyCommandMixin on SpinifyBase { - final Map completer})> - _replies = - completer})>{}; - - @override - Future send(List data) => _doOnReady( - () => _sendCommandAsync( - SpinifySendRequest( - timestamp: DateTime.now(), - data: data, - ), - ), - ); - - Future _sendCommand(SpinifyCommand command) async { - config.logger?.call( - const SpinifyLogLevel.debug(), - 'send_command_begin', - 'Command ${command.type}{id: ${command.id}} sent begin', - { - 'command': command, - }, - ); - try { - // coverage:ignore-start - assert(command.id > -1, 'Command ID should be greater or equal to 0'); - assert(_replies[command.id] == null, 'Command ID should be unique'); - assert(_transport != null, 'Transport is not connected'); - assert(!state.isClosed, 'State is closed'); - // coverage:ignore-end - final completer = Completer(); - _replies[command.id] = (command: command, completer: completer); - await _transport?.send(command); // await _sendCommandAsync(command); - final result = await completer.future.timeout(config.timeout); - config.logger?.call( - const SpinifyLogLevel.config(), - 'send_command_success', - 'Command ${command.type}{id: ${command.id}} sent successfully', - { - 'command': command, - 'result': result, - }, - ); - return result; - } on Object catch (error, stackTrace) { - final tuple = _replies.remove(command.id); - if (tuple != null && !tuple.completer.isCompleted) { - tuple.completer.completeError(error, stackTrace); - config.logger?.call( - const SpinifyLogLevel.warning(), - 'send_command_error', - 'Error sending command ${command.type}{id: ${command.id}}', - { - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - } - rethrow; - } - } - - Future _sendCommandAsync(SpinifyCommand command) async { - config.logger?.call( - const SpinifyLogLevel.debug(), - 'send_command_async_begin', - 'Comand ${command.type}{id: ${command.id}} sent async begin', - { - 'command': command, - }, - ); - try { - // coverage:ignore-start - assert(command.id > -1, 'Command ID should be greater or equal to 0'); - assert(_transport != null, 'Transport is not connected'); - assert(!state.isClosed, 'State is closed'); - // coverage:ignore-end - await _transport?.send(command); - config.logger?.call( - const SpinifyLogLevel.config(), - 'send_command_async_success', - 'Command sent ${command.type}{id: ${command.id}} async successfully', - { - 'command': command, - }, - ); - } on Object catch (error, stackTrace) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'send_command_async_error', - 'Error sending command ${command.type}{id: ${command.id}} async', - { - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - } - } - - @override - @sideEffect - Future _onReply(SpinifyReply reply) async { - // coverage:ignore-start - assert( - reply.id >= 0 && reply.id <= _metrics.commandId, - 'Reply ID should be greater or equal to 0 ' - 'and less or equal than command ID'); - // coverage:ignore-end - if (reply.isResult) { - if (reply.id case int id when id > 0) { - final completer = _replies.remove(id)?.completer; - // coverage:ignore-start - assert( - completer != null, - 'Reply completer not found', - ); - assert( - completer?.isCompleted == false, - 'Reply completer already completed', - ); - // coverage:ignore-end - if (reply is SpinifyErrorResult) { - completer?.completeError(SpinifyReplyException( - replyCode: reply.code, - replyMessage: reply.message, - temporary: reply.temporary, - )); - } else { - completer?.complete(reply); - } - } - } - await super._onReply(reply); - } - - @override - Future _onDisconnected({required bool temporary}) async { - late final error = StateError('Client is disconnected'); - late final stackTrace = StackTrace.current; - for (final tuple in _replies.values) { - if (tuple.completer.isCompleted) continue; - tuple.completer.completeError(error); - config.logger?.call( - const SpinifyLogLevel.warning(), - 'disconnected_reply_error', - 'Reply for command ${tuple.command.type}{id: ${tuple.command.id}} ' - 'error on disconnect', - { - 'command': tuple.command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - } - _replies.clear(); - await super._onDisconnected(temporary: temporary); - } -} - -/// Base mixin for Spinify subscription management. -base mixin SpinifySubscriptionMixin on SpinifyBase, SpinifyCommandMixin { - final StreamController _eventController = - StreamController.broadcast(); - - @override - late final SpinifyChannelEvents stream = - SpinifyChannelEvents(_eventController.stream); - - @override - ({ - Map client, - Map server - }) get subscriptions => ( - client: UnmodifiableMapView( - _clientSubscriptionRegistry), - server: UnmodifiableMapView( - _serverSubscriptionRegistry), - ); - - /// Registry of client subscriptions. - final Map _clientSubscriptionRegistry = - {}; - - /// Registry of server subscriptions. - final Map _serverSubscriptionRegistry = - {}; - - @override - SpinifySubscription? getSubscription(String channel) => - _clientSubscriptionRegistry[channel] ?? - _serverSubscriptionRegistry[channel]; - - @override - SpinifyClientSubscription? getClientSubscription(String channel) => - _clientSubscriptionRegistry[channel]; - - @override - SpinifyServerSubscription? getServerSubscription(String channel) => - _serverSubscriptionRegistry[channel]; - - @override - SpinifyClientSubscription newSubscription( - String channel, { - SpinifySubscriptionConfig? config, - bool subscribe = false, - }) { - final sub = _clientSubscriptionRegistry[channel] ?? - _serverSubscriptionRegistry[channel]; - if (sub != null) { - this.config.logger?.call( - const SpinifyLogLevel.warning(), - 'subscription_exists_error', - 'Subscription already exists', - { - 'channel': channel, - 'subscription': sub, - }, - ); - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription already exists', - ); - } - final newSub = - _clientSubscriptionRegistry[channel] = SpinifyClientSubscriptionImpl( - client: this, - channel: channel, - config: config ?? const SpinifySubscriptionConfig.byDefault(), - ); - if (subscribe) newSub.subscribe(); - return newSub; - } - - @override - Future removeSubscription( - SpinifyClientSubscription subscription) async { - final subFromRegistry = - _clientSubscriptionRegistry.remove(subscription.channel); - try { - await subFromRegistry?.unsubscribe(); - // coverage:ignore-start - assert( - subFromRegistry != null, - 'Subscription not found in the registry', - ); - assert( - identical(subFromRegistry, subscription), - 'Subscription should be the same instance as in the registry', - ); - // coverage:ignore-end - } on Object catch (error, stackTrace) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'subscription_remove_error', - 'Error removing subscription', - { - 'channel': subscription.channel, - 'subscription': subscription, - }, - ); - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: subscription.channel, - message: 'Error while unsubscribing', - error: error, - ), - stackTrace, - ); - } finally { - subFromRegistry?.close(); - } - } - - @override - Future _onReply(SpinifyReply reply) async { - await super._onReply(reply); - if (reply is SpinifyPush) { - // Add push to the stream. - final event = reply.event; - _eventController.add(event); // Add event to the broadcast stream. - config.logger?.call( - const SpinifyLogLevel.debug(), - 'push_received', - 'Push ${event.type} received', - { - 'event': event, - }, - ); - if (event.channel.isEmpty) { - /* ignore push without channel */ - } else if (event is SpinifySubscribe) { - // Add server subscription to the registry on subscribe event. - _serverSubscriptionRegistry.putIfAbsent( - event.channel, - () => SpinifyServerSubscriptionImpl( - client: this, - channel: event.channel, - recoverable: event.recoverable, - epoch: event.since.epoch, - offset: event.since.offset, - )) - ..onEvent(event) - .._setState(SpinifySubscriptionState.subscribed(data: event.data)); - } else if (event is SpinifyUnsubscribe) { - // Remove server subscription from the registry on unsubscribe event. - _serverSubscriptionRegistry.remove(event.channel) - ?..onEvent(event) - .._setState(SpinifySubscriptionState.unsubscribed()); - // Unsubscribe client subscription on unsubscribe event. - if (_clientSubscriptionRegistry[event.channel] - case SpinifyClientSubscriptionImpl subscription) { - subscription.onEvent(event); - if (event.code < 2500) { - // Unsubscribe client subscription on unsubscribe event. - subscription - ._unsubscribe( - code: event.code, - reason: event.reason, - sendUnsubscribe: false, - ) - .ignore(); - } else { - // Resubscribe client subscription on unsubscribe event. - subscription._resubscribe().ignore(); - } - } - } else { - // Notify subscription about new event. - final sub = _serverSubscriptionRegistry[event.channel] ?? - _clientSubscriptionRegistry[event.channel]; - sub?.onEvent(event); - if (sub == null) { - // coverage:ignore-start - assert( - false, - 'Subscription not found for event ${event.channel}', - ); - // coverage:ignore-end - config.logger?.call( - const SpinifyLogLevel.warning(), - 'subscription_not_found_error', - 'Subscription ${event.channel} not found for event', - { - 'channel': event.channel, - 'event': event, - }, - ); - } else if (event is SpinifyPublication && sub.recoverable) { - // Update subscription offset on publication. - if (event.offset case fixnum.Int64 newOffset when newOffset > 0) - sub.offset = newOffset; - } - } - } else if (reply is SpinifyConnectResult) { - // Update server subscriptions. - final newServerSubs = reply.subs ?? {}; - for (final entry in newServerSubs.entries) { - final MapEntry( - key: channel, - value: value - ) = entry; - final sub = _serverSubscriptionRegistry.putIfAbsent( - channel, - () => SpinifyServerSubscriptionImpl( - client: this, - channel: channel, - recoverable: value.recoverable, - epoch: value.since.epoch, - offset: value.since.offset, - )) - .._setState(SpinifySubscriptionState.subscribed(data: value.data)); - - // Notify about new publications. - for (var publication in value.publications) { - // If publication has wrong channel, fix it. - // Thats a workaround because we do not have channel - // in the publication in this server SpinifyConnectResult reply. - if (publication.channel != channel) { - // coverage:ignore-start - assert( - publication.channel.isEmpty, - 'Publication contains wrong channel', - ); - // coverage:ignore-end - publication = publication.copyWith(channel: channel); - } - _eventController.add(publication); - sub.onEvent(publication); - // Update subscription offset on publication. - if (sub.recoverable) { - if (publication.offset case fixnum.Int64 newOffset - when newOffset > sub.offset) { - sub.offset = newOffset; - } - } - } - } - - // Remove server subscriptions that are not in the new list. - final currentServerSubs = _serverSubscriptionRegistry.keys.toSet(); - for (final key in currentServerSubs) { - if (newServerSubs.containsKey(key)) continue; - _serverSubscriptionRegistry.remove(key) - ?.._setState(SpinifySubscriptionState.unsubscribed()) - ..close(); - } - - // We should resubscribe client subscriptions here. - for (final subscription in _clientSubscriptionRegistry.values) - subscription._resubscribe().ignore(); - } - } - - @override - Future close() async { - await super.close(); - final unsubscribed = SpinifySubscriptionState.unsubscribed(); - for (final sub in _clientSubscriptionRegistry.values) - sub - .._setState(unsubscribed) - ..close(); - for (final sub in _serverSubscriptionRegistry.values) - sub - .._setState(unsubscribed) - ..close(); - _clientSubscriptionRegistry.clear(); - _serverSubscriptionRegistry.clear(); - _eventController.close().ignore(); - } -} - -/// Base mixin for Spinify client connection management (connect & disconnect). -base mixin SpinifyConnectionMixin - on - SpinifyBase, - SpinifyCommandMixin, - SpinifyStateMixin, - SpinifySubscriptionMixin { - Timer? _reconnectTimer; - Completer? _readyCompleter; - - @protected - @nonVirtual - Timer? _refreshTimer; - - @override - Future connect(String url) async { - //if (state.url == url) return; - final completer = _readyCompleter = switch (_readyCompleter) { - Completer value when !value.isCompleted => value, - _ => Completer(), - }; - try { - if (state.isConnected || state.isConnecting) await disconnect(); - } on Object {/* ignore */} - assert(!completer.isCompleted, 'Completer should not be completed'); - assert(state.isDisconnected, 'State should be disconnected'); - try { - _setState(SpinifyState$Connecting(url: _metrics.reconnectUrl = url)); - assert(state.isConnecting, 'State should be connecting'); - - // Create new transport. - _transport = await _createTransport( - url: url, - config: config, - metrics: _metrics, - onReply: _onReply, - onDisconnect: _onDisconnected, - ); - // ..onReply = _onReply - // ..onDisconnect = () => _onDisconnected().ignore(); - - // Prepare connect request. - final SpinifyConnectRequest request; - { - final token = await config.getToken?.call(); - // coverage:ignore-start - assert(token == null || token.length > 5, 'Spinify JWT is too short'); - // coverage:ignore-end - final payload = await config.getPayload?.call(); - final id = _getNextCommandId(); - final now = DateTime.now(); - request = SpinifyConnectRequest( - id: id, - timestamp: now, - token: token, - data: payload, - subs: { - for (final sub in _serverSubscriptionRegistry.values) - sub.channel: SpinifySubscribeRequest( - id: id, - timestamp: now, - channel: sub.channel, - recover: sub.recoverable, - epoch: sub.epoch, - offset: sub.offset, - token: null, - data: null, - positioned: null, - recoverable: null, - joinLeave: null, - ), - }, - name: config.client.name, - version: config.client.version, - ); - } - - final reply = await _sendCommand(request); - - if (!state.isConnecting) - throw const SpinifyConnectionException( - message: 'Connection is not in connecting state', - ); - - _setState(SpinifyState$Connected( - url: url, - client: reply.client, - version: reply.version, - expires: reply.expires, - ttl: reply.ttl, - node: reply.node, - pingInterval: reply.pingInterval, - sendPong: reply.sendPong, - session: reply.session, - data: reply.data, - )); - - _setUpRefreshConnection(); - - // Notify ready. - if (!completer.isCompleted) completer.complete(); - _readyCompleter = null; - - await _onConnected(); - - config.logger?.call( - const SpinifyLogLevel.config(), - 'connected', - 'Connected to server with $url successfully', - { - 'url': url, - 'request': request, - 'result': reply, - }, - ); - } on Object catch (error, stackTrace) { - if (!completer.isCompleted) completer.completeError(error, stackTrace); - _readyCompleter = null; - config.logger?.call( - const SpinifyLogLevel.error(), - 'connect_error', - 'Error connecting to server $url', - { - 'url': url, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - - _transport?.disconnect().ignore(); - - switch (error) { - case SpinifyErrorResult result: - if (result.code == 109) { - // Token expired error. - _setUpReconnectTimer(); // Retry resubscribe - } else if (result.temporary) { - // Temporary error. - _setUpReconnectTimer(); // Retry resubscribe - } else { - // Disable resubscribe timer - //moveToUnsubscribed(result.code, result.message, false); - _setState(SpinifyState$Disconnected(temporary: false)); - } - case SpinifyConnectionException _: - _setUpReconnectTimer(); // Some spinify exception - retry resubscribe - rethrow; - default: - _setUpReconnectTimer(); // Unknown error - retry resubscribe - } - - Error.throwWithStackTrace( - SpinifyConnectionException( - message: 'Error connecting to server $url', - error: error, - ), - stackTrace, - ); - } - } - - void _setUpRefreshConnection() { - _refreshTimer?.cancel(); - if (state - case SpinifyState$Connected( - :String url, - :bool expires, - :DateTime? ttl, - :String? node, - :Duration? pingInterval, - :bool? sendPong, - :String? session, - :List? data, - ) when expires && ttl != null) { - final duration = ttl.difference(DateTime.now()) - config.timeout; - if (duration < Duration.zero) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'refresh_connection_cancelled', - 'Spinify token TTL is too short for refresh connection', - { - 'url': url, - 'duration': duration, - 'ttl': ttl, - }, - ); - // coverage:ignore-start - assert(false, 'Token TTL is too short'); - // coverage:ignore-end - return; - } - _refreshTimer = Timer(duration, () async { - if (!state.isConnected) return; - final token = await config.getToken?.call(); - if (token == null || token.isEmpty) { - // coverage:ignore-start - assert(token == null || token.length > 5, 'Spinify JWT is too short'); - // coverage:ignore-end - config.logger?.call( - const SpinifyLogLevel.warning(), - 'refresh_connection_cancelled', - 'Spinify JWT is empty or too short for refresh connection', - { - 'url': url, - 'token': token, - }, - ); - return; - } - final request = SpinifyRefreshRequest( - id: _getNextCommandId(), - timestamp: DateTime.now(), - token: token, - ); - final SpinifyRefreshResult result; - try { - result = await _sendCommand(request); - } on Object catch (error, stackTrace) { - config.logger?.call( - const SpinifyLogLevel.error(), - 'refresh_connection_error', - 'Error refreshing connection', - { - 'url': url, - 'command': request, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - return; - } - _setState(SpinifyState$Connected( - url: url, - client: result.client, - version: result.version, - expires: result.expires, - ttl: result.ttl, - node: node, - pingInterval: pingInterval, - sendPong: sendPong, - session: session, - data: data, - )); - _setUpRefreshConnection(); - config.logger?.call( - const SpinifyLogLevel.config(), - 'refresh_connection_success', - 'Successfully refreshed connection to $url', - { - 'request': request, - 'result': result, - }, - ); - }); - } - } - - @override - Future _onConnected() async { - await super._onConnected(); - _tearDownReconnectTimer(); - _metrics.lastConnectAt = DateTime.now(); - _metrics.connects++; - } - - void _setUpReconnectTimer() { - _reconnectTimer?.cancel(); - final lastUrl = _metrics.reconnectUrl; - if (lastUrl == null) return; - final attempt = _metrics.reconnectAttempts ?? 0; - final delay = Backoff.nextDelay( - attempt, - config.connectionRetryInterval.min.inMilliseconds, - config.connectionRetryInterval.max.inMilliseconds, - ); - _metrics.reconnectAttempts = attempt + 1; - if (delay <= Duration.zero) { - if (!state.isDisconnected) return; - config.logger?.call( - const SpinifyLogLevel.config(), - 'reconnect_attempt', - 'Reconnecting to $lastUrl immediately.', - { - 'url': lastUrl, - 'delay': delay, - 'attempt': attempt, - }, - ); - Future.sync(() => connect(lastUrl)).ignore(); - return; - } - config.logger?.call( - const SpinifyLogLevel.debug(), - 'reconnect_delayed', - 'Setting up reconnect timer to $lastUrl ' - 'after ${delay.inMilliseconds} ms.', - { - 'url': lastUrl, - 'delay': delay, - 'attempt': attempt, - }, - ); - _metrics.nextReconnectAt = DateTime.now().add(delay); - _reconnectTimer = Timer( - delay, - () { - //_nextReconnectionAttempt = null; - if (!state.isDisconnected) return; - config.logger?.call( - const SpinifyLogLevel.config(), - 'reconnect_attempt', - 'Reconnecting to $lastUrl after ${delay.inMilliseconds} ms.', - { - 'url': lastUrl, - 'delay': delay, - }, - ); - Future.sync(() => connect(lastUrl)).ignore(); - }, - ); - //connect(_reconnectUrl!); - } - - void _tearDownReconnectTimer() { - _metrics - ..reconnectAttempts = null - ..nextReconnectAt = null; - _reconnectTimer?.cancel(); - _reconnectTimer = null; - } - - @override - Future ready() async { - if (state.isConnected) return; - if (state.isClosed) - throw const SpinifyConnectionException( - message: 'Connection is closed permanently', - ); - return (_readyCompleter ??= Completer()).future; - } - - @override - Future disconnect() => - _disconnect(code: 1000, reason: 'disconnected by client'); - - /// Disconnect client from the server with optional reconnect and reason. - Future _disconnect( - {int? code, String? reason, bool reconnect = false}) async { - if (!reconnect) { - // Disable reconnect because we are disconnecting manually/intentionally. - _metrics.reconnectUrl = null; - _tearDownReconnectTimer(); - } - if (state.isDisconnected) return Future.value(); - await _transport?.disconnect(code, reason); - await _onDisconnected(temporary: reconnect); - } - - @override - Future _onDisconnected({required bool temporary}) async { - _refreshTimer?.cancel(); - _transport = null; - // Reconnect if that callback called not from disconnect method. - if (_metrics.reconnectUrl != null) _setUpReconnectTimer(); - if (state.isConnected || state.isConnecting) { - _metrics.lastDisconnectAt = DateTime.now(); - _metrics.disconnects++; - } - await super._onDisconnected(temporary: temporary); - } - - @override - Future _onReply(SpinifyReply reply) async { - await super._onReply(reply); - if (reply - case SpinifyPush( - event: SpinifyDisconnect(:String reason, :bool reconnect) - )) { - if (reconnect) { - // Disconnect client temporarily. - await _transport?.disconnect(1000, reason); - await _onDisconnected(temporary: true); - } else { - // Disconnect client permanently. - await disconnect(); - } - } - } - - @override - Future close() async { - await _transport?.disconnect(1000, 'Client closing'); - await super.close(); - } -} - -/// Base mixin for Spinify client ping-pong management. -base mixin SpinifyPingPongMixin - on SpinifyBase, SpinifyStateMixin, SpinifyConnectionMixin { - @protected - @nonVirtual - Timer? _pingTimer; - - /* @override - Future ping() => _doOnReady( - () => _sendCommand( - SpinifyPingRequest(timestamp: DateTime.now()), - ), - ); */ - - /// Stop keepalive timer. - @protected - @nonVirtual - void _tearDownPingTimer() => _pingTimer?.cancel(); - - /// Start or restart keepalive timer, - /// you should restart it after each received ping message. - /// Or connection will be closed by timeout. - @protected - @nonVirtual - void _restartPingTimer() { - _tearDownPingTimer(); - // coverage:ignore-start - assert(!isClosed, 'Client is closed'); - assert(state.isConnected, 'Invalid state'); - // coverage:ignore-end - if (state case SpinifyState$Connected(:Duration? pingInterval) - when pingInterval != null && pingInterval > Duration.zero) { - _pingTimer = Timer( - pingInterval + config.serverPingDelay, - () async { - // Reconnect if no pong received. - if (state case SpinifyState$Connected(:String url)) { - config.logger?.call( - const SpinifyLogLevel.warning(), - 'no_pong_reconnect', - 'No pong from server - reconnecting', - { - 'url': url, - 'pingInterval': pingInterval, - 'serverPingDelay': config.serverPingDelay, - }, - ); - try { - await _disconnect( - code: 2, - reason: 'No ping from server', - reconnect: true, - ); - await Future.delayed(Duration.zero); - } finally { - await connect(url); - } - } - /* disconnect( - SpinifyConnectingCode.noPing, - 'No ping from server', - ); */ - }, - ); - } - } - - @override - Future _onConnected() async { - _tearDownPingTimer(); - await super._onConnected(); - _restartPingTimer(); - } - - @override - Future _onReply(SpinifyReply reply) async { - if (!reply.isResult && reply is SpinifyServerPing) { - final command = SpinifyPingRequest(timestamp: DateTime.now()); - _metrics - ..lastPingAt = command.timestamp - ..receivedPings = _metrics.receivedPings + 1; - if (state case SpinifyState$Connected(:bool sendPong) when sendPong) { - // No need to handle error in a special way - - // if pong can't be sent but connection is closed anyway. - _sendCommandAsync(command).ignore(); - } - config.logger?.call( - const SpinifyLogLevel.debug(), - 'server_ping_received', - 'Ping from server received, pong sent', - { - 'ping': reply, - 'pong': command, - }, - ); - _restartPingTimer(); - } - await super._onReply(reply); - } - - @override - Future _onDisconnected({required bool temporary}) async { - _tearDownPingTimer(); - await super._onDisconnected(temporary: temporary); - } - - @override - Future close() async { - _tearDownPingTimer(); - await super.close(); - } -} - -/// Base mixin for Spinify client publications management. -base mixin SpinifyPublicationsMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future publish(String channel, List data) => - getSubscription(channel)?.publish(data) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); -} - -/// Base mixin for Spinify client presence management. -base mixin SpinifyPresenceMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future> presence(String channel) => - getSubscription(channel)?.presence() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); - - @override - Future presenceStats(String channel) => - getSubscription(channel)?.presenceStats() ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); -} - -/// Base mixin for Spinify client history management. -base mixin SpinifyHistoryMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future history( - String channel, { - int? limit, - SpinifyStreamPosition? since, - bool? reverse, - }) => - getSubscription(channel)?.history( - limit: limit, - since: since, - reverse: reverse, - ) ?? - Future.error( - SpinifySubscriptionException( - channel: channel, - message: 'Subscription not found', - ), - StackTrace.current, - ); -} - -/// Base mixin for Spinify client RPC management. -base mixin SpinifyRPCMixin on SpinifyBase, SpinifyCommandMixin { - @override - Future> rpc(String method, [List? data]) => _doOnReady( - () => _sendCommand( - SpinifyRPCRequest( - id: _getNextCommandId(), - timestamp: DateTime.now(), - method: method, - data: data ?? const [], - ), - ).then>((reply) => reply.data), - ); -} - -/// Base mixin for Spinify client metrics management. -base mixin SpinifyMetricsMixin on SpinifyBase { - @override - SpinifyMetrics get metrics => _metrics.freeze(); -} - -/// {@template spinify} -/// Spinify client for Centrifuge. -/// -/// Centrifugo SDKs use WebSocket as the main data transport and send/receive -/// messages encoded according to our bidirectional protocol. -/// That protocol is built on top of the Protobuf schema -/// (both JSON and binary Protobuf formats are supported). -/// It provides asynchronous communication, sending RPC, -/// multiplexing subscriptions to channels, etc. -/// -/// Client SDK wraps the protocol and exposes a set of APIs to developers. -/// -/// Client connection has 4 states: -/// - [SpinifyState$Disconnected] -/// - [SpinifyState$Connecting] -/// - [SpinifyState$Connected] -/// - [SpinifyState$Closed] -/// -/// {@endtemplate} -/// {@category Client} -final class Spinify extends SpinifyBase - with - SpinifyStateMixin, - SpinifyCommandMixin, - SpinifySubscriptionMixin, - SpinifyConnectionMixin, - SpinifyPingPongMixin, - SpinifyPublicationsMixin, - SpinifyPresenceMixin, - SpinifyHistoryMixin, - SpinifyRPCMixin, - SpinifyMetricsMixin { - /// {@macro spinify} - Spinify({SpinifyConfig? config}) - : super(config: config ?? SpinifyConfig.byDefault()); - - /// Create client and connect. - /// - /// {@macro spinify} - factory Spinify.connect(String url, {SpinifyConfig? config}) => - Spinify(config: config)..connect(url); -} diff --git a/lib/src/spinify_interface.dart b/lib/src/spinify_interface.dart index dd6069b..32ebd74 100644 --- a/lib/src/spinify_interface.dart +++ b/lib/src/spinify_interface.dart @@ -42,12 +42,16 @@ abstract interface class ISpinify Future ready(); /// Disconnect from the server. - Future disconnect(); + /// If [force] is true then client will be disconnected immediately, + /// otherwise client will wait for all operations to complete. + Future disconnect({bool force = false}); /// Client if not needed anymore. /// Permanent close connection to the server and /// free all allocated resources. - Future close(); + /// If [force] is true then client will be closed immediately, + /// otherwise client will wait for all operations to complete. + Future close({bool force = false}); } /// Spinify client state owner interface. diff --git a/lib/src/subscription_impl.dart b/lib/src/subscription_impl.dart deleted file mode 100644 index b537011..0000000 --- a/lib/src/subscription_impl.dart +++ /dev/null @@ -1,661 +0,0 @@ -part of 'spinify_impl.dart'; - -@internal -abstract base class SpinifySubscriptionBase implements SpinifySubscription { - SpinifySubscriptionBase({ - required SpinifySubscriptionMixin client, - required this.channel, - required this.recoverable, - required this.epoch, - required this.offset, - }) : _clientWR = WeakReference(client), - _clientConfig = client.config { - _metrics = _client._metrics.channels - .putIfAbsent(channel, SpinifyMetrics$Channel$Mutable.new); - } - - @override - final String channel; - - /// Spinify client weak reference. - final WeakReference _clientWR; - - /// Spinify client - SpinifySubscriptionMixin get _client { - final target = _clientWR.target; - // coverage:ignore-start - if (target == null) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Spinify client is do not exist anymore', - ); - } - // coverage:ignore-end - return target; - } - - /// Spinify channel metrics. - late final SpinifyMetrics$Channel$Mutable _metrics; - - /// Spinify client configuration. - final SpinifyConfig _clientConfig; - - /// Spinify logger. - SpinifyLogger? get _logger => _clientConfig.logger; - - final StreamController _stateController = - StreamController.broadcast(); - - final StreamController _eventController = - StreamController.broadcast(); - - Future _sendCommand( - SpinifyCommand Function(int nextId) builder, - ) => - _client._doOnReady( - () => _client._sendCommand( - builder(_client._getNextCommandId()), - ), - ); - - @override - bool recoverable; - - @override - String epoch; - - @override - fixnum.Int64 offset; - - @override - SpinifySubscriptionState get state => _metrics.state; - - @override - SpinifySubscriptionStates get states => - SpinifySubscriptionStates(_stateController.stream); - - @override - SpinifyChannelEvents get stream => - SpinifyChannelEvents(_eventController.stream); - - @sideEffect - @mustCallSuper - void onEvent(SpinifyChannelEvent event) { - // coverage:ignore-start - assert( - event.channel == channel, - 'Subscription "$channel" received event for another channel', - ); - // coverage:ignore-end - _eventController.add(event); - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_event_received', - 'Subscription "$channel" received ${event.type} event', - { - 'channel': channel, - 'subscription': this, - 'event': event, - if (event is SpinifyPublication) 'publication': event, - }, - ); - } - - @mustCallSuper - void _setState(SpinifySubscriptionState state) { - final previous = _metrics.state; - if (previous == state) return; - _stateController.add(_metrics.state = state); - _logger?.call( - const SpinifyLogLevel.config(), - 'subscription_state_changed', - 'Subscription "$channel" state changed to ${state.type}', - { - 'channel': channel, - 'subscription': this, - 'previous': previous, - 'state': state, - }, - ); - } - - @mustCallSuper - @interactive - void close() { - _stateController.close().ignore(); - _eventController.close().ignore(); - // coverage:ignore-start - assert(state.isUnsubscribed, - 'Subscription "$channel" is not unsubscribed before closing'); - // coverage:ignore-end - } - - @override - @interactive - Future ready() async { - if (_client.isClosed) - throw SpinifySubscriptionException( - channel: channel, - message: 'Client is closed', - ); - if (_metrics.state.isSubscribed) return; - if (_stateController.isClosed) - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription is closed permanently', - ); - final state = await _stateController.stream - .firstWhere((state) => !state.isSubscribing); - if (!state.isSubscribed) - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription failed to subscribe', - ); - } - - @override - @interactive - Future history({ - int? limit, - SpinifyStreamPosition? since, - bool? reverse, - }) => - _sendCommand( - (id) => SpinifyHistoryRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - limit: limit, - since: since, - reverse: reverse, - ), - ).then( - (reply) => SpinifyHistory( - publications: List.unmodifiable( - reply.publications.map((pub) => pub.copyWith(channel: channel))), - since: reply.since, - ), - ); - - @override - @interactive - Future> presence() => - _sendCommand( - (id) => SpinifyPresenceRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - ), - ).then>((reply) => reply.presence); - - @override - @interactive - Future presenceStats() => - _sendCommand( - (id) => SpinifyPresenceStatsRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - ), - ).then( - (reply) => SpinifyPresenceStats( - channel: channel, - clients: reply.numClients, - users: reply.numUsers, - ), - ); - - @override - @interactive - Future publish(List data) => _sendCommand( - (id) => SpinifyPublishRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - data: data, - ), - ); -} - -@internal -final class SpinifyClientSubscriptionImpl extends SpinifySubscriptionBase - implements SpinifyClientSubscription { - SpinifyClientSubscriptionImpl({ - required super.client, - required super.channel, - required this.config, - }) : super( - recoverable: config.recoverable, - epoch: config.since?.epoch ?? '', - offset: config.since?.offset ?? fixnum.Int64.ZERO, - ); - - @override - final SpinifySubscriptionConfig config; - - /// Whether the subscription should recover. - bool _recover = false; - - /// Interactively subscribes to the channel. - @override - @interactive - Future subscribe() async { - // Check if the client is connected - switch (_client.state) { - case SpinifyState$Connected _: - break; - case SpinifyState$Connecting _: - case SpinifyState$Disconnected _: - await _client.ready(); - case SpinifyState$Closed _: - throw SpinifySubscriptionException( - channel: channel, - message: 'Client is closed', - ); - } - - // Check if the subscription is already subscribed - switch (state) { - case SpinifySubscriptionState$Subscribed _: - return; - case SpinifySubscriptionState$Subscribing _: - await ready(); - case SpinifySubscriptionState$Unsubscribed _: - await _resubscribe(); - } - } - - /// Interactively unsubscribes from the channel. - @override - @interactive - Future unsubscribe([ - int code = 0, - String reason = 'unsubscribe called', - ]) => - _unsubscribe( - code: code, - reason: reason, - sendUnsubscribe: true, - ); - - /// Unsubscribes from the channel. - Future _unsubscribe({ - required int code, - required String reason, - required bool sendUnsubscribe, - }) async { - final currentState = _metrics.state; - _tearDownResubscribeTimer(); - _tearDownRefreshSubscriptionTimer(); - if (currentState.isUnsubscribed) return; - _setState(SpinifySubscriptionState$Unsubscribed()); - _metrics.lastUnsubscribeAt = DateTime.now(); - _metrics.unsubscribes++; - try { - if (sendUnsubscribe && - currentState.isSubscribed && - _client.state.isConnected) { - await _sendCommand( - (id) => SpinifyUnsubscribeRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - ), - ); - } - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'subscription_unsubscribe_error', - 'Subscription "$channel" failed to unsubscribe', - { - 'channel': channel, - 'subscription': this, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - _client._transport?.disconnect(4, 'unsubscribe error').ignore(); - if (error is SpinifyException) rethrow; - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: channel, - message: 'Error while unsubscribing', - error: error, - ), - stackTrace, - ); - } - } - - /// `SubscriptionImpl{}._resubscribe()` from `centrifuge` package - Future _resubscribe() async { - if (!_metrics.state.isUnsubscribed) return; - try { - _setState(SpinifySubscriptionState$Subscribing()); - - final token = await config.getToken?.call(); - // Token can be null if it is not required for subscription. - if (token != null && token.length <= 5) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Subscription token is empty', - ); - } - - final data = await config.getPayload?.call(); - - final recover = - _recover && offset > fixnum.Int64.ZERO && epoch.isNotEmpty; - - final result = await _sendCommand( - (id) => SpinifySubscribeRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - token: token, - recoverable: recoverable, - recover: recover, - offset: recover ? offset : null, - epoch: recover ? epoch : null, - positioned: config.positioned, - joinLeave: config.joinLeave, - data: data, - ), - ); - - if (state.isUnsubscribed) { - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_resubscribe_skipped', - 'Subscription "$channel" resubscribe skipped, ' - 'subscription is unsubscribed.', - { - 'channel': channel, - 'subscription': this, - }, - ); - await _unsubscribe( - code: 0, - reason: 'resubscribe skipped', - sendUnsubscribe: false, - ); - } - - // If subscription is recoverable and server sends recoverable flag - // then we should update epoch and offset values. - if (result.recoverable) { - _recover = true; - epoch = result.since.epoch; - offset = result.since.offset; - } - - _setState(SpinifySubscriptionState$Subscribed(data: result.data)); - - // Set up refresh subscription timer if needed. - if (result.expires) { - if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { - _setUpRefreshSubscriptionTimer(ttl: ttl); - } else { - // coverage:ignore-start - assert( - false, - 'Subscription "$channel" has invalid TTL: ${result.ttl}', - ); - // coverage:ignore-end - } - } - - // Handle received publications and update offset. - for (final pub in result.publications) { - _client._eventController.add(pub); - onEvent(pub); - if (pub.offset case fixnum.Int64 value when value > offset) { - offset = value; - } - } - - _onSubscribed(); // Successful subscription completed - - _logger?.call( - const SpinifyLogLevel.config(), - 'subscription_subscribed', - 'Subscription "$channel" subscribed', - { - 'channel': channel, - 'subscription': this, - }, - ); - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'subscription_resubscribe_error', - 'Subscription "$channel" failed to resubscribe', - { - 'channel': channel, - 'subscription': this, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - switch (error) { - case SpinifyErrorResult result: - if (result.code == 109) { - _setUpResubscribeTimer(); // Token expired error, retry resubscribe - } else if (result.temporary) { - _setUpResubscribeTimer(); // Temporary error, retry resubscribe - } else { - // Disable resubscribe timer and unsubscribe - _unsubscribe( - code: result.code, - reason: result.message, - sendUnsubscribe: false, - ).ignore(); - } - case SpinifySubscriptionException _: - _setUpResubscribeTimer(); // Some spinify exception, retry resubscribe - rethrow; - default: - _setUpResubscribeTimer(); // Unknown error, retry resubscribe - } - Error.throwWithStackTrace( - SpinifySubscriptionException( - channel: channel, - message: 'Failed to resubscribe to "$channel"', - error: error, - ), - stackTrace, - ); - } - } - - /// Successful subscription completed. - void _onSubscribed() { - _tearDownResubscribeTimer(); - _metrics.lastSubscribeAt = DateTime.now(); - _metrics.subscribes++; - } - - /// Resubscribe timer. - Timer? _resubscribeTimer; - - /// Set up resubscribe timer. - void _setUpResubscribeTimer() { - _resubscribeTimer?.cancel(); - final attempt = _metrics.resubscribeAttempts ?? 0; - final delay = Backoff.nextDelay( - attempt, - _client.config.connectionRetryInterval.min.inMilliseconds, - _client.config.connectionRetryInterval.max.inMilliseconds, - ); - _metrics.resubscribeAttempts = attempt + 1; - if (delay <= Duration.zero) { - if (!state.isUnsubscribed) return; - _logger?.call( - const SpinifyLogLevel.config(), - 'subscription_resubscribe_attempt', - 'Resubscibing to $channel immediately.', - { - 'channel': channel, - 'delay': delay, - 'subscription': this, - 'attempts': attempt, - }, - ); - Future.sync(subscribe).ignore(); - return; - } - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_resubscribe_delayed', - 'Setting up resubscribe timer for $channel ' - 'after ${delay.inMilliseconds} ms.', - { - 'channel': channel, - 'delay': delay, - 'subscription': this, - 'attempts': attempt, - }, - ); - _metrics.nextResubscribeAt = DateTime.now().add(delay); - _resubscribeTimer = Timer(delay, () { - if (!state.isUnsubscribed) return; - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_resubscribe_attempt', - 'Resubscribing to $channel after ${delay.inMilliseconds} ms.', - { - 'channel': channel, - 'subscription': this, - 'attempts': attempt, - }, - ); - Future.sync(_resubscribe).ignore(); - }); - } - - /// Tear down resubscribe timer. - void _tearDownResubscribeTimer() { - _metrics - ..resubscribeAttempts = 0 - ..nextResubscribeAt = null; - _resubscribeTimer?.cancel(); - _resubscribeTimer = null; - } - - /// Refresh subscription timer. - Timer? _refreshTimer; - - /// Set up refresh subscription timer. - void _setUpRefreshSubscriptionTimer({required DateTime ttl}) { - _tearDownRefreshSubscriptionTimer(); - _metrics.ttl = ttl; - _refreshTimer = Timer(ttl.difference(DateTime.now()), _refreshToken); - } - - /// Tear down refresh subscription timer. - void _tearDownRefreshSubscriptionTimer() { - _refreshTimer?.cancel(); - _refreshTimer = null; - _metrics.ttl = null; - } - - /// Refresh subscription token. - void _refreshToken() => runZonedGuarded( - () async { - _tearDownRefreshSubscriptionTimer(); - if (!state.isSubscribed || !_client.state.isConnected) return; - final token = await config.getToken?.call(); - if (token == null || token.isEmpty) { - throw SpinifySubscriptionException( - channel: channel, - message: 'Token is empty', - ); - } - final result = await _sendCommand( - (id) => SpinifySubRefreshRequest( - id: id, - channel: channel, - timestamp: DateTime.now(), - token: token, - ), - ); - - DateTime? newTtl; - if (result.expires) { - if (result.ttl case DateTime ttl when ttl.isAfter(DateTime.now())) { - newTtl = ttl; - _setUpRefreshSubscriptionTimer(ttl: ttl); - } else { - // coverage:ignore-start - assert( - false, - 'Subscription "$channel" has invalid TTL: ${result.ttl}', - ); - // coverage:ignore-end - } - } - - _logger?.call( - const SpinifyLogLevel.debug(), - 'subscription_refresh_token', - 'Subscription "$channel" token refreshed', - { - 'channel': channel, - 'subscription': this, - if (newTtl != null) 'ttl': newTtl, - }, - ); - }, - (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'subscription_refresh_token_error', - 'Subscription "$channel" failed to refresh token', - { - 'channel': channel, - 'subscription': this, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - - // Calculate new TTL for refresh subscription timer - late final ttl = - DateTime.now().add(Backoff.nextDelay(0, 5 * 1000, 10 * 1000)); - switch (error) { - case SpinifyErrorResult result: - if (result.temporary) { - _setUpRefreshSubscriptionTimer(ttl: ttl); - } else { - // Disable refresh subscription timer and unsubscribe - _unsubscribe( - code: result.code, - reason: result.message, - sendUnsubscribe: true, - ).ignore(); - } - case SpinifySubscriptionException _: - _setUpRefreshSubscriptionTimer(ttl: ttl); - default: - _setUpRefreshSubscriptionTimer(ttl: ttl); - } - }, - ); -} - -@internal -final class SpinifyServerSubscriptionImpl extends SpinifySubscriptionBase - implements SpinifyServerSubscription { - SpinifyServerSubscriptionImpl({ - required super.client, - required super.channel, - required super.recoverable, - required super.epoch, - required super.offset, - }); - - @override - SpinifyChannelEvents get stream => - _client.stream.filter(channel: channel); -} diff --git a/lib/src/transport_fake.dart b/lib/src/transport_fake.dart deleted file mode 100644 index 9f7b795..0000000 --- a/lib/src/transport_fake.dart +++ /dev/null @@ -1,312 +0,0 @@ -// ignore_for_file: avoid_setters_without_getters -// coverage:ignore-file - -import 'dart:async'; -import 'dart:convert'; -import 'dart:math' as math; - -import 'package:fixnum/fixnum.dart'; - -import 'model/channel_event.dart'; -import 'model/command.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; - -/// Create a fake Spinify transport. -SpinifyTransportBuilder $createFakeSpinifyTransport({ - SpinifyReply? Function(SpinifyCommand command)? overrideCommand, - void Function(ISpinifyTransport? transport)? out, -}) => - ({ - /// URL for the connection - required url, - - /// Spinify client configuration - required config, - - /// Metrics - required metrics, - - /// Callback for reply messages - required Future Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required Future Function({required bool temporary}) onDisconnect, - }) async { - final transport = SpinifyTransportFake( - overrideCommand: overrideCommand, - ) - ..metrics = metrics - ..onReply = onReply - ..onDisconnect = ({required temporary}) { - out?.call(null); - return onDisconnect(temporary: temporary); - }; - await transport._connect(url); - out?.call(transport); - return transport; - }; - -/// Spinify fake transport -class SpinifyTransportFake implements ISpinifyTransport { - /// Create a fake transport. - SpinifyTransportFake({ - // Delay in milliseconds - this.delay = 10, - SpinifyReply? Function(SpinifyCommand command)? overrideCommand, - }) : _random = math.Random(), - _overrideCommand = overrideCommand; - - final SpinifyReply? Function(SpinifyCommand command)? _overrideCommand; - - /// Delay in milliseconds in the fake transport to simulate network latency. - int delay; - final math.Random _random; - - Future _sleep() => Future.delayed( - Duration(milliseconds: _random.nextInt(delay ~/ 2) + delay ~/ 2)); - - bool get _isConnected => _timer != null; - Timer? _timer; - - Future _connect(String url) async { - if (_isConnected) return; - await _sleep(); - _timer = Timer.periodic(const Duration(seconds: 25), (timer) { - if (!_isConnected) timer.cancel(); - _response((now) => SpinifyPingResult(id: 0, timestamp: now)); - }); - } - - @override - Future send(SpinifyCommand command) async { - if (!_isConnected) throw StateError('Not connected'); - metrics - ..bytesSent += 1 - ..messagesSent += 1; - await _sleep(); - if (_overrideCommand != null) { - final reply = _overrideCommand.call(command); - if (reply != null) _onReply?.call(reply).ignore(); - return; - } - switch (command) { - case SpinifyPingRequest(:int id): - _response( - (now) => SpinifyPingResult( - id: id, - timestamp: now, - ), - ); - case SpinifyConnectRequest(:int id): - _response( - (now) => SpinifyConnectResult( - id: id, - timestamp: now, - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'notification:index': SpinifySubscribeResult( - id: id, - timestamp: now, - data: null, - expires: false, - ttl: null, - positioned: false, - publications: const [], - recoverable: false, - recovered: false, - since: (epoch: '...', offset: Int64.ZERO), - wasRecovering: false, - ), - }, - pingInterval: const Duration(seconds: 25), - sendPong: false, - session: 'fake', - node: 'fake', - ), - ); - case SpinifySubscribeRequest(:int id): - _response( - (now) => SpinifySubscribeResult( - id: id, - timestamp: now, - data: null, - expires: false, - ttl: null, - positioned: false, - publications: const [], - recoverable: false, - recovered: false, - since: (epoch: '...', offset: Int64.ZERO), - wasRecovering: false, - ), - ); - case SpinifyUnsubscribeRequest(:int id): - _response( - (now) => SpinifyUnsubscribeResult( - id: id, - timestamp: now, - ), - ); - case SpinifyPublishRequest(:int id): - _response( - (now) => SpinifyPublishResult( - id: id, - timestamp: now, - ), - ); - case SpinifyPresenceRequest(:int id): - _response( - (now) => SpinifyPresenceResult( - id: id, - timestamp: now, - presence: const {}, - ), - ); - case SpinifyPresenceStatsRequest(:int id): - _response( - (now) => SpinifyPresenceStatsResult( - id: id, - timestamp: now, - numClients: 0, - numUsers: 0, - ), - ); - case SpinifyHistoryRequest(:int id): - _response( - (now) => SpinifyHistoryResult( - id: id, - timestamp: now, - since: (epoch: '...', offset: Int64.ZERO), - publications: const [], - ), - ); - case SpinifyRPCRequest(:int id, :String method, :List data): - _response( - (now) => SpinifyRPCResult( - id: id, - timestamp: now, - data: switch (method) { - 'getCurrentYear' => - utf8.encode('{"year": ${DateTime.now().year}}'), - 'echo' => data, - _ => throw ArgumentError('Unknown method: $method'), - }, - ), - ); - case SpinifyRefreshRequest(:int id): - _response( - (now) => SpinifyRefreshResult( - id: id, - timestamp: now, - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - ), - ); - case SpinifySubRefreshRequest(:int id): - _response( - (now) => SpinifySubRefreshResult( - id: id, - timestamp: now, - expires: false, - ttl: null, - ), - ); - case SpinifySendRequest(): - // Asynchronously send a message to the server - } - } - - void _response(SpinifyReply Function(DateTime now) reply) => Timer( - Duration(milliseconds: delay), - () { - if (!_isConnected) return; - metrics - ..bytesReceived += 1 - ..messagesReceived += 1; - _onReply?.call(reply(DateTime.now())).ignore(); - }, - ); - - /// Metrics - late SpinifyMetrics$Mutable metrics; - - /// Callback for reply messages - set onReply(Future Function(SpinifyReply reply) handler) => - _onReply = handler; - Future Function(SpinifyReply reply)? _onReply; - - /// Callback for disconnect event - set onDisconnect(Future Function({required bool temporary}) handler) => - _onDisconnect = handler; - Future Function({required bool temporary})? _onDisconnect; - - @override - Future disconnect([int? code, String? reason]) async { - if (!_isConnected) return; - await _sleep(); - int? closeCode; - String? closeReason; - var reconnect = true; - if (code case int value when value > 0) { - switch (value) { - case 1009: - // reconnect is true by default - closeCode = 3; // disconnectCodeMessageSizeLimit; - closeReason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - closeCode = 1; // connectingCodeTransportClosed; - closeReason = reason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - closeCode = value; - closeReason = reason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - closeCode = value; - closeReason = reason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - closeCode = value; - closeReason = reason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - closeCode = value; - closeReason = reason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - closeCode = value; - closeReason = reason; - reconnect = true; - default: - closeCode = value; - closeReason = reason; - reconnect = false; - } - } - closeCode ??= 1; // connectingCodeTransportClosed - closeReason ??= 'transport closed'; - await _onDisconnect?.call(temporary: reconnect); - _timer?.cancel(); - _timer = null; - } -} diff --git a/lib/src/transport_ws_pb_js.dart b/lib/src/transport_ws_pb_js.dart deleted file mode 100644 index 83fb3cd..0000000 --- a/lib/src/transport_ws_pb_js.dart +++ /dev/null @@ -1,446 +0,0 @@ -// coverage:ignore-file - -import 'dart:async'; -import 'dart:convert'; -import 'dart:js_interop' as js; -import 'dart:typed_data'; - -import 'package:meta/meta.dart'; -import 'package:protobuf/protobuf.dart' as pb; -import 'package:web/web.dart' as web; - -import 'model/channel_event.dart'; -import 'model/command.dart'; -import 'model/config.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; -import 'protobuf/client.pb.dart' as pb; -import 'protobuf/protobuf_codec.dart'; -import 'util/event_queue.dart'; - -const _BlobCodec _blobCodec = _BlobCodec(); - -@immutable -final class _BlobCodec { - const _BlobCodec(); - - @internal - web.Blob write(Object data) { - switch (data) { - case String text: - return web.Blob([Uint8List.fromList(utf8.encode(text)).toJS].toJS); - case TypedData td: - return web.Blob([ - Uint8List.view( - td.buffer, - td.offsetInBytes, - td.lengthInBytes, - ).toJS - ].toJS); - case ByteBuffer bb: - return web.Blob([bb.asUint8List().toJS].toJS); - case List bytes: - return web.Blob([Uint8List.fromList(bytes).toJS].toJS); - case web.Blob blob: - return web.Blob([blob].toJS); - default: - throw ArgumentError.value(data, 'data', 'Invalid data type.'); - } - } - - @internal - Future read(js.JSAny? data) async { - switch (data) { - case String text: - return utf8.encode(text); - case web.Blob blob: - final arrayBuffer = await blob.arrayBuffer().toDart; - return arrayBuffer.toDart.asUint8List(); - case TypedData td: - return Uint8List.view( - td.buffer, - td.offsetInBytes, - td.lengthInBytes, - ); - case ByteBuffer bb: - return bb.asUint8List(); - case List bytes: - return Uint8List.fromList(bytes); - default: - assert(false, 'Unsupported data type: $data'); - throw ArgumentError.value(data, 'data', 'Invalid data type.'); - } - } -} - -/// Create a WebSocket Protocol Buffers transport. -@internal -Future $create$WS$PB$Transport({ - /// URL for the connection - required String url, - - /// Spinify client configuration - required SpinifyConfig config, - - /// Metrics - required SpinifyMetrics$Mutable metrics, - - /// Callback for reply messages - required void Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required void Function({required bool temporary}) onDisconnect, -}) async { - // ignore: close_sinks - final socket = web.WebSocket( - url, - {'centrifuge-protobuf'} - .map((e) => e.toJS) - .toList(growable: false) - .toJS, - ); - - SpinifyTransport$WS$PB$JS? transport; - - final eventQueue = EventQueue(); // Event queue for WebSocket events - - // ignore: cancel_subscriptions - StreamSubscription? onOpen, onError, onMessage, onClose; - try { - final completer = Completer(); - - // coverage:ignore-start - onOpen = socket.onOpen.listen((event) { - eventQueue.add(() { - if (transport != null || completer.isCompleted) return; - completer.complete(); - }); - }); - - onError = socket.onError.listen((event) { - eventQueue.add(() async { - if (transport != null && !transport.disconnected) { - await transport.disconnect(); - return; - } - if (completer.isCompleted) return; - switch (event) { - case web.ErrorEvent value - when value.error != null || value.message.isNotEmpty: - completer.completeError(Exception( - 'WebSocket connection error: ${value.error ?? value.message}')); - default: - completer.completeError( - Exception('WebSocket connection error: Unknown error')); - } - }); - }); - - /* socket.onmessage = (web.MessageEvent event) { - final data = event.data; - eventQueue.add(() async { - if (transport == null || transport.disconnected) return; - final bytes = await _blobCodec.read(data); - transport._onData(bytes); - }); - }.toJS; */ - onMessage = socket.onMessage.listen((event) { - eventQueue.add(() async { - final bytes = await _blobCodec.read(event.data); - if (transport == null || transport.disconnected) return; - transport._onData(bytes); - }); - }); - - onClose = socket.onClose.listen((event) { - final code = event.code; - final reason = event.reason; - eventQueue.add(() async { - for (final e in [onOpen, onError, onMessage, onClose]) - e?.cancel().ignore(); - - if (transport != null && !transport.disconnected) { - transport - .._closeCode = code - .._closeReason = reason - .._onDone(); - await transport.disconnect(code, reason); - } - - if (socket.readyState != 3) socket.close(code, reason); - eventQueue.close(force: true).ignore(); - - if (completer.isCompleted) return; - completer.completeError( - Exception('WebSocket connection closed: $code $reason')); - }); - }); - - await completer.future; - - // 0 CONNECTING Socket has been created. The connection is not yet open. - // 1 OPEN The connection is open and ready to communicate. - // 2 CLOSING The connection is in the process of closing. - // 3 CLOSED The connection is closed or couldn't be opened. - assert(socket.readyState == 1, 'Socket is not open'); - // coverage:ignore-end - - // ignore: join_return_with_assignment - transport = SpinifyTransport$WS$PB$JS( - socket, - config, - metrics, - onReply, - onDisconnect, - ); - - return transport; - } on Object { - for (final e in [onOpen, onError, onMessage, onClose]) e?.cancel().ignore(); - if (socket.readyState != 3) socket.close(); - eventQueue.close(force: true).ignore(); - rethrow; - // coverage:ignore-end - } -} - -/// Create a WebSocket Protocol Buffers transport. -@internal -final class SpinifyTransport$WS$PB$JS implements ISpinifyTransport { - SpinifyTransport$WS$PB$JS( - this._socket, - SpinifyConfig config, - this._metrics, - this._onReply, - this._onDisconnect, - ) : _logger = config.logger, - _encoder = switch (config.logger) { - null => const ProtobufCommandEncoder(), - _ => ProtobufCommandEncoder(config.logger), - }, - _decoder = switch (config.logger) { - null => const ProtobufReplyDecoder(), - _ => ProtobufReplyDecoder(config.logger), - }, - disconnected = false; - - final web.WebSocket _socket; - final Converter _encoder; - final Converter _decoder; - final SpinifyLogger? _logger; - - bool disconnected; - int? _closeCode; - String? _closeReason; - - /// Metrics - final SpinifyMetrics$Mutable _metrics; - - /// Callback for reply messages - final void Function(SpinifyReply reply) _onReply; - - /// Callback for disconnect event - final void Function({required bool temporary}) _onDisconnect; - - /// Fired when data is received through a WebSocket. - void _onData(Object? bytes) { - if (bytes is! Uint8List || bytes.isEmpty) { - assert(false, 'Data is not byte array'); - return; - } - - _metrics - ..bytesReceived += bytes.length - ..messagesReceived += 1; - final reader = pb.CodedBufferReader(bytes); - while (!reader.isAtEnd()) { - try { - final message = pb.Reply(); - reader.readMessage(message, pb.ExtensionRegistry.EMPTY); - final reply = _decoder.convert(message); - _onReply.call(reply); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_on_reply', - 'Reply ${reply.type}{id: ${reply.id}} received', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'length': bytes.length, - 'reply': reply, - 'protobuf': message, - }, - ); - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_on_reply_error', - 'Error reading reply message', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - assert(false, 'Error reading message: $error'); - continue; - } - } - } - - @override - Future send(SpinifyCommand command) async { - try { - final message = _encoder.convert(command); - final commandData = message.writeToBuffer(); - final length = commandData.lengthInBytes; - final writer = pb.CodedBufferWriter() - ..writeInt32NoTag(length); //..writeRawBytes(commandData); - final bytes = writer.toBuffer() + commandData; - switch (bytes) { - case Uint8List uint8List: - _socket.send(uint8List.toJS); - case TypedData td: - _socket.send(Uint8List.view( - td.buffer, - td.offsetInBytes, - td.lengthInBytes, - ).toJS); - case List bytes: - _socket.send(Uint8List.fromList(bytes).toJS); - } - _metrics - ..bytesSent += bytes.length - ..messagesSent += 1; - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_send', - 'Command ${command.type}{id: ${command.id}} sent', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'protobuf': message, - 'length': bytes.length, - 'bytes': bytes, - }, - ); - } on Object catch (error, stackTrace) { - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_send_error', - 'Error sending command ${command.type}{id: ${command.id}}', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - } - } - - void _onDone() { - final timestamp = DateTime.now(); - int? code; - String? reason; - var reconnect = true; - if (_closeCode case int closeCode when closeCode > 0) { - switch (closeCode) { - case 1009: - // reconnect is true by default - code = 3; // disconnectCodeMessageSizeLimit; - reason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - code = 1; // connectingCodeTransportClosed; - reason = _closeReason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - code = closeCode; - reason = _closeReason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - code = closeCode; - reason = _closeReason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - code = closeCode; - reason = _closeReason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - code = closeCode; - reason = _closeReason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - code = closeCode; - reason = _closeReason; - reconnect = true; - default: - code = closeCode; - reason = _closeReason; - reconnect = false; - } - } - code ??= 1; // connectingCodeTransportClosed - reason ??= 'transport closed'; - _onReply.call( - SpinifyPush( - timestamp: timestamp, - event: SpinifyDisconnect( - channel: '', // empty channel - timestamp: timestamp, - code: code, - reason: reason, - reconnect: reconnect, - ), - ), - ); - _onDisconnect.call(temporary: reconnect); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_disconnect', - 'Transport disconnected ' - '${reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: $reason', - { - 'code': code, - 'reason': reason, - 'reconnect': reconnect, - }, - ); - } - - @override - Future disconnect([int? code, String? reason]) async { - disconnected = true; - _closeCode = code; - _closeReason = reason; - if (_socket.readyState == 3) - return; - else if (code != null && reason != null) - _socket.close(code, reason); - else if (code != null) - _socket.close(code); - else - _socket.close(); - //assert(_socket.readyState == 3, 'Socket is not closed'); - } -} diff --git a/lib/src/transport_ws_pb_stub.dart b/lib/src/transport_ws_pb_stub.dart deleted file mode 100644 index 930f20c..0000000 --- a/lib/src/transport_ws_pb_stub.dart +++ /dev/null @@ -1,27 +0,0 @@ -// coverage:ignore-file -import 'package:meta/meta.dart'; - -import 'model/config.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; - -/// Create a WebSocket Protocol Buffers transport. -@internal -Future $create$WS$PB$Transport({ - /// URL for the connection - required String url, - - /// Spinify client configuration - required SpinifyConfig config, - - /// Metrics - required SpinifyMetrics$Mutable metrics, - - /// Callback for reply messages - required void Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required void Function({required bool temporary}) onDisconnect, -}) => - throw UnimplementedError(); diff --git a/lib/src/transport_ws_pb_vm.dart b/lib/src/transport_ws_pb_vm.dart deleted file mode 100644 index f311d23..0000000 --- a/lib/src/transport_ws_pb_vm.dart +++ /dev/null @@ -1,294 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:meta/meta.dart'; -import 'package:protobuf/protobuf.dart' as pb; - -import 'model/channel_event.dart'; -import 'model/command.dart'; -import 'model/config.dart'; -import 'model/metric.dart'; -import 'model/reply.dart'; -import 'model/transport_interface.dart'; -import 'protobuf/client.pb.dart' as pb; -import 'protobuf/protobuf_codec.dart'; - -/// Create a WebSocket Protocol Buffers transport. -@internal -Future $create$WS$PB$Transport({ - /// URL for the connection - required String url, - - /// Spinify client configuration - required SpinifyConfig config, - - /// Metrics - required SpinifyMetrics$Mutable metrics, - - /// Callback for reply messages - required void Function(SpinifyReply reply) onReply, - - /// Callback for disconnect event - required void Function({required bool temporary}) onDisconnect, -}) async { - // ignore: close_sinks - final socket = await io.WebSocket.connect( - url, - headers: config.headers, - protocols: {'centrifuge-protobuf'}, - ); - final transport = SpinifyTransport$WS$PB$VM( - socket, - config, - metrics, - onReply, - onDisconnect, - ); - // 0 CONNECTING Socket has been created. The connection is not yet open. - // 1 OPEN The connection is open and ready to communicate. - // 2 CLOSING The connection is in the process of closing. - // 3 CLOSED The connection is closed or couldn't be opened. - assert(socket.readyState == io.WebSocket.open, 'Socket is not open'); - return transport; -} - -/// Create a WebSocket Protocol Buffers transport. -@internal -final class SpinifyTransport$WS$PB$VM implements ISpinifyTransport { - SpinifyTransport$WS$PB$VM( - this._socket, - SpinifyConfig config, - this._metrics, - this._onReply, - this._onDisconnect, - ) : _logger = config.logger, - _encoder = switch (config.logger) { - null => const ProtobufCommandEncoder(), - _ => ProtobufCommandEncoder(config.logger), - }, - _decoder = switch (config.logger) { - null => const ProtobufReplyDecoder(), - _ => ProtobufReplyDecoder(config.logger), - } { - _subscription = _socket.listen( - _onData, - cancelOnError: false, - onDone: _onDone, - ); - } - - final io.WebSocket _socket; - final Converter _encoder; - final Converter _decoder; - final SpinifyLogger? _logger; - late final StreamSubscription _subscription; - - /// Metrics - final SpinifyMetrics$Mutable _metrics; - - /// Callback for reply messages - final void Function(SpinifyReply reply) _onReply; - - /// Callback for disconnect event - final void Function({required bool temporary}) _onDisconnect; - - void _onData(Object? bytes) { - // coverage:ignore-start - if (bytes is! List || bytes.isEmpty) { - assert(false, 'Data is not byte array'); - return; - } - // coverage:ignore-end - _metrics - ..bytesReceived += bytes.length - ..messagesReceived += 1; - final reader = pb.CodedBufferReader(bytes); - while (!reader.isAtEnd()) { - try { - final message = pb.Reply(); - reader.readMessage(message, pb.ExtensionRegistry.EMPTY); - final reply = _decoder.convert(message); - _onReply.call(reply); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_on_reply', - 'Reply ${reply.type}{id: ${reply.id}} received', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'length': bytes.length, - 'reply': reply, - 'protobuf': message, - }, - ); - } on Object catch (error, stackTrace) { - // coverage:ignore-start - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_on_reply_error', - 'Error reading reply message', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'bytes': bytes, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - assert(false, 'Error reading message: $error'); - continue; - // coverage:ignore-end - } - } - } - - @override - Future send(SpinifyCommand command) async { - try { - final message = _encoder.convert(command); - final commandData = message.writeToBuffer(); - final length = commandData.lengthInBytes; - final writer = pb.CodedBufferWriter() - ..writeInt32NoTag(length); //..writeRawBytes(commandData); - final bytes = writer.toBuffer() + commandData; - _socket.add(bytes); - _metrics - ..bytesSent += bytes.length - ..messagesSent += 1; - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_send', - 'Command ${command.type}{id: ${command.id}} sent', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'protobuf': message, - 'length': bytes.length, - 'bytes': bytes, - }, - ); - } on Object catch (error, stackTrace) { - // coverage:ignore-start - _logger?.call( - const SpinifyLogLevel.error(), - 'transport_send_error', - 'Error sending command ${command.type}{id: ${command.id}}', - { - 'protocol': 'protobuf', - 'transport': 'websocket', - 'command': command, - 'error': error, - 'stackTrace': stackTrace, - }, - ); - rethrow; - // coverage:ignore-end - } - } - - void _onDone() { - final timestamp = DateTime.now(); - int? code; - String? reason; - var reconnect = true; - if (_socket - case io.WebSocket( - :int closeCode, - :String? closeReason, - ) when closeCode > 0) { - switch (closeCode) { - case 1009: - // reconnect is true by default - code = 3; // disconnectCodeMessageSizeLimit; - reason = 'message size limit exceeded'; - reconnect = true; - case < 3000: - // We expose codes defined by Centrifuge protocol, - // hiding details about transport-specific error codes. - // We may have extra optional transportCode field in the future. - // reconnect is true by default - code = 1; // connectingCodeTransportClosed; - reason = closeReason; - reconnect = true; - case >= 3000 && <= 3499: - // reconnect is true by default - code = closeCode; - reason = closeReason; - reconnect = true; - case >= 3500 && <= 3999: - // application terminal codes - code = closeCode; - reason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 4000 && <= 4499: - // custom disconnect codes - // reconnect is true by default - code = closeCode; - reason = closeReason; - reconnect = true; - case >= 4500 && <= 4999: - // custom disconnect codes - // application terminal codes - code = closeCode; - reason = closeReason ?? 'application terminal code'; - reconnect = false; - case >= 5000: - // reconnect is true by default - code = closeCode; - reason = closeReason; - reconnect = true; - default: - code = closeCode; - reason = closeReason; - reconnect = false; - } - } - code ??= 1; // connectingCodeTransportClosed - reason ??= 'transport closed'; - _onReply.call( - SpinifyPush( - timestamp: timestamp, - event: SpinifyDisconnect( - channel: '', // empty channel - timestamp: timestamp, - code: code, - reason: reason, - reconnect: reconnect, - ), - ), - ); - _onDisconnect.call(temporary: reconnect); - _logger?.call( - const SpinifyLogLevel.transport(), - 'transport_disconnect', - 'Transport disconnected ' - '${reconnect ? 'temporarily' : 'permanently'} ' - 'with reason: $reason', - { - 'code': code, - 'reason': reason, - 'reconnect': reconnect, - }, - ); - } - - @override - Future disconnect([int? code, String? reason]) async { - await _subscription.cancel(); - // coverage:ignore-start - if (_socket.readyState == 3) - return; - else if (code != null && reason != null) - await _socket.close(code, reason); - else if (code != null) - await _socket.close(code); - else - await _socket.close(); - // coverage:ignore-end - // Thats a bug in the dart:io, the socket is not closed immediately - //assert(_socket.readyState == io.WebSocket.closed, 'Socket is not closed'); - } -} diff --git a/lib/src/util/backoff.dart b/lib/src/util/backoff.dart index c3439c6..bc27811 100644 --- a/lib/src/util/backoff.dart +++ b/lib/src/util/backoff.dart @@ -19,6 +19,6 @@ abstract final class Backoff { if (minDelay >= maxDelay) return Duration(milliseconds: maxDelay); final val = math.min(maxDelay, minDelay * math.pow(2, step.clamp(0, 31))); final interval = _rnd.nextInt(val.toInt()); - return Duration(milliseconds: math.min(maxDelay, minDelay + interval)); + return Duration(milliseconds: (minDelay + interval).clamp(0, maxDelay)); } } diff --git a/lib/src/util/guarded.dart b/lib/src/util/guarded.dart new file mode 100644 index 0000000..2a317ea --- /dev/null +++ b/lib/src/util/guarded.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +/// Runs the given [callback] in a zone that catches uncaught errors and +/// forwards them to the returned future. +/// +/// [ignore] is used to ignore the errors and not throw them. +@internal +Future asyncGuarded( + Future Function() callback, { + bool ignore = false, +}) async { + Object? $error; + StackTrace? $stackTrace; + + await runZonedGuarded>( + () async { + try { + await callback(); + } on Object catch (error, stackTrace) { + $error = error; + $stackTrace = stackTrace; + } + }, + (error, stackTrace) { + // This should never be called. + //debugger(); + $error = error; + $stackTrace = stackTrace; + }, + ); + + final error = $error; + if (error == null) return; + if (ignore) return; + Error.throwWithStackTrace(error, $stackTrace ?? StackTrace.empty); +} + +/// Runs the given [callback] in a zone that catches uncaught errors and +/// rethrows them. +/// +/// [ignore] is used to ignore the errors and not throw them. +@internal +void guarded( + void Function() callback, { + bool ignore = false, +}) { + Object? $error; + StackTrace? $stackTrace; + + runZonedGuarded( + () { + try { + callback(); + } on Object catch (error, stackTrace) { + $error = error; + $stackTrace = stackTrace; + } + }, + (error, stackTrace) { + // This should never be called. + //debugger(); + $error = error; + $stackTrace = stackTrace; + }, + ); + + final error = $error; + if (error == null) return; + if (ignore) return; + Error.throwWithStackTrace(error, $stackTrace ?? StackTrace.empty); +} diff --git a/lib/src/util/list_equals.dart b/lib/src/util/list_equals.dart new file mode 100644 index 0000000..32e093e --- /dev/null +++ b/lib/src/util/list_equals.dart @@ -0,0 +1,13 @@ +import 'package:meta/meta.dart'; + +/// Check if two lists are equal. +@internal +bool listEquals(List? a, List? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + if (identical(a, b)) return true; + for (var index = 0; index < a.length; index += 1) { + if (a[index] != b[index]) return false; + } + return true; +} diff --git a/lib/src/util/map_equals.dart b/lib/src/util/map_equals.dart new file mode 100644 index 0000000..cb8f536 --- /dev/null +++ b/lib/src/util/map_equals.dart @@ -0,0 +1,13 @@ +import 'package:meta/meta.dart'; + +/// Check if two maps are equal. +@internal +bool mapEquals(Map? a, Map? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + if (identical(a, b)) return true; + for (final key in a.keys) { + if (!b.containsKey(key) || b[key] != a[key]) return false; + } + return true; +} diff --git a/lib/src/util/mutex.dart b/lib/src/util/mutex.dart new file mode 100644 index 0000000..7ff5193 --- /dev/null +++ b/lib/src/util/mutex.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +/// A request for a mutex lock. +class _Mutex$Request { + /// Creates a new mutex request. + _Mutex$Request._(Completer completer) + : _completer = completer, + future = completer.future; + + /// Creates a new mutex request with a synchronous completer. + factory _Mutex$Request.sync() => _Mutex$Request._(Completer.sync()); + + /// Creates a new mutex request with a asynchronous completer. + //factory _Mutex$Request.async() => _Mutex$Request._(Completer()); + + final Completer _completer; // The completer for the request. + bool get isCompleted => _completer.isCompleted; // Is completed? + bool get isNotCompleted => !_completer.isCompleted; // Is not completed? + + // Releases the lock. + void release() { + final completer = _completer; + if (completer.isCompleted) return; + completer.complete(); + } + + final Future future; // The future for the request. + _Mutex$Request? prev; // The previous request in the chain. +} + +/// A mutual exclusion lock. +@internal +abstract interface class IMutex { + /// The number of locks currently held. + int get locks; + + /// The list of pending locks. + List> get pending; + + /// Protects a callback with the mutex. + Future protect(Future Function() callback); + + /// Locks the mutex. + Future lock(); + + /// Unlocks the mutex. + void unlock(); + + /// Waits for the last lock at the current moment to be released. + /// This method do not add a new lock. + Future wait(); +} + +/// A mutual exclusion lock. +@internal +class MutexImpl implements IMutex { + /// Creates a new mutex. + MutexImpl(); + + _Mutex$Request? _last; // The last requested block + _Mutex$Request? _current; // The first and current running block + int _locks = 0; // The number of locks currently held + + /// The number of locks currently held. + @override + int get locks => _locks; + + /// The list of pending locks. + @override + List> get pending { + final pending = List>.filled( + _locks, + Future.value(), + growable: false, + ); + for (var i = _locks - 1, request = _last; + i >= 0; + i--, request = request?.prev) { + final future = request?.future; + if (future != null) + pending[i] = future; + else + assert(false, 'Invalid lock state'); // coverage:ignore-line + } + return pending; + } + + /// Protects a callback with the mutex. + @override + Future protect(Future Function() callback) async { + await lock(); + try { + return await callback(); + } finally { + unlock(); + } + } + + /// Locks the mutex. + @override + Future lock() async { + _locks++; + final prev = _last; + final current = _last = _Mutex$Request.sync()..prev = prev; + // Wait for the previous lock to be released. + if (prev != null && prev.isNotCompleted) await prev.future; + _current = current..prev = null; // Set the current lock. + } + + /// Unlocks the mutex. + @override + void unlock() { + final current = _current; + if (current == null) return; + _locks--; + _current = null; + current.release(); + } + + @override + Future wait() async { + final last = _last; + if (last != null) await last.future; + } +} + +/// A fake mutex that does nothing. +@internal +class MutexDisabled implements IMutex { + MutexDisabled(); + + static final Future _future = Future.value(); + + @override + int get locks => 0; + + @override + List> get pending => const []; + + @override + Future protect(Future Function() callback) => callback(); + + @override + Future lock() => _future; + + @override + void unlock() {} + + @override + Future wait() => _future; +} diff --git a/lib/src/web_socket_js.dart b/lib/src/web_socket_js.dart new file mode 100644 index 0000000..3dd465d --- /dev/null +++ b/lib/src/web_socket_js.dart @@ -0,0 +1,298 @@ +// coverage:ignore-file + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop' as js; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:web/web.dart' as web; + +import 'model/annotations.dart'; +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +const _BlobCodec _codec = _BlobCodec(); + +/// Create web socket client for Browser and JS environment. +@unsafe +@internal +@Throws([SpinifyTransportException]) +Future $webSocketConnect({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}) async { + StreamSubscription? onOpen, onError; + // ignore: close_sinks + web.WebSocket? socket; + try { + final completer = Completer(); + final s = socket = web.WebSocket( + url, + {...?protocols} + .map((e) => e.toJS) + .toList(growable: false) + .toJS, + ) + // Change binary type from "blob" to "arraybuffer" + ..binaryType = 'arraybuffer'; + + // The socket API guarantees that only a single open event will be + // emitted. + onOpen = s.onOpen.take(1).listen( + (event) { + if (completer.isCompleted) return; + completer.complete(WebSocket$JS(socket: s)); + }, + cancelOnError: false, + ); + onError = s.onError.take(1).listen( + (event) { + if (completer.isCompleted) return; + // Unfortunately, the underlying WebSocket API doesn't expose any + // specific information about the error itself. + completer.completeError( + SpinifyTransportException( + message: 'WebSocket connection failed', + error: event, + ), + StackTrace.current, + ); + }, + cancelOnError: false, + ); + + if (s.readyState == web.WebSocket.OPEN) { + completer.complete(WebSocket$JS(socket: s)); + } else if (s.readyState == web.WebSocket.CLOSING || + s.readyState == web.WebSocket.CLOSED) { + completer.completeError( + const SpinifyTransportException( + message: 'WebSocket connection already closed', + ), + StackTrace.current, + ); + } + return await completer.future; // Return the WebSocket instance. + } on SpinifyTransportException { + socket?.close(1002, 'Protocol error during connection setup'); + rethrow; + } on Object catch (error, stackTrace) { + socket?.close(1002, 'Protocol error during connection setup'); + Error.throwWithStackTrace( + SpinifyTransportException( + message: 'Failed to connect to $url', + error: error, + ), + stackTrace, + ); + } finally { + onOpen?.cancel().ignore(); + onError?.cancel().ignore(); + } +} + +@internal +class WebSocket$JS implements WebSocket { + WebSocket$JS({required web.WebSocket socket}) : _socket = socket { + final controller = StreamController(sync: true); + + stream = controller.stream.asyncMap(_codec.read).transform>( + StreamTransformer, List>.fromHandlers( + handleData: _dataHandler, + handleError: _errorHandler, + handleDone: _doneHandler, + ), + ); + + StreamSubscription? onMessage, onClose; + + var done = false; + void onDone([_]) { + if (done) return; // Ignore multiple calls. + done = true; + controller.close().ignore(); + onMessage?.cancel().ignore(); + onClose?.cancel().ignore(); + if (socket.readyState != web.WebSocket.CLOSED) socket.close(); + } + + onMessage = _socket.onMessage.listen( + controller.add, + cancelOnError: false, + onDone: onDone, + ); + + onClose = _socket.onClose.listen( + (event) { + _closeCode = event.code; + _closeReason = event.reason; + onDone(); + }, + cancelOnError: false, + onDone: onDone, + ); + } + + /// Handle incoming data. + void _dataHandler(List data, EventSink> sink) { + // coverage:ignore-start + if (data.isEmpty) return; + // coverage:ignore-end + sink.add(data); + } + + /// Handle incoming error. + void _errorHandler( + Object error, + StackTrace stackTrace, + EventSink> sink, + ) { + // coverage:ignore-start + switch (error) { + case SpinifyTransportException error: + sink.addError(error, stackTrace); + case ArgumentError error: + sink.addError( + SpinifyTransportException( + message: 'Invalid WebSocket message data type', + error: error, + ), + stackTrace, + ); + case Exception error: + sink.addError( + SpinifyTransportException( + message: switch (error.toString()) { + 'Exception' => 'Unknown WebSocket exception', + String message => message, + }, + error: error, + ), + stackTrace, + ); + default: + sink.addError( + SpinifyTransportException( + message: 'Unknown WebSocket error', + error: error, + ), + stackTrace, + ); + } + // coverage:ignore-end + } + + /// Handle socket close. + void _doneHandler(EventSink> sink) { + sink.close(); + _isClosed = true; + } + + final web.WebSocket _socket; + + @override + int? get closeCode => _closeCode; + int? _closeCode; + + @override + String? get closeReason => _closeReason; + String? _closeReason; + + @override + bool get isClosed => _isClosed; + bool _isClosed = false; + + /// The number of bytes of data that have been queued but not yet transmitted + /// to the network. + //int? get bufferedAmount => _socket.bufferedAmount; + + @override + late final Stream> stream; + + @override + void add(List event) => _socket.send(_codec.write(event)); + + @override + Future close([int? code, String? reason]) async { + _closeCode ??= code; + _closeReason ??= reason; + // coverage:ignore-start + try { + if (_socket.readyState == 3) + return; + else if (code != null && reason != null) + _socket.close(code, reason); + else if (code != null) + _socket.close(code); + else + _socket.close(); + //assert(_socket.readyState == 3, 'Socket is not closed'); + } on Object {/* ignore */} + // coverage:ignore-end + } +} + +@immutable +final class _BlobCodec { + const _BlobCodec(); + + @internal + js.JSAny write(Object data) { + // return web.Blob([Uint8List.fromList(bytes).toJS].toJS); + switch (data) { + case List bytes: + return Uint8List.fromList(bytes).toJS; + case String text: + return Uint8List.fromList(utf8.encode(text)).toJS; + case TypedData td: + return Uint8List.view( + td.buffer, + td.offsetInBytes, + td.lengthInBytes, + ).toJS; + case ByteBuffer bb: + return bb.asUint8List().toJS; + case js.JSObject blob: + return blob; // if (blob.isA()) + default: + throw ArgumentError.value(data, 'data', 'Invalid data type.'); + } + } + + @internal + Future> read(web.MessageEvent message) async { + final data = message.data; + if (data == null) { + return []; + } else if (data.typeofEquals('object') && + (data as js.JSObject).instanceOfString('ArrayBuffer')) { + return (data as js.JSArrayBuffer).toDart.asUint8List(); + } else if (data.typeofEquals('string')) { + return utf8.encode((data as js.JSString).toDart); + } + switch (data) { + case List bytes: + return bytes; + case String text: + return utf8.encode(text); + case ByteBuffer bb: + return bb.asUint8List(); + case TypedData td: + return Uint8List.view( + td.buffer, + td.offsetInBytes, + td.lengthInBytes, + ); + default: + if (data.isA()) { + final arrayBuffer = await (data as web.Blob).arrayBuffer().toDart; + return arrayBuffer.toDart.asUint8List(); + } else { + assert(false, 'Unsupported data type: $data'); + throw ArgumentError.value(data, 'data', 'Invalid data type.'); + } + } + } +} diff --git a/lib/src/web_socket_stub.dart b/lib/src/web_socket_stub.dart new file mode 100644 index 0000000..ce3db24 --- /dev/null +++ b/lib/src/web_socket_stub.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'model/annotations.dart'; +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +/// Stub for WebSocket client. +@unsafe +@internal +@Throws([SpinifyTransportException]) +Future $webSocketConnect({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}) => + throw const SpinifyTransportException( + message: 'WebSocket is not supported at current platform', + ); diff --git a/lib/src/web_socket_vm.dart b/lib/src/web_socket_vm.dart new file mode 100644 index 0000000..b4be8a7 --- /dev/null +++ b/lib/src/web_socket_vm.dart @@ -0,0 +1,211 @@ +// coverage:ignore-file + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import 'model/annotations.dart'; +import 'model/exception.dart'; +import 'model/transport_interface.dart'; + +/// Create web socket client for Dart VM (dart:io) environment. +@unsafe +@internal +@Throws([SpinifyTransportException]) +Future $webSocketConnect({ + required String url, // e.g. 'ws://localhost:8000/connection/websocket' + Map? headers, // e.g. {'Authorization': 'Bearer '} + Iterable? protocols, // e.g. {'centrifuge-protobuf'} +}) async { + io.WebSocket? socket; + try { + // ignore: close_sinks + final s = socket = await io.WebSocket.connect( + url, + headers: headers, + protocols: protocols, + ) + // Disable ping interval + ..pingInterval = null; + return WebSocket$VM(socket: s); + } on SpinifyTransportException { + socket?.close(1002, 'Protocol error during connection setup').ignore(); + rethrow; + } on Object catch (error, stackTrace) { + socket?.close(1002, 'Protocol error during connection setup').ignore(); + Error.throwWithStackTrace( + SpinifyTransportException( + message: 'Failed to connect to $url', + error: error, + ), + stackTrace, + ); + } +} + +@internal +class WebSocket$VM implements WebSocket { + WebSocket$VM({required io.WebSocket socket}) : _socket = socket { + stream = _socket.transform>( + StreamTransformer>.fromHandlers( + handleData: _dataHandler, + handleError: _errorHandler, + handleDone: _doneHandler, + ), + ); + } + + /// Handle incoming data. + void _dataHandler(Object? data, EventSink> sink) { + final List bytes; + // coverage:ignore-start + switch (data) { + case List b: + bytes = b; + case TypedData td: + bytes = Uint8List.view( + td.buffer, + td.offsetInBytes, + td.lengthInBytes, + ); + case ByteBuffer bb: + bytes = bb.asUint8List(); + case String s: + bytes = utf8.encode(s); + default: + sink.addError( + SpinifyTransportException( + message: 'Invalid WebSocket message', + error: ArgumentError.value(data, 'data', 'Invalid message'), + data: data, + ), + ); + return; + } + if (bytes.isEmpty) return; + // coverage:ignore-end + sink.add(bytes); + } + + /// Handle incoming error. + void _errorHandler( + Object error, + StackTrace stackTrace, + EventSink> sink, + ) { + // coverage:ignore-start + switch (error) { + case SpinifyTransportException error: + sink.addError(error, stackTrace); + case io.WebSocketException error: + sink.addError( + SpinifyTransportException( + message: 'WebSocket error', + error: error, + ), + stackTrace, + ); + case io.SocketException error: + sink.addError( + SpinifyTransportException( + message: 'Socket error', + error: error, + ), + stackTrace, + ); + case io.HandshakeException error: + sink.addError( + SpinifyTransportException( + message: 'Handshake error', + error: error, + ), + stackTrace, + ); + case io.TlsException error: + sink.addError( + SpinifyTransportException( + message: 'TLS error', + error: error, + ), + stackTrace, + ); + case io.HttpException error: + sink.addError( + SpinifyTransportException( + message: 'HTTP error', + error: error, + ), + stackTrace, + ); + case Exception error: + sink.addError( + SpinifyTransportException( + message: switch (error.toString()) { + 'Exception' => 'Unknown WebSocket exception', + String message => message, + }, + error: error, + ), + stackTrace, + ); + default: + sink.addError( + SpinifyTransportException( + message: 'Unknown WebSocket error', + error: error, + ), + stackTrace, + ); + } + // coverage:ignore-end + } + + /// Handle socket close. + void _doneHandler(EventSink> sink) { + sink.close(); + _isClosed = true; + } + + final io.WebSocket _socket; + + @override + int? get closeCode => _socket.closeCode ?? _closeCode; + int? _closeCode; + + @override + String? get closeReason => _socket.closeReason ?? _closeReason; + String? _closeReason; + + @override + bool get isClosed => _isClosed; + bool _isClosed = false; + + @override + late final Stream> stream; + + @override + void add(List event) => _socket.add(event); + + @override + Future close([int? code, String? reason]) async { + _closeCode ??= code; + _closeReason ??= reason; + // coverage:ignore-start + try { + if (_socket.readyState == 3) + return; + else if (code != null && reason != null) + _socket.close(code, reason).ignore(); + else if (code != null) + _socket.close(code).ignore(); + else + _socket.close().ignore(); + // Thats a bug in the dart:io, the socket is not closed immediately + //assert(_socket.readyState == io.WebSocket.closed); + } on Object {/* ignore */} + // coverage:ignore-end + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 99f4ab7..557c105 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Dart and Flutter over WebSockets with Protobuf support. -version: 0.0.4 +version: 0.1.0-pre.1 homepage: https://centrifugal.dev @@ -57,12 +57,13 @@ dependencies: stack_trace: ^1.11.0 dev_dependencies: - build_runner: ^2.4.6 - pubspec_generator: ^4.0.0 + build_runner: ^2.4.10 + pubspec_generator: ^4.1.0-pre.1 benchmark_harness: ^2.2.2 - lints: ^5.0.0 - test: ^1.24.4 - fake_async: ^1.3.1 + lints: '>=4.0.0 <6.0.0' + test: ^1.25.8 + fake_async: ^1.3.2 + mockito: ^5.0.0 # https://github.com/dart-lang/mockito/issues/732 # https://github.com/dart-lang/mockito/pull/738 # https://github.com/dart-lang/mockito/issues/755 diff --git a/test/smoke/smoke_test.dart b/test/smoke/smoke_test.dart index bf06b5e..bb9d000 100644 --- a/test/smoke/smoke_test.dart +++ b/test/smoke/smoke_test.dart @@ -206,37 +206,13 @@ void main() { notification!; await expectLater( - notification.history, - throwsA( - isA() - .having( - (e) => e.replyCode, - 'replyCode', - equals(108), - ) - .having( - (e) => e.message.trim().toLowerCase(), - 'message', - equals('not available'), - ), - ), + notification.history(), + throwsA(isA()), // Not available ); await expectLater(notification.presence(), completes); await expectLater( - notification.presenceStats, - throwsA( - isA() - .having( - (e) => e.replyCode, - 'replyCode', - equals(108), - ) - .having( - (e) => e.message.trim().toLowerCase(), - 'message', - equals('not available'), - ), - ), + notification.presenceStats(), + throwsA(isA()), // Not available ); await client.close(); expect(client.state, isA()); diff --git a/test/unit/codec_test.dart b/test/unit/codec_test.dart new file mode 100644 index 0000000..fd7f12b --- /dev/null +++ b/test/unit/codec_test.dart @@ -0,0 +1,331 @@ +import 'package:protobuf/protobuf.dart' as pb; +import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; +import 'package:test/test.dart'; + +void main() => group('Codec', () { + test('Command_encoding', () { + final command = SpinifySendRequest( + timestamp: DateTime(2021, 1, 1), + data: [for (var i = 0; i < 256; i++) i], + ); + const codec = SpinifyProtobufCommandEncoder(); + final bytesFromCodec = codec.convert(command); + expect(bytesFromCodec.length, greaterThan(0)); + + // Try read the bytes back. + final reader = pb.CodedBufferReader(bytesFromCodec); + final decoded = pb.Command(); + reader.readMessage(decoded, pb.ExtensionRegistry.EMPTY); + + expect(reader.isAtEnd(), isTrue); + expect(decoded.id, equals(command.id)); + expect(decoded.send.data, equals(command.data)); + + // Compare with direct encoding through protobuf and concatenation. + final commandData = decoded.writeToBuffer(); + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(commandData.lengthInBytes); + final bytesFromTest = writer.toBuffer() + commandData; + expect(bytesFromCodec.length, equals(bytesFromTest.length)); + expect(bytesFromCodec, equals(bytesFromTest)); + }); + + test('Protobuf_commands', () { + final commands = [ + SpinifyConnectRequest( + id: 1, + timestamp: DateTime(2021, 1, 1), + token: 'token', + data: const [1, 2, 3], + name: 'name', + version: '1.2.3', + subs: { + 'channel': SpinifySubscribeRequest( + id: 2, + timestamp: DateTime(2021, 1, 1), + channel: 'channel', + data: const [4, 5, 6], + epoch: 'epoch', + joinLeave: true, + offset: Int64.ZERO, + positioned: true, + recover: true, + recoverable: true, + token: 'token', + ), + }, + ), + SpinifySubscribeRequest( + channel: 'channel', + data: const [1, 2, 3], + epoch: 'epoch', + id: 1, + joinLeave: true, + offset: Int64.ZERO, + positioned: true, + recover: true, + recoverable: true, + timestamp: DateTime(2021, 1, 1), + token: 'token', + ), + SpinifyUnsubscribeRequest( + channel: 'channel', + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyPublishRequest( + channel: 'channel', + data: const [1, 2, 3], + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyPresenceRequest( + channel: 'channel', + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyPresenceStatsRequest( + channel: 'channel', + id: 1, + timestamp: DateTime(2021, 1, 1), + ), + SpinifyHistoryRequest( + channel: 'channel', + id: 1, + limit: 1, + since: (epoch: 'epoch', offset: Int64.ZERO), + timestamp: DateTime(2021, 1, 1), + reverse: false, + ), + SpinifyPingRequest(timestamp: DateTime(2021, 1, 1)), + SpinifySendRequest( + data: const [1, 2, 3], + timestamp: DateTime(2021, 1, 1), + ), + SpinifyRPCRequest( + data: const [1, 2, 3], + id: 1, + method: 'method', + timestamp: DateTime(2021, 1, 1), + ), + SpinifyRefreshRequest( + id: 1, + timestamp: DateTime(2021, 1, 1), + token: 'token', + ), + SpinifySubRefreshRequest( + id: 1, + timestamp: DateTime(2021, 1, 1), + token: 'token', + channel: 'channel', + ), + ]; + final codec = SpinifyProtobufCodec(); + for (final command in commands) { + expect( + codec.encoder.convert(command), + allOf( + isNotEmpty, + isA>(), + ), + ); + } + }); + + test('Protobuf_replies', () { + final replies = [ + pb.Reply(), + pb.Reply() + ..id = 1 + ..error = pb.Error() + ..error.message = 'message' + ..error.code = 1 + ..error.temporary = true, + pb.Reply() + ..id = 1 + ..connect = pb.ConnectResult() + ..connect.expires = true + ..connect.ttl = 1 + ..connect.version = 'version' + ..connect.client = 'client' + ..connect.data = [1, 2, 3] + ..connect.node = 'node' + ..connect.ping = 600 + ..connect.pong = true + ..connect.session = 'session' + ..connect.subs.addAll({ + 'channel': pb.SubscribeResult() + ..expires = true + ..ttl = 1 + ..recoverable = true + ..epoch = 'epoch' + }), + pb.Reply() + ..id = 1 + ..subscribe = pb.SubscribeResult() + ..subscribe.expires = true + ..subscribe.ttl = 1 + ..subscribe.recoverable = true + ..subscribe.epoch = 'epoch' + ..subscribe.recovered = true + ..subscribe.data = [1, 2, 3] + ..subscribe.positioned = true + ..subscribe.wasRecovering = true + ..subscribe.publications.addAll({ + pb.Publication() + ..data = [1, 2, 3] + ..offset = Int64.ZERO + ..tags.addAll({'tag': 'tag'}) + ..info = pb.ClientInfo() + ..info.client = 'client' + ..info.user = 'user' + ..info.chanInfo = [1, 2, 3] + }), + pb.Reply() + ..id = 1 + ..unsubscribe = pb.UnsubscribeResult(), + pb.Reply() + ..id = 1 + ..publish = pb.PublishResult(), + pb.Reply() + ..id = 1 + ..presence = pb.PresenceResult() + ..presence.presence.addAll({ + 'client': pb.ClientInfo() + ..client = 'client' + ..user = 'user' + ..chanInfo = [1, 2, 3] + }), + pb.Reply() + ..id = 1 + ..presenceStats = pb.PresenceStatsResult() + ..presenceStats.numClients = 1 + ..presenceStats.numUsers = 1, + pb.Reply() + ..id = 1 + ..history = pb.HistoryResult() + ..history.epoch = 'epoch' + ..history.offset = Int64.ZERO + ..history.publications.addAll({ + pb.Publication() + ..data = [1, 2, 3] + ..info = pb.ClientInfo() + ..info.client = 'client' + ..info.user = 'user' + ..info.chanInfo = [1, 2, 3] + }), + pb.Reply() + ..id = 1 + ..rpc = pb.RPCResult() + ..rpc.data = [1, 2, 3], + pb.Reply() + ..id = 1 + ..refresh = pb.RefreshResult() + ..refresh.expires = true + ..refresh.ttl = 1 + ..refresh.client = 'client' + ..refresh.version = 'version', + pb.Reply() + ..id = 1 + ..subRefresh = pb.SubRefreshResult() + ..subRefresh.expires = true + ..subRefresh.ttl = 1, + pb.Reply() + ..push = pb.Push() + ..push.pub = pb.Publication() + ..push.pub.data = [1, 2, 3] + ..push.pub.offset = Int64.ZERO + ..push.pub.tags.addAll({'tag': 'tag'}) + ..push.pub.info = pb.ClientInfo() + ..push.pub.info.client = 'client' + ..push.pub.info.user = 'user' + ..push.pub.info.chanInfo = [1, 2, 3], + pb.Reply() + ..push = pb.Push() + ..push.join = pb.Join() + ..push.join.info = pb.ClientInfo() + ..push.join.info.client = 'client' + ..push.join.info.user = 'user' + ..push.join.info.chanInfo = [1, 2, 3], + pb.Reply() + ..push = pb.Push() + ..push.leave = pb.Leave() + ..push.leave.info = pb.ClientInfo(), + pb.Reply() + ..push = pb.Push() + ..push.unsubscribe = pb.Unsubscribe() + ..push.unsubscribe.code = 1 + ..push.unsubscribe.reason = 'reason', + pb.Reply() + ..push = pb.Push() + ..push.message = pb.Message() + ..push.message.data = [1, 2, 3], + pb.Reply() + ..push = pb.Push() + ..push.subscribe = pb.Subscribe() + ..push.subscribe.recoverable = true + ..push.subscribe.epoch = 'epoch' + ..push.subscribe.data = [1, 2, 3] + ..push.subscribe.positioned = true, + pb.Reply() + ..push = pb.Push() + ..push.connect = pb.Connect() + ..push.connect.expires = true + ..push.connect.ttl = 1 + ..push.connect.version = 'version' + ..push.connect.client = 'client' + ..push.connect.data = [1, 2, 3] + ..push.connect.node = 'node' + ..push.connect.ping = 600 + ..push.connect.pong = true + ..push.connect.session = 'session' + ..push.connect.subs.addAll({ + 'channel': pb.SubscribeResult() + ..expires = true + ..ttl = 1 + ..recoverable = true + ..epoch = 'epoch' + }), + pb.Reply() + ..push = pb.Push() + ..push.disconnect = pb.Disconnect() + ..push.disconnect.code = 1 + ..push.disconnect.reason = 'reason', + pb.Reply() + ..push = pb.Push() + ..push.refresh = pb.Refresh() + ..push.refresh.expires = true + ..push.refresh.ttl = 1, + ]; + final codec = SpinifyProtobufCodec(); + for (final reply in replies) { + final replyData = reply.writeToBuffer(); + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(replyData.lengthInBytes) + ..writeRawBytes(replyData); + final bytes = writer.toBuffer(); + expect( + codec.decoder.convert(bytes).single, + isA(), + ); + } + }); + + test('Unknown_replies', () { + final codec = SpinifyProtobufCodec(); + expect( + codec.decoder.convert([]), + isEmpty, + ); + final replyData = (pb.Reply()..push = pb.Push()).writeToBuffer(); + final writer = pb.CodedBufferWriter() + ..writeInt32NoTag(replyData.lengthInBytes) + ..writeRawBytes(replyData); + final bytes = writer.toBuffer(); + expect( + codec.decoder.convert(bytes), + isEmpty, + ); + }); + }); diff --git a/test/unit/codecs.dart b/test/unit/codecs.dart new file mode 100644 index 0000000..0b02219 --- /dev/null +++ b/test/unit/codecs.dart @@ -0,0 +1,23 @@ +// ignore_for_file: avoid_classes_with_only_static_members + +import 'package:protobuf/protobuf.dart' as pb; + +abstract final class ProtobufCodec { + /// Encode a protobuf message to a list of bytes. + static List encode(pb.GeneratedMessage msg) { + final bytes = msg.writeToBuffer(); + return (pb.CodedBufferWriter() + ..writeInt32NoTag(bytes.lengthInBytes) + ..writeRawBytes(bytes)) + .toBuffer(); + } + + /// Decode a protobuf message from a list of bytes. + static T decode(T msg, List bytes) { + final reader = pb.CodedBufferReader(bytes); + assert(!reader.isAtEnd(), 'No data to read'); + reader.readMessage(msg, pb.ExtensionRegistry.EMPTY); + assert(reader.isAtEnd(), 'Not all data was read'); + return msg; + } +} diff --git a/test/unit/config_test.dart b/test/unit/config_test.dart index e9a9aba..4df60bb 100644 --- a/test/unit/config_test.dart +++ b/test/unit/config_test.dart @@ -12,12 +12,10 @@ void main() { test('Fields', () { final logBuffer = SpinifyLogBuffer(size: 10); - Future transportBuilder({ + Future transportBuilder({ required String url, - required SpinifyConfig config, - required SpinifyMetrics metrics, - required Future Function(SpinifyReply reply) onReply, - required Future Function({required bool temporary}) onDisconnect, + Map? headers, + Iterable? protocols, }) => throw UnimplementedError(); diff --git a/test/unit/model_test.dart b/test/unit/model_test.dart new file mode 100644 index 0000000..d02b731 --- /dev/null +++ b/test/unit/model_test.dart @@ -0,0 +1,1829 @@ +// ignore_for_file: non_const_call_to_literal_constructor + +import 'package:fixnum/fixnum.dart'; +import 'package:spinify/src/model/annotations.dart' as annotations; +import 'package:spinify/src/model/channel_event.dart' as channel_event; +import 'package:spinify/src/model/channel_events.dart' as channel_events; +import 'package:spinify/src/model/client_info.dart' as client_info; +import 'package:spinify/src/model/codes.dart' as codes; +import 'package:spinify/src/model/command.dart' as command; +import 'package:spinify/src/model/exception.dart' as exception; +import 'package:spinify/src/model/history.dart' as history; +import 'package:spinify/src/model/metric.dart' as metric; +import 'package:spinify/src/model/presence_stats.dart' as presence_stats; +import 'package:spinify/src/model/pubspec.yaml.g.dart' as pubspec; +import 'package:spinify/src/model/reply.dart' as reply; +import 'package:spinify/src/model/state.dart' as state; +import 'package:spinify/src/model/states_stream.dart' as states_stream; +import 'package:spinify/src/model/subscription_config.dart' + as subscription_config; +import 'package:spinify/src/model/subscription_state.dart' + as subscription_state; +import 'package:spinify/src/model/subscription_states.dart' + as subscription_states; +import 'package:spinify/src/protobuf/protobuf_codec.dart' as protobuf_codec; +import 'package:spinify/src/util/list_equals.dart' as list_equals; +import 'package:test/test.dart'; + +void main() { + group('Model', () { + group('Annotations', () { + test('Instances', () { + expect( + annotations.interactive, + isA(), + ); + expect( + annotations.sideEffect, + isA(), + ); + expect( + annotations.safe, + isA(), + ); + expect( + annotations.unsafe, + isA(), + ); + expect( + annotations.SpinifyAnnotation('name'), + isA(), + ); + expect( + annotations.Throws(const [Exception]), + isA(), + ); + }); + + test('Getters', () { + expect( + const annotations.Throws([Exception]), + isA() + .having( + (e) => e.name, + 'name', + equals('throws'), + ) + .having( + (e) => e.meta, + 'meta', + allOf( + isA>(), + isEmpty, + ), + ) + .having( + (e) => e.exceptions, + 'exceptions', + allOf( + isA>(), + hasLength(1), + contains(Exception), + ), + ), + ); + }); + }); + + group('Codes', () { + test('Instances', () { + expect(codes.SpinifyDisconnectCode.disconnect(), isA()); + expect(codes.SpinifyDisconnectCode.noPingFromServer(), isA()); + expect(codes.SpinifyDisconnectCode.internalServerError(), isA()); + expect(codes.SpinifyDisconnectCode.unauthorized(), isA()); + expect(codes.SpinifyDisconnectCode.unknownChannel(), isA()); + expect(codes.SpinifyDisconnectCode.permissionDenied(), isA()); + expect(codes.SpinifyDisconnectCode.methodNotFound(), isA()); + expect(codes.SpinifyDisconnectCode.alreadySubscribed(), isA()); + expect(codes.SpinifyDisconnectCode.limitExceeded(), isA()); + expect(codes.SpinifyDisconnectCode.badRequest(), isA()); + expect(codes.SpinifyDisconnectCode.notAvailable(), isA()); + expect(codes.SpinifyDisconnectCode.tokenExpired(), isA()); + expect(codes.SpinifyDisconnectCode.expired(), isA()); + expect(codes.SpinifyDisconnectCode.tooManyRequests(), isA()); + expect(codes.SpinifyDisconnectCode.unrecoverablePosition(), isA()); + expect(codes.SpinifyDisconnectCode.normalClosure(), isA()); + expect(codes.SpinifyDisconnectCode.abnormalClosure(), isA()); + }); + + test('Normalize', () { + for (var i = -1; i <= 5000; i++) { + final code = codes.SpinifyDisconnectCode(i); + expect( + code.reconnect, + allOf(isA(), same(code.reconnect)), + reason: 'Code: $i should ' + '${code.reconnect ? '' : 'not '}' + 'reconnect', + ); + /* final tuple = codes.SpinifyDisconnectCode.normalize(i); + expect( + tuple.code, + allOf(isA(), equals(i)), + ); + expect( + tuple.reason, + allOf(isA(), isNotEmpty), + ); + expect( + tuple.reconnect, + allOf(isA(), same(tuple.code.reconnect)), + reason: 'Code: $i should ' + '${tuple.code.reconnect ? '' : 'not '}' + 'reconnect', + ); */ + } + }); + }); + + group('Channel_events', () { + test('Variants', () { + final now = DateTime.now(); + const channel = 'channel'; + final events = [ + channel_event.SpinifyPublication( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + channel_event.SpinifyPresence.join( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyPresence.leave( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyUnsubscribe( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + ), + channel_event.SpinifySubscribe( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + positioned: true, + recoverable: true, + since: (epoch: 'epoch', offset: Int64(10)), + ), + channel_event.SpinifyMessage( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + ), + channel_event.SpinifyConnect( + timestamp: now, + channel: channel, + client: 'client', + version: 'version', + data: const [1, 2, 3], + expires: true, + ttl: now.add(const Duration(seconds: 10)), + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + node: 'node', + ), + channel_event.SpinifyDisconnect( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + reconnect: true, + ), + channel_event.SpinifyRefresh( + timestamp: now, + channel: channel, + expires: true, + ttl: now.add(const Duration(seconds: 10)), + ), + ]; + + for (final event in events) { + expect( + event, + isA() + .having( + (e) => e.runtimeType, + 'runtimeType', + equals(event.runtimeType), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(now), + ) + .having( + (e) => e.channel, + 'channel', + same(channel), + ), + ); + + expect( + event.type, + allOf( + isA(), + isNotEmpty, + ), + ); + + expect( + event.copyWith(channel: event.channel), + allOf( + equals(event), + isNot(same(event)), + isA() + .having( + (e) => e.channel, + 'channel', + same(event.channel), + ) + .having( + (e) => e.type, + 'type', + same(event.type), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(event.timestamp), + ) + .having( + (e) => e.hashCode, + 'hashCode', + same(event.hashCode), + ), + ), + ); + + expect( + event.copyWith(channel: 'another'), + allOf( + isNot(equals(event)), + isNot(same(event)), + isA() + .having( + (e) => e.channel, + 'channel', + isNot(same(event.channel)), + ) + .having( + (e) => e.type, + 'type', + same(event.type), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(event.timestamp), + ) + .having( + (e) => e.hashCode, + 'hashCode', + isNot(same(event.hashCode)), + ), + ), + ); + + expect( + event.toString(), + equals('${event.type}{channel: $channel}'), + ); + + expect( + event.mapOrNull( + connect: (e) => e, + disconnect: (e) => e, + message: (e) => e, + presence: (e) => e, + publication: (e) => e, + refresh: (e) => e, + subscribe: (e) => e, + unsubscribe: (e) => e, + ), + allOf( + isNotNull, + isA(), + same(event), + ), + ); + + expect( + event.mapOrNull(), + isNull, + ); + + expect( + event.map( + connect: (e) => e.isConnect, + disconnect: (e) => e.isDisconnect, + message: (e) => e.isMessage, + presence: (e) => e.isPresence, + publication: (e) => e.isPublication, + refresh: (e) => e.isRefresh, + subscribe: (e) => e.isSubscribe, + unsubscribe: (e) => e.isUnsubscribe, + ), + allOf( + isA(), + isTrue, + ), + ); + + expect( + [ + event.isConnect, + event.isDisconnect, + event.isMessage, + event.isPresence, + event.isPublication, + event.isRefresh, + event.isSubscribe, + event.isUnsubscribe, + ], + containsOnce(true), + ); + } + expect(events.sort, returnsNormally); + }); + + test('Presense', () { + final now = DateTime.now(); + const channel = 'channel'; + final join = channel_event.SpinifyPresence.join( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ); + final leave = channel_event.SpinifyPresence.leave( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ); + + expect(join.isJoin, isTrue); + expect(leave.isLeave, isTrue); + expect(join.isLeave, isFalse); + expect(leave.isJoin, isFalse); + }); + + test('Streams', () { + final now = DateTime.now(); + const channel = 'channel'; + final events = [ + channel_event.SpinifyPublication( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + channel_event.SpinifyPresence.join( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyPresence.leave( + timestamp: now, + channel: channel, + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + ), + channel_event.SpinifyUnsubscribe( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + ), + channel_event.SpinifySubscribe( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + positioned: true, + recoverable: true, + since: (epoch: 'epoch', offset: Int64(10)), + ), + channel_event.SpinifyMessage( + timestamp: now, + channel: channel, + data: const [1, 2, 3], + ), + channel_event.SpinifyConnect( + timestamp: now, + channel: channel, + client: 'client', + version: 'version', + data: const [1, 2, 3], + expires: true, + ttl: now.add(const Duration(seconds: 10)), + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + node: 'node', + ), + channel_event.SpinifyDisconnect( + timestamp: now, + channel: channel, + code: 1000, + reason: 'reason', + reconnect: true, + ), + channel_event.SpinifyRefresh( + timestamp: now, + channel: channel, + expires: true, + ttl: now.add(const Duration(seconds: 10)), + ), + ]; + for (var i = 0; i < events.length; i++) { + final event = events[i]; + channel_events.SpinifyChannelEvents stream() => + channel_events.SpinifyChannelEvents(Stream.value(event)); + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + expectLater( + stream(), + emitsInOrder([ + same(event), + emitsDone, + ]), + ); + expectLater( + stream().filter(channel: 'another'), + emitsDone, + ); + expectLater( + stream().filter(channel: channel), + emitsInOrder([ + same(event), + emitsDone, + ]), + ); + expectLater( + stream().publication(channel: channel), + emitsInOrder([ + if (event.isPublication) same(event), + emitsDone, + ]), + ); + expectLater( + stream().presence(channel: channel), + emitsInOrder([ + if (event.isPresence) same(event), + emitsDone, + ]), + ); + expectLater( + stream().unsubscribe(channel: channel), + emitsInOrder([ + if (event.isUnsubscribe) same(event), + emitsDone, + ]), + ); + expectLater( + stream().message(channel: channel), + emitsInOrder([ + if (event.isMessage) same(event), + emitsDone, + ]), + ); + expectLater( + stream().subscribe(channel: channel), + emitsInOrder([ + if (event.isSubscribe) same(event), + emitsDone, + ]), + ); + expectLater( + stream().connect(channel: channel), + emitsInOrder([ + if (event.isConnect) same(event), + emitsDone, + ]), + ); + expectLater( + stream().disconnect(channel: channel), + emitsInOrder([ + if (event.isDisconnect) same(event), + emitsDone, + ]), + ); + expectLater( + stream().refresh(channel: channel), + emitsInOrder([ + if (event.isRefresh) same(event), + emitsDone, + ]), + ); + } + }); + + test('Client_info', () { + final info = client_info.SpinifyClientInfo( + client: 'client', + user: 'user', + channelInfo: const [1, 2, 3], + connectionInfo: const [4, 5, 6], + ); + expect(info, isA()); + expect( + info.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyClientInfo{'), + endsWith('}'), + ), + ); + expect(info == info, isTrue); + expect( + list_equals.listEquals( + info.channelInfo, + info.channelInfo?.toList(growable: false), + ), + isTrue, + ); + expect( + list_equals.listEquals( + info.connectionInfo, + info.connectionInfo?.toList(growable: false), + ), + isTrue, + ); + expect( + info == + client_info.SpinifyClientInfo( + user: info.user, + client: info.client, + connectionInfo: info.connectionInfo?.toList(growable: false), + channelInfo: info.channelInfo?.toList(growable: false), + ), + isTrue, + ); + expect( + info == + client_info.SpinifyClientInfo( + user: info.user, + client: info.client, + connectionInfo: info.connectionInfo?.toList(growable: false), + channelInfo: const [7, 8, 9], + ), + isFalse, + ); + }); + + test('Publications', () { + final publication1 = channel_event.SpinifyPublication( + timestamp: DateTime.now(), + channel: 'channel', + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ); + final publication2 = channel_event.SpinifyPublication( + timestamp: publication1.timestamp, + channel: publication1.channel, + offset: publication1.offset, + info: publication1.info, + data: [...publication1.data], + tags: {...?publication1.tags}, + ); + expect(publication1, isA()); + expect(publication1.hashCode, isPositive); + expect( + publication1.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('Publication{'), + endsWith('}'), + ), + ); + expect(publication1, equals(publication1)); + expect(publication1, equals(publication2)); + }); + + test('History', () { + final history1 = history.SpinifyHistory( + publications: [ + channel_event.SpinifyPublication( + timestamp: DateTime.now(), + channel: 'channel', + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + since: ( + epoch: 'epoch', + offset: Int64(10), + ), + ); + final history2 = history.SpinifyHistory( + publications: [ + ...history1.publications, + ], + since: history1.since, + ); + expect(history1, isA()); + expect(history1.hashCode, isPositive); + expect( + history1.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyHistory{'), + endsWith('}'), + ), + ); + expect( + list_equals.listEquals(history1.publications, history2.publications), + isTrue, + ); + expect( + history1 == history2, + isTrue, + ); + }); + + test('PresenceStats', () { + const stats1 = presence_stats.SpinifyPresenceStats( + channel: 'channel', + clients: 5, + users: 3, + ); + final stats2 = presence_stats.SpinifyPresenceStats( + channel: stats1.channel, + clients: stats1.clients, + users: stats1.users, + ); + const stats3 = presence_stats.SpinifyPresenceStats( + channel: 'another', + clients: 6, + users: 4, + ); + expect(stats1, isA()); + expect(stats1.hashCode, isPositive); + expect( + stats1.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyPresenceStats{'), + endsWith('}'), + ), + ); + expect(stats1, equals(stats1)); + expect(stats1, equals(stats2)); + expect(stats1, isNot(equals(stats3))); + }); + }); + + group('Commands', () { + test('Instances', () { + const id = 1; + final timestamp = DateTime.now(); + const channel = 'channel'; + const token = 'token'; + final commands = [ + command.SpinifyConnectRequest( + id: id, + timestamp: timestamp, + data: const [1, 2, 3], + name: 'name', + token: token, + version: 'version', + subs: { + channel: command.SpinifySubscribeRequest( + channel: channel, + data: const [1, 2, 3], + epoch: 'epoch', + joinLeave: true, + offset: Int64(10), + positioned: true, + recover: true, + recoverable: true, + id: id, + timestamp: timestamp, + token: token, + ), + }, + ), + command.SpinifySubscribeRequest( + channel: channel, + data: const [1, 2, 3], + epoch: 'epoch', + joinLeave: true, + offset: Int64(10), + positioned: true, + recover: true, + recoverable: true, + id: id, + timestamp: timestamp, + token: token, + ), + command.SpinifyUnsubscribeRequest( + channel: channel, + id: id, + timestamp: timestamp, + ), + command.SpinifyPublishRequest( + channel: channel, + data: const [1, 2, 3], + id: id, + timestamp: timestamp, + ), + command.SpinifyPingRequest( + timestamp: timestamp, + ), + command.SpinifyPresenceRequest( + channel: channel, + id: id, + timestamp: timestamp, + ), + command.SpinifyPresenceStatsRequest( + channel: channel, + id: id, + timestamp: timestamp, + ), + command.SpinifyHistoryRequest( + channel: channel, + id: id, + timestamp: timestamp, + limit: 10, + reverse: true, + since: (epoch: token, offset: Int64(10)), + ), + command.SpinifySendRequest( + data: const [1, 2, 3], + timestamp: timestamp, + ), + command.SpinifyRPCRequest( + data: const [1, 2, 3], + id: id, + timestamp: timestamp, + method: 'method', + ), + command.SpinifyRefreshRequest( + id: id, + timestamp: timestamp, + token: token, + ), + command.SpinifySubRefreshRequest( + id: id, + timestamp: timestamp, + token: token, + channel: channel, + ), + ]; + + for (var i = 0; i < commands.length; i++) { + final c = commands[i]; + expect( + c, + isA() + .having( + (e) => e.id, + 'id', + c.hasId ? equals(id) : equals(0), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(timestamp), + ) + .having( + (e) => e.type, + 'type', + isNotEmpty, + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + startsWith(c.type), + ), + ); + expect(c == c, isTrue); + final encoder = protobuf_codec.SpinifyProtobufCodec().encoder; + expect( + encoder.convert(c), + allOf( + isA>(), + isNotEmpty, + ), + ); + for (var j = 0; j < commands.length; j++) { + final other = commands[j]; + expect( + c == other, + c.type == other.type, + ); + } + } + + expect(commands.sort, returnsNormally); + + final ping1 = command.SpinifyPingRequest( + timestamp: DateTime(2000), + ); + final ping2 = command.SpinifyPingRequest( + timestamp: DateTime(2001), + ); + expect(ping1.compareTo(ping2), lessThan(0)); + expect(ping1 == ping2, isFalse); + }); + }); + + group('Exceptions', () { + test('Instances', () { + const message = 'message'; + final error = Exception('error'); + final exceptions = [ + exception.SpinifyConnectionException( + message: message, + error: error, + ), + exception.SpinifyReplyException( + replyCode: 1000, + replyMessage: message, + temporary: true, + error: error, + ), + exception.SpinifyPingException( + message: message, + error: error, + ), + exception.SpinifySubscriptionException( + message: message, + error: error, + channel: 'channel', + ), + exception.SpinifySendException( + message: message, + error: error, + ), + exception.SpinifyRPCException( + error: error, + message: message, + ), + exception.SpinifyFetchException( + error: error, + message: message, + ), + exception.SpinifyRefreshException( + error: error, + message: message, + ), + exception.SpinifyTransportException( + error: error, + message: message, + data: const [1, 2, 3], + ), + ]; + + for (var i = 0; i < exceptions.length; i++) { + final e = exceptions[i]; + expect( + e, + isA() + .having( + (e) => e.message, + 'message', + equals(message), + ) + .having( + (e) => e.error, + 'error', + same(error), + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + message, + ), + ); + + expect(e == e, isTrue); + } + }); + + test('Visitor', () { + final e = exception.SpinifyPingException( + error: exception.SpinifyPingException( + error: exception.SpinifyPingException( + error: exception.SpinifyPingException( + error: Exception('Fake'), + ), + ), + ), + ); + + final list = []; + e.visitor(list.add); + expect(list, hasLength(5)); + }); + }); + + group('Reply', () { + test('Instances', () { + const id = 1; + final timestamp = DateTime.now(); + const channel = 'channel'; + final replies = [ + reply.SpinifyServerPing( + timestamp: timestamp, + ), + reply.SpinifyPush( + timestamp: timestamp, + event: channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ), + reply.SpinifyConnectResult( + client: 'client', + version: 'version', + timestamp: timestamp, + id: id, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + data: const [1, 2, 3], + node: 'node', + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + subs: { + channel: reply.SpinifySubscribeResult( + data: const [1, 2, 3], + positioned: true, + recoverable: true, + id: id, + timestamp: timestamp, + expires: true, + publications: [ + channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + recovered: true, + since: (epoch: 'epoch', offset: Int64(10)), + ttl: timestamp.add(const Duration(seconds: 10)), + wasRecovering: true, + ), + }, + ), + reply.SpinifySubscribeResult( + id: id, + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + recoverable: true, + publications: [ + channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + recovered: true, + since: (epoch: 'epoch', offset: Int64(10)), + data: const [1, 2, 3], + positioned: true, + wasRecovering: true, + ), + reply.SpinifyUnsubscribeResult( + id: id, + timestamp: timestamp, + ), + reply.SpinifyPublishResult( + id: id, + timestamp: timestamp, + ), + reply.SpinifyPresenceResult( + id: id, + timestamp: timestamp, + presence: { + channel: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + user: 'user', + connectionInfo: const [4, 5, 6], + ), + }, + ), + reply.SpinifyPresenceStatsResult( + id: id, + timestamp: timestamp, + numClients: 5, + numUsers: 3, + ), + reply.SpinifyHistoryResult( + id: id, + timestamp: timestamp, + publications: [ + channel_event.SpinifyPublication( + timestamp: timestamp, + channel: channel, + data: const [1, 2, 3], + offset: Int64(10), + info: client_info.SpinifyClientInfo( + channelInfo: const [1, 2, 3], + client: 'client', + connectionInfo: const [4, 5, 6], + user: 'user', + ), + tags: const {'key': 'value'}, + ), + ], + since: (epoch: 'epoch', offset: Int64(10)), + ), + reply.SpinifyPingResult( + id: id, + timestamp: timestamp, + ), + reply.SpinifyRPCResult( + id: id, + timestamp: timestamp, + data: const [1, 2, 3], + ), + reply.SpinifyRefreshResult( + id: id, + timestamp: timestamp, + client: 'client', + version: 'version', + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + ), + reply.SpinifySubRefreshResult( + id: id, + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + ), + reply.SpinifyErrorResult( + id: id, + timestamp: timestamp, + code: 1000, + message: 'message', + temporary: true, + ), + ]; + + for (var i = 0; i < replies.length; i++) { + final r = replies[i]; + expect( + r, + isA() + .having( + (e) => e.id, + 'id', + r.hasId ? equals(id) : equals(0), + ) + .having( + (e) => e.timestamp, + 'timestamp', + same(timestamp), + ) + .having( + (e) => e.type, + 'type', + isNotEmpty, + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + startsWith(r.type), + ), + ); + + expect(r.isResult, r.hasId); + + expect( + r, + anyOf( + isNot(isA()), + isA().having( + (e) => e.channel == e.event.channel, + 'channel', + isTrue, + ), + ), + ); + + for (var j = 0; j < replies.length; j++) { + final other = replies[j]; + expect( + r, + r.type != other.type ? isNot(same(other)) : same(other), + ); + expect( + r, + r.type != other.type ? isNot(equals(other)) : equals(other), + ); + } + } + + expect(replies.sort, returnsNormally); + + final ping1 = reply.SpinifyPingResult( + timestamp: DateTime(2000), + id: 1, + ); + final ping2 = reply.SpinifyPingResult( + timestamp: DateTime(2001), + id: 1, + ); + expect(ping1.compareTo(ping2), lessThan(0)); + expect(ping1, isNot(equals(ping2))); + }); + }); + + group('States', () { + test('Instances', () { + final timestamp = DateTime.now(); + final states = [ + state.SpinifyState.disconnected( + timestamp: timestamp, + temporary: false, + ), + state.SpinifyState.connecting( + timestamp: timestamp, + url: 'url', + ), + state.SpinifyState.connected( + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + url: 'url', + client: 'client', + data: const [1, 2, 3], + node: 'node', + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + version: 'version', + ), + state.SpinifyState.closed( + timestamp: timestamp, + ), + ]; + + for (var i = 0; i < states.length; i++) { + final s = states[i]; + expect( + s, + isA() + .having( + (e) => e.timestamp, + 'timestamp', + same(timestamp), + ) + .having( + (e) => e.type, + 'type', + isNotEmpty, + ) + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + isNotEmpty, + ), + ); + + expect(s.hashCode, isPositive); + expect(s, equals(s)); + + expect( + s.mapOrNull( + connected: (e) => e, + connecting: (e) => e, + disconnected: (e) => e, + closed: (e) => e, + ), + allOf( + isNotNull, + isA(), + same(s), + ), + ); + + expect( + s.map( + connected: (e) => state.SpinifyState$Connected( + expires: e.expires, + url: e.url, + client: e.client, + data: e.data, + node: e.node, + pingInterval: e.pingInterval, + sendPong: e.sendPong, + session: e.session, + timestamp: DateTime(0), + ttl: e.ttl, + version: e.version, + ), + connecting: (e) => state.SpinifyState$Connecting( + timestamp: DateTime(0), + url: e.url, + ), + disconnected: (e) => state.SpinifyState$Disconnected( + timestamp: DateTime(0), + temporary: e.temporary, + ), + closed: (e) => state.SpinifyState$Closed( + timestamp: DateTime(0), + ), + ), + isNot(equals(s)), + ); + + expect( + s.maybeMap( + orElse: () => 1, + ), + equals(1), + ); + + expect(s.mapOrNull(), isNull); + + expect( + s.map( + closed: (e) => e.isClosed, + connected: (e) => e.isConnected, + connecting: (e) => e.isConnecting, + disconnected: (e) => e.isDisconnected, + ), + isTrue, + ); + + expect(s.isDisconnected, isA()); + expect(s.isConnected, isA()); + expect(s.isConnecting, isA()); + expect(s.isClosed, isA()); + + expect( + s.url, + anyOf( + isNull, + 'url', + ), + ); + + expect( + s.mapOrNull( + connected: (e) => e.url, + connecting: (e) => e.url, + closed: (e) => e.url, + disconnected: (e) => e.url, + ), + anyOf( + isNull, + 'url', + ), + ); + + for (var j = 0; j < states.length; j++) { + final other = states[j]; + expect( + s, + s.type != other.type ? isNot(same(other)) : same(other), + ); + expect( + s, + s.type != other.type ? isNot(equals(other)) : equals(other), + ); + } + } + + expect(states.sort, returnsNormally); + }); + + test('Disconnected', () { + final timestamp = DateTime.now(); + final state1 = state.SpinifyState$Disconnected( + timestamp: timestamp, + temporary: false, + ); + final state2 = state.SpinifyState$Disconnected( + timestamp: timestamp, + temporary: false, + ); + final state3 = state.SpinifyState$Disconnected( + timestamp: timestamp.add(const Duration(seconds: 1)), + temporary: true, + ); + expect(state1, isA()); + expect(state1.hashCode, isPositive); + expect( + state1.toString(), + isNotEmpty, + ); + expect(state1, equals(state1)); + expect(state1, equals(state2)); + expect(state1, isNot(equals(state3))); + expect( + state1.permanent, + isNot(state1.temporary), + ); + expect( + state3.permanent, + isNot(state3.temporary), + ); + }); + + test('Stream', () { + final timestamp = DateTime.now(); + final states = [ + state.SpinifyState.disconnected( + timestamp: timestamp, + temporary: false, + ), + state.SpinifyState.connecting( + timestamp: timestamp, + url: 'url', + ), + state.SpinifyState.connected( + timestamp: timestamp, + expires: true, + ttl: timestamp.add(const Duration(seconds: 10)), + url: 'url', + client: 'client', + data: const [1, 2, 3], + node: 'node', + pingInterval: const Duration(seconds: 5), + sendPong: true, + session: 'session', + version: 'version', + ), + state.SpinifyState.closed( + timestamp: timestamp, + ), + ]; + + for (var i = 0; i < states.length; i++) { + final s = states[i]; + states_stream.SpinifyStatesStream stream() => + states_stream.SpinifyStatesStream(Stream.value(s)); + + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + + expectLater( + stream(), + emitsInOrder([ + same(s), + emitsDone, + ]), + ); + + expectLater( + stream().closed, + emitsInOrder([ + if (s.isClosed) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().connected, + emitsInOrder([ + if (s.isConnected) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().connecting, + emitsInOrder([ + if (s.isConnecting) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().disconnected, + emitsInOrder([ + if (s.isDisconnected) same(s), + emitsDone, + ]), + ); + } + }); + }); + + group('Metric', () { + test('Freeze', () { + final mutable = metric.SpinifyMetrics$Mutable(); + expect(mutable.freeze, returnsNormally); + }); + + test('ToJson', () { + final mutable = metric.SpinifyMetrics$Mutable(); + expect(mutable.toJson, returnsNormally); + }); + + test('ToString', () { + final mutable = metric.SpinifyMetrics$Mutable(); + expect( + mutable.toString(), + allOf( + isA(), + isNotEmpty, + startsWith('SpinifyMetrics{'), + endsWith('}'), + ), + ); + }); + + test('CompareTo', () { + final list = [ + metric.SpinifyMetrics$Mutable(), + metric.SpinifyMetrics$Mutable(), + ]; + expect( + list.sort, + returnsNormally, + ); + expect( + list.map((e) => e.freeze()).toList().sort, + returnsNormally, + ); + }); + + test('Getters', () { + final metrics = metric.SpinifyMetrics$Mutable(); + expect(metrics.messagesSent, isA()); + expect(metrics.messagesReceived, isA()); + }); + + test('Channels', () { + final m = metric.SpinifyMetrics$Mutable() + ..channels.addAll({ + 'channel': metric.SpinifyMetrics$Channel$Mutable(), + }); + expect(m.channels, hasLength(1)); + expect(m.freeze, returnsNormally); + expect(m.channels['channel'], isA()); + expect( + m.channels['channel']!.toString(), + allOf( + isA(), + isNotEmpty, + startsWith(r'SpinifyMetrics$Channel{'), + endsWith('}'), + )); + expect(m.toJson, returnsNormally); + }); + }); + + group('Subscription_state', () { + test('Instance', () { + final unsubscribed = + subscription_state.SpinifySubscriptionState$Unsubscribed(); + final subscribing = + subscription_state.SpinifySubscriptionState$Subscribing(); + final subscribed = + subscription_state.SpinifySubscriptionState$Subscribed(); + final list = [unsubscribed, subscribing, subscribed]; + for (var i = 0; i < list.length; i++) { + final s = list[i]; + subscription_states.SpinifySubscriptionStates stream() => + subscription_states.SpinifySubscriptionStates(Stream.value(s)); + expect( + s, + isA() + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + isNotEmpty, + ), + ); + expect(s, equals(s)); + expect(s, isNot(equals(list[(i + 1) % list.length]))); + + expect( + s.maybeMap( + orElse: () => 1, + ), + equals(1), + ); + + expect(s.mapOrNull(), isNull); + + expect( + s.map( + subscribed: (e) => e.isSubscribed, + subscribing: (e) => e.isSubscribing, + unsubscribed: (e) => e.isUnsubscribed, + ), + isTrue, + ); + + expect(s.isSubscribed, isA()); + expect(s.isSubscribing, isA()); + expect(s.isUnsubscribed, isA()); + + expect( + s.type, + allOf( + isNotNull, + isNotEmpty, + ), + ); + + expect( + s.mapOrNull( + subscribed: (e) => e.type, + subscribing: (e) => e.type, + unsubscribed: (e) => e.type, + ), + allOf( + isNotNull, + isNotEmpty, + ), + ); + + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + + expectLater( + stream(), + emitsInOrder([ + same(s), + emitsDone, + ]), + ); + + for (var j = 0; j < list.length; j++) { + final other = list[j]; + expect( + s, + s.type != other.type ? isNot(same(other)) : same(other), + ); + expect( + s, + s.type != other.type ? isNot(equals(other)) : equals(other), + ); + } + } + + expect(list.sort, returnsNormally); + }); + + test('Equality', () { + final unsubscribed = + subscription_state.SpinifySubscriptionState.unsubscribed(); + final subscribing = + subscription_state.SpinifySubscriptionState.subscribing(); + final subscribed = + subscription_state.SpinifySubscriptionState.subscribed(); + + final unsubscribed2 = + subscription_state.SpinifySubscriptionState.unsubscribed( + timestamp: DateTime(0)); + final subscribing2 = + subscription_state.SpinifySubscriptionState.subscribing( + timestamp: DateTime(0)); + final subscribed2 = + subscription_state.SpinifySubscriptionState.subscribed( + timestamp: DateTime(0)); + expect(unsubscribed, isNot(equals(unsubscribed2))); + expect(subscribing, isNot(equals(subscribing2))); + expect(subscribed, isNot(equals(subscribed2))); + expect(unsubscribed, equals(unsubscribed)); + expect(subscribing, equals(subscribing)); + expect(subscribed, equals(subscribed)); + }); + + test('Stream', () { + final timestamp = DateTime.now(); + final states = [ + subscription_state.SpinifySubscriptionState.unsubscribed( + timestamp: timestamp), + subscription_state.SpinifySubscriptionState.subscribing( + timestamp: timestamp), + subscription_state.SpinifySubscriptionState.subscribed( + timestamp: timestamp), + ]; + + for (var i = 0; i < states.length; i++) { + final s = states[i]; + subscription_states.SpinifySubscriptionStates stream() => + subscription_states.SpinifySubscriptionStates(Stream.value(s)); + + expect( + stream(), + allOf( + isA>(), + isA(), + ), + ); + + expectLater( + stream(), + emitsInOrder([ + same(s), + emitsDone, + ]), + ); + + expectLater( + stream().unsubscribed(), + emitsInOrder([ + if (s.isUnsubscribed) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().subscribing(), + emitsInOrder([ + if (s.isSubscribing) same(s), + emitsDone, + ]), + ); + + expectLater( + stream().subscribed(), + emitsInOrder([ + if (s.isSubscribed) same(s), + emitsDone, + ]), + ); + } + }); + }); + + group('Subscription_config', () { + test('ByDefault', () { + expect( + subscription_config.SpinifySubscriptionConfig.byDefault, + returnsNormally, + ); + }); + + test('Instance', () { + final config = subscription_config.SpinifySubscriptionConfig( + getPayload: () async => [1, 2, 3], + getToken: () async => 'token', + resubscribeInterval: (min: Duration.zero, max: Duration.zero), + since: (epoch: 'epoch', offset: Int64(10)), + timeout: Duration.zero, + joinLeave: true, + positioned: true, + recoverable: true, + ); + expect( + config, + isA() + .having( + (e) => e.hashCode, + 'hashCode', + isPositive, + ) + .having( + (e) => e.toString(), + 'toString', + isNotEmpty, + ), + ); + }); + }); + + group('Pubspec_yaml_g', () { + test('Instance', () { + final s = pubspec.Pubspec.source; // ignore: prefer_const_declarations + expect(s, isA>()); + final t = pubspec.Pubspec.timestamp; + expect(t, isA()); + }); + }); + }); +} diff --git a/test/unit/server_subscription_test.dart b/test/unit/server_subscription_test.dart deleted file mode 100644 index e64ac48..0000000 --- a/test/unit/server_subscription_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:fake_async/fake_async.dart'; -import 'package:spinify/spinify.dart'; -import 'package:test/test.dart'; - -void main() { - group('SpinifyServerSubscription', () { - test( - 'Emulate server subscription', - () => fakeAsync( - (async) { - final client = Spinify( - config: SpinifyConfig( - transportBuilder: $createFakeSpinifyTransport( - overrideCommand: (command) => switch (command) { - SpinifyConnectRequest request => SpinifyConnectResult( - id: request.id, - timestamp: DateTime.now(), - client: 'fake', - version: '0.0.1', - expires: false, - ttl: null, - data: null, - subs: { - 'notification:index': SpinifySubscribeResult( - id: request.id, - timestamp: DateTime.now(), - data: const [], - expires: false, - ttl: null, - positioned: false, - publications: [ - SpinifyPublication( - channel: 'notification:index', - data: const [], - info: SpinifyClientInfo( - client: 'fake', - user: 'fake', - channelInfo: const [], - connectionInfo: const [], - ), - timestamp: DateTime.now(), - tags: const { - 'type': 'notification', - }, - offset: Int64.ZERO, - ), - ], - recoverable: false, - recovered: false, - since: (epoch: '...', offset: Int64.ZERO), - wasRecovering: false, - ), - }, - pingInterval: const Duration(seconds: 25), - sendPong: false, - session: 'fake', - node: 'fake', - ), - _ => null, - }, - ), - ), - )..connect('ws://localhost:8000/connection/websocket'); - async.elapse(client.config.timeout); - expect( - client.subscriptions.server, - isA>() - .having( - (s) => s.length, - 'length', - 1, - ) - .having( - (s) => s['notification:index'], - 'notification:index', - isA(), - ), - ); - }, - ), - ); - }); -} diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index 1acb051..b79abe1 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -1,240 +1,427 @@ +// ignore_for_file: cascade_invocations + +import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:fake_async/fake_async.dart'; +import 'package:mockito/annotations.dart'; import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; import 'package:test/test.dart'; +import 'codecs.dart'; +import 'web_socket_fake.dart'; + +@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) void main() { - group('Spinify', () { - final buffer = SpinifyLogBuffer(size: 10); - - Spinify createFakeClient([ - void Function(ISpinifyTransport? transport)? out, - ]) => - Spinify( - config: SpinifyConfig( - transportBuilder: $createFakeSpinifyTransport(out: out), - logger: buffer.add, - ), - ); + group( + 'Spinify', + () { + const url = 'ws://localhost:8000/connection/websocket'; + final buffer = SpinifyLogBuffer(size: 10); - test('Create_and_close_client', () async { - final client = createFakeClient(); - expect(client.isClosed, isFalse); - expect(client.state, isA()); - await client.close(); - expect(client.state, isA()); - expect(client.isClosed, isTrue); - }); - - test('Create_and_close_multiple_clients', () async { - final clients = List.generate(10, (_) => createFakeClient()); - expect(clients.every((client) => !client.isClosed), isTrue); - await Future.wait(clients.map((client) => client.close())); - expect(clients.every((client) => client.isClosed), isTrue); - }); - - test('Change_client_state', () async { - final client = createFakeClient(); - expect(client.state, isA()); - await client.connect('ws://localhost:8000/connection/websocket'); - expect(client.state, isA()); - await client.disconnect(); - expect(client.state, isA()); - await client.close(); - expect(client.state, isA()); - }); - - test('Change_client_states', () { - final client = createFakeClient(); - Stream.fromIterable([ - () => client.connect('ws://localhost:8000/connection/websocket'), - client.disconnect, - () => client.connect('ws://localhost:8000/connection/websocket'), - client.disconnect, - client.close, - ]).asyncMap(Future.new).drain(); - expect(client.state, isA()); - expectLater( - client.states, - emitsInOrder([ - isA(), - isA(), - isA(), - isA(), - isA(), - isA(), - isA() - ])); - }); + Spinify createFakeClient({ + Future Function(String)? transport, + Future Function()? getToken, + }) => + Spinify( + config: SpinifyConfig( + getToken: getToken, + transportBuilder: ({required url, headers, protocols}) => + transport?.call(url) ?? Future.value(WebSocket$Fake()), + logger: buffer.add, + ), + ); - test( - 'Reconnect_after_disconnected_transport', + test('Constructor', () { + expect(Spinify.new, returnsNormally); + expect(() => Spinify(config: SpinifyConfig()), returnsNormally); + }); + + test( + 'Create_and_close_client', + () async { + final client = createFakeClient(); + expect(client.isClosed, isFalse); + expect(client.state, isA()); + await client.close(); + expect(client.state, isA()); + expect(client.isClosed, isTrue); + }, + ); + + test( + 'Connect', () => fakeAsync((async) { - ISpinifyTransport? transport; - final client = createFakeClient((t) => transport = t) - ..connect('ws://localhost:8000/connection/websocket'); - expect(client.state, isA()); + final client = Spinify.connect( + url, + config: SpinifyConfig( + transportBuilder: ({required url, headers, protocols}) async => + WebSocket$Fake(), + logger: buffer.add, + ), + ); + async.flushMicrotasks(); + expect(client.state, isA()); + async.elapse(client.config.timeout); + expect(client.state, isA()); + client.close(); + }), + ); + + test( + 'Disconnect_disconnected', + () { + final client = createFakeClient(); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + emitsDone, + ], + ), + ); + return fakeAsync((async) { + expect(client.state.isDisconnected, isTrue); + expect(client.state.isClosed, isFalse); + async.elapse(client.config.timeout); + expect(client.state.isDisconnected, isTrue); + for (var i = 0; i < 10; i++) { + client.disconnect(); async.elapse(client.config.timeout); - expect(client.state, isA()); + expect(client.state.isDisconnected, isTrue); + } + client.close(); + async.flushMicrotasks(); + expect(client.state.isClosed, isTrue); + client.close(); + expect(client.state.isClosed, isTrue); + }); + }, + ); + + test( + 'Create_and_close_multiple_clients', + () async { + final clients = List.generate(10, (_) => createFakeClient()); + expect(clients.every((client) => !client.isClosed), isTrue); + await Future.wait(clients.map((client) => client.close())); + expect(clients.every((client) => client.isClosed), isTrue); + }, + ); + + test( + 'Change_client_state', + () async { + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = + createFakeClient(transport: (_) async => transport..reset()); + expect(transport.isClosed, isFalse); + expect(client.state, isA()); + await client.connect(url); + expect(client.state, isA()); + await client.disconnect(); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + ); + await client.connect(url); + expect(client.state, isA()); + await client.close(); + expect(client.state, isA()); + expect(client.isClosed, isTrue); + expect(transport.isClosed, isTrue); + expect(transport.closeCode, equals(1000)); + }, + ); + + test( + 'Change_client_states', + () { + final transport = WebSocket$Fake(); // ignore: close_sinks + final client = + createFakeClient(transport: (_) async => transport..reset()); + Stream.fromIterable([ + () => client.connect(url), + client.disconnect, + () => client.connect(url), + client.disconnect, + client.close, + ]).asyncMap(Future.new).drain(); + expect(client.state, isA()); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA() + ])); + }, + ); + + test( + 'Reconnect_after_disconnected_transport', + () { + late WebSocket$Fake transport; + final client = createFakeClient( + transport: (_) async => transport = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'notifications:index': pb.SubscribeResult( + expires: false, + ttl: null, + data: [ + 0 + ], + publications: [ + pb.Publication( + info: pb.ClientInfo( + user: 'fake', + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + ), + data: [1, 2, 3], + ) + ]), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }); + return fakeAsync( + (async) { + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); expect(transport, isNotNull); - expect(transport, isA()); - transport!.disconnect(); + expect(transport, isA()); + transport.close(); async.elapse(const Duration(milliseconds: 50)); - expect(client.state, isA()); + expect( + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + ); async.elapse(Duration( milliseconds: client .config.connectionRetryInterval.min.inMilliseconds ~/ 2)); - expect(client.state, isA()); - async.elapse(client.config.connectionRetryInterval.max); - expect(client.state, isA()); - client.close(); - expectLater( - client.states, - emitsInOrder([ - isA(), - isA() - ])); - async.elapse(client.config.connectionRetryInterval.max); - expect(client.state, isA()); - })); - - test( - 'Rpc_requests', - () => fakeAsync((async) { - final client = createFakeClient() - ..connect('ws://localhost:8000/connection/websocket'); - expect(client.state, isA()); - async.elapse(client.config.timeout); - expect(client.state, isA()); - - // Send a request expect( - client.rpc('echo', utf8.encode('Hello, World!')), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals('Hello, World!'), - )), + client.state, + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), ); - async.elapse(client.config.timeout); - expect(client.state, isA()); - - // Send 1000 requests - for (var i = 0; i < 1000; i++) { - expect( - client.rpc('echo', utf8.encode(i.toString())), - completion(isA>().having( - (data) => utf8.decode(data), - 'data', - equals(i.toString()), - )), - ); - } - - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.disconnect(); - async.elapse(client.config.timeout); - expect(client.state, isA()); - client.connect('ws://localhost:8000/connection/websocket'); - async.elapse(client.config.timeout); - expect(client.state, isA()); - - // Another request + async.elapse(client.config.connectionRetryInterval.max); expect( - client.rpc('getCurrentYear', []), - completion(isA>().having( - (data) => jsonDecode(utf8.decode(data))['year'], - 'year', - DateTime.now().year, - )), + client.state, + isA().having( + (s) => s.url, + 'url', + equals(url), + ), + ); + expectLater( + client.states, + emitsInOrder( + [ + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ], + ), ); - async.elapse(client.config.timeout); - - expect(client.state, isA()); client.close(); - async.elapse(client.config.timeout); + async.elapse(client.config.connectionRetryInterval.max); expect(client.state, isA()); - })); + }, + ); + }, + ); - test( - 'Metrics', - () => fakeAsync((async) { - final client = createFakeClient(); - expect(() => client.metrics, returnsNormally); - expect( - client.metrics, - allOf([ - isA().having( - (m) => m.state.isConnected, - 'isConnected', - isFalse, - ), - isA().having( - (m) => m.state, - 'state', - equals(client.state), - ), - isA().having( - (m) => m.connects, - 'connects', - 0, - ), - isA().having( - (m) => m.disconnects, - 'disconnects', - 0, - ), - isA().having( - (m) => m.messagesReceived, - 'messagesReceived', - equals(Int64.ZERO), - ), - isA().having( - (m) => m.messagesSent, - 'messagesSent', - equals(Int64.ZERO), - ), - ])); - client.connect('ws://localhost:8000/connection/websocket'); - async.elapse(client.config.timeout); - expect( - client.metrics, - allOf([ - isA().having( - (m) => m.state.isConnected, - 'isConnected', - isTrue, - ), - isA().having( - (m) => m.state, - 'state', - equals(client.state), - ), - isA().having( - (m) => m.connects, - 'connects', - 1, - ), - isA().having( - (m) => m.disconnects, - 'disconnects', - 0, - ), - isA().having( - (m) => m.messagesReceived, - 'messagesReceived', - greaterThan(Int64.ZERO), - ), - isA().having( - (m) => m.messagesSent, - 'messagesSent', - greaterThan(Int64.ZERO), + test( + 'Server_subscriptions', + () => fakeAsync( + (async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = createFakeClient(transport: (_) async => ws); + + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'public:chat': pb.SubscribeResult( + expires: false, + ttl: null, + data: [], + ), + 'personal:user#42': pb.SubscribeResult( + expires: false, + ttl: null, + data: [], + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), ), - ])); - client + ); + } + }); + }; + + client.connect(url); + async.elapse(client.config.timeout); + expect(client.state, isA()); + expect(client.subscriptions.server, hasLength(2)); + expect(client.getServerSubscription('public:chat'), isNotNull); + expect(client.getServerSubscription('personal:user#42'), isNotNull); + expect(client.getSubscription('public:chat'), isNotNull); + expect(client.getSubscription('personal:user#42'), isNotNull); + expect(client.getServerSubscription('unknown'), isNull); + expect(client.getSubscription('unknown'), isNull); + client.close(); + }, + ), + ); + + test( + 'Metrics', + () => fakeAsync((async) { + final ws = WebSocket$Fake(); // ignore: close_sinks + final client = + createFakeClient(transport: (_) async => ws..reset()); + expect(() => client.metrics, returnsNormally); + expect( + client.metrics, + allOf([ + isA().having( + (m) => m.state.isConnected, + 'isConnected', + isFalse, + ), + isA().having( + (m) => m.state, + 'state', + equals(client.state), + ), + isA().having( + (m) => m.connects, + 'connects', + 0, + ), + isA().having( + (m) => m.disconnects, + 'disconnects', + 0, + ), + isA().having( + (m) => m.chunksReceived, + 'messagesReceived', + equals(Int64.ZERO), + ), + isA().having( + (m) => m.chunksSent, + 'chunksSent', + equals(Int64.ZERO), + ), + ])); + client.connect(url); + async.elapse(client.config.timeout); + expect( + client.metrics, + allOf([ + isA().having( + (m) => m.state.isConnected, + 'isConnected', + isTrue, + ), + isA().having( + (m) => m.state, + 'state', + equals(client.state), + ), + isA().having( + (m) => m.connects, + 'connects', + 1, + ), + isA().having( + (m) => m.disconnects, + 'disconnects', + 0, + ), + isA().having( + (m) => m.chunksReceived, + 'messagesReceived', + greaterThan(Int64.ZERO), + ), + isA().having( + (m) => m.chunksSent, + 'chunksSent', + greaterThan(Int64.ZERO), + ), + ])); + /* client ..newSubscription('channel') ..close(); async.elapse(client.config.timeout); @@ -270,7 +457,1377 @@ void main() { expect( client.metrics.channels['channel'], isA().having((c) => c.toString(), - 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); - })); - }); + 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); */ + })); + + test( + 'Ping_pong', + () => fakeAsync( + (async) { + var serverPingCount = 0; + var serverPongCount = 0; + final client = createFakeClient(transport: (_) async { + final ws = WebSocket$Fake(); + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + }); + } else if (command.hasPing()) { + serverPongCount++; + } + }; + return ws; + }); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + async.elapse(client.config.serverPingDelay * 10); + expect(serverPingCount, greaterThan(0)); + expect(serverPongCount, equals(serverPingCount)); + client.close(); + }, + ), + ); + + test( + 'Ping_without_pong', + () => fakeAsync( + (async) { + var serverPingCount = 0, serverPongCount = 0; + final client = createFakeClient(transport: (_) async { + final ws = WebSocket$Fake(); + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + serverPingCount++; + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + }); + } else if (command.hasPing()) { + serverPongCount++; + } + }; + return ws; + }); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + async.elapse(client.config.serverPingDelay * 10); + expect(serverPingCount, greaterThan(0)); + expect(serverPongCount, isZero); + client.close(); + }, + ), + ); + + test( + 'Missing_pings', + () => fakeAsync( + (async) { + final webSockets = []; + var serverPingCount = 0, serverPongCount = 0; + final client = createFakeClient(transport: (_) async { + final ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + scheduleMicrotask(() { + sink.add(ProtobufCodec.encode(reply)); + }); + } else if (command.hasPing()) { + serverPongCount++; + } + }; + webSockets.add(ws); + return ws; + }); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + isA(), + ], + ), + ); + unawaited(client.connect(url)); + async.elapse(client.config.timeout); + expect(client.state, isA()); + final pingInterval = + (client.state as SpinifyState$Connected).pingInterval!; + async.elapse( + (pingInterval + + client.config.timeout + + client.config.serverPingDelay) * + 10, + ); + expect(webSockets.length, greaterThan(1)); + expect(serverPingCount, isZero); + expect(serverPongCount, isZero); + client.close(); + async.elapse(const Duration(seconds: 1)); + expect(webSockets.every((ws) => ws.isClosed), isTrue); + }, + ), + ); + + test( + 'ready', + () => fakeAsync((async) { + final client = createFakeClient(); + expectLater(client.ready(), completes); + client.connect(url); + //expectLater(client.ready(), completes); + async.elapse(client.config.timeout); + expect(client.state, isA()); + expectLater(client.ready(), completes); + async.elapse(client.config.timeout); + client.close(); + }), + ); + + test('do_not_ready', () { + final client = createFakeClient(); + expectLater( + client.ready(), + throwsA(isA()), + ); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + ); + client.close(); + }); + + test('subscribtion_asserts', () { + final client = createFakeClient(); + expect( + () => client.newSubscription(''), + throwsA(isA()), + ); + expect( + () => client.newSubscription(' '), + throwsA(isA()), + ); + expect( + () => client.newSubscription(String.fromCharCode(0x7f + 1)), + throwsA(isA()), + ); + expect( + () => client.newSubscription('๐Ÿ˜€, ๐ŸŒ, ๐ŸŽ‰, ๐Ÿ‘‹'), + throwsA(isA()), + ); + expect( + () => client.newSubscription('channel' * 100), + throwsA(isA()), + ); + expect( + () => client.newSubscription('channel'), + returnsNormally, + ); + expect( + () => client.newSubscription('channel'), + throwsA(isA()), + ); + client.close(); + }); + + test('Auto_refresh', () { + Timer? pingTimer; + var pings = 0, refreshes = 0; + final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async { + pingTimer?.cancel(); + final ws = WebSocket$Fake(); + ws.onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: {}, + ping: 120, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply())); + pings++; + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + refreshes++; + } + }); + }; + return ws; + }, + ); + return fakeAsync((async) { + client.connect(url); + async.elapse(const Duration(hours: 3)); + expect(client.state.isConnected, isTrue); + expect(client.isClosed, isFalse); + client.close(); + pingTimer?.cancel(); + async.flushMicrotasks(); + expect(client.state.isClosed, isTrue); + expect(pings, greaterThanOrEqualTo(3 * 60 * 60 ~/ 120)); + expect(refreshes, greaterThanOrEqualTo(3 * 60 * 60 ~/ 600)); + }); + }); + + test('Error_future', () { + final fakeException = Exception('Fake error'); + expect(fakeException, isA()); + Future.error(fakeException).ignore(); + unawaited( + expectLater( + Future.error(fakeException), + throwsA(isA()), + ), + ); + unawaited( + expectLater( + Future.delayed(const Duration(milliseconds: 5), () { + throw fakeException; + }), + throwsA(isA()), + ), + ); + }); + + test('Completer_without_future', () async { + var zoneHandler = 0; + runZonedGuarded(() { + try { + final completer = Completer(); + /* completer.future.ignore() */ + completer.completeError( + Exception('Fake error'), + StackTrace.empty, + ); + } on Object {/* ignore */} + }, (error, stackTrace) { + zoneHandler++; + }); + await Future.delayed(Duration.zero); + expect(zoneHandler, equals(1)); + }); + + test( + 'Disconnect_during_connection', + () => fakeAsync((async) { + final client = createFakeClient(); + client.connect(url); + async.flushMicrotasks(); + expect(client.state, isA()); + client.disconnect(); + async.flushMicrotasks(); + expect(client.state.isConnecting, isTrue); // Still connecting + async.elapse(client.config.timeout); // Wait for some time + expect(client.state.isDisconnected, isTrue); // Disconnected + expect(client.state.isClosed, isFalse); // Not closed + client.close(); + async.flushMicrotasks(); + expect(client.state.isClosed, isTrue); + }), + ); + + test( + 'Few_connects_in_a_row', + () { + final client = createFakeClient(); + expectLater( + client.states, + emitsInOrder( + [ + isA().having( + (s) => s.url, + 'url', + equals('url1'), + ), + isA().having( + (s) => s.url, + 'url', + equals('url1'), + ), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA().having( + (s) => s.url, + 'url', + equals('url2'), + ), + isA().having( + (s) => s.url, + 'url', + equals('url2'), + ), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA().having( + (s) => s.url, + 'url', + equals('url3'), + ), + isA().having( + (s) => s.url, + 'url', + equals('url3'), + ), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ], + ), + ); + return fakeAsync((async) { + client.connect('url1'); + client.connect('url2'); + client.connect('url3'); + async.elapse(const Duration(seconds: 1)); + expect( + client.state, + isA().having( + (s) => s.url, + 'url', + equals('url3'), + )); + client.close(); + async.flushMicrotasks(); + }); + }, + ); + + test('Closed_after_close', () { + final client = createFakeClient(); + expectLater( + client.states, + emitsInOrder( + [ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ], + ), + ); + client.connect(url); + expect(client.isClosed, isFalse); + client.close(); + expectLater(client.states.last, completion(isA())); + }); + + test('Send', () { + final client = createFakeClient(); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + client.connect(url); + expectLater( + client.send([1, 2, 3]), + completes, + ); + client.close(); + expectLater( + client.send([1, 2, 3]), + throwsA(isA()), + ); + }); + + test('Send_and_close', () async { + final builder = BytesBuilder(); + final client = createFakeClient( + transport: (_) async { + final ws = WebSocket$Fake(); + final onAdd = ws.onAdd; + ws.onAdd = (bytes, sink) { + onAdd(bytes, sink); + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasSend()) { + builder.add(command.send.data); + } + }); + }; + return ws; + }, + ); + await client.connect(url); + await client.send([1, 2, 3]); + await client.send([4, 5, 6]); + await client.close(); + expect( + builder.takeBytes(), + equals(Uint8List.fromList([0x01, 0x02, 0x03, 0x04, 0x05, 0x06])), + ); + }); + + test('Publish', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + publish: pb.PublishResult(), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake publish error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + unawaited(expectLater( + client.publish('channel', [1, 2, 3]), + throwsA(isA()), + )); + unawaited(client.connect(url)); + unawaited(expectLater( + client.publish('channel', [1, 2, 3]), + completes, + )); + unawaited(expectLater( + client.publish('another', [1, 2, 3]), + throwsA(isA()), + )); + unawaited(expectLater( + client.publish('unknown', [1, 2, 3]), + throwsA(isA()), + )); + unawaited(expectLater( + client.close(), + completes, + )); + unawaited(expectLater( + client.publish('channel', [1, 2, 3]), + throwsA(isA()), + )); + }); + + test('Presense', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresence() && + command.presence.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + presence: pb.PresenceResult( + presence: { + 'channel': pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + }, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresence() && + command.presence.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake presence error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + unawaited(expectLater( + client.presence('channel'), + throwsA(isA()), + )); + unawaited(client.connect(url)); + unawaited(expectLater( + client.presence('channel'), + completion( + isA>().having( + (info) => info.keys, + 'keys', + contains('channel'), + ), + ), + )); + unawaited(expectLater( + client.presence('another'), + throwsA(isA()), + )); + unawaited(expectLater( + client.presence('unknown'), + throwsA(isA()), + )); + unawaited(client.close()); + unawaited(expectLater( + client.presence('channel'), + throwsA(isA()), + )); + }); + + test('PresenceStats', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresenceStats() && + command.presenceStats.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + presenceStats: pb.PresenceStatsResult( + numClients: 3, + numUsers: 5, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPresenceStats() && + command.presenceStats.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake presence stats error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + unawaited(expectLater( + client.presenceStats('channel'), + throwsA(isA()), + )); + unawaited(client.connect(url)); + unawaited(expectLater( + client.presenceStats('channel'), + completion( + isA() + .having( + (stats) => stats.channel, + 'channel', + equals('channel'), + ) + .having( + (stats) => stats.clients, + 'clients', + equals(3), + ) + .having( + (stats) => stats.users, + 'users', + equals(5), + ), + ), + )); + unawaited(expectLater( + client.presenceStats('another'), + throwsA(isA()), + )); + unawaited(expectLater( + client.presenceStats('unknown'), + throwsA(isA()), + )); + unawaited(expectLater( + client.close(), + completes, + )); + unawaited(expectLater( + client.presenceStats('channel'), + throwsA(isA()), + )); + }); + + test('History', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: { + 'channel': pb.SubscribeResult( + expires: false, + ttl: null, + ), + 'another': pb.SubscribeResult( + expires: false, + ttl: null, + ), + }, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasHistory() && + command.history.channel == 'channel') { + final reply = pb.Reply( + id: command.id, + history: pb.HistoryResult( + epoch: 'epoch', + offset: Int64(5), + publications: [ + pb.Publication( + offset: Int64(5), + data: [1, 2, 3], + info: pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + ), + pb.Publication( + offset: Int64(6), + data: [4, 5, 6], + info: pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + ), + pb.Publication( + offset: Int64(7), + data: [7, 8, 9], + info: pb.ClientInfo( + chanInfo: [1, 2, 3], + connInfo: [1, 2, 3], + client: 'fake', + user: 'fake', + ), + ), + ], + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasHistory() && + command.history.channel == 'another') { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake history error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + + unawaited(expectLater( + client.history('channel'), + throwsA(isA()), + )); + + unawaited(client.connect(url)); + + unawaited(expectLater( + client.history( + 'channel', + limit: 3, + reverse: false, + since: (epoch: 'epoch', offset: Int64(5)), + ), + completion( + isA() + .having( + (history) => history.since, + 'since', + equals((epoch: 'epoch', offset: Int64(5))), + ) + .having( + (history) => history.publications, + 'publications', + hasLength(3), + ), + ), + )); + + unawaited(expectLater( + client.history('another'), + throwsA(isA()), + )); + + unawaited(expectLater( + client.history('unknown'), + throwsA(isA()), + )); + + unawaited(expectLater( + client.close(), + completes, + )); + + unawaited(expectLater( + client.history('channel'), + throwsA(isA()), + )); + }); + + test('RPC', () async { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: {}, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc() && command.rpc.method == 'echo') { + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: command.rpc.data, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc()) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake rpc error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + + unawaited(expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + )); + + unawaited(expectLater( + client.connect(url), + completes, + )); + + unawaited(expectLater( + client.rpc('echo', [1, 2, 3]), + completion( + isA>().having( + (data) => data, + 'data', + equals([1, 2, 3]), + ), + ), + )); + + unawaited(expectLater( + client.rpc('unknown', [1, 2, 3]), + throwsA(isA()), + )); + + unawaited(expectLater( + client.rpc('unknown', [1, 2, 3]), + throwsA(isA()), + )); + + unawaited(expectLater( + client.close(), + completes, + )); + + unawaited(expectLater( + client.rpc('echo', [1, 2, 3]), + throwsA(isA()), + )); + }); + + test( + 'RPC_many_requests', + () => fakeAsync((async) { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + ping: 600, + pong: false, + subs: {}, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc() && command.rpc.method == 'echo') { + final reply = pb.Reply( + id: command.id, + rpc: pb.RPCResult( + data: command.rpc.data, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasRpc()) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake rpc error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }, + ); + + expect( + client.connect(url), + completes, + ); + + async.elapse(client.config.timeout); + + expect( + client.state, + isA(), + ); + + expect( + client.rpc('echo', utf8.encode('Hello, World!')), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals('Hello, World!'), + )), + ); + + async.elapse(const Duration(hours: 1)); + + // Send 1000 requests + for (var i = 0; i < 50; i++) { + expect( + client.rpc('echo', utf8.encode(i.toString())), + completion(isA>().having( + (data) => utf8.decode(data), + 'data', + equals(i.toString()), + )), + ); + } + + async.elapse(const Duration(hours: 1)); + + expect(client.state, isA()); + + expect( + client.disconnect(), + completes, + ); + + async.elapse(const Duration(hours: 1)); + + expect(client.state, isA()); + + expect( + client.close(), + completes, + ); + + async.flushTimers(); + + expect(client.state, isA()); + }), + ); + + test( + 'Connection_error_retry_temporary', + () => fakeAsync( + (async) { + Timer? pingTimer; + var retries = 0; + late final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async { + pingTimer?.cancel(); + late WebSocket$Fake ws; // ignore: close_sinks + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + if (retries < 2) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3000, + message: 'Fake connection error', + temporary: true, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: {}, + ping: 120, + pong: false, + session: 'fake', + node: 'fake', + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + } else { + sink.add(ProtobufCodec.encode(pb.Reply())); + } + }, + ); + } + retries++; + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }; + }, + ); + expect(client.state.isDisconnected, isTrue); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isTrue, + ), + isA(), + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ]), + ); + + expect( + client.connect(url), + throwsA(isA()), + ); + + async.elapse(client.config.connectionRetryInterval.max * 3); + + client.close(); + pingTimer?.cancel(); + + async.elapse(const Duration(seconds: 5)); + + expect(client.state.isClosed, isTrue); + + expect(retries, equals(3)); + expect(client.metrics.connects, 1); + expect(client.metrics.disconnects, 3); + }, + ), + ); + + test( + 'Connection_error_retry_permament', + () => fakeAsync( + (async) { + var retries = 0; + late final client = createFakeClient( + getToken: () async => 'token', + transport: (_) async { + late WebSocket$Fake + ws; // ignore: close_sinks, unused_local_variable + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + error: pb.Error( + code: 3500, + message: 'Fake connection error', + temporary: false, + ), + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + retries++; + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } + }); + }; + }, + ); + expect(client.state.isDisconnected, isTrue); + + expectLater( + client.states, + emitsInOrder([ + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + isA().having( + (s) => s.temporary, + 'temporary', + isFalse, + ), + isA(), + emitsDone, + ]), + ); + expect( + client.connect(url), + throwsA(isA()), + ); + async.elapse(client.config.connectionRetryInterval.max); + + expect(client.state.isDisconnected, isTrue); + expect( + client.connect(url), + throwsA(isA()), + ); + + async.elapse(client.config.connectionRetryInterval.max); + + expect(client.state.isDisconnected, isTrue); + + client.close(); + + async.elapse(const Duration(seconds: 5)); + + expect(client.state.isClosed, isTrue); + expect(retries, equals(2)); + expect(client.metrics.connects, 0); + expect(client.metrics.disconnects, 3); + }, + ), + ); + }, + ); } diff --git a/test/unit/spinify_test.mocks.dart b/test/unit/spinify_test.mocks.dart new file mode 100644 index 0000000..9616d8e --- /dev/null +++ b/test/unit/spinify_test.mocks.dart @@ -0,0 +1,66 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in spinify/test/unit/spinify_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:spinify/src/model/transport_interface.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [WebSocket]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWebSocket extends _i1.Mock implements _i2.WebSocket { + @override + _i3.Stream> get stream => (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i3.Stream>.empty(), + returnValueForMissingStub: _i3.Stream>.empty(), + ) as _i3.Stream>); + + @override + bool get isClosed => (super.noSuchMethod( + Invocation.getter(#isClosed), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + void add(List? data) => super.noSuchMethod( + Invocation.method( + #add, + [data], + ), + returnValueForMissingStub: null, + ); + + @override + void close([ + int? code, + String? reason, + ]) => + super.noSuchMethod( + Invocation.method( + #close, + [ + code, + reason, + ], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/unit/subscription_test.dart b/test/unit/subscription_test.dart new file mode 100644 index 0000000..70dab04 --- /dev/null +++ b/test/unit/subscription_test.dart @@ -0,0 +1,743 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:fake_async/fake_async.dart'; +import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; +import 'package:test/test.dart'; + +import 'codecs.dart'; +import 'web_socket_fake.dart'; + +//import 'subscription_test.mocks.dart'; + +//@GenerateNiceMocks([MockSpec(as: #MockWebSocket)]) +void main() { + const url = 'ws://localhost:8000/connection/websocket'; + final buffer = SpinifyLogBuffer(size: 10); + + Spinify createFakeClient({ + Future Function(String)? transport, + Future Function()? getToken, + }) => + Spinify( + config: SpinifyConfig( + getToken: getToken, + transportBuilder: ({required url, headers, protocols}) => + transport?.call(url) ?? Future.value(WebSocket$Fake()), + logger: buffer.add, + ), + ); + + group('ServerSubscription', () { + test( + 'Emulate_server_subscription', + () => fakeAsync( + (async) { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: { + 'notification:index': pb.SubscribeResult( + data: const [], + epoch: '...', + offset: Int64.ZERO, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + data: const [], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'notification', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + }, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } + }); + }, + )..connect(url); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isNotEmpty); + expect(client.subscriptions.server['notification:index'], isNotNull); + expect( + client.getServerSubscription('notification:index'), + same(client.subscriptions.server['notification:index']), + ); + expect( + client.getClientSubscription('notification:index'), + isNull, + ); + expect( + client.subscriptions.client['notification:index'], + isNull, + ); + expect( + client.subscriptions.server, + isA>() + .having( + (s) => s.length, + 'length', + 1, + ) + .having( + (s) => s['notification:index'], + 'notification:index', + isA(), + ), + ); + + client.close(); + }, + ), + ); + + test( + 'Events', + () => fakeAsync((async) { + Timer? pingTimer, notificationTimer; + final client = createFakeClient( + transport: (_) async { + pingTimer?.cancel(); + notificationTimer?.cancel(); + var offset = Int64.ZERO; + late WebSocket$Fake ws; // ignore: close_sinks + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: { + 'notification:index': pb.SubscribeResult( + data: utf8.encode('notification:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + 'echo:index': pb.SubscribeResult( + data: utf8.encode('echo:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + offset: offset, + data: const [1, 2, 3], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'echo', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + }, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + notificationTimer = Timer.periodic( + const Duration(minutes: 5), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply( + push: pb.Push( + channel: 'notification:index', + message: pb.Message( + data: utf8.encode(DateTime.now() + .toUtc() + .toIso8601String()), + ), + ), + ))); + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'echo:index') { + offset++; + final reply = pb.Reply() + ..id = command.id + ..publish = pb.PublishResult(); + final bytes = ProtobufCodec.encode(reply); + sink + ..add(bytes) + ..add( + ProtobufCodec.encode( + pb.Reply( + push: pb.Push( + channel: 'echo:index', + pub: pb.Publication( + offset: offset, + tags: const {}, + data: command.publish.data, + info: pb.ClientInfo( + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [4, 5, 6], + user: 'fake', + ), + ), + ), + ), + ), + ); + } + }); + }; + }, + )..connect(url); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expectLater( + client.subscriptions.server['notification:index']?.stream + .message(), + emitsInOrder([ + for (var i = 0; i < 10; i++) + isA().having( + (m) => m.data, + 'data', + isA>().having( + (bytes) => DateTime.parse(utf8.decode(bytes)), + 'DateTime.parse', + isA(), + ), + ), + ]), + ); + final echoEvents = []; + client.subscriptions.server['echo:index']?.stream + .forEach(echoEvents.add); + for (var i = 0; i < 10; i++) { + async.elapse(const Duration(minutes: 5)); + client.publish('echo:index', utf8.encode(i.toString())); + } + async.elapse(const Duration(days: 1)); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isNotEmpty); + pingTimer?.cancel(); + client.close(); + async.elapse(client.config.timeout); + expect( + echoEvents, + equals([ + for (var i = 0; i < 10; i++) + isA() + .having( + (m) => m.data, + 'data', + equals(utf8.encode(i.toString())), + ) + .having( + (m) => m.offset, + 'offset', + equals(Int64(i + 1)), + ), + ]), + ); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + })); + }); + + group('ClientSubscription', () { + test( + 'Emulate_client_subscription', + () => fakeAsync( + (async) { + final client = createFakeClient( + transport: (_) async => WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } else if (command.hasSubscribe()) { + final reply = pb.Reply( + id: command.id, + subscribe: pb.SubscribeResult( + data: const [], + epoch: '...', + offset: Int64.ZERO, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + data: const [], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'notification', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + ); + sink.add(ProtobufCodec.encode(reply)); + } else if (command.hasUnsubscribe()) { + final reply = pb.Reply( + id: command.id, + unsubscribe: pb.UnsubscribeResult(), + ); + sink.add(ProtobufCodec.encode(reply)); + } else { + debugger(); + } + }); + }, + )..connect(url); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.server, isEmpty); + expect(client.subscriptions.client, isEmpty); + final notifications = client.newSubscription('notification:index'); + expect( + client.subscriptions.client['notification:index'], + allOf( + isNotNull, + isA() + .having( + (s) => s.channel, + 'channel', + 'notification:index', + ) + .having( + (s) => s.state, + 'state', + isA(), + ), + ), + ); + notifications.subscribe(); + async.elapse(client.config.timeout); + expect( + client.subscriptions.client['notification:index'], + allOf( + isNotNull, + isA().having( + (s) => s.state, + 'state', + isA(), + ), + ), + ); + expect( + client.getClientSubscription('notification:index'), + allOf( + isA(), + same(notifications), + same(client.subscriptions.client['notification:index']), + ), + ); + expect( + client.getServerSubscription('notification:index'), + isNull, + ); + expect( + client.subscriptions.client, + isA>() + .having( + (s) => s.length, + 'length', + 1, + ) + .having( + (s) => s['notification:index'], + 'notification:index', + isA(), + ), + ); + client.disconnect(); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isFalse); + expect(notifications.state.isUnsubscribed, isTrue); + client.close(); + async.elapse(client.config.timeout); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + }, + ), + ); + + test( + 'Events', + () => fakeAsync( + (async) { + Timer? pingTimer, notificationTimer; + final client = createFakeClient( + transport: (_) async { + pingTimer?.cancel(); + notificationTimer?.cancel(); + var echo = false; + var offset = Int64.ZERO; + late WebSocket$Fake ws; // ignore: close_sinks + return ws = WebSocket$Fake() + ..onAdd = (bytes, sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + scheduleMicrotask(() { + if (command.hasConnect()) { + final reply = pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + data: null, + subs: {}, + ping: 600, + pong: true, + session: 'fake', + node: 'fake', + ), + ); + sink.add(ProtobufCodec.encode(reply)); + pingTimer = Timer.periodic( + Duration(milliseconds: reply.connect.ping), + (timer) { + if (ws.isClosed) { + timer.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply())); + }, + ); + } else if (command.hasRefresh()) { + if (command.refresh.token.isEmpty) return; + final reply = pb.Reply() + ..id = command.id + ..refresh = pb.RefreshResult( + client: 'fake', + version: '0.0.1', + expires: true, + ttl: 600, + ); + final bytes = ProtobufCodec.encode(reply); + sink.add(bytes); + } else if (command.hasPublish() && + command.publish.channel == 'echo:index' && + echo) { + offset++; + final reply = pb.Reply() + ..id = command.id + ..publish = pb.PublishResult(); + final bytes = ProtobufCodec.encode(reply); + sink + ..add(bytes) + ..add( + ProtobufCodec.encode( + pb.Reply( + push: pb.Push( + channel: 'echo:index', + pub: pb.Publication( + offset: offset, + tags: const {}, + data: command.publish.data, + info: pb.ClientInfo( + client: 'fake', + chanInfo: [1, 2, 3], + connInfo: [4, 5, 6], + user: 'fake', + ), + ), + ), + ), + ), + ); + } else if (command.hasSubscribe() && + command.subscribe.channel == 'notification:index') { + final reply = pb.Reply( + id: command.id, + subscribe: pb.SubscribeResult( + data: utf8.encode('notification:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + ); + sink.add(ProtobufCodec.encode(reply)); + notificationTimer?.cancel(); + notificationTimer = Timer.periodic( + const Duration(minutes: 5), + (timer) { + if (ws.isClosed) { + timer.cancel(); + notificationTimer?.cancel(); + return; + } + sink.add(ProtobufCodec.encode(pb.Reply( + push: pb.Push( + channel: 'notification:index', + message: pb.Message( + data: utf8.encode( + DateTime.now().toUtc().toIso8601String()), + ), + ), + ))); + }, + ); + } else if (command.hasUnsubscribe() && + command.unsubscribe.channel == 'notification:index') { + final reply = pb.Reply( + id: command.id, + unsubscribe: pb.UnsubscribeResult(), + ); + sink.add(ProtobufCodec.encode(reply)); + notificationTimer?.cancel(); + } else if (command.hasSubscribe() && + command.subscribe.channel == 'echo:index') { + final reply = pb.Reply( + id: command.id, + subscribe: pb.SubscribeResult( + data: utf8.encode('echo:index'), + epoch: '...', + offset: offset, + expires: false, + ttl: null, + positioned: false, + publications: [ + pb.Publication( + offset: offset, + data: const [1, 2, 3], + info: pb.ClientInfo( + client: 'fake', + user: 'fake', + ), + tags: const { + 'type': 'echo', + }, + ), + ], + recoverable: false, + recovered: false, + wasRecovering: false, + ), + ); + sink.add(ProtobufCodec.encode(reply)); + echo = true; + } else if (command.hasUnsubscribe() && + command.unsubscribe.channel == 'echo:index') { + final reply = pb.Reply( + id: command.id, + unsubscribe: pb.UnsubscribeResult(), + ); + sink.add(ProtobufCodec.encode(reply)); + echo = false; + } + }); + }; + }, + ) + ..connect(url) + ..newSubscription('notification:index') + ..newSubscription('echo:index') + ..subscriptions.client.values.forEach((s) => s.subscribe()); + expectLater( + client.states, + emitsInOrder([ + isA(), + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + expect(client.subscriptions.server, isEmpty); + expect( + client.subscriptions.client, + allOf( + isNotEmpty, + hasLength(2), + ), + ); + for (final sub in client.subscriptions.client.values) { + expect( + sub.state, + isA(), + ); + expectLater( + sub.states, + emitsInOrder([ + isA(), + isA(), + isA(), + emitsDone, + ]), + ); + } + async.elapse(client.config.timeout); + expect(client.state.isConnected, isTrue); + expectLater( + client.subscriptions.client['notification:index']?.stream.message(), + emitsInOrder([ + for (var i = 0; i < 10; i++) + isA().having( + (m) => m.data, + 'data', + isA>().having( + (bytes) => DateTime.parse(utf8.decode(bytes)), + 'DateTime.parse', + isA(), + ), + ), + ]), + ); + final echoEvents = []; + client.subscriptions.client['echo:index']?.stream + .forEach(echoEvents.add); + for (var i = 0; i < 10; i++) { + async.elapse(const Duration(minutes: 5)); + client.publish('echo:index', utf8.encode(i.toString())); + } + async.elapse(const Duration(days: 1)); + expect(client.state.isConnected, isTrue); + expect(client.subscriptions.client, isNotEmpty); + pingTimer?.cancel(); + client.close(); + async.elapse(client.config.timeout); + expect(client.subscriptions.client, isEmpty); + expect( + echoEvents, + equals([ + for (var i = 0; i < 10; i++) + isA() + .having( + (m) => m.data, + 'data', + equals(utf8.encode(i.toString())), + ) + .having( + (m) => m.offset, + 'offset', + equals(Int64(i + 1)), + ), + ]), + ); + expect(client.state.isConnected, isFalse); + expect(client.isClosed, isTrue); + }, + ), + ); + }); +} diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart new file mode 100644 index 0000000..7ca6c65 --- /dev/null +++ b/test/unit/util_test.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:spinify/src/util/backoff.dart'; +import 'package:spinify/src/util/event_queue.dart'; +import 'package:spinify/src/util/guarded.dart'; +import 'package:spinify/src/util/list_equals.dart'; +import 'package:spinify/src/util/map_equals.dart'; +import 'package:spinify/src/util/mutex.dart'; +import 'package:test/test.dart'; + +void main() => group('Util', () { + test('Backoff', () { + expect(() => Backoff.nextDelay(5, 10, 50), returnsNormally); + expect(Backoff.nextDelay(5, 10, 50), isA()); + expect(Backoff.nextDelay(5, 5, 5), isA()); + }); + + test( + 'EventQueue', + () => fakeAsync( + (async) { + expect(EventQueue.new, returnsNormally); + var queue = EventQueue(); + expect(queue.isClosed, isFalse); + expectLater(queue.add(() {}), completes); + var counter = 0; + expectLater(queue.add(() => counter++), completes); + expect(counter, 0); + expectLater(queue.close(), completes); + async.elapse(Duration.zero); + expect(counter, 1); + expect(queue.isClosed, isTrue); + + queue = EventQueue(); + expectLater(queue.add(() {}), completes); + expectLater(queue.add(() {}), throwsStateError); + expectLater(queue.close(force: true), completes); + expectLater(queue.close(force: true), completes); + expectLater(queue.close(force: false), completes); + async.elapse(Duration.zero); + expect(queue.isClosed, isTrue); + expectLater(() => queue.add(() {}), throwsStateError); + }, + ), + ); + + test('Guarded', () { + expect( + () => guarded(() => 1), + returnsNormally, + ); + expect( + () => guarded(() => throw Exception(), ignore: false), + throwsException, + ); + expect( + () => guarded(() => throw Exception(), ignore: true), + returnsNormally, + ); + expect( + () => guarded( + () => Future.delayed( + Duration.zero, + () { + throw Exception(); + }, + ), + ignore: false, + ), + returnsNormally, + ); + expect( + () => guarded( + () => Future.delayed( + Duration.zero, + () { + throw Exception(); + }, + ), + ignore: true, + ), + returnsNormally, + ); + + expect( + () => guarded(() { + Completer().completeError(Exception()); + }), + returnsNormally, + ); + }); + + test('AsyncGuarded', () { + expectLater( + asyncGuarded(() async => 1), + completes, + ); + expectLater( + asyncGuarded(() async => throw Exception(), ignore: false), + throwsException, + ); + expectLater( + asyncGuarded(() async => throw Exception(), ignore: true), + completes, + ); + expectLater( + asyncGuarded(() async { + Completer().completeError(Exception()); + }), + throwsException, + ); + expectLater( + asyncGuarded(() async { + await Future.delayed(Duration.zero); + Completer().completeError(Exception()); + }), + completes, + ); + }); + + test('ListEquals', () { + expect(listEquals([1, 2, 3], [1, 2, 3]), isTrue); + expect(listEquals([1, 2, 3], [1, 2, 4]), isFalse); + expect(listEquals([1, 2, 3], [1, 2]), isFalse); + expect(listEquals(null, [1, 2, 3, 4]), isFalse); + expect(listEquals([1, 2, 3, 4], null), isFalse); + expect(listEquals(null, null), isTrue); + }); + + test('MapEquals', () { + expect(mapEquals({1: 2, 3: 4}, {1: 2, 3: 4}), isTrue); + expect(mapEquals({1: 2, 3: 4}, {1: 2, 3: 5}), isFalse); + expect(mapEquals({1: 2, 3: 4}, {1: 2}), isFalse); + expect(mapEquals(null, {1: 2, 3: 4}), isFalse); + expect(mapEquals({1: 2, 3: 4}, null), isFalse); + expect(mapEquals(null, null), isTrue); + }); + + test('Mutex', () async { + { + final m = MutexDisabled(); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited( + expectLater( + m.protect(() => Future.value(1)), + completion(equals(1)), + ), + ); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited( + expectLater( + m.lock(), + completes, + ), + ); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + expect( + m.unlock, + returnsNormally, + ); + unawaited( + expectLater( + m.wait(), + completes, + ), + ); + } + + fakeAsync((async) { + final m = MutexImpl(); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited( + expectLater( + m.lock(), + completes, + ), + ); + expect(m.locks, equals(1)); + expect(m.pending, isNotEmpty); + unawaited( + expectLater( + m.lock(), + completes, + ), + ); + expect(m.locks, equals(2)); + unawaited( + expectLater( + m.wait(), + completes, + ), + ); + expect(m.pending, hasLength(2)); + expect(m.unlock, returnsNormally); + expect(m.locks, equals(1)); + expect(m.pending, hasLength(1)); + expect(m.unlock, returnsNormally); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + unawaited( + expectLater( + m.wait(), + completes, + ), + ); + + unawaited( + expectLater( + m.protect( + () => Future.delayed(const Duration(hours: 1), () => 1), + ), + completion(equals(1)), + ), + ); + async.flushMicrotasks(); + expect(m.locks, equals(1)); + async.flushTimers(); + expect(m.locks, equals(0)); + }); + + fakeAsync((async) { + final m = MutexImpl(); + final list = [for (var i = 0; i < 10; i++) i]; + final result = []; + final copy = list.toList(); + for (var i = 0; i < list.length; i++) { + unawaited( + expectLater( + m.protect(() async { + await Future.delayed( + Duration(seconds: list.length - i), + ); + final value = copy.removeAt(0); + result.add(value); + }), + completes, + ), + ); + } + async.flushMicrotasks(); + expect(m.locks, equals(list.length)); + expect(m.pending, hasLength(list.length)); + async.elapse(const Duration(seconds: 10)); + expect(m.locks, equals(list.length - 1)); + expect(m.pending, hasLength(list.length - 1)); + async.flushTimers(); + expect(listEquals(result, list), isTrue); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + }); + + fakeAsync((async) { + final m = MutexImpl(); + final list = [for (var i = 0; i < 10; i++) i]; + final result = []; + final copy = list.toList(); + for (var i = 0; i < list.length; i++) { + m.lock(); + Future.delayed(Duration(seconds: list.length - i), m.unlock); + final value = copy.removeAt(0); + result.add(value); + } + expect(m.locks, equals(list.length)); + expect(m.pending, hasLength(list.length)); + async.flushTimers(); + expect(m.locks, equals(0)); + expect(m.pending, isEmpty); + expect(listEquals(result, list), isTrue); + }); + }); + }); diff --git a/test/unit/web_socket_fake.dart b/test/unit/web_socket_fake.dart new file mode 100644 index 0000000..a34e639 --- /dev/null +++ b/test/unit/web_socket_fake.dart @@ -0,0 +1,145 @@ +// ignore_for_file: use_setters_to_change_properties + +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:spinify/spinify.dart'; +import 'package:spinify/src/protobuf/client.pb.dart' as pb; + +import 'codecs.dart'; + +/// Fake WebSocket implementation. +@visibleForTesting +class WebSocket$Fake implements WebSocket { + /// Create a fake WebSocket. + WebSocket$Fake() { + _init(); + } + + void _init() { + _socket?.close(); + // ignore: close_sinks + final controller = _socket = StreamController>(sync: true); + _stream = controller.stream.transform>( + StreamTransformer, List>.fromHandlers( + handleData: _dataHandler, + handleError: _errorHandler, + /* handleDone: _doneHandler, */ + ), + ); + onAdd = _defaultOnAddCallback; + /* onDone = _defaultOnDoneCallback; */ + } + + // Default callbacks to handle connects and disconnects. + void _defaultOnAddCallback(List bytes, Sink> sink) { + final command = ProtobufCodec.decode(pb.Command(), bytes); + Future.delayed(const Duration(milliseconds: 5), () { + if (isClosed) return; // Connection is closed, ignore command processing. + if (command.hasConnect()) { + sink.add( + ProtobufCodec.encode( + pb.Reply( + id: command.id, + connect: pb.ConnectResult( + client: 'fake', + version: '0.0.1', + expires: false, + ttl: null, + data: null, + subs: {}, + ping: 600, + pong: false, + session: 'fake', + node: 'fake', + ), + ), + ), + ); + } + }); + } + + /* void _defaultOnDoneCallback() {} */ + + StreamController>? _socket; + + Stream>? _stream; + + @override + Stream> get stream => _stream ?? const Stream>.empty(); + + /// Handle incoming data. + void _dataHandler(List data, EventSink> sink) => + sink.add(data); + + /// Handle incoming error. + void _errorHandler( + Object error, + StackTrace stackTrace, + EventSink> sink, + ) => + sink.addError( + SpinifyTransportException( + message: 'Fake WebSocket error', + error: error, + ), + stackTrace, + ); + + /* /// Handle socket close. + void _doneHandler(EventSink> sink) { + sink.close(); + _isClosed = true; + onDone.call(); + } */ + + @override + int? get closeCode => _closeCode; + int? _closeCode; + + @override + String? get closeReason => _closeReason; + String? _closeReason; + + @override + bool get isClosed => _isClosed; + bool _isClosed = false; + + @override + void add(List bytes) { + onAdd(bytes, _socket!.sink); + } + + /// Add callback to handle sending data and allow to respond with reply. + late void Function(List bytes, Sink> sink) onAdd = + _defaultOnAddCallback; + + /* /// Add callback to handle socket close event. + late void Function() onDone = _defaultOnDoneCallback; */ + + /// Send asynchroniously a reply to the client. + void reply(List bytes) { + _socket?.sink.add(bytes); + } + + @override + void close([int? code, String? reason]) { + _closeCode = code; + _closeReason = reason; + _isClosed = true; + final socket = _socket; + if (socket != null && !socket.isClosed) { + _socket?.close().ignore(); + _socket = null; + } + } + + /// Reset the WebSocket client. + void reset() { + _closeCode = null; + _closeReason = null; + _isClosed = false; + _init(); + } +} diff --git a/test/unit_test.dart b/test/unit_test.dart index 8e1ddd3..14c1fa2 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,17 +1,23 @@ import 'package:test/test.dart'; +import 'unit/codec_test.dart' as codec_test; import 'unit/config_test.dart' as config_test; import 'unit/jwt_test.dart' as jwt_test; import 'unit/logs_test.dart' as logs_test; -import 'unit/server_subscription_test.dart' as server_subscription_test; +import 'unit/model_test.dart' as model_test; import 'unit/spinify_test.dart' as spinify_test; +import 'unit/subscription_test.dart' as subscription_test; +import 'unit/util_test.dart' as util_test; void main() { group('Unit', () { - logs_test.main(); + util_test.main(); + model_test.main(); config_test.main(); - spinify_test.main(); - server_subscription_test.main(); + logs_test.main(); + codec_test.main(); jwt_test.main(); + spinify_test.main(); + subscription_test.main(); }); }