diff --git a/.gitignore b/.gitignore index 7285f00..57db449 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ app.*.map.json log.pana.json # Test +coverage/ .coverage/ /test/**/*.json /test/.test_coverage.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 0a75671..de08548 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Example (lcl)", + "name": "[Flutter] Example (Local)", "request": "launch", "type": "dart", "flutterMode": "debug", @@ -14,10 +14,12 @@ "console": "debugConsole", "runTestsOnDevice": false, "toolArgs": [], - "args": ["--dart-define-from-file=config/local.json"] + "args": [ + "--dart-define-from-file=config/local.json" + ] }, { - "name": "Example (dev)", + "name": "[Flutter] Example (Development)", "request": "launch", "type": "dart", "flutterMode": "debug", @@ -29,7 +31,98 @@ "console": "debugConsole", "runTestsOnDevice": false, "toolArgs": [], - "args": ["--dart-define-from-file=config/development.json"] + "args": [ + "--dart-define-from-file=config/development.json" + ] + }, + // https://pub.dev/packages/test + // dart test test/unit_test.dart --color --platform=vm + { + "name": "[Dart] Unit test (VM)", + "request": "launch", + "type": "dart", + "program": "test/unit_test.dart", + "env": { + "ENVIRONMENT": "test" + }, + "console": "debugConsole", + "runTestsOnDevice": false, + "templateFor": "test", + "toolArgs": [ + "--color", + "--debug", + "--coverage=coverage", + "--reporter=expanded", + "--platform=vm", // chrome + "--file-reporter=json:coverage/tests.json", + "--timeout=30s", + "--concurrency=12" + /* "--name=handles failed connection attempts" */ + ], + "args": [] + }, + // dart test test/unit_test.dart --color --platform=chrome + { + "name": "[Dart] Unit Test (Browser)", + "request": "launch", + "type": "dart", + "program": "test/unit_test.dart", + "env": { + "ENVIRONMENT": "test" + }, + "console": "debugConsole", + "runTestsOnDevice": false, + "templateFor": "test", + "toolArgs": [ + "--color", + "--debug", + "--coverage=coverage", + "--reporter=expanded", + "--platform=chrome", + "--file-reporter=json:coverage/tests.json", + "--timeout=30s", + "--concurrency=12" + /* "--name=can send binary data" */ + ], + "args": [] + }, + // dart test test/smoke_test.dart --color --platform=vm + { + "name": "[Dart] Smoke Test (VM)", + "request": "launch", + "type": "dart", + "program": "test/smoke_test.dart", + "env": { + "ENVIRONMENT": "test" + }, + "console": "debugConsole", + "runTestsOnDevice": false, + "templateFor": "test", + "toolArgs": [ + "--color", + "--debug", + "--coverage=coverage", + "--reporter=expanded", + "--platform=vm", // chrome + "--file-reporter=json:coverage/tests.json", + "--timeout=30s", + "--concurrency=12" + ], + "args": [], + "preLaunchTask": "echo-server:start", + "postDebugTask": "echo-server:stop" + }, + // dart run server/bin/server.dart + { + "name": "[Go] Echo Server", + "request": "launch", + "type": "go", + "program": "tool/echo/echo.go", + "cwd": "${workspaceFolder}/tool/echo", + "env": { + "ENVIRONMENT": "WebSocket Server" + }, + "console": "internalConsole" } ] -} +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d7e8541..923251a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": "Dependencies", + "label": "dart:dependencies", "type": "shell", "command": [ "dart pub get" @@ -14,14 +14,13 @@ "problemMatcher": [] }, { - "label": "Get protoc plugin", + "label": "dart:get-protoc-plugin", + "detail": "Get protoc plugin", "type": "shell", "command": [ "dart pub global activate protoc_plugin" ], - "dependsOn": [ - "Dependencies" - ], + "dependsOn": [], "group": { "kind": "none", "isDefault": true @@ -29,7 +28,8 @@ "problemMatcher": [] }, { - "label": "Generate protobuf", + "label": "dart:generate-protobuf", + "detail": "Generate protobuf files", "type": "shell", "command": [ "protoc", @@ -37,7 +37,7 @@ "--dart_out=lib/src/transport/protobuf lib/src/transport/protobuf/client.proto" ], "dependsOn": [ - "Get protoc plugin" + "dart:get-protoc-plugin" ], "group": { "kind": "none", @@ -46,25 +46,15 @@ "problemMatcher": [] }, { - "label": "Codegenerate", + "label": "dart:codegenerate", + "detail": "Generate code for the project", "type": "shell", "command": [ "dart run build_runner build --delete-conflicting-outputs" ], "dependsOn": [ - "Dependencies" - ], - "group": { - "kind": "none", - "isDefault": true - }, - "problemMatcher": [] - }, - { - "label": "Format", - "type": "shell", - "command": [ - "dart format --fix -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/transport/protobuf/" + "dart:dependencies", + "dart:generate-protobuf" ], "group": { "kind": "none", @@ -73,18 +63,11 @@ "problemMatcher": [] }, { - "label": "Prepare example", + "label": "dart:format", + "detail": "Format all files in the project", "type": "shell", - "options": { - "cwd": "${workspaceFolder}/example" - }, "command": [ - "dart pub global activate intl_utils", - "dart pub global run intl_utils:generate", - "fvm flutter pub get", - /* "&& fvm flutter gen-l10n --arb-dir lib/src/common/localization --output-dir lib/src/common/localization/generated --template-arb-file intl_en.arb", */ - "&& fvm flutter pub run build_runner build --delete-conflicting-outputs", - "&& dart format --fix -l 80 ." + "dart format --fix -l 80 lib test tool example" ], "group": { "kind": "none", @@ -93,7 +76,8 @@ "problemMatcher": [] }, { - "label": "Start Centrifugo Server", + "label": "centrifugo:start", + "detail": "Start centrifugo server", "type": "shell", "windows": { "command": "docker", @@ -160,7 +144,8 @@ } }, { - "label": "Stop Centrifugo Server", + "label": "centrifugo:stop", + "detail": "Stop centrifugo server", "type": "shell", "command": "docker", "args": [ @@ -178,7 +163,8 @@ } }, { - "label": "Generate new user token", + "label": "centrifugo:gentoken", + "detail": "Generate new user token for centrifugo server", "type": "shell", "command": "docker", "args": [ @@ -204,16 +190,38 @@ } }, { - "label": "Run echo server", + "label": "echo-server:start", + "type": "shell", + "command": "dart", + "detail": "Start echo server", + "options": { + "cwd": "${workspaceFolder}/tool", + }, + "args": [ + "run", + "echo_up.dart" + ], + "group": { + "kind": "none", + "isDefault": true + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "echo-server:stop", "type": "shell", - "command": "go", - "detail": "Running echo server", + "command": "dart", + "detail": "Stop echo server", "options": { - "cwd": "${workspaceFolder}/tool/echo", + "cwd": "${workspaceFolder}/tool", }, "args": [ "run", - "main.go" + "echo_down.dart" ], "group": { "kind": "none", diff --git a/Makefile b/Makefile index 8ee645f..f419dc7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,14 @@ -.PHONY: format get outdated test publish deploy centrifugo-up centrifugo-down coverage analyze check pana generate +.PHONY: format get outdated test publish deploy echo-up echo-down coverage analyze check pana generate + +ifeq ($(OS),Windows_NT) + RM = del /Q + MKDIR = mkdir + PWD = $(shell $(PWD)) +else + RM = rm -f + MKDIR = mkdir -p + PWD = pwd +endif format: @echo "Formatting the code" @@ -12,23 +22,18 @@ outdated: @dart pub outdated --show-all --dev-dependencies --dependency-overrides --transitive --no-prereleases test: get - @dart test --debug --coverage=.coverage --platform chrome,vm + @dart test --debug --coverage=coverage --platform chrome,vm test/unit_test.dart publish: generate @yes | dart pub publish deploy: publish -# http://localhost:8000 -# https://centrifugal.dev/docs/server/console_commands -centrifugo: - @docker run -it --rm --ulimit nofile=65536:65536 -p 8000:8000 --name centrifugo -v $(PWD)/centrifugo-config.json:/centrifugo/config.json centrifugo/centrifugo:latest centrifugo --client_insecure --admin --admin_insecure --log_level=debug -c config.json +echo-up: + @dart run tool/echo_up.dart -centrifugo-up: - @docker run -d --rm --ulimit nofile=65536:65536 -p 8000:8000 --name centrifugo -v $(PWD)/centrifugo-config.json:/centrifugo/config.json centrifugo/centrifugo:latest centrifugo --client_insecure --admin --admin_insecure --log_level=debug - -centrifugo-down: - @docker stop centrifugo +echo-down: + @dart run tool/echo_down.dart coverage: get @dart test --concurrency=6 --platform vm --coverage=coverage test/ diff --git a/test/smoke/smoke_test.dart b/test/smoke/smoke_test.dart index 4a85020..1de96af 100644 --- a/test/smoke/smoke_test.dart +++ b/test/smoke/smoke_test.dart @@ -4,9 +4,9 @@ import 'package:spinify/spinify.dart'; import 'package:test/test.dart'; void main() { - group('Smoke test', () { + group('Connection', () { const url = 'ws://localhost:8000/connection/websocket'; - test('Connection', () async { + test('Connect_and_disconnect', () async { final client = Spinify(); await client.connect(url); expect(client.state, isA()); diff --git a/test/smoke_test.dart b/test/smoke_test.dart new file mode 100644 index 0000000..9a02e12 --- /dev/null +++ b/test/smoke_test.dart @@ -0,0 +1,11 @@ +// ignore_for_file: unnecessary_lambdas + +import 'package:test/test.dart'; + +import 'smoke/smoke_test.dart' as smoke_test; + +void main() { + group('Smoke', () { + smoke_test.main(); + }); +} diff --git a/tool/echo/main.go b/tool/echo/echo.go similarity index 75% rename from tool/echo/main.go rename to tool/echo/echo.go index 7799454..b3399b7 100644 --- a/tool/echo/main.go +++ b/tool/echo/echo.go @@ -2,9 +2,12 @@ package main import ( "context" + "errors" + "flag" log "log" "os" "os/signal" + "strconv" "time" "io" @@ -14,23 +17,25 @@ import ( "github.com/centrifugal/centrifuge" ) +var port = flag.Int("port", 8000, "Port to bind app to") + // waitExitSignal waits for the SIGINT or SIGTERM signal to shutdown the centrifuge node. // It creates a channel to receive signals and a channel to indicate when the shutdown is complete. // Then it notifies the channel for SIGINT and SIGTERM signals and starts a goroutine to wait for the signal. // Once the signal is received, it shuts down the centrifuge node and indicates that the shutdown is complete. -func waitExitSignal(n *centrifuge.Node) { - // Create a channel to receive signals. - sigCh := make(chan os.Signal, 1) +func waitExitSignal(n *centrifuge.Node, s *http.Server, sigCh chan os.Signal) { // Create a channel to indicate when the shutdown is complete. done := make(chan bool, 1) // Notify the channel for SIGINT and SIGTERM signals. signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) // Start a goroutine to wait for the signal. go func() { + // Wait for the signal. <-sigCh - // Shutdown the centrifuge node. - _ = n.Shutdown(context.Background()) - // Indicate that the shutdown is complete. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = n.Shutdown(ctx) + _ = s.Shutdown(ctx) done <- true }() // Wait for the shutdown to complete. @@ -126,7 +131,7 @@ func Centrifuge() (*centrifuge.Node, error) { }) client.OnDisconnect(func(e centrifuge.DisconnectEvent) { - log.Printf("user %s disconnected, disconnect: %s", client.UserID(), e.Disconnect) + log.Printf("[user %s] disconnected, disconnect: %s", client.UserID(), e.Disconnect) }) }) @@ -151,22 +156,54 @@ func main() { os.Exit(1) } + mux := http.DefaultServeMux + // Serve Websocket connections using WebsocketHandler. - wsHandler := centrifuge.NewWebsocketHandler(node, centrifuge.WebsocketConfig{}) - http.Handle("/connection/websocket", authMiddleware(wsHandler)) + websocketHandler := centrifuge.NewWebsocketHandler(node, centrifuge.WebsocketConfig{ + ReadBufferSize: 1024, + UseWriteBufferPool: true, + }) + mux.Handle("/connection/websocket", authMiddleware(websocketHandler)) + + //mux.Handle("/metrics", promhttp.Handler()) + //mux.Handle("/", http.FileServer(http.Dir("./"))) + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) - // The second route is for serving index.html file. - //http.Handle("/", http.FileServer(http.Dir("./"))) + // Create a channel to shutdown the server. + sigCh := make(chan os.Signal, 1) + + // Shutdown the node when /exit endpoint is hit. + mux.HandleFunc("/exit", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + // Close after 1 sec to let response go to client. + time.AfterFunc(time.Second, func() { + sigCh <- syscall.SIGTERM // Close server. + }) + }) - log.Printf("Starting server, http://localhost:8000/connection/websocket") - if err := http.ListenAndServe(":8000", nil); err != nil { - log.Fatal(err) + server := &http.Server{ + Handler: mux, + Addr: ":" + strconv.Itoa(*port), + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, } - log.Printf("Service started") + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + os.Exit(1) + } + }() + + log.Printf("Server is running, http://localhost:8000/connection/websocket") // Wait for an exit signal before shutting down the node and exiting. - waitExitSignal(node) + waitExitSignal(node, server, sigCh) log.Printf("Server stopped") os.Exit(0) diff --git a/tool/echo_down.dart b/tool/echo_down.dart new file mode 100644 index 0000000..91c310c --- /dev/null +++ b/tool/echo_down.dart @@ -0,0 +1,16 @@ +import 'dart:async'; +import 'dart:io' as io; + +/// Exit the server. +void main() => io.HttpClient() + .getUrl(Uri( + scheme: 'http', + host: '127.0.0.1', + port: 8000, + path: 'exit', + )) + .then((request) => request.close()) + .timeout(const Duration(seconds: 15)) + .then((response) => response.statusCode == 200) + .onError((error, stackTrace) => false) + .whenComplete(() => io.exit(0)); diff --git a/tool/echo_up.dart b/tool/echo_up.dart new file mode 100644 index 0000000..84a9f20 --- /dev/null +++ b/tool/echo_up.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:io' as io; + +/// Start the server. +void main() => Future(() async { + try { + if (await _ping()) { + _info('Server is already running'); + io.exit(0); + } + await _startServer(); + if (await _ping()) { + _info('Server is running'); + io.exit(0); + } + _error('Failed to start server: no response from server'); + io.exit(2); + } on Object catch (e, _) { + _error('Failed to start server: $e'); + io.exit(1); + } + }); + +void _info(String message) => io.stdout.writeln(message); +void _error(String message) => io.stderr.writeln(message); + +Future _ping() => io.HttpClient() + .getUrl(Uri( + scheme: 'http', + host: '127.0.0.1', + port: 8000, + path: 'health', + )) + .then((request) => request.close()) + .timeout(const Duration(seconds: 15)) + .then((response) => response.statusCode == 200) + .onError((error, stackTrace) => false); + +Future _startServer() async { + String result; + + final workingDirectory = switch (io.Directory.current) { + io.Directory directory + when directory.listSync().whereType().any((f) => + f.path.split(io.Platform.pathSeparator).lastOrNull == + 'pubspec.yaml') => + '${directory.path}' + '${io.Platform.pathSeparator}' + 'tool' + '${io.Platform.pathSeparator}' + 'echo', + io.Directory directory => directory + .listSync(recursive: true) + .whereType() + .firstWhere( + (f) => f.path.split(io.Platform.pathSeparator).last == 'echo.go') + .parent + .path, + }; + + Stream exec(Object /* String | List */ command) { + final [executable, ...arguments] = switch (command) { + String string => string.split(' '), + List list => list, + _ => throw ArgumentError.value(command, 'command'), + }; + if (executable.isEmpty) return const Stream.empty(); + //final s = io.Platform.pathSeparator; + final controller = StreamController>(); + Future(() async { + io.Process? process; + final subs = >>[]; + try { + process = await io.Process.start( + executable, + arguments, + mode: io.ProcessStartMode.normal, + runInShell: false, + workingDirectory: workingDirectory, + ); + process.stdout.listen(controller.add); + process.stderr.listen(controller.add); + await process.exitCode; + } finally { + for (final sub in subs) sub.cancel().ignore(); + controller.close().ignore(); + process?.kill(); + } + }); + return controller.stream + .transform(io.systemEncoding.decoder) + //.transform(const LineSplitter()) + .map((line) => line.trim().toLowerCase()); + } + + Future execToString(String command) => + exec(command).join('\n').then((output) => output.trim().toLowerCase()); + + result = await execToString('go version'); + if (result.isNotEmpty && result.startsWith('go version')) { + final done = await exec('go run echo.go') + .firstWhere((line) => line.contains('server is running')) + .timeout(const Duration(seconds: 15)) + .onError((_, __) => '') + .then((v) => v.isNotEmpty); + if (done) return; + throw Exception('Failed to start go server'); + } + + result = await execToString('docker --version'); + if (result.isNotEmpty && result.startsWith('docker version')) { + final done = await exec( + 'docker run --rm ' // -it + '--ulimit nofile=65536:65536 ' + '-p 8000:8000 ' + '--name centrifuge ' + '-v ${io.Directory.current.path.replaceAll(r'\', '/')}/tool/echo:/app ' + '-w /app ' + 'golang:latest ' + 'go run echo.go', + ) + .firstWhere((line) => line.contains('server is running')) + .timeout(const Duration(seconds: 30)) + .onError((_, __) => '') + .then((v) => v.isNotEmpty); + if (done) return; + throw Exception('Failed to start docker server'); + } + + throw Exception('No go or docker found'); +}