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');