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:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:55:14 +03:00
1 parent d8a0729029
commit 81cac1214d
48 files changed
+1073 -423

No files matched your search

+71 -27
View File
@@ -1,47 +1,91 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:args/args.dart';
import 'package:logging/logging.dart' as pkg_logging;
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
/// Dedicated server: `dart compile exe` of this file is shipped as
/// `watch-together-server`. Runs [serve] until SIGINT or SIGTERM.
Future<void> main(List<String> args) async {
final parser = ArgParser()
..addOption('port', abbr: 'p', defaultsTo: '8080')
..addOption('secret', defaultsTo: '')
..addOption('name', defaultsTo: 'room')
..addOption('media', defaultsTo: '')
..addOption('advertise-host', defaultsTo: '')
..addOption('config')
..addFlag('help', abbr: 'h', negatable: false);
final parsed = parser.parse(args);
..addOption('port', abbr: 'p', defaultsTo: '8080', help: 'TCP port to listen on (all IPv4 interfaces).')
..addOption('secret', defaultsTo: '', help: 'Shared password. Empty means an open server: anyone who can reach it can join.')
..addOption('name', defaultsTo: 'server', help: 'Server name, reported by /health.')
..addOption('media', defaultsTo: '', help: 'Local file to serve as the starting media.')
..addOption('advertise-host', defaultsTo: '', help: 'Host put in file-media URLs instead of the one each client dialled.')
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
final ArgResults parsed;
try {
parsed = parser.parse(args);
} on FormatException catch (e) {
stderr.writeln('${e.message}\n\n${parser.usage}');
exitCode = 64;
return;
}
if (parsed['help'] as bool) {
// ignore: avoid_print
print(parser.usage);
print('watch-together-server (proto ${RelayConstants.protoVersion})\n\n${parser.usage}');
return;
}
initLogging(level: pkg_logging.Level.INFO);
AppConfig config;
final configPath = parsed['config'] as String?;
if (configPath != null) {
config = await AppConfig.loadFile(configPath);
} else {
config = AppConfig(
port: int.tryParse('${parsed['port']}') ?? 8080,
secret: '${parsed['secret']}',
name: '${parsed['name']}',
mediaPath: '${parsed['media']}',
advertiseHost: '${parsed['advertise-host']}',
);
try {
if (configPath != null) {
config = await AppConfig.loadFile(configPath);
} else {
final port = int.tryParse('${parsed['port']}');
if (port == null || port < 0 || port > 65535) {
throw ConfigException('bad --port: ${parsed['port']}');
}
config = AppConfig(
port: port,
secret: '${parsed['secret']}',
name: '${parsed['name']}',
mediaPath: '${parsed['media']}',
advertiseHost: '${parsed['advertise-host']}',
);
}
} on ConfigException catch (e) {
stderr.writeln(e.message);
exitCode = 64;
return;
}
if (config.secret.isEmpty) {
stderr.writeln(
'WARNING: no --secret set: this is an open server, anyone who can reach port ${config.port} can join.');
}
final server = RoomServer(config);
await server.start();
// ignore: avoid_print
print('READY port=${server.boundPort} room=${config.name}');
await ProcessSignal.sigint.watch().first;
await server.stop();
final events = ReceivePort('server events');
final done = serve(config.toMap(), events: events.sendPort);
final signals = <StreamSubscription<ProcessSignal>>[];
await for (final msg in events) {
if (msg is! Map) continue;
if (msg['t'] == 'error') {
stderr.writeln('server failed to start: ${msg['message']}');
exitCode = 1;
break;
}
if (msg['t'] == 'ready') {
final stop = msg['stop'] as SendPort;
// ignore: avoid_print
print('READY port=${msg['port']} name=${config.name}');
void onSignal(ProcessSignal _) => stop.send({'t': 'stop'});
signals.add(ProcessSignal.sigint.watch().listen(onSignal));
if (!Platform.isWindows) {
signals.add(ProcessSignal.sigterm.watch().listen(onSignal));
}
break;
}
}
await done;
events.close();
for (final s in signals) {
await s.cancel();
}
}
+3 -2
View File
@@ -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';
+47
View File
@@ -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;
+1 -1
View File
@@ -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;
@@ -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');
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: sync_relay_server
description: Pure-Dart room + media server. Embeddable, no Flutter.
description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none
version: 0.1.0
environment:
+32 -16
View File
@@ -16,14 +16,15 @@ class _Peer {
}
static Future<_Peer> join(String host, int port, String name,
{String secret = ''}) async {
{String secret = '', String hostKey = ''}) async {
final ws = await WebSocket.connect('ws://$host:$port/sync');
final p = _Peer(ws);
ws.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0))
media: const HelloMedia(name: '', size: 0, duration: 0),
hostKey: hostKey)
.toJson()));
await p.next('welcome');
return p;
@@ -57,13 +58,13 @@ Future<String?> _lan() async {
}
void main() {
late RoomServer server;
late SyncServer server;
late File file;
setUp(() async {
file = File('${Directory.systemTemp.path}/hard_${DateTime.now().microsecondsSinceEpoch}.bin')
..writeAsBytesSync(List.filled(64, 1));
server = RoomServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
await server.start();
});
@@ -122,22 +123,37 @@ void main() {
await a.ws.close();
});
test('host (loopback) cannot be kicked; others can', () async {
final lan = await _lan();
if (lan == null) {
markTestSkipped('no LAN address');
return;
}
final host = await _Peer.join('127.0.0.1', server.boundPort, 'host');
final friend = await _Peer.join(lan, server.boundPort, 'friend');
final hostId = server.hub.engine.members.values.firstWhere((m) => m.name == 'host').id;
friend.send(jsonEncode(KickRequest(target: hostId).toJson()));
test('host (hostKey) cannot be kicked; loopback without it can', () async {
await server.stop();
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
await server.start();
final host =
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
final guesser =
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
String idOf(String name) =>
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson()));
final err = await friend.next('err');
expect(err['message'], "can't kick the host");
expect(host.closed, isFalse);
await expectLater(server.hub.kickById(hostId, 'x'), throwsA(isA<AuthException>()));
await expectLater(
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>()));
// Same machine, no key (or a wrong one): an ordinary member.
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson()));
await guesser.next('kicked');
await server.hub.kickById(idOf('friend'), 'x');
await friend.next('kicked');
await host.ws.close();
await friend.ws.close();
});
test('no hostKey configured: nobody is the host', () async {
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', hostKey: '');
final b = await _Peer.join('127.0.0.1', server.boundPort, 'b', hostKey: 'x');
expect(server.session.tokens.values.where((t) => t.isHost), isEmpty);
await a.ws.close();
await b.ws.close();
});
test('empty secret accepts any hello secret', () async {
+2 -2
View File
@@ -60,10 +60,10 @@ Future<void> _pollBool(
void main() {
group('presence lifecycle live', () {
late RoomServer server;
late SyncServer server;
setUp(() async {
server = RoomServer(
server = SyncServer(
const AppConfig(port: 0, secret: 'cycle', name: 'cycle'));
await server.start();
});
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
Future<void> _run((Map<String, Object?>, SendPort) args) =>
serve(args.$1, events: args.$2);
void main() {
test('serve in a real isolate: ready, /health, stop, clean exit', () async {
final events = ReceivePort();
final exit = ReceivePort();
final errors = ReceivePort();
final config = const AppConfig(port: 0, secret: '', name: 'iso', hostKey: 'hk').toMap();
await Isolate.spawn(_run, (config, events.sendPort),
onExit: exit.sendPort, onError: errors.sendPort);
final crashes = <Object?>[];
errors.listen(crashes.add);
final logs = <Map>[];
final ready = Completer<Map>();
events.listen((m) {
if (m is! Map) return;
if (m['t'] == 'log') logs.add(m);
if (m['t'] == 'ready' && !ready.isCompleted) ready.complete(m);
if (m['t'] == 'error' && !ready.isCompleted) ready.completeError('${m['message']}');
});
final r = await ready.future.timeout(const Duration(seconds: 10));
final port = r['port'] as int;
expect(port, greaterThan(0));
final http = HttpClient();
final resp = await (await http.get('127.0.0.1', port, '/health')).close();
final body = jsonDecode(await resp.transform(utf8.decoder).join()) as Map;
http.close();
expect(body['ok'], isTrue);
expect(body['name'], 'iso');
expect(body['proto'], RelayConstants.protoVersion);
expect(logs.any((l) => '${l['message']}'.contains('listening on $port')), isTrue);
(r['stop'] as SendPort).send({'t': 'stop'});
await exit.first.timeout(const Duration(seconds: 10));
expect(crashes, isEmpty);
// The port is free again.
final again = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await again.close();
events.close();
errors.close();
});
test('serve reports a bind failure as an error event', () async {
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final events = ReceivePort();
final exit = ReceivePort();
await Isolate.spawn(
_run,
(AppConfig(port: blocker.port, secret: '', name: 'x').toMap(),
events.sendPort),
onExit: exit.sendPort);
final first = await events
.firstWhere((m) => m is Map && m['t'] != 'log')
.timeout(const Duration(seconds: 10)) as Map;
expect(first['t'], 'error');
await exit.first.timeout(const Duration(seconds: 10));
await blocker.close();
});
}
@@ -8,28 +8,19 @@ import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
Future<String?> _lanAddress() async {
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
for (final a in i.addresses) {
if (!a.isLoopback) return a.address;
}
}
return null;
}
void main() {
group('room auth live', () {
group('server auth live', () {
late File file;
late Uint8List bytes;
late RoomServer server;
late SyncServer server;
late HttpClient http;
setUp(() async {
bytes = Uint8List.fromList(List<int>.generate(8192, (i) => i % 251));
file = File(
'${Directory.systemTemp.path}/room_test_${DateTime.now().microsecondsSinceEpoch}.bin');
'${Directory.systemTemp.path}/server_test_${DateTime.now().microsecondsSinceEpoch}.bin');
await file.writeAsBytes(bytes);
server = RoomServer(AppConfig(
server = SyncServer(AppConfig(
port: 0, secret: 'shh', name: 'test', mediaPath: file.path));
await server.start();
http = HttpClient();
@@ -71,14 +62,14 @@ void main() {
}
}
test('health reports room, proto, viewers, bytes', () async {
test('health reports name, proto, viewers, bytes', () async {
final req = await http.get('127.0.0.1', server.boundPort, '/health');
final resp = await req.close();
expect(resp.statusCode, 200);
final body = await resp.transform(utf8.decoder).join();
final decoded = jsonDecode(body) as Map;
expect(decoded['ok'], isTrue);
expect(decoded['room'], 'test');
expect(decoded['name'], 'test');
expect(decoded['proto'], RelayConstants.protoVersion);
expect(decoded['viewers'], 0);
expect(decoded['bytesServed'], 0);
@@ -247,12 +238,6 @@ void main() {
});
test('kick via socket: peer kicked, unknown target errors', () async {
final lan = await _lanAddress();
if (lan == null) {
markTestSkipped('no non-loopback IPv4 to act as a non-host peer');
return;
}
// Loopback peers are the host and unkickable, so bob dials the LAN address.
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
[String host = '127.0.0.1']) async {
final ch = WebSocketChannel.connect(
@@ -304,7 +289,7 @@ void main() {
}
final (a, _, _, messagesA) = await join('alice');
final (b, tokenB, idB, messagesB) = await join('bob', lan);
final (b, tokenB, idB, messagesB) = await join('bob');
try {
a.sink.add(jsonEncode(
KickRequest(target: idB, reason: 'afk?').toJson()));
+2 -2
View File
@@ -23,7 +23,7 @@ void main() {
});
test('server starts, answers /health, stops cleanly', () async {
final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test'));
final server = SyncServer(const AppConfig(port: 0, secret: 't', name: 'test'));
await server.start();
expect(server.isRunning, isTrue);
final port = server.boundPort;
@@ -45,7 +45,7 @@ void main() {
});
test('unknown path returns 404', () async {
final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test'));
final server = SyncServer(const AppConfig(port: 0, secret: 't', name: 'test'));
await server.start();
final client = HttpClient();
try {
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
});
group('core welcome + proto', () {
test('proto bumped to 4 for readiness + room fan-out', () {
expect(RelayConstants.protoVersion, 4);
test('proto 5: state broadcast + hello hostKey', () {
expect(RelayConstants.protoVersion, 5);
});
test('welcome round-trips with token + member id', () {