diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 075d269..b57bef6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -237,7 +237,7 @@ "--name=centrifugo", "centrifugo/centrifugo:latest", "centrifugo", - "--client_insecure", + "--client.insecure", "--admin", "--admin_insecure", "--log_level=debug" @@ -255,7 +255,7 @@ "--name=centrifugo", "centrifugo/centrifugo:latest", "centrifugo", - //"--client_insecure", + //"--client.insecure", "--admin", "--admin_insecure", "--log_level=debug" @@ -273,7 +273,7 @@ "--name=centrifugo", "centrifugo/centrifugo:latest", "centrifugo", - "--client_insecure", + "--client.insecure", "--admin", "--admin_insecure", "--log_level=debug" diff --git a/README.md b/README.md index 621468d..7eff573 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Windows 11 Pro 64-bit CPU 13th Gen Intel Core i7-13700K Chrome Version 131.0.6778.86 (Official Build) (64-bit) Docker version 27.1.1 -Docker image centrifugo/centrifugo:v5 +Docker image centrifugo/centrifugo:latest Flutter 3.24.5 • Dart 3.5.4 Package spinify v0.1.0 Package centrifuge-dart v0.14.1 diff --git a/example/benchmark/README.md b/example/benchmark/README.md index 2dcede4..39f68e2 100644 --- a/example/benchmark/README.md +++ b/example/benchmark/README.md @@ -52,9 +52,9 @@ Logs: `docker-compose logs -f` services: centrifugo-benchmark: container_name: centrifugo-benchmark - image: centrifugo/centrifugo:v5 + image: centrifugo/centrifugo:latest restart: unless-stopped - command: centrifugo --client_insecure --admin + command: centrifugo --client.insecure --admin tty: true ports: - 8000:8000 diff --git a/example/benchmark/docker-compose.yml b/example/benchmark/docker-compose.yml index 7421c62..04523a8 100644 --- a/example/benchmark/docker-compose.yml +++ b/example/benchmark/docker-compose.yml @@ -6,9 +6,9 @@ services: centrifugo-benchmark: container_name: centrifugo-benchmark - image: centrifugo/centrifugo:v5 + image: centrifugo/centrifugo:latest restart: unless-stopped - command: centrifugo --client_insecure --admin + command: centrifugo --client.insecure --admin tty: true ports: - 8000:8000 diff --git a/example/benchmark/lib/src/help_tab.dart b/example/benchmark/lib/src/help_tab.dart index ca73ab4..298b51e 100644 --- a/example/benchmark/lib/src/help_tab.dart +++ b/example/benchmark/lib/src/help_tab.dart @@ -157,9 +157,9 @@ const String _helpComposeContent = ''' services: centrifugo-benchmark: container_name: centrifugo-benchmark - image: centrifugo/centrifugo:v5 + image: centrifugo/centrifugo:latest restart: unless-stopped - command: centrifugo --client_insecure --admin + command: centrifugo --client.insecure --admin tty: true ports: - 8000:8000 diff --git a/example/clock/README.md b/example/clock/README.md new file mode 100644 index 0000000..2104fd2 --- /dev/null +++ b/example/clock/README.md @@ -0,0 +1,9 @@ +# Clock example + +## How to run + +```bash +docker compose up --build +``` + +and open [http://localhost:3081](http://localhost:3081) in your browser. diff --git a/example/clock/backend/.dockerignore b/example/clock/backend/.dockerignore new file mode 100644 index 0000000..8d15be0 --- /dev/null +++ b/example/clock/backend/.dockerignore @@ -0,0 +1 @@ +.dart_tool/ \ No newline at end of file diff --git a/example/clock/backend/Dockerfile b/example/clock/backend/Dockerfile new file mode 100644 index 0000000..3823a4b --- /dev/null +++ b/example/clock/backend/Dockerfile @@ -0,0 +1,34 @@ +# Dockerfile для сервера на Dart +FROM dart:stable AS backend_build + +# Установим рабочую директорию +WORKDIR /app + +# Копируем pubspec и pubspec.lock для установки зависимостей +COPY pubspec.* ./ + +# Устанавливаем зависимости +RUN dart pub get + +# Копируем остальной код приложения +COPY . . + +# Компилируем серверное приложение +RUN dart compile exe bin/main.dart -o /server + +# Финальный образ +FROM debian:bullseye-slim + +# Устанавливаем необходимые зависимости +RUN apt-get update && apt-get install -y \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Копируем скомпилированный сервер +COPY --from=backend_build /server /usr/local/bin/server + +# Порт для сервера +EXPOSE 8080 + +# Команда запуска сервера +CMD ["/usr/local/bin/server"] \ No newline at end of file diff --git a/example/clock/backend/analysis_options.yaml b/example/clock/backend/analysis_options.yaml new file mode 100644 index 0000000..abd3e1f --- /dev/null +++ b/example/clock/backend/analysis_options.yaml @@ -0,0 +1,233 @@ +include: package:lints/recommended.yaml + +analyzer: + exclude: + # Build + - "build/**" + # Tests + - "test/**.mocks.dart" + - ".test_coverage.dart" + - "coverage/**" + # Assets + - "assets/**" + # Generated + - "lib/src/common/localization/generated/**" + - "lib/src/common/constants/pubspec.yaml.g.dart" + - "lib/src/common/model/generated/**" + - "**.g.dart" + - "**.gql.dart" + - "**.freezed.dart" + - "**.config.dart" + - "**.mocks.dart" + - "**.gen.dart" + - "**.pb.dart" + - "**.pbenum.dart" + - "**.pbjson.dart" + # Flutter Version Manager + - ".fvm/**" + # Tools + #- "tool/**" + - "scripts/**" + - ".dart_tool/**" + # Platform + - "ios/**" + - "android/**" + - "web/**" + - "macos/**" + - "windows/**" + - "linux/**" + + # Enable the following options to enable strong mode. + language: + strict-casts: true + strict-raw-types: true + strict-inference: true + + errors: + # Allow having TODOs in the code + todo: ignore + + # Info + directives_ordering: info + always_declare_return_types: info + + # Warning + unsafe_html: warning + missing_return: warning + missing_required_param: warning + no_logic_in_create_state: warning + empty_catches: warning + + # Error + always_use_package_imports: error + avoid_relative_lib_imports: error + avoid_slow_async_io: error + avoid_types_as_parameter_names: error + valid_regexps: error + always_require_non_null_named_parameters: error + +linter: + rules: + # Public packages + #public_member_api_docs: true + #lines_longer_than_80_chars: true + + # Enabling rules + always_use_package_imports: true + avoid_relative_lib_imports: true + + # Disable rules + sort_pub_dependencies: false + prefer_relative_imports: false + prefer_final_locals: false + avoid_escaping_inner_quotes: false + curly_braces_in_flow_control_structures: false + one_member_abstracts: false + + # Enabled + use_named_constants: true + unnecessary_constructor_name: true + sort_constructors_first: true + exhaustive_cases: true + sort_unnamed_constructors_first: true + type_literal_in_constant_pattern: true + always_put_required_named_parameters_first: true + avoid_annotating_with_dynamic: true + avoid_bool_literals_in_conditional_expressions: true + avoid_double_and_int_checks: true + avoid_field_initializers_in_const_classes: true + avoid_implementing_value_types: true + avoid_js_rounded_ints: true + avoid_print: true + avoid_renaming_method_parameters: true + avoid_returning_null_for_void: true + avoid_single_cascade_in_expression_statements: true + avoid_slow_async_io: true + avoid_unnecessary_containers: true + avoid_unused_constructor_parameters: true + avoid_void_async: true + await_only_futures: true + cancel_subscriptions: true + cascade_invocations: true + close_sinks: true + control_flow_in_finally: true + empty_statements: true + collection_methods_unrelated_type: true + join_return_with_assignment: true + leading_newlines_in_multiline_strings: true + literal_only_boolean_expressions: true + missing_whitespace_between_adjacent_strings: true + no_adjacent_strings_in_list: true + no_logic_in_create_state: true + no_runtimeType_toString: true + only_throw_errors: true + overridden_fields: true + package_names: true + package_prefixed_library_names: true + parameter_assignments: true + prefer_asserts_in_initializer_lists: true + prefer_asserts_with_message: true + prefer_const_constructors: true + prefer_const_constructors_in_immutables: true + prefer_const_declarations: true + prefer_const_literals_to_create_immutables: true + prefer_constructors_over_static_methods: true + prefer_expression_function_bodies: true + prefer_final_in_for_each: true + prefer_foreach: true + prefer_if_elements_to_conditional_expressions: true + prefer_inlined_adds: true + prefer_int_literals: true + prefer_is_not_operator: true + prefer_null_aware_operators: true + prefer_typing_uninitialized_variables: true + prefer_void_to_null: true + provide_deprecation_message: true + sized_box_for_whitespace: true + sort_child_properties_last: true + test_types_in_equals: true + throw_in_finally: true + unnecessary_null_aware_assignments: true + unnecessary_overrides: true + unnecessary_parenthesis: true + unnecessary_raw_strings: true + unnecessary_statements: true + unnecessary_string_escapes: true + unnecessary_string_interpolations: true + unsafe_html: true + use_raw_strings: true + use_string_buffers: true + valid_regexps: true + void_checks: true + + # Pedantic 1.9.0 + always_declare_return_types: true + annotate_overrides: true + avoid_empty_else: true + avoid_init_to_null: true + avoid_null_checks_in_equality_operators: true + avoid_return_types_on_setters: true + avoid_shadowing_type_parameters: true + avoid_types_as_parameter_names: true + camel_case_extensions: true + empty_catches: true + empty_constructor_bodies: true + library_names: true + library_prefixes: true + no_duplicate_case_values: true + null_closures: true + omit_local_variable_types: true + prefer_adjacent_string_concatenation: true + prefer_collection_literals: true + prefer_conditional_assignment: true + prefer_contains: true + prefer_final_fields: true + prefer_for_elements_to_map_fromIterable: true + prefer_generic_function_type_aliases: true + prefer_if_null_operators: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_iterable_whereType: true + prefer_single_quotes: true + prefer_spread_collections: true + recursive_getters: true + slash_for_doc_comments: true + type_init_formals: true + unawaited_futures: true + unnecessary_const: true + unnecessary_new: true + unnecessary_null_in_if_null_operators: true + unnecessary_this: true + unrelated_type_equality_checks: true + use_function_type_syntax_for_parameters: true + use_rethrow_when_possible: true + + # Effective_dart 1.2.0 + camel_case_types: true + file_names: true + non_constant_identifier_names: true + constant_identifier_names: true + directives_ordering: true + package_api_docs: true + implementation_imports: true + prefer_interpolation_to_compose_strings: true + unnecessary_brace_in_string_interps: true + avoid_function_literals_in_foreach_calls: true + prefer_function_declarations_over_variables: true + unnecessary_lambdas: true + unnecessary_getters_setters: true + prefer_initializing_formals: true + avoid_catches_without_on_clauses: true + avoid_catching_errors: true + use_to_and_as_if_applicable: true + avoid_classes_with_only_static_members: true + prefer_mixin: true + use_setters_to_change_properties: true + avoid_setters_without_getters: true + avoid_returning_this: true + type_annotate_public_apis: true + avoid_types_on_closure_parameters: true + avoid_private_typedef_functions: true + avoid_positional_boolean_parameters: true + hash_and_equals: true + avoid_equals_and_hash_code_on_mutable_classes: true diff --git a/example/clock/backend/bin/main.dart b/example/clock/backend/bin/main.dart new file mode 100644 index 0000000..1ed3f49 --- /dev/null +++ b/example/clock/backend/bin/main.dart @@ -0,0 +1,3 @@ +import 'package:spinify_clock_backend/server.dart'; + +void main() => runServer(); diff --git a/example/clock/backend/lib/server.dart b/example/clock/backend/lib/server.dart new file mode 100644 index 0000000..ed57e8d --- /dev/null +++ b/example/clock/backend/lib/server.dart @@ -0,0 +1,62 @@ +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:l/l.dart'; +import 'package:spinify/spinify.dart'; + +void runServer() => l.capture( + () => runZonedGuarded( + () async { + final spinify = Spinify.connect( + 'ws://centrifugo:8000/connection/websocket', + config: SpinifyConfig( + client: (name: 'Server', version: '1.0.0'), + logger: (level, event, message, context) => l.log( + LogMessage.verbose( + timestamp: DateTime.now(), + level: switch (level) { + 0 => const LogLevel.debug(), + 1 => const LogLevel.vvvv(), + 2 => const LogLevel.vvv(), + 3 => const LogLevel.info(), + 4 => const LogLevel.warning(), + 5 => const LogLevel.error(), + 6 => const LogLevel.shout(), + _ => const LogLevel.info(), + }, + message: message, + context: context, + ), + ), + ), + ); + final subscription = + spinify.newSubscription('clock', subscribe: true); + final encoder = const JsonEncoder().fuse(const Utf8Encoder()); + Timer.periodic(const Duration(seconds: 1), (timer) { + if (!subscription.state.isSubscribed) return; + final now = DateTime.now(); + subscription.publish( + encoder.convert( + { + 'hour': now.hour, + 'minute': now.minute, + 'second': now.second, + }, + ), + ); + }); + }, + l.e, + ), + LogOptions( + outputInRelease: true, + handlePrint: true, + printColors: false, + output: LogOutput.platform, + overrideOutput: (message) => '[${message.level}] ${message.message}', + messageFormatting: (message) => message, + ), + ); diff --git a/example/clock/backend/pubspec.yaml b/example/clock/backend/pubspec.yaml new file mode 100644 index 0000000..ef51566 --- /dev/null +++ b/example/clock/backend/pubspec.yaml @@ -0,0 +1,64 @@ +# ============================================================================== +# PROJECT METADATA +# ============================================================================== + +name: spinify_clock_backend + +description: "Spinify Clock: Backend" + +publish_to: 'none' + +version: 0.0.1+1 + +homepage: https://github.com/plugfox/spinify + +repository: https://github.com/plugfox/spinify + +issue_tracker: https://github.com/plugfox/spinify/issues + +#funding: +# - + +#topics: +# - + +platforms: + linux: + macos: + windows: + +#screenshots: +# - description: 'Screenshot 1' +# path: screenshot_1.png + + +# ============================================================================== +# PROJECT STRUCTURE +# ============================================================================== + +# https://dart.dev/tools/pub/workspaces +#workspace: +# - ../shared + +#executables: +# - + + +# ============================================================================== +# DEPENDENCIES +# ============================================================================== + +environment: + sdk: '>=3.6.1 <4.0.0' + +dependencies: + # Annotation + l: ^5.0.0 + meta: any + + # Centrifugo + spinify: any + +dev_dependencies: + # Linting + lints: ^5.1.1 diff --git a/example/clock/docker-compose.yml b/example/clock/docker-compose.yml new file mode 100644 index 0000000..ee56fcd --- /dev/null +++ b/example/clock/docker-compose.yml @@ -0,0 +1,77 @@ +# Docker Compose configuration file for running Centrifugo powered clock application. +# docker compose build +# docker compose up --build +# docker compose up -d +# docker compose down +# docker compose logs -f + +services: + # Backend service + backend: + container_name: backend + image: clock_backend:latest + restart: unless-stopped + command: /usr/local/bin/server + depends_on: + - centrifugo + ports: + - 3080:8080 + networks: + - clock_network + healthcheck: + test: ["CMD", "sh", "-c", "wget -nv -O - http://localhost:8080/health"] + interval: 3s + timeout: 3s + retries: 3 + environment: + - "TZ=UTC" # set timezone to UTC for backend + build: + context: backend + dockerfile: Dockerfile + + # Frontend service + # http://localhost:3081 + frontend: + container_name: frontend + image: clock_frontend:latest + restart: unless-stopped + depends_on: + - centrifugo + ports: + - 3081:80 + healthcheck: + test: ["CMD", "sh", "-c", "wget -nv -O - http://localhost:80"] + interval: 3s + timeout: 3s + retries: 3 + build: + context: frontend + dockerfile: Dockerfile + + # Centrifugo service + # docker compose up centrifugo + centrifugo: + container_name: centrifugo + image: centrifugo/centrifugo:latest + restart: unless-stopped + command: centrifugo --client.insecure --health.enabled + tty: true + ports: + - 3082:8000 + networks: + - clock_network + healthcheck: + test: ["CMD", "sh", "-c", "wget -nv -O - http://localhost:8000/health"] + interval: 3s + timeout: 3s + retries: 3 + ulimits: + nofile: + soft: 65535 + hard: 65535 + environment: + - "CENTRIFUGO_CLIENT_ALLOWED_ORIGINS=*" + +networks: + clock_network: + driver: bridge \ No newline at end of file diff --git a/example/clock/frontend/.dockerignore b/example/clock/frontend/.dockerignore new file mode 100644 index 0000000..8d15be0 --- /dev/null +++ b/example/clock/frontend/.dockerignore @@ -0,0 +1 @@ +.dart_tool/ \ No newline at end of file diff --git a/example/clock/frontend/Dockerfile b/example/clock/frontend/Dockerfile new file mode 100644 index 0000000..73c9c63 --- /dev/null +++ b/example/clock/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Dockerfile для веб-приложения Dart (JS) +FROM dart:stable AS frontend_build + +# Установим рабочую директорию +WORKDIR /app + +# Копируем pubspec и pubspec.lock для установки зависимостей +COPY pubspec.* ./ + +# Устанавливаем зависимости +RUN dart pub get + +# Копируем остальной код приложения +COPY . . + +# Сборка JS +#RUN dart compile js -O3 -o build/main.dart.js web/main.dart +RUN dart pub global activate webdev && webdev build --output=build --release + +# Финальный образ для сервиса на NGINX +FROM nginx:alpine + +# Копируем скомпилированные файлы +COPY --from=frontend_build /app/build/web /usr/share/nginx/html + +# Порт для NGINX +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/example/clock/frontend/analysis_options.yaml b/example/clock/frontend/analysis_options.yaml new file mode 100644 index 0000000..abd3e1f --- /dev/null +++ b/example/clock/frontend/analysis_options.yaml @@ -0,0 +1,233 @@ +include: package:lints/recommended.yaml + +analyzer: + exclude: + # Build + - "build/**" + # Tests + - "test/**.mocks.dart" + - ".test_coverage.dart" + - "coverage/**" + # Assets + - "assets/**" + # Generated + - "lib/src/common/localization/generated/**" + - "lib/src/common/constants/pubspec.yaml.g.dart" + - "lib/src/common/model/generated/**" + - "**.g.dart" + - "**.gql.dart" + - "**.freezed.dart" + - "**.config.dart" + - "**.mocks.dart" + - "**.gen.dart" + - "**.pb.dart" + - "**.pbenum.dart" + - "**.pbjson.dart" + # Flutter Version Manager + - ".fvm/**" + # Tools + #- "tool/**" + - "scripts/**" + - ".dart_tool/**" + # Platform + - "ios/**" + - "android/**" + - "web/**" + - "macos/**" + - "windows/**" + - "linux/**" + + # Enable the following options to enable strong mode. + language: + strict-casts: true + strict-raw-types: true + strict-inference: true + + errors: + # Allow having TODOs in the code + todo: ignore + + # Info + directives_ordering: info + always_declare_return_types: info + + # Warning + unsafe_html: warning + missing_return: warning + missing_required_param: warning + no_logic_in_create_state: warning + empty_catches: warning + + # Error + always_use_package_imports: error + avoid_relative_lib_imports: error + avoid_slow_async_io: error + avoid_types_as_parameter_names: error + valid_regexps: error + always_require_non_null_named_parameters: error + +linter: + rules: + # Public packages + #public_member_api_docs: true + #lines_longer_than_80_chars: true + + # Enabling rules + always_use_package_imports: true + avoid_relative_lib_imports: true + + # Disable rules + sort_pub_dependencies: false + prefer_relative_imports: false + prefer_final_locals: false + avoid_escaping_inner_quotes: false + curly_braces_in_flow_control_structures: false + one_member_abstracts: false + + # Enabled + use_named_constants: true + unnecessary_constructor_name: true + sort_constructors_first: true + exhaustive_cases: true + sort_unnamed_constructors_first: true + type_literal_in_constant_pattern: true + always_put_required_named_parameters_first: true + avoid_annotating_with_dynamic: true + avoid_bool_literals_in_conditional_expressions: true + avoid_double_and_int_checks: true + avoid_field_initializers_in_const_classes: true + avoid_implementing_value_types: true + avoid_js_rounded_ints: true + avoid_print: true + avoid_renaming_method_parameters: true + avoid_returning_null_for_void: true + avoid_single_cascade_in_expression_statements: true + avoid_slow_async_io: true + avoid_unnecessary_containers: true + avoid_unused_constructor_parameters: true + avoid_void_async: true + await_only_futures: true + cancel_subscriptions: true + cascade_invocations: true + close_sinks: true + control_flow_in_finally: true + empty_statements: true + collection_methods_unrelated_type: true + join_return_with_assignment: true + leading_newlines_in_multiline_strings: true + literal_only_boolean_expressions: true + missing_whitespace_between_adjacent_strings: true + no_adjacent_strings_in_list: true + no_logic_in_create_state: true + no_runtimeType_toString: true + only_throw_errors: true + overridden_fields: true + package_names: true + package_prefixed_library_names: true + parameter_assignments: true + prefer_asserts_in_initializer_lists: true + prefer_asserts_with_message: true + prefer_const_constructors: true + prefer_const_constructors_in_immutables: true + prefer_const_declarations: true + prefer_const_literals_to_create_immutables: true + prefer_constructors_over_static_methods: true + prefer_expression_function_bodies: true + prefer_final_in_for_each: true + prefer_foreach: true + prefer_if_elements_to_conditional_expressions: true + prefer_inlined_adds: true + prefer_int_literals: true + prefer_is_not_operator: true + prefer_null_aware_operators: true + prefer_typing_uninitialized_variables: true + prefer_void_to_null: true + provide_deprecation_message: true + sized_box_for_whitespace: true + sort_child_properties_last: true + test_types_in_equals: true + throw_in_finally: true + unnecessary_null_aware_assignments: true + unnecessary_overrides: true + unnecessary_parenthesis: true + unnecessary_raw_strings: true + unnecessary_statements: true + unnecessary_string_escapes: true + unnecessary_string_interpolations: true + unsafe_html: true + use_raw_strings: true + use_string_buffers: true + valid_regexps: true + void_checks: true + + # Pedantic 1.9.0 + always_declare_return_types: true + annotate_overrides: true + avoid_empty_else: true + avoid_init_to_null: true + avoid_null_checks_in_equality_operators: true + avoid_return_types_on_setters: true + avoid_shadowing_type_parameters: true + avoid_types_as_parameter_names: true + camel_case_extensions: true + empty_catches: true + empty_constructor_bodies: true + library_names: true + library_prefixes: true + no_duplicate_case_values: true + null_closures: true + omit_local_variable_types: true + prefer_adjacent_string_concatenation: true + prefer_collection_literals: true + prefer_conditional_assignment: true + prefer_contains: true + prefer_final_fields: true + prefer_for_elements_to_map_fromIterable: true + prefer_generic_function_type_aliases: true + prefer_if_null_operators: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_iterable_whereType: true + prefer_single_quotes: true + prefer_spread_collections: true + recursive_getters: true + slash_for_doc_comments: true + type_init_formals: true + unawaited_futures: true + unnecessary_const: true + unnecessary_new: true + unnecessary_null_in_if_null_operators: true + unnecessary_this: true + unrelated_type_equality_checks: true + use_function_type_syntax_for_parameters: true + use_rethrow_when_possible: true + + # Effective_dart 1.2.0 + camel_case_types: true + file_names: true + non_constant_identifier_names: true + constant_identifier_names: true + directives_ordering: true + package_api_docs: true + implementation_imports: true + prefer_interpolation_to_compose_strings: true + unnecessary_brace_in_string_interps: true + avoid_function_literals_in_foreach_calls: true + prefer_function_declarations_over_variables: true + unnecessary_lambdas: true + unnecessary_getters_setters: true + prefer_initializing_formals: true + avoid_catches_without_on_clauses: true + avoid_catching_errors: true + use_to_and_as_if_applicable: true + avoid_classes_with_only_static_members: true + prefer_mixin: true + use_setters_to_change_properties: true + avoid_setters_without_getters: true + avoid_returning_this: true + type_annotate_public_apis: true + avoid_types_on_closure_parameters: true + avoid_private_typedef_functions: true + avoid_positional_boolean_parameters: true + hash_and_equals: true + avoid_equals_and_hash_code_on_mutable_classes: true diff --git a/example/clock/frontend/lib/site.dart b/example/clock/frontend/lib/site.dart new file mode 100644 index 0000000..efb6cc9 --- /dev/null +++ b/example/clock/frontend/lib/site.dart @@ -0,0 +1,77 @@ +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:l/l.dart'; +import 'package:spinify/spinify.dart'; +import 'package:spinify_clock_frontend/src/clock_layer.dart'; +import 'package:spinify_clock_frontend/src/engine.dart'; + +void runSite() => l.capture( + () => runZonedGuarded( + () async { + final layer = ClockLayer(); + final _ = RenderingEngine.instance + ..addLayer(layer) + ..start(); + + final spinify = Spinify.connect( + 'ws://127.0.0.1:3082/connection/websocket', + config: SpinifyConfig( + client: (name: 'Website', version: '1.0.0'), + logger: (level, event, message, context) => l.log( + LogMessage.verbose( + timestamp: DateTime.now(), + level: switch (level) { + 0 => const LogLevel.debug(), + 1 => const LogLevel.vvvv(), + 2 => const LogLevel.vvv(), + 3 => const LogLevel.info(), + 4 => const LogLevel.warning(), + 5 => const LogLevel.error(), + 6 => const LogLevel.shout(), + _ => const LogLevel.info(), + }, + message: message, + context: context, + ), + ), + ), + ); + final subscription = + spinify.newSubscription('clock', subscribe: true); + final decoder = const Utf8Decoder() + .fuse(const JsonDecoder()) + .cast, Map>(); + subscription.stream.publication().listen( + (event) { + final update = decoder.convert(event.data); + if (update + case { + 'hour': int hour, + 'minute': int minute, + 'second': int second + }) { + layer.setTime( + hour: hour, + minute: minute, + second: second, + ); + } + }, + cancelOnError: false, + ); + l.i('Engine started'); + }, + l.e, + ), + LogOptions( + outputInRelease: true, + handlePrint: true, + printColors: false, + output: LogOutput.platform, + overrideOutput: (message) => '[${message.level}] ${message.message}', + messageFormatting: (message) => message, + ), + ); diff --git a/example/clock/frontend/lib/src/clock_layer.dart b/example/clock/frontend/lib/src/clock_layer.dart new file mode 100644 index 0000000..c4b8b91 --- /dev/null +++ b/example/clock/frontend/lib/src/clock_layer.dart @@ -0,0 +1,335 @@ +// ignore_for_file: cascade_invocations + +import 'dart:js_interop'; +import 'dart:math' as math; +import 'dart:typed_data' as td; + +import 'package:intl/intl.dart' as intl; +import 'package:spinify_clock_frontend/src/engine.dart'; +import 'package:web/web.dart'; + +class ClockLayer implements ResizableLayer { + ClockLayer(); + + bool _dirty = false; + DateTime _time = DateTime(0); + static final intl.DateFormat _timeFormat = intl.DateFormat('HH:mm:ss'); + int _fontSize = 32; + + /// Set the time to display on the clock. + void setTime({ + required int hour, + required int minute, + required int second, + }) { + if (second == _time.second && minute == _time.minute && hour == _time.hour) + return; + _time = DateTime(0, 1, 1, hour, minute, second); + if (_initialized) _updateTimeVertices(); + _dirty = true; + } + + bool _initialized = false; + late WebGLProgram _program; + late WebGLBuffer _vertexBuffer; + late WebGLBuffer _colorBuffer; + + // Shader locations + late int _positionLocation; + late int _colorLocation; + late WebGLUniformLocation _resolutionLocation; + + int _width = window.innerWidth; + int _height = window.innerHeight; + late double _centerX; + late double _centerY; + late double _radius; + + /// Римские цифры для часов + static const int _circleSegments = 24; + static const List _romanNumerals = [ + 'XII', + 'I', + 'II', + 'III', + 'IV', + 'V', + 'VI', + 'VII', + 'VIII', + 'IX', + 'X', + 'XI', + ]; + static final td.Float32List _hourColors = + td.Float32List.fromList([0, 0, 1, 1, 0, 0, 1, 1]); // Синий цвет + static final td.Float32List _minuteColors = + td.Float32List.fromList([0, 1, 0, 1, 0, 1, 0, 1]); // Зеленый цвет + static final td.Float32List _secondColors = + td.Float32List.fromList([1, 0, 0, 1, 1, 0, 0, 1]); // Красный цвет + static final td.Float32List _markColors = + td.Float32List.fromList([0.5, 0.5, 0.5, 1, 0.5, 0.5, 0.5, 1]); + static final td.Float32List _circleColors = + td.Float32List.fromList(List.filled(_circleSegments ~/ 2 * 4 * 4, 1)); + + @override + bool get isVisible => true; + + void _initializeGL(RenderContext context) { + if (_initialized) return; + + final gl = context.ctxGL; + + // Vertex shader для позиции и цвета + final vertexShader = gl.createShader(WebGL2RenderingContext.VERTEX_SHADER)!; + gl.shaderSource(vertexShader, ''' + attribute vec2 a_position; + attribute vec4 a_color; + uniform vec2 u_resolution; + varying vec4 v_color; + + void main() { + vec2 zeroToOne = a_position / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clipSpace = zeroToTwo - 1.0; + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + v_color = a_color; + } + '''); + gl.compileShader(vertexShader); + + // Fragment shader для цвета + final fragmentShader = + gl.createShader(WebGL2RenderingContext.FRAGMENT_SHADER)!; + gl.shaderSource(fragmentShader, ''' + precision mediump float; + varying vec4 v_color; + + void main() { + gl_FragColor = v_color; + } + '''); + gl.compileShader(fragmentShader); + + // Создаем и линкуем программу + _program = gl.createProgram()!; + gl.attachShader(_program, vertexShader); + gl.attachShader(_program, fragmentShader); + gl.linkProgram(_program); + + // Получаем локации атрибутов и uniform-переменных + _positionLocation = gl.getAttribLocation(_program, 'a_position'); + _colorLocation = gl.getAttribLocation(_program, 'a_color'); + _resolutionLocation = gl.getUniformLocation(_program, 'u_resolution')!; + + // Создаем буферы + _vertexBuffer = gl.createBuffer()!; + _colorBuffer = gl.createBuffer()!; + + _initialized = true; + } + + /// Рисует линии на экране + void _drawLines( + RenderContext context, + td.Float32List vertices, + td.Float32List colors, + ) { + final gl = context.ctxGL; + + // Устанавливаем вершины + gl.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, _vertexBuffer); + gl.bufferData( + WebGL2RenderingContext.ARRAY_BUFFER, + vertices.toJS, + WebGL2RenderingContext.STATIC_DRAW, + ); + gl.enableVertexAttribArray(_positionLocation); + gl.vertexAttribPointer( + _positionLocation, + 2, + WebGL2RenderingContext.FLOAT, + false, + 0, + 0, + ); + + // Устанавливаем цвета + gl.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, _colorBuffer); + gl.bufferData( + WebGL2RenderingContext.ARRAY_BUFFER, + colors.toJS, + WebGL2RenderingContext.STATIC_DRAW, + ); + gl.enableVertexAttribArray(_colorLocation); + gl.vertexAttribPointer( + _colorLocation, + 4, + WebGL2RenderingContext.FLOAT, + false, + 0, + 0, + ); + + // Рисуем линии + gl.drawArrays(WebGL2RenderingContext.LINES, 0, vertices.length ~/ 2); + } + + /// Создает вершины для стрелок часов + td.Float32List _createHandVertices(double angle, double length) => + td.Float32List(4) + ..[0] = _centerX // Начальная точка (центр циферблата) + ..[1] = _centerY // Начальная точка (центр циферблата) + ..[2] = _centerX + + length * math.cos(angle) // Конечная точка (конец стрелки) + ..[3] = _centerY + + length * math.sin(angle); // Конечная точка (конец стрелки) + + /// Создает вершины для циферблата + td.Float32List _createCircleVertices(int segments) { + final vertices = td.Float32List(segments * 4); + final radius = _radius * 0.9; + for (var i = 0; i < segments; i++) { + final angle1 = i * 2 * math.pi / segments; + final angle2 = (i + 1) * 2 * math.pi / segments; + vertices + ..[i * 4 + 0] = _centerX + radius * math.cos(angle1) + ..[i * 4 + 1] = _centerY + radius * math.sin(angle1) + ..[i * 4 + 2] = _centerX + radius * math.cos(angle2) + ..[i * 4 + 3] = _centerY + radius * math.sin(angle2); + } + + return vertices; + } + + @override + void mount(RenderContext context) { + _initializeGL(context); + _updateDimensions(); + } + + void _updateDimensions() { + _centerX = _width / 2; + _centerY = _height / 2; + _radius = math.min(_width, _height) * 0.4; + } + + @override + void onResize(int width, int height) { + _width = width; + _height = height; + _updateDimensions(); + _updateTimeVertices(); + _fontSize = switch (math.min(_width, _height)) { + > 1024 => 64, + > 768 => 48, + > 512 => 32, + > 256 => 24, + > 128 => 16, + > 64 => 12, + > 32 => 10, + _ => 8, + }; + _dirty = true; + } + + td.Float32List _hourVertices = td.Float32List(4), + _minuteVertices = td.Float32List(4), + _secondVertices = td.Float32List(4); + + void _updateTimeVertices() { + final hours = _time.hour % 12; + final minutes = _time.minute; + final seconds = _time.second; + + final hourAngle = (hours + minutes / 60) * 2 * math.pi / 12 - math.pi / 2; + final minuteAngle = minutes * 2 * math.pi / 60 - math.pi / 2; + final secondAngle = seconds * 2 * math.pi / 60 - math.pi / 2; + + _hourVertices = _createHandVertices(hourAngle, _radius * 0.5); + _minuteVertices = _createHandVertices(minuteAngle, _radius * 0.7); + _secondVertices = _createHandVertices(secondAngle, _radius * 0.8); + } + + @override + void update(RenderContext context, double delta) {} + + @override + void render(RenderContext context, double delta) { + if (!_dirty) return; // Skip rendering if not dirty + _dirty = false; + + final gl = context.ctxGL; + + // Очищаем и подготавливаем GL + gl.viewport(0, 0, _width, _height); + //gl.clearColor(0.1, 0.1, 0.1, 1.0); // Фон WebGL + gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); + //gl.lineWidth(2); + + gl.useProgram(_program); + gl.uniform2f(_resolutionLocation, _width.toDouble(), _height.toDouble()); + + // Рисуем циферблат (окружность) + final circleVertices = _createCircleVertices(_circleSegments); + _drawLines(context, circleVertices, _circleColors); + + // Рисуем часовую стрелку + _drawLines(context, _hourVertices, _hourColors); + + // Рисуем минутную стрелку + _drawLines(context, _minuteVertices, _minuteColors); + + // Рисуем секундную стрелку + _drawLines(context, _secondVertices, _secondColors); + + // Рисуем деления часов + final outerRadius = _radius * 0.9; + final innerRadius = _radius * 0.8; + for (var i = 0; i < 12; i++) { + final angle = i * 2 * math.pi / 12; + final markVertices = td.Float32List(4) + ..[0] = _centerX + innerRadius * math.cos(angle) + ..[1] = _centerY + innerRadius * math.sin(angle) + ..[2] = _centerX + outerRadius * math.cos(angle) + ..[3] = _centerY + outerRadius * math.sin(angle); + + _drawLines(context, markVertices, _markColors); + } + + // Draw text on top of the canvas + + context.ctx2D + ..clearRect(0, 0, context.width, context.height) + ..font = '${_fontSize}px Arial' + ..fillStyle = 'white'.toJS + ..textAlign = 'center' + ..textBaseline = 'middle'; + + // Draw roman numerals + final numbersRadius = _radius * 0.9; + for (var i = 0; i < 12; i++) { + final angle = i * 2 * math.pi / 12 - math.pi / 2; + final text = _romanNumerals[i]; + final textX = _centerX + (numbersRadius + _fontSize) * math.cos(angle); + final textY = _centerY + (numbersRadius + _fontSize) * math.sin(angle); + context.ctx2D.fillText(text, textX, textY); + } + + final text = _timeFormat.format(_time); + //final metrics = context.ctx2D.measureText(text); + context.ctx2D + // Draw time at the center of the canvas + .fillText(text, _centerX, _centerY); + } + + @override + void unmount(RenderContext context) { + if (_initialized) { + final gl = context.ctxGL; + gl.deleteProgram(_program); + gl.deleteBuffer(_vertexBuffer); + gl.deleteBuffer(_colorBuffer); + } + } +} diff --git a/example/clock/frontend/lib/src/engine.dart b/example/clock/frontend/lib/src/engine.dart new file mode 100644 index 0000000..a1c1e7f --- /dev/null +++ b/example/clock/frontend/lib/src/engine.dart @@ -0,0 +1,305 @@ +// ignore_for_file: prefer_constructors_over_static_methods + +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:l/l.dart'; +import 'package:web/web.dart'; + +// Rendering context +class RenderContext { + RenderContext._({ + required int width, + required int height, + required this.canvasGL, + required this.ctxGL, + required this.canvasUI, + required this.ctx2D, + required this.resources, + }) : _width = width, + _height = height; + + /// Width of the canvas. + int get width => _width; + int _width; + + /// Height of the canvas. + int get height => _height; + int _height; + + /// WebGL canvas for rendering shaders. + final HTMLCanvasElement canvasGL; + + /// WebGL2 context. + final WebGL2RenderingContext ctxGL; + + /// 2D canvas for rendering UI. + final HTMLCanvasElement canvasUI; + + /// 2D context. + final CanvasRenderingContext2D ctx2D; + + /// Resources for rendering, such as textures, shaders and buffers. + final Map resources; + + /// Get a resource from the context. + T getResource(String key) => resources[key] as T; + + /// Set a resource in the context. + void setResource(String key, T value) => resources[key] = value; + + /// Remove a resource from the context. + void delResource(String key) => resources.remove(key); +} + +// Core rendering infrastructure +abstract interface class Layer { + /// Whether the layer is visible. + bool get isVisible; + + /// Called when the layer is mounted. + void mount(RenderContext context); + + /// Update the layer with the given delta time. + void update(RenderContext context, double delta); + + /// Render the layer with the given context and delta time. + void render(RenderContext context, double delta); + + /// Called when the layer is unmounted. + void unmount(RenderContext context); +} + +/// Layer that can be resized. +abstract interface class ResizableLayer implements Layer { + /// Called when the layer is resized. + void onResize(int width, int height); +} + +/// Rendering engine that manages layers and rendering. +class RenderingEngine { + RenderingEngine._({ + required ShadowRoot shadow, + required HTMLDivElement container, + required List layers, + required RenderContext context, + }) : _shadow = shadow, + _container = container, + _layers = layers, + _context = context; + + static RenderingEngine? _instance; + + /// Singleton instance of the rendering engine. + static RenderingEngine get instance => _instance ??= () { + final app = document.querySelector('#app'); + if (app == null) throw StateError('Failed to find app element'); + final children = app.children; + for (var i = children.length - 1; i >= 0; i--) + children.item(i)!.remove(); + + final shadow = app.attachShadow(ShadowRootInit( + mode: 'open', + clonable: false, + serializable: false, + delegatesFocus: false, + slotAssignment: 'manual', + )); + + final container = HTMLDivElement() + ..id = 'engine' + ..style.position = 'fixed' + ..style.top = '0' + ..style.left = '0' + ..style.width = '100%' + ..style.height = '100%' + ..style.overflow = 'hidden'; + + final width = window.innerWidth; + final height = window.innerHeight; + + final layers = []; + // Initialize WebGL Canvas + final canvasGL = document.createElement('canvas') as HTMLCanvasElement + ..id = 'gl-canvas' + ..width = width + ..height = height + ..style.position = 'absolute' + ..style.top = '0' + ..style.left = '0' + ..style.zIndex = '0'; + + // Get WebGL context with alpha for transparency + final ctxGL = canvasGL.getContext( + 'webgl2', + { + 'alpha': false, + 'depth': false, + 'antialias': true, // false, - for performance and pixel art + 'powerPreference': 'high-performance', + 'preserveDrawingBuffer': false, + }.jsify(), + ) as WebGL2RenderingContext; + // Initialize 2D Canvas + final canvasUI = document.createElement('canvas') as HTMLCanvasElement + ..id = 'ui-canvas' + ..width = width + ..height = height + ..style.position = 'absolute' + ..style.top = '0' + ..style.left = '0' + ..style.zIndex = '1'; + + final ctx2D = canvasUI.getContext( + '2d', + { + 'alpha': true, + 'willReadFrequently': false, + }.jsify(), + ) as CanvasRenderingContext2D; + // Append canvases to the body + shadow.append(container + ..append(canvasGL) + ..append(canvasUI)); + final engine = RenderingEngine._( + shadow: shadow, + container: container, + layers: layers, + context: RenderContext._( + width: width, + height: height, + canvasGL: canvasGL, + ctxGL: ctxGL, + canvasUI: canvasUI, + ctx2D: ctx2D, + resources: {}, + ), + ); + return engine; + }(); + + final ShadowRoot _shadow; + final HTMLDivElement _container; + final List _layers; + + bool _isClosed = false; + bool _isRunning = false; + double _lastFrameTime = 0; + + // Rendering context + final RenderContext _context; + + Timer? _healthCehckTimer; + + /// Resize the rendering engine. + void _onResize(int width, int height) { + if (_isClosed) return; + if (_context.width == width && _context.height == height) return; + l.d('Resize to $width x $height'); + _context + .._width = width + .._height = height; + _context.canvasGL + ..width = width + ..height = height; + _context.canvasUI + ..width = width + ..height = height; + // Notify layers about resize + for (final layer in _layers) { + if (layer case ResizableLayer resizableLayer) { + resizableLayer.onResize(width, height); + } + } + } + + late final JSExportedDartFunction _onResizeJS = ((Event event) { + _onResize(window.innerWidth, window.innerHeight); + }).toJS; + + /// Add a layer to the rendering engine. + void addLayer(Layer layer) { + _layers.add(layer); + layer.mount(_context); + if (layer is ResizableLayer) + layer.onResize(_context.width, _context.height); + } + + /// Remove a layer from the rendering engine. + void removeLayer(Layer layer) { + if (_layers.remove(layer)) layer.unmount(_context); + } + + /// Tick the rendering engine. + void _renderFrame(num currentTime) { + if (!_isRunning) return; + + // Calculate delta time + final deltaTime = (currentTime - _lastFrameTime) / 1000.0; + _lastFrameTime = currentTime.toDouble(); + + // Clear both contexts + //_webGl.clear(WebGL.COLOR_BUFFER_BIT | WebGL.DEPTH_BUFFER_BIT); + //_ctx2d.clearRect(0, 0, _canvas.width, _canvas.height); + + // Update and render all visible layers + for (final layer in _layers) { + if (!layer.isVisible) continue; + layer + ..update(_context, deltaTime) + ..render(_context, deltaTime); + } + + window.requestAnimationFrame(_renderFrameJS); + } + + late final JSExportedDartFunction _renderFrameJS = _renderFrame.toJS; + + /// Start the rendering engine. + void start() { + if (_isRunning) return; + + final container = _container; + + window.addEventListener('resize', _onResizeJS); + + // Health check + _healthCehckTimer?.cancel(); + _healthCehckTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_isClosed) timer.cancel(); + if (container.isConnected) return; + l.w('Engine container is not connected'); + dispose(); + }); + + // Start rendering + _isRunning = true; + _lastFrameTime = window.performance.now(); + window.requestAnimationFrame(_renderFrameJS); + } + + /// Stop the rendering engine. + void stop() { + _isRunning = false; + window.removeEventListener('resize', _onResizeJS); + _healthCehckTimer?.cancel(); + } + + /// Dispose the rendering engine. + void dispose() { + stop(); + for (final layer in _layers) layer.unmount(_context); + _layers.clear(); + _context + ..canvasGL.remove() + ..canvasUI.remove(); + final app = document.querySelector('#app'); + if (app != null) { + app.removeChild(_shadow); + final children = app.children; + for (var i = children.length - 1; i >= 0; i--) children.item(i)!.remove(); + } + _isClosed = true; + _instance = null; + } +} diff --git a/example/clock/frontend/pubspec.yaml b/example/clock/frontend/pubspec.yaml new file mode 100644 index 0000000..bd0048f --- /dev/null +++ b/example/clock/frontend/pubspec.yaml @@ -0,0 +1,75 @@ +# ============================================================================== +# PROJECT METADATA +# ============================================================================== + +name: spinify_clock_frontend + +description: "Spinify Clock: Frontend" + +publish_to: 'none' + +version: 0.0.1+1 + +homepage: https://github.com/plugfox/spinify + +repository: https://github.com/plugfox/spinify + +issue_tracker: https://github.com/plugfox/spinify/issues + +#funding: +# - + +#topics: +# - + +platforms: + web: + +#screenshots: +# - description: 'Screenshot 1' +# path: screenshot_1.png + + +# ============================================================================== +# PROJECT STRUCTURE +# ============================================================================== + +# https://dart.dev/tools/pub/workspaces +#workspace: +# - ../shared + +#executables: +# - + + +# ============================================================================== +# DEPENDENCIES +# ============================================================================== + +environment: + sdk: '>=3.6.1 <4.0.0' + +dependencies: + # Localization + intl: ^0.20.2 + + # Annotation + l: ^5.0.0 + meta: any + + # Web + web: ^1.1.0 + + # Centrifugo + spinify: any + +dev_dependencies: + # Linting + lints: ^5.1.1 + + # Testing + test: any + + # Code generation + build_runner: ^2.4.13 + build_web_compilers: ^4.1.1 diff --git a/example/clock/frontend/web/index.html b/example/clock/frontend/web/index.html new file mode 100644 index 0000000..903c495 --- /dev/null +++ b/example/clock/frontend/web/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + Spinify: Clock + + + + + + +
+ + + + + \ No newline at end of file diff --git a/example/clock/frontend/web/main.dart b/example/clock/frontend/web/main.dart new file mode 100644 index 0000000..cabf639 --- /dev/null +++ b/example/clock/frontend/web/main.dart @@ -0,0 +1,5 @@ +import 'package:web/web.dart' as web; +import 'package:spinify_clock_frontend/site.dart'; + +// & webdev serve +void main() => runSite(); diff --git a/example/clock/frontend/web/styles.css b/example/clock/frontend/web/styles.css new file mode 100644 index 0000000..d640c02 --- /dev/null +++ b/example/clock/frontend/web/styles.css @@ -0,0 +1,14 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + font-family: 'Roboto', sans-serif; +} + +#output { + padding: 20px; + text-align: center; +} diff --git a/lib/src/protobuf/protobuf_codec.dart b/lib/src/protobuf/protobuf_codec.dart index d9dbda9..404d0a0 100644 --- a/lib/src/protobuf/protobuf_codec.dart +++ b/lib/src/protobuf/protobuf_codec.dart @@ -205,7 +205,7 @@ final class SpinifyProtobufReplyDecoder timestamp: DateTime.now(), code: error.code, message: error.message, - temporary: error.temporary, + temporary: !error.hasTemporary() || error.temporary, ); // coverage:ignore-end } else { @@ -599,7 +599,7 @@ final class SpinifyProtobufReplyDecoder timestamp: now, code: error.code, message: error.message, - temporary: error.temporary, + temporary: !error.hasTemporary() || error.temporary, ); } else { // coverage:ignore-start diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index b79abe1..76e8eb1 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -649,6 +649,67 @@ void main() { ), ); + test( + 'Emulate_long_lifespan', + () => 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()); + for (var day = 0; day < 5; day++) { + async.elapse(const Duration(days: 1)); + expect(client.state, isA()); + } + expect(client.state, isA()); + expect(serverPingCount, greaterThanOrEqualTo(720000)); + expect(serverPongCount, equals(serverPingCount)); + client.close(); + }, + ), + ); + test( 'ready', () => fakeAsync((async) {