Server on an isolate, dedicated server binary, room -> server (proto 5)
- serve(Map config, events) is the one server entrypoint: the app hosts it via Isolate.spawn (crash surfaced on the Host card, logs forwarded as [server]); bin/server.dart is the dedicated server, compiled into the bundle as watch-together-server. --headless gone. - Host identity via a per-hosting hostKey in hello, replacing the loopback heuristic. - Rename room -> server across code, files and wire (t:"state", /health name); protocol bumped to 5. - CI compiles the server binary; isolate smoke tests in server and desktop suites. Docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8a0729029
commit
81cac1214d
48 files changed
+1073
-423
No files matched your search
@@ -1,4 +1,5 @@
|
||||
export 'src/media_endpoint.dart';
|
||||
export 'src/room_hub.dart';
|
||||
export 'src/room_server.dart';
|
||||
export 'src/serve.dart';
|
||||
export 'src/server_hub.dart';
|
||||
export 'src/session.dart';
|
||||
export 'src/sync_server.dart';
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:logging/logging.dart' as pkg_logging;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'sync_server.dart';
|
||||
|
||||
/// The one server entrypoint: the dedicated binary and the app's hosting
|
||||
/// isolate both run this. [config] holds primitives only (an
|
||||
/// [AppConfig.toMap]) so it can cross an isolate boundary.
|
||||
///
|
||||
/// On [events] it reports `{'t':'ready','port':p,'stop':SendPort}` or
|
||||
/// `{'t':'error','message':m}`, and forwards log records as
|
||||
/// `{'t':'log','level','logger','message'[,'error']}`. Sending
|
||||
/// `{'t':'stop'}` to the `stop` port stops the server; the returned future
|
||||
/// completes once it is down. Without [events] a start failure throws.
|
||||
Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
|
||||
StreamSubscription<pkg_logging.LogRecord>? logs;
|
||||
if (events != null) {
|
||||
logs = pkg_logging.Logger.root.onRecord.listen((r) => events.send({
|
||||
't': 'log',
|
||||
'level': r.level.name,
|
||||
'logger': r.loggerName,
|
||||
'message': r.message,
|
||||
if (r.error != null) 'error': '${r.error}',
|
||||
}));
|
||||
}
|
||||
final SyncServer server;
|
||||
try {
|
||||
server = SyncServer(AppConfig.fromMap(config));
|
||||
await server.start();
|
||||
} catch (e) {
|
||||
await logs?.cancel();
|
||||
if (events == null) rethrow;
|
||||
events.send({'t': 'error', 'message': '$e'});
|
||||
return;
|
||||
}
|
||||
final control = ReceivePort('serve control');
|
||||
events?.send({'t': 'ready', 'port': server.boundPort, 'stop': control.sendPort});
|
||||
await for (final msg in control) {
|
||||
if (msg is Map && msg['t'] == 'stop') break;
|
||||
}
|
||||
control.close();
|
||||
await server.stop();
|
||||
await logs?.cancel();
|
||||
}
|
||||
@@ -7,20 +7,25 @@ import 'package:web_socket/web_socket.dart';
|
||||
|
||||
import 'session.dart';
|
||||
|
||||
final _hubLog = createLogger('room_hub');
|
||||
final _hubLog = createLogger('server_hub');
|
||||
|
||||
class RoomHub {
|
||||
class ServerHub {
|
||||
final SessionMap sessions;
|
||||
final String expectedSecret;
|
||||
final RoomEngine engine;
|
||||
/// See [AppConfig.hostKey]; empty means nobody is the host.
|
||||
final String hostKey;
|
||||
final SyncEngine engine;
|
||||
final Map<String, RelicWebSocket> sockets = {};
|
||||
|
||||
RoomHub(this.sessions, this.expectedSecret, {RoomEngine? engine})
|
||||
: engine = engine ?? RoomEngine();
|
||||
ServerHub(this.sessions, this.expectedSecret,
|
||||
{SyncEngine? engine, this.hostKey = ''})
|
||||
: engine = engine ?? SyncEngine();
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
Session get session => sessions.getOrCreate('default', expectedSecret);
|
||||
|
||||
void broadcastRoom() {
|
||||
void broadcastState() {
|
||||
final raw = jsonEncode(engine.broadcast().toJson());
|
||||
for (final ws in sockets.values.toList()) {
|
||||
ws.trySendText(raw);
|
||||
@@ -81,7 +86,7 @@ class RoomHub {
|
||||
}
|
||||
|
||||
void changed() {
|
||||
broadcastRoom();
|
||||
broadcastState();
|
||||
broadcastFeed();
|
||||
}
|
||||
|
||||
@@ -114,18 +119,13 @@ String sanitizeMediaName(String name) {
|
||||
return 'media';
|
||||
}
|
||||
|
||||
bool _isLoopback(String address) =>
|
||||
address == '::1' ||
|
||||
address.startsWith('127.') ||
|
||||
address.startsWith('::ffff:127.');
|
||||
|
||||
void wsErr(RelicWebSocket ws, String message) {
|
||||
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
||||
}
|
||||
|
||||
void mountSyncRoute(
|
||||
RelicApp app,
|
||||
RoomHub hub,
|
||||
ServerHub hub,
|
||||
String Function(String sid, String token, SessionMedia? media, String host)
|
||||
urlBuilder,
|
||||
) {
|
||||
@@ -134,7 +134,6 @@ void mountSyncRoute(
|
||||
final dialledHost = req.url.hasPort
|
||||
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
|
||||
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
|
||||
final fromLoopback = _isLoopback(req.connectionInfo.remote.address.toString());
|
||||
return WebSocketUpgrade((final ws) async {
|
||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||
final firstText = Completer<String>();
|
||||
@@ -221,7 +220,7 @@ void mountSyncRoute(
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
hub.engine.applyReport(
|
||||
token, report.position, report.buffered, report.eventId);
|
||||
hub.broadcastRoom();
|
||||
hub.broadcastState();
|
||||
case 'chat':
|
||||
final chat = ChatMessage.fromJson(decoded);
|
||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||
@@ -309,7 +308,7 @@ void mountSyncRoute(
|
||||
await close();
|
||||
return;
|
||||
}
|
||||
session.tokens[token]!.isHost = fromLoopback;
|
||||
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
|
||||
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||
final activeToken = token;
|
||||
hub.sockets[activeToken] = ws;
|
||||
@@ -10,7 +10,7 @@ class ViewerToken {
|
||||
final String name;
|
||||
final String avatar;
|
||||
bool kicked = false;
|
||||
/// Connected from a loopback address: the host's own in-process client.
|
||||
/// Sent the server's host key in hello: the hosting app's own client.
|
||||
bool isHost = false;
|
||||
int bytesServed = 0;
|
||||
DateTime lastSeen;
|
||||
|
||||
+15
-10
@@ -7,22 +7,22 @@ import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'media_endpoint.dart';
|
||||
import 'room_hub.dart';
|
||||
import 'server_hub.dart';
|
||||
import 'session.dart';
|
||||
|
||||
class RoomServer {
|
||||
class SyncServer {
|
||||
final AppConfig config;
|
||||
final pkg_logging.Logger _log = createLogger('room_server');
|
||||
final pkg_logging.Logger _log = createLogger('sync_server');
|
||||
final RelicApp _app = RelicApp();
|
||||
final SessionMap sessions = SessionMap();
|
||||
late final RoomHub hub;
|
||||
late final ServerHub hub;
|
||||
RelicServer? _server;
|
||||
DateTime? _startedAt;
|
||||
Timer? _heartbeat;
|
||||
bool _routesMounted = false;
|
||||
|
||||
RoomServer(this.config) {
|
||||
hub = RoomHub(sessions, config.secret);
|
||||
SyncServer(this.config) {
|
||||
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey);
|
||||
}
|
||||
|
||||
bool get isRunning => _server != null;
|
||||
@@ -50,7 +50,7 @@ class RoomServer {
|
||||
return Response.ok(
|
||||
body: Body.fromString(jsonEncode({
|
||||
'ok': true,
|
||||
'room': config.name,
|
||||
'name': config.name,
|
||||
'proto': RelayConstants.protoVersion,
|
||||
'viewers': viewers,
|
||||
'bytesServed': bytes,
|
||||
@@ -93,16 +93,21 @@ class RoomServer {
|
||||
_server = await _app.serve(
|
||||
address: InternetAddress.anyIPv4,
|
||||
port: config.port,
|
||||
// A killed isolate never closes its listening socket, so the port stays
|
||||
// bound in this process. Shared lets the next server in the same
|
||||
// process bind it again (it then gets all connections); another
|
||||
// process is still refused.
|
||||
shared: true,
|
||||
);
|
||||
_startedAt = DateTime.now();
|
||||
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
hub.engine.sweepStale();
|
||||
hub.sweepClosedSockets();
|
||||
hub.dropSilentSockets();
|
||||
hub.broadcastRoom();
|
||||
hub.broadcastState();
|
||||
hub.broadcastFeed();
|
||||
});
|
||||
_log.info('room "${config.name}" listening on $boundPort');
|
||||
_log.info('server "${config.name}" listening on $boundPort');
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
@@ -112,6 +117,6 @@ class RoomServer {
|
||||
await hub.closeAllSockets();
|
||||
await _app.close();
|
||||
_server = null;
|
||||
_log.info('room "${config.name}" stopped');
|
||||
_log.info('server "${config.name}" stopped');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user