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

+8 -2
View File
@@ -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 {
+2 -2
View File
@@ -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;
+20 -10
View File
@@ -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'),
+7 -7
View File
@@ -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,