From fdc49bcb8dc3eaf28b02fd53d03310dcdf91d8c4 Mon Sep 17 00:00:00 2001 From: Nate <n8r@tuta.io> Date: Wed, 5 Feb 2025 22:41:08 -0700 Subject: [PATCH] Added better logging and websocket, more readme details --- README.md | 35 +++++++++++++----- backend/lib/authenticator.dart | 42 ++------------------- backend/lib/utils/environment.dart | 59 ++++++++++++++++++++++++++++++ backend/main.dart | 50 ++++++++++++++++++++++--- backend/pubspec.lock | 24 ++++++------ backend/pubspec.yaml | 1 + backend/routes/ws.dart | 9 +++++ backend/scripts/test_runner.dart | 2 +- 8 files changed, 156 insertions(+), 66 deletions(-) create mode 100644 backend/lib/utils/environment.dart create mode 100644 backend/routes/ws.dart diff --git a/README.md b/README.md index 98d3c78..6097395 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ Flutter and Dart full stack template, Lovingly called FartStack -## TLDR +Optimising for iteration speed, correctness, and deployability. -Dev is done all in dart, with the flutter framework as a frontend. The -backend uses [dart_frog](), a dart backend framework with a focus on developer -experience. This whole stack revolves around my Dart expertise, so its not about -being the fastest stack, but the quickest stack I can build in. Because building -a product is like a fart: You can't be sure its not crap until the follow-through. +Because building a product is like a fart: You can't be sure its not crap until the follow-through. + +## HowTo + +Dev is done all in dart, with the flutter framework as a frontend. The backend uses [dart_frog](https://dartfrog.vgv.dev/), a dart backend +framework with a focus on developer experience. This whole stack revolves around my Dart expertise, so its not about +being blazingly fast, but the quickest stack I can build in with a statically-typed language. ### Installing @@ -21,9 +23,11 @@ If not, well, you need to setup `flutter`, `dart` (should get installed with flu This codebase makes use of [RPS](https://pub.dev/packages/rps/install), just to standardize calling various tools, like Make, but for dart. -- Frontend is started with `cd frontend` and `flutter run` (not included in rps because there are lots of cli flags I change often with flutter run) +- Frontend is started with `cd frontend` and `flutter run` (not included in rps because there are lots of cli flags I +change often with flutter run) - Backend is started with `cd backend` and `rps dev` or `dart_frog dev` -- The `build_runners` (codegen) for each subproject can all be run `cd backend` and `rps watch` for development or `rps build` for a one off codegen +- The `build_runners` (codegen) for each subproject can all be run `cd backend` and `rps watch` for development or `rps +build` for a one off codegen - If you dont want to bother installing rps, you can run the script directly: ```sh dart backend/scripts/run_build_runner.dart @@ -41,7 +45,7 @@ dart run build_runner watch # or other args End to end tests can be run with `cd backend` and `rps e2e`. All end to end tests are located in the `backend/test_e2e/tests` folder. -There is a helper script that compiles and launches the backend and runs some end to end tests through. +There is a helper script that compiles and launches the backend and runs some end to end tests through it. #### Frontend @@ -54,3 +58,16 @@ WIP ## Backend `./backend/` +Uses the great [dart frog](https://dartfrog.vgv.dev/) framework. Its an file-based framework, so to create a new route on `/new_route/bingo`, +you would create a new file in `routes/new_route/bingo.dart` that contains a function +```dart +Response onRequest(RequestContext context) { + return Response +} +``` + +Easy Peasy. + +Go to the docks for more. + +In the box, fartstack has authentication with JWT tokens setup diff --git a/backend/lib/authenticator.dart b/backend/lib/authenticator.dart index bc52c90..aafba32 100644 --- a/backend/lib/authenticator.dart +++ b/backend/lib/authenticator.dart @@ -1,13 +1,12 @@ -import 'dart:io'; - import 'package:backend/database.dart'; import 'package:backend/service/db_access.dart'; +import 'package:backend/utils/environment.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:logging/logging.dart'; import 'package:shared_models/jwt.dart'; import 'package:shared_models/user.dart'; -final jwtSecret = _getSecret(); +final jwtSecret = getJWTSecret(); const expTimeSecs = 3600; final log = Logger('Authenticator'); @@ -28,7 +27,7 @@ class Authenticator { JWTBody(uuid: newUser.uuid, roomUuid: newUser.gameRoomUuid, iat: iat, exp: iat + expTimeSecs).toJson(), ); - return (jwt.sign(SecretKey(jwtSecret)), newUser); + return (jwt.sign(SecretKey(jwtSecret!)), newUser); } Future<(User?, JWTTokenStatus)> verifyToken( @@ -38,7 +37,7 @@ class Authenticator { log.info('Verifying jwt: ${token.substring(0, 10)}...${token.substring(token.length - 10)}'); final payload = JWT.verify( token, - SecretKey(jwtSecret), + SecretKey(jwtSecret!), ); final jwt = JWTBody.fromJson(payload.payload as Map<String, dynamic>); @@ -54,36 +53,3 @@ class Authenticator { } } } - -// load any env vars inside root of project's .env file, then looks for JWT_TOKEN_SECRET -String _getSecret() { - final envs = {...Platform.environment}; - try { - final result = Process.runSync('git', ['rev-parse', '--show-toplevel']); - if (result.exitCode != 0) { - log.warning('Failed to get git root directory: ${result.stderr}'); - throw Exception('Failed to get git root directory'); - } - final rootDir = (result.stdout as String).trim(); - final envFile = File('$rootDir/.env'); - if (envFile.existsSync()) { - for (final line in envFile.readAsLinesSync()) { - if (line.trim().isEmpty || line.startsWith('#')) continue; - final parts = line.split('='); - if (parts.length != 2) continue; - final key = parts[0].trim(); - final value = parts[1].trim(); - envs[key] = value; - } - } - } catch (e) { - log.warning('Failed to load .env file: $e'); - } - // check for secret - final secret = envs['JWT_TOKEN_SECRET']; - if (secret == null || secret.isEmpty) { - throw Exception('JWT secret not configured. Define JWT_TOKEN_SECRET in environment.'); - } else { - return secret; - } -} diff --git a/backend/lib/utils/environment.dart b/backend/lib/utils/environment.dart new file mode 100644 index 0000000..ee2f896 --- /dev/null +++ b/backend/lib/utils/environment.dart @@ -0,0 +1,59 @@ +// load any env vars inside root of project's .env file, then looks for JWT_TOKEN_SECRET +import 'dart:io'; +import 'dart:math'; + +import 'package:logging/logging.dart'; + +final log = Logger('Environment'); + +bool _isDevEnv = false; + +void checkEnvironment(bool isDevEnv) { + _isDevEnv = isDevEnv; + getJWTSecret(); +} + +String? getJWTSecret() { + final envs = {...Platform.environment}; + if (_isDevEnv) { + log.fine('Trying to load .env file...'); + try { + final result = Process.runSync('git', ['rev-parse', '--show-toplevel']); + if (result.exitCode != 0) { + log.warning('Failed to get git root directory: ${result.stderr}'); + throw Exception('Failed to get git root directory'); + } + final rootDir = (result.stdout as String).trim(); + final envFile = File('$rootDir/.env'); + if (envFile.existsSync()) { + for (final line in envFile.readAsLinesSync()) { + if (line.trim().isEmpty || line.startsWith('#')) continue; + final parts = line.split('='); + if (parts.length != 2) continue; + final key = parts[0].trim(); + final value = parts[1].trim(); + envs[key] = value; + } + } + } catch (e) { + log.warning('Failed to load .env file: $e'); + } + } + + // check for secret + final secret = envs['JWT_TOKEN_SECRET']; + if (secret == null || secret.isEmpty) { + if (_isDevEnv) { + log.warning('JWT secret not configured. Define JWT_TOKEN_SECRET in environment.'); + final secret = List.generate( + 24, (_) => String.fromCharCode((65 + Random().nextInt(26)) + (Random().nextInt(2) == 0 ? 0 : 32))).join(); + log.warning('Generated random JWT secret for development: $secret'); + return secret; + } else { + log.severe('Stopping prod server because JWT secret is not defined.'); + throw Exception('JWT secret not configured. Define JWT_TOKEN_SECRET in environment.'); + } + } else { + return secret; + } +} diff --git a/backend/main.dart b/backend/main.dart index 686a600..c69d4c6 100644 --- a/backend/main.dart +++ b/backend/main.dart @@ -1,6 +1,7 @@ import 'dart:developer' as dev; import 'dart:io'; +import 'package:backend/utils/environment.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:logging/logging.dart'; @@ -8,20 +9,57 @@ bool _listening = false; Future<HttpServer> run(Handler handler, InternetAddress ip, int port) async { final isDevelopment = (await dev.Service.getInfo()).serverUri != null; + // Logic to prevent multiple listeners with hot-reload + // Changes to this are not hot-reloaded, you must restart the server if (!_listening) { final logLevel = Platform.environment['LOG_LEVEL'] ?? (isDevelopment ? 'FINEST' : 'INFO'); Logger.root.level = Level.LEVELS.firstWhere((l) => l.name == logLevel, orElse: () => Level.INFO); // defaults to Level.INFO Logger.root.onRecord.listen((record) { - stdout.writeln('[${record.level.name}]:[${record.loggerName}] ${record.time}: ${record.message}'); - if (record.level.value >= Level.WARNING.value) { - stdout.writeln( - '[${record.level.name}]:[${record.loggerName}] ${record.error ?? "No error provided"}\n${record.stackTrace ?? "No trace provided"}', - ); - } + writeLogRecord(record, record.level.value >= Level.SEVERE.value ? stderr : stdout); }); _listening = true; } + checkEnvironment(isDevelopment); + + for (final lvl in Level.LEVELS) { + writeLogRecord(LogRecord(lvl, 'Test message', 'main'), stdout); + } + return serve(handler, ip, port); } + +const Map<String, String> _levelColors = { + 'FINEST': '\x1B[1;37m', // White + 'FINER': '\x1B[1;38m', // Gray + 'FINE': '\x1B[1;35m', // Purple + 'CONFIG': '\x1B[1;36m', // Cyan + 'INFO': '\x1B[1;32m', // Green + 'WARNING': '\x1B[1;33m', // Yellow + 'SEVERE': '\x1B[1;31m', // Red + 'SHOUT': '\x1B[1;38;5;52m\x1B[1;48;5;213m', // Red on pink +}; + +const String _resetColor = '\x1B[0m'; + +String _getColoredLevel(String levelName) { + return '${_levelColors[levelName] ?? ''}$levelName$_resetColor'; +} + +void writeLogRecord(LogRecord record, IOSink iosink) { + // Write the basic log message with colored level + iosink.writeln( + '[${_getColoredLevel(record.level.name)}]:[${record.loggerName}] ' + '${record.time}: ${record.message}', + ); + + // Additional details for severe logs + if (record.level.value >= Level.SEVERE.value) { + iosink.writeln( + '[${_getColoredLevel(record.level.name)}]:[${record.loggerName}] ' + '${record.error?.toString() ?? "No error provided"}\n' + '${record.stackTrace?.toString() ?? "No trace provided"}', + ); + } +} diff --git a/backend/pubspec.lock b/backend/pubspec.lock index a89e298..2222a20 100644 --- a/backend/pubspec.lock +++ b/backend/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + dart_frog_web_socket: + dependency: "direct main" + description: + name: dart_frog_web_socket + sha256: "02844c5fd59721824fdcf16e81dbfbfd54a9dfcc8a89a856483960a44159e1f6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dart_jsonwebtoken: dependency: "direct main" description: @@ -513,10 +521,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.0.4" source_gen: dependency: transitive description: @@ -693,22 +701,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" - url: "https://pub.dev" - source: hosted - version: "0.1.6" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "2.4.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/backend/pubspec.yaml b/backend/pubspec.yaml index eddc4d0..ec2d7f4 100644 --- a/backend/pubspec.yaml +++ b/backend/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: dart_frog: ^1.1.0 dart_frog_auth: ^1.2.0 + dart_frog_web_socket: ^1.0.0 dart_jsonwebtoken: ^2.16.0 drift: ^2.24.0 logging: ^1.3.0 diff --git a/backend/routes/ws.dart b/backend/routes/ws.dart new file mode 100644 index 0000000..8142840 --- /dev/null +++ b/backend/routes/ws.dart @@ -0,0 +1,9 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:dart_frog_web_socket/dart_frog_web_socket.dart'; + +Future<Response> onRequest(RequestContext context) async { + final handler = webSocketHandler((channel, protocol) { + channel.stream.listen(print); + }); + return handler(context); +} diff --git a/backend/scripts/test_runner.dart b/backend/scripts/test_runner.dart index ccfe4e9..09f2497 100644 --- a/backend/scripts/test_runner.dart +++ b/backend/scripts/test_runner.dart @@ -69,7 +69,7 @@ Future<void> _waitForServer() async { } } catch (e) { attempts++; - await Future.delayed(const Duration(seconds: 1)); + await Future<void>.delayed(const Duration(seconds: 1)); } } throw Exception('Server failed to start after $maxAttempts seconds');