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
@@ -15,6 +15,9 @@ class AppConfig {
|
||||
/// Optional explicit host for file-media URLs; empty means use the host the
|
||||
/// client dialled.
|
||||
final String advertiseHost;
|
||||
/// Secret the hosting app's own client sends in hello to be marked host
|
||||
/// (unkickable). Empty means no member is the host (dedicated server).
|
||||
final String hostKey;
|
||||
|
||||
const AppConfig({
|
||||
required this.port,
|
||||
@@ -23,20 +26,22 @@ class AppConfig {
|
||||
this.mediaPath = '',
|
||||
this.logLevel = 'INFO',
|
||||
this.advertiseHost = '',
|
||||
this.hostKey = '',
|
||||
});
|
||||
|
||||
factory AppConfig.defaults() =>
|
||||
const AppConfig(port: 8080, secret: '', name: 'room');
|
||||
const AppConfig(port: 8080, secret: '', name: 'server');
|
||||
|
||||
factory AppConfig.fromMap(Map<String, Object?> map) {
|
||||
try {
|
||||
return AppConfig(
|
||||
port: (map['port'] as num?)?.toInt() ?? 8080,
|
||||
secret: (map['secret'] as String?) ?? '',
|
||||
name: (map['name'] as String?) ?? 'room',
|
||||
name: (map['name'] as String?) ?? 'server',
|
||||
mediaPath: (map['mediaPath'] as String?) ?? '',
|
||||
logLevel: (map['logLevel'] as String?) ?? 'INFO',
|
||||
advertiseHost: (map['advertiseHost'] as String?) ?? '',
|
||||
hostKey: (map['hostKey'] as String?) ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
throw ConfigException('invalid config map: $e');
|
||||
@@ -50,6 +55,7 @@ class AppConfig {
|
||||
'mediaPath': mediaPath,
|
||||
'logLevel': logLevel,
|
||||
'advertiseHost': advertiseHost,
|
||||
'hostKey': hostKey,
|
||||
};
|
||||
|
||||
static Future<AppConfig> loadFile(String path) async {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 4;
|
||||
static const int protoVersion = 5;
|
||||
|
||||
static const int chunkBytes = 256 * 1024;
|
||||
static const int creditWindowChunks = 8;
|
||||
@@ -19,7 +19,7 @@ abstract class RelayConstants {
|
||||
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap
|
||||
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size
|
||||
// for private-server use. PNG keeps transparency; JPEG did not.
|
||||
// Trade: avatars ride in the 1 Hz room broadcast, so worst-case host
|
||||
// Trade: avatars ride in the 1 Hz state broadcast, so worst-case host
|
||||
// uplink scales with this cap. Follow-up (not now): send avatar bytes
|
||||
// once, reference by hash afterwards.
|
||||
static const int maxAvatarBytes = 256 * 1024;
|
||||
|
||||
@@ -67,7 +67,15 @@ class HelloMessage {
|
||||
final String secret;
|
||||
final HelloUser user;
|
||||
final HelloMedia media;
|
||||
const HelloMessage({required this.proto, required this.secret, required this.user, required this.media});
|
||||
/// The hosting app's own client proves it is the host with this; empty
|
||||
/// for everyone else.
|
||||
final String hostKey;
|
||||
const HelloMessage(
|
||||
{required this.proto,
|
||||
required this.secret,
|
||||
required this.user,
|
||||
required this.media,
|
||||
this.hostKey = ''});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'hello',
|
||||
@@ -75,6 +83,7 @@ class HelloMessage {
|
||||
'secret': secret,
|
||||
'user': user.toJson(),
|
||||
'media': media.toJson(),
|
||||
if (hostKey.isNotEmpty) 'hostKey': hostKey,
|
||||
};
|
||||
|
||||
factory HelloMessage.fromJson(Map<String, Object?> json) {
|
||||
@@ -87,16 +96,17 @@ class HelloMessage {
|
||||
secret: (json['secret'] as String?) ?? '',
|
||||
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||
hostKey: (json['hostKey'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RoomState {
|
||||
class SyncState {
|
||||
final double position;
|
||||
final bool paused;
|
||||
final int mediaId;
|
||||
final DateTime serverTime;
|
||||
const RoomState({required this.position, required this.paused, required this.mediaId, required this.serverTime});
|
||||
const SyncState({required this.position, required this.paused, required this.mediaId, required this.serverTime});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'position': position,
|
||||
@@ -105,7 +115,7 @@ class RoomState {
|
||||
'serverTime': serverTime.toIso8601String(),
|
||||
};
|
||||
|
||||
factory RoomState.fromJson(Map<String, Object?> json) => RoomState(
|
||||
factory SyncState.fromJson(Map<String, Object?> json) => SyncState(
|
||||
position: finiteDouble(json['position'], 'position'),
|
||||
paused: (json['paused'] as bool?) ?? true,
|
||||
mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0),
|
||||
@@ -296,7 +306,7 @@ class MemberView {
|
||||
);
|
||||
}
|
||||
|
||||
class RoomBroadcast {
|
||||
class StateBroadcast {
|
||||
final int eventId;
|
||||
final String transport;
|
||||
final double position;
|
||||
@@ -304,7 +314,7 @@ class RoomBroadcast {
|
||||
final int mediaId;
|
||||
final Map<String, Object?>? media;
|
||||
final List<MemberView> members;
|
||||
const RoomBroadcast({
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
required this.transport,
|
||||
required this.position,
|
||||
@@ -315,7 +325,7 @@ class RoomBroadcast {
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'room',
|
||||
't': 'state',
|
||||
'eventId': eventId,
|
||||
'transport': transport,
|
||||
'position': position,
|
||||
@@ -325,11 +335,11 @@ class RoomBroadcast {
|
||||
'members': members.map((m) => m.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory RoomBroadcast.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'room') throw const ProtocolException('not a room broadcast');
|
||||
factory StateBroadcast.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'state') throw const ProtocolException('not a state broadcast');
|
||||
final members = json['members'];
|
||||
final media = json['media'];
|
||||
return RoomBroadcast(
|
||||
return StateBroadcast(
|
||||
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
||||
transport: (json['transport'] as String?) ?? 'held',
|
||||
position: finiteDouble(json['position'], 'position'),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'constants.dart';
|
||||
import 'protocol.dart';
|
||||
|
||||
typedef RoomClock = DateTime Function();
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
enum Transport { playing, held }
|
||||
|
||||
enum Correction { none, slowDown, seek }
|
||||
|
||||
Correction correctionFor({required double ownPosition, required double roomPosition}) {
|
||||
final drift = ownPosition - roomPosition;
|
||||
Correction correctionFor({required double ownPosition, required double serverPosition}) {
|
||||
final drift = ownPosition - serverPosition;
|
||||
if (drift.abs() > RelayConstants.driftSeekThresholdSecs) return Correction.seek;
|
||||
if (drift > RelayConstants.slowDownThresholdSecs) return Correction.slowDown;
|
||||
return Correction.none;
|
||||
@@ -52,8 +52,8 @@ class EngineFeed {
|
||||
}
|
||||
}
|
||||
|
||||
class RoomEngine {
|
||||
final RoomClock clock;
|
||||
class SyncEngine {
|
||||
final SyncClock clock;
|
||||
final Duration staleAfter;
|
||||
final Duration dropAfter;
|
||||
final EngineFeed feed = EngineFeed();
|
||||
@@ -67,7 +67,7 @@ class RoomEngine {
|
||||
HelloMedia? media;
|
||||
int _nextId = 1;
|
||||
|
||||
RoomEngine({RoomClock? clock, Duration? staleAfter, Duration? dropAfter})
|
||||
SyncEngine({SyncClock? clock, Duration? staleAfter, Duration? dropAfter})
|
||||
: clock = clock ?? (() => DateTime.now().toUtc()),
|
||||
staleAfter = staleAfter ?? const Duration(seconds: 15),
|
||||
dropAfter = dropAfter ?? const Duration(seconds: 30),
|
||||
@@ -249,7 +249,7 @@ class RoomEngine {
|
||||
}
|
||||
}
|
||||
|
||||
RoomBroadcast broadcast() => RoomBroadcast(
|
||||
StateBroadcast broadcast() => StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: transport == Transport.playing ? 'playing' : 'held',
|
||||
position: position,
|
||||
|
||||
@@ -15,7 +15,7 @@ void main() {
|
||||
test('fromMap tolerates missing keys', () {
|
||||
final c = AppConfig.fromMap({});
|
||||
expect(c.port, 8080);
|
||||
expect(c.name, 'room');
|
||||
expect(c.name, 'server');
|
||||
});
|
||||
|
||||
test('round-trips through map', () {
|
||||
@@ -24,8 +24,10 @@ void main() {
|
||||
secret: 's',
|
||||
name: 'n',
|
||||
mediaPath: 'm',
|
||||
advertiseHost: '1.2.3.4');
|
||||
advertiseHost: '1.2.3.4',
|
||||
hostKey: 'hk');
|
||||
final back = AppConfig.fromMap(c.toMap());
|
||||
expect(back.hostKey, 'hk');
|
||||
expect(back.port, 1234);
|
||||
expect(back.secret, 's');
|
||||
expect(back.mediaPath, 'm');
|
||||
@@ -64,15 +66,29 @@ void main() {
|
||||
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
||||
expect(back.user.name, 'alice');
|
||||
expect(back.media.size, 1500000000);
|
||||
expect(back.hostKey, isEmpty);
|
||||
expect(msg.toJson().containsKey('hostKey'), isFalse);
|
||||
});
|
||||
|
||||
test('hello carries hostKey when set', () {
|
||||
const msg = HelloMessage(
|
||||
proto: 1,
|
||||
secret: '',
|
||||
user: HelloUser(name: 'host'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: 'hk',
|
||||
);
|
||||
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
||||
expect(back.hostKey, 'hk');
|
||||
});
|
||||
|
||||
test('hello rejects wrong type', () {
|
||||
expect(() => HelloMessage.fromJson({'t': 'bye'}), throwsA(isA<ProtocolException>()));
|
||||
});
|
||||
|
||||
test('room state round-trips', () {
|
||||
final s = RoomState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1));
|
||||
final back = RoomState.fromJson(Map<String, Object?>.from(s.toJson()));
|
||||
test('sync state round-trips', () {
|
||||
final s = SyncState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1));
|
||||
final back = SyncState.fromJson(Map<String, Object?>.from(s.toJson()));
|
||||
expect(back.position, 12.5);
|
||||
expect(back.paused, isFalse);
|
||||
expect(back.mediaId, 3);
|
||||
|
||||
@@ -5,12 +5,12 @@ import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime t;
|
||||
late RoomEngine engine;
|
||||
late SyncEngine engine;
|
||||
DateTime clock() => t;
|
||||
|
||||
setUp(() {
|
||||
t = DateTime.utc(2026, 1, 1);
|
||||
engine = RoomEngine(clock: clock);
|
||||
engine = SyncEngine(clock: clock);
|
||||
});
|
||||
|
||||
String join(String token, String name) => engine.applyJoin(token, name, '');
|
||||
@@ -37,12 +37,12 @@ void main() {
|
||||
});
|
||||
|
||||
group('membership + gate', () {
|
||||
test('join lands out-of-set, room stays held', () {
|
||||
test('join lands out-of-set, playback stays held', () {
|
||||
join('a', 'alice');
|
||||
expect(engine.transport, Transport.held);
|
||||
expect(engine.members['a']!.inSet, isFalse);
|
||||
final room = engine.broadcast();
|
||||
expect(room.members.single.inSet, isFalse);
|
||||
final state = engine.broadcast();
|
||||
expect(state.members.single.inSet, isFalse);
|
||||
});
|
||||
|
||||
test('single ready + buffered auto-plays', () {
|
||||
@@ -54,7 +54,7 @@ void main() {
|
||||
expect(engine.transport, Transport.playing);
|
||||
});
|
||||
|
||||
test('late joiner does not halt a playing room', () {
|
||||
test('late joiner does not halt playback', () {
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 4.0);
|
||||
@@ -77,7 +77,7 @@ void main() {
|
||||
expect(engine.members['b']!.inSet, isTrue);
|
||||
});
|
||||
|
||||
test('disconnect removes from quorum, room resumes', () {
|
||||
test('disconnect removes from quorum, playback resumes', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
@@ -218,7 +218,7 @@ void main() {
|
||||
expect(engine.members['a']!.ready, isTrue);
|
||||
});
|
||||
|
||||
test('unpause uses room clock, laggards seek forward', () {
|
||||
test('unpause uses server clock, laggards seek forward', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
@@ -355,29 +355,29 @@ void main() {
|
||||
|
||||
group('tiered correction', () {
|
||||
test('big drift seeks either direction', () {
|
||||
expect(correctionFor(ownPosition: 100, roomPosition: 0), Correction.seek);
|
||||
expect(correctionFor(ownPosition: 0, roomPosition: 100), Correction.seek);
|
||||
expect(correctionFor(ownPosition: 100, serverPosition: 0), Correction.seek);
|
||||
expect(correctionFor(ownPosition: 0, serverPosition: 100), Correction.seek);
|
||||
});
|
||||
|
||||
test('ahead beyond 1.5s slows to 0.95x', () {
|
||||
expect(correctionFor(ownPosition: 3, roomPosition: 0), Correction.slowDown);
|
||||
expect(correctionFor(ownPosition: 1.6, roomPosition: 0), Correction.slowDown);
|
||||
expect(correctionFor(ownPosition: 3, serverPosition: 0), Correction.slowDown);
|
||||
expect(correctionFor(ownPosition: 1.6, serverPosition: 0), Correction.slowDown);
|
||||
});
|
||||
|
||||
test('deadzone does nothing', () {
|
||||
expect(correctionFor(ownPosition: 1.0, roomPosition: 0), Correction.none);
|
||||
expect(correctionFor(ownPosition: 0, roomPosition: 3.0), Correction.none);
|
||||
expect(correctionFor(ownPosition: 5.1, roomPosition: 0), Correction.seek);
|
||||
expect(correctionFor(ownPosition: 5.0, roomPosition: 0), Correction.slowDown);
|
||||
expect(correctionFor(ownPosition: 1.0, serverPosition: 0), Correction.none);
|
||||
expect(correctionFor(ownPosition: 0, serverPosition: 3.0), Correction.none);
|
||||
expect(correctionFor(ownPosition: 5.1, serverPosition: 0), Correction.seek);
|
||||
expect(correctionFor(ownPosition: 5.0, serverPosition: 0), Correction.slowDown);
|
||||
});
|
||||
});
|
||||
|
||||
group('broadcast shape', () {
|
||||
test('room JSON round-trips with member views', () {
|
||||
test('state JSON round-trips with member views', () {
|
||||
join('a', 'alice');
|
||||
final room = engine.broadcast();
|
||||
final state = engine.broadcast();
|
||||
final back =
|
||||
RoomBroadcast.fromJson(Map<String, Object?>.from(room.toJson()));
|
||||
StateBroadcast.fromJson(Map<String, Object?>.from(state.toJson()));
|
||||
expect(back.transport, 'held');
|
||||
expect(back.members.single.name, 'alice');
|
||||
expect(back.members.single.id, 'm1');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
+7
-22
@@ -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()));
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in new issue
Block a user