import 'dart:convert'; import 'dart:io'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; import 'chat_store.dart'; import 'config.dart'; import 'data_store.dart'; class MothershipServer { MothershipServer({required this.config, required this.dataStore, required this.chatStore}); final MothershipConfig config; final MothershipDataStore dataStore; final MothershipChatStore chatStore; Future start() async { final router = Router() ..post('/sync/heartbeat', _handleHeartbeat) ..post('/sync/hash', _handleHash) ..post('/chat/send', _handleChatSend) ..get('/chat/pending', _handleChatPending) ..post('/chat/ack', _handleChatAck) ..get('/status', _handleStatus) ..get('/', _handleDashboard); final handler = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(_apiKeyMiddleware(config.apiKey)) .addHandler(router); final server = await serve(handler, config.host, config.port); return server; } Middleware _apiKeyMiddleware(String expectedKey) { return (innerHandler) { return (request) async { // Dashboard ('/')は無認証、その他は API キーを要求 if (request.url.path.isNotEmpty && request.url.path != 'status') { final key = request.headers['x-api-key']; if (key != expectedKey) { return Response(401, body: 'Invalid API key'); } } return innerHandler(request); }; }; } Future _handleHeartbeat(Request request) async { final body = await request.readAsString(); final json = jsonDecode(body) as Map; final clientId = json['clientId'] as String?; if (clientId == null || clientId.isEmpty) { return Response(400, body: 'clientId is required'); } final remainingSeconds = json['remainingLifespanSeconds'] as int?; await dataStore.recordHeartbeat( clientId: clientId, remaining: remainingSeconds != null ? Duration(seconds: remainingSeconds) : null, ); return Response.ok('ok'); } Future _handleHash(Request request) async { final body = await request.readAsString(); final json = jsonDecode(body) as Map; final clientId = json['clientId'] as String?; final hash = json['hash'] as String?; if (clientId == null || hash == null) { return Response(400, body: 'clientId and hash are required'); } await dataStore.recordHash(clientId: clientId, hash: hash); return Response.ok('ok'); } Future _handleChatSend(Request request) async { final body = await request.readAsString(); final json = jsonDecode(body) as Map; final clientId = json['clientId'] as String?; if (clientId == null || clientId.isEmpty) { return Response(400, body: 'clientId is required'); } final messages = (json['messages'] as List?) ?? []; final envelopes = messages .whereType() .map((e) => ChatEnvelope( messageId: e['messageId'] as String, body: e['body'] as String, createdAt: DateTime.fromMillisecondsSinceEpoch((e['createdAt'] as int?) ?? 0, isUtc: true), )) .toList(); await chatStore.appendInbound(clientId, envelopes); return Response.ok(jsonEncode({'stored': envelopes.length}), headers: {'content-type': 'application/json'}); } Future _handleChatPending(Request request) async { final clientId = request.url.queryParameters['clientId']; if (clientId == null || clientId.isEmpty) { return Response(400, body: 'clientId is required'); } final messages = await chatStore.pendingOutbound(clientId); final payload = {'messages': messages.map((e) => e.toJson()).toList()}; return Response.ok(jsonEncode(payload), headers: {'content-type': 'application/json'}); } Future _handleChatAck(Request request) async { final body = await request.readAsString(); final json = jsonDecode(body) as Map; final clientId = json['clientId'] as String?; if (clientId == null || clientId.isEmpty) { return Response(400, body: 'clientId is required'); } final delivered = (json['delivered'] as List?)?.cast() ?? []; await chatStore.acknowledge(clientId, delivered); return Response.ok('ok'); } Future _handleStatus(Request request) async { final status = dataStore.listStatuses().map((e) => e.toJson()).toList(); return Response.ok(jsonEncode({'clients': status}), headers: {'content-type': 'application/json'}); } Future _handleDashboard(Request request) async { final rows = dataStore.listStatuses().map((status) { final lastSync = status.lastSync?.toIso8601String() ?? '-'; final remaining = status.remainingLifespan != null ? '${status.remainingLifespan!.inHours ~/ 24}d ${status.remainingLifespan!.inHours % 24}h' : '-'; return '${status.clientId}$lastSync${status.lastHash ?? '-'}$remaining'; }).join(); final html = ''' Mothership Dashboard

母艦お局様 - ステータス

$rows
Client ID Last Sync Last Hash Remaining
'''; return Response.ok(html, headers: {'content-type': 'text/html; charset=utf-8'}); } }