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');
|
||||
|
||||
Reference in new issue
Block a user