@xiboplayer/sync 0.6.12 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,25 @@
1
1
  # @xiboplayer/sync
2
2
 
3
- **Multi-display synchronization for Xibo video walls — same-machine and cross-device.**
3
+ **Multi-display synchronization for Xibo video walls — same-machine and cross-device. New in v0.7.0.**
4
4
 
5
5
  ## Overview
6
6
 
7
- Coordinates layout transitions and video playback across multiple displays:
7
+ Coordinates layout transitions and video playback across multiple displays with <8ms precision:
8
8
 
9
- - **Same-machine sync** (Phase 2) BroadcastChannel for multi-tab/multi-window setups on a single device
10
- - **Cross-device sync** (Phase 3) WebSocket relay for LAN video walls where each screen is a separate mini-PC
9
+ - **Cross-device sync** WebSocket relay for LAN video walls where each screen is a separate device
10
+ - **Same-machine sync** — BroadcastChannel for multi-tab/multi-window setups on a single device
11
11
 
12
12
  Both modes share the same sync protocol — only the transport layer differs.
13
13
 
14
14
  ### Capabilities
15
15
 
16
16
  - **Synchronized layout transitions** — lead signals followers to change layout, waits for all to be ready, then sends a simultaneous "show" signal
17
+ - **12 choreography effects** — diagonal cascade, wave sweep, center-out, and more for dramatic transition patterns
17
18
  - **Coordinated video start** — video playback begins at the same moment on all displays
18
19
  - **Stats/logs delegation** — followers delegate proof-of-play stats and log submission through the lead, avoiding duplicate CMS traffic
20
+ - **Token authentication** — shared CMS key secures the WebSocket relay
21
+ - **Sync group isolation** — multiple sync groups can share the same relay via `syncGroupId`
22
+ - **Offline LAN sync** — persisted config enables sync without CMS connectivity
19
23
  - **Automatic follower discovery** — heartbeats every 5s, stale detection after 15s
20
24
  - **Graceful degradation** — if a follower is unresponsive, the lead proceeds after a 10s timeout
21
25
  - **Auto-reconnect** — WebSocket transport reconnects with exponential backoff (1s → 30s)
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Multi-Display Sync Demo — test all sync features locally
4
+ *
5
+ * Starts a relay + simulates 4 displays in a 2×2 grid with choreography.
6
+ * Demonstrates: group isolation, wall mode (layoutMap), topology,
7
+ * auto-detected totalDisplays, and transition choreography.
8
+ *
9
+ * Usage:
10
+ * node packages/sync/examples/test-multi-display.js
11
+ *
12
+ * What it does:
13
+ * 1. Starts a standalone relay on port 9590
14
+ * 2. Creates 4 SyncManager instances (1 lead + 3 followers)
15
+ * 3. All join group "lobby" with 2×2 topology
16
+ * 4. Lead requests layout changes — followers sync
17
+ * 5. Choreography staggers are computed and logged
18
+ * 6. Wall mode maps lead's layout to position-specific layouts
19
+ */
20
+
21
+ import http from 'node:http';
22
+ import { attachSyncRelay } from '../../proxy/src/sync-relay.js';
23
+ import { SyncManager } from '../src/sync-manager.js';
24
+ import { WebSocketTransport } from '../src/ws-transport.js';
25
+ import { computeStagger } from '../src/choreography.js';
26
+
27
+ const PORT = 9590;
28
+ const RELAY_URL = `ws://127.0.0.1:${PORT}/sync`;
29
+
30
+ // ── Display configs ──────────────────────────────────────────────
31
+ const displays = [
32
+ {
33
+ id: 'display-lead',
34
+ isLead: true,
35
+ topology: { x: 0, y: 0, orientation: 0 },
36
+ layoutMap: null, // lead plays the original layout
37
+ },
38
+ {
39
+ id: 'display-top-right',
40
+ isLead: false,
41
+ topology: { x: 1, y: 0, orientation: 0 },
42
+ layoutMap: { '100': 201 }, // wall mode: lead's 100 → this display's 201
43
+ },
44
+ {
45
+ id: 'display-bottom-left',
46
+ isLead: false,
47
+ topology: { x: 0, y: 1, orientation: 90 }, // portrait totem
48
+ layoutMap: { '100': 202 },
49
+ },
50
+ {
51
+ id: 'display-bottom-right',
52
+ isLead: false,
53
+ topology: { x: 1, y: 1, orientation: 0 },
54
+ layoutMap: { '100': 203 },
55
+ },
56
+ ];
57
+
58
+ const CHOREOGRAPHY = 'diagonal-tl';
59
+ const STAGGER_MS = 200;
60
+ const GRID_COLS = 2;
61
+ const GRID_ROWS = 2;
62
+
63
+ // ── Start relay ──────────────────────────────────────────────────
64
+ const server = http.createServer((_req, res) => {
65
+ res.writeHead(200, { 'Content-Type': 'application/json' });
66
+ res.end(JSON.stringify({ service: 'test-relay', status: 'ok' }));
67
+ });
68
+ attachSyncRelay(server);
69
+
70
+ server.listen(PORT, async () => {
71
+ console.log(`\n🔄 Relay running on ws://127.0.0.1:${PORT}/sync\n`);
72
+
73
+ // Wait for server to be ready
74
+ await sleep(200);
75
+
76
+ // ── Create SyncManagers ──────────────────────────────────────
77
+ const managers = [];
78
+
79
+ for (const display of displays) {
80
+ const syncConfig = {
81
+ syncGroup: 'lobby',
82
+ syncPublisherPort: PORT,
83
+ syncSwitchDelay: 500,
84
+ syncVideoPauseDelay: 100,
85
+ isLead: display.isLead,
86
+ relayUrl: RELAY_URL,
87
+ topology: display.topology,
88
+ layoutMap: display.layoutMap,
89
+ choreography: CHOREOGRAPHY,
90
+ staggerMs: STAGGER_MS,
91
+ gridCols: GRID_COLS,
92
+ gridRows: GRID_ROWS,
93
+ };
94
+
95
+ const transport = new WebSocketTransport(RELAY_URL, {
96
+ syncGroup: 'lobby',
97
+ displayId: display.id,
98
+ topology: display.topology,
99
+ });
100
+
101
+ const manager = new SyncManager({
102
+ displayId: display.id,
103
+ syncConfig,
104
+ transport,
105
+ onLayoutChange: (layoutId) => {
106
+ // Wall mode: map lead's layout to position-specific
107
+ const mapped = display.layoutMap?.[layoutId] ?? layoutId;
108
+ const tag = mapped !== layoutId ? ` (wall: ${layoutId}→${mapped})` : '';
109
+ console.log(` 📺 ${display.id}: Loading layout ${mapped}${tag}`);
110
+
111
+ // Simulate load time
112
+ setTimeout(() => {
113
+ manager.reportReady(layoutId);
114
+ console.log(` ✅ ${display.id}: Ready`);
115
+ }, 100 + Math.random() * 200);
116
+ },
117
+ onLayoutShow: (layoutId) => {
118
+ const stagger = computeStagger({
119
+ choreography: CHOREOGRAPHY,
120
+ topology: display.topology,
121
+ gridCols: GRID_COLS,
122
+ gridRows: GRID_ROWS,
123
+ staggerMs: STAGGER_MS,
124
+ });
125
+ console.log(` 🎬 ${display.id}: Show layout ${layoutId} (delay: ${stagger}ms, ${CHOREOGRAPHY})`);
126
+ },
127
+ onVideoStart: (layoutId, regionId) => {
128
+ console.log(` ▶️ ${display.id}: Video start in layout ${layoutId} region ${regionId}`);
129
+ },
130
+ onGroupUpdate: (totalDisplays, topology) => {
131
+ console.log(` 📡 ${display.id}: Group update — ${totalDisplays} displays, topology:`, JSON.stringify(topology));
132
+ },
133
+ });
134
+
135
+ manager.start();
136
+ managers.push({ display, manager });
137
+ console.log(`${display.isLead ? '👑' : '👤'} ${display.id} joined (${display.topology.x},${display.topology.y}) orientation=${display.topology.orientation ?? 0}°`);
138
+ await sleep(300);
139
+ }
140
+
141
+ console.log('\n--- All displays connected ---\n');
142
+ await sleep(500);
143
+
144
+ // ── Show choreography stagger table ────────────────────────
145
+ console.log(`\n📊 Choreography: ${CHOREOGRAPHY} (${STAGGER_MS}ms stagger)`);
146
+ console.log('┌─────────────────────────┬──────────┬──────────┐');
147
+ console.log('│ Display │ Position │ Delay │');
148
+ console.log('├─────────────────────────┼──────────┼──────────┤');
149
+ for (const { display } of managers) {
150
+ const stagger = computeStagger({
151
+ choreography: CHOREOGRAPHY,
152
+ topology: display.topology,
153
+ gridCols: GRID_COLS,
154
+ gridRows: GRID_ROWS,
155
+ staggerMs: STAGGER_MS,
156
+ });
157
+ console.log(`│ ${display.id.padEnd(23)} │ (${display.topology.x},${display.topology.y}) │ ${String(stagger).padStart(4)}ms │`);
158
+ }
159
+ console.log('└─────────────────────────┴──────────┴──────────┘\n');
160
+
161
+ // ── Lead requests layout change ────────────────────────────
162
+ const lead = managers.find(m => m.display.isLead);
163
+
164
+ console.log('🚀 Lead requesting layout 100 (wall mode: each display maps to its own layout)...\n');
165
+ await lead.manager.requestLayoutChange(100);
166
+ console.log('\n✅ Layout 100 shown across all displays!\n');
167
+
168
+ await sleep(1000);
169
+
170
+ console.log('🚀 Lead requesting layout 200 (mirror mode: all show same layout)...\n');
171
+ await lead.manager.requestLayoutChange(200);
172
+ console.log('\n✅ Layout 200 shown across all displays!\n');
173
+
174
+ await sleep(500);
175
+
176
+ // ── Show other choreography patterns ──────────────────────
177
+ console.log('\n📊 All choreography patterns for this 2×2 grid:\n');
178
+ const patterns = ['simultaneous', 'wave-right', 'wave-left', 'wave-down', 'wave-up',
179
+ 'diagonal-tl', 'diagonal-tr', 'diagonal-bl', 'diagonal-br',
180
+ 'center-out', 'outside-in'];
181
+
182
+ for (const pattern of patterns) {
183
+ const delays = displays.map(d => computeStagger({
184
+ choreography: pattern,
185
+ topology: d.topology,
186
+ gridCols: GRID_COLS,
187
+ gridRows: GRID_ROWS,
188
+ staggerMs: STAGGER_MS,
189
+ }));
190
+ const grid = `(0,0)=${delays[0]}ms (1,0)=${delays[1]}ms | (0,1)=${delays[2]}ms (1,1)=${delays[3]}ms`;
191
+ console.log(` ${pattern.padEnd(14)} → ${grid}`);
192
+ }
193
+
194
+ // ── Cleanup ────────────────────────────────────────────────
195
+ console.log('\n🧹 Cleaning up...');
196
+ for (const { manager } of managers) {
197
+ manager.stop();
198
+ }
199
+ server.close();
200
+ console.log('Done.\n');
201
+ });
202
+
203
+ function sleep(ms) {
204
+ return new Promise(resolve => setTimeout(resolve, ms));
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/sync",
3
- "version": "0.6.12",
3
+ "version": "0.7.0",
4
4
  "description": "Multi-display synchronization for Xibo Player",
5
5
  "type": "module",
6
6
  "main": "src/sync-manager.js",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/utils": "0.6.12"
15
+ "@xiboplayer/utils": "0.7.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * Transition choreography — compute stagger delays for cascading
5
+ * layout transitions across multiple displays.
6
+ *
7
+ * Supports two modes:
8
+ *
9
+ * 1D mode (position-based):
10
+ * Displays are numbered 0..N-1 in a row. Simple linear choreographies.
11
+ * Config: { position, totalDisplays }
12
+ *
13
+ * 2D mode (topology-based):
14
+ * Each display has (x, y) coordinates and an orientation vector.
15
+ * Enables directional sweeps, diagonal cascades, and radial effects.
16
+ * Config: { topology: { x, y, orientation? }, gridCols, gridRows }
17
+ *
18
+ * Topology format:
19
+ * { x: 1, y: 0, orientation: 0 }
20
+ * - x, y: grid coordinates (0-indexed)
21
+ * - orientation: degrees clockwise from upright (0=landscape, 90=portrait-right,
22
+ * 180=inverted, 270=portrait-left). Defaults to 0.
23
+ *
24
+ * Choreographies:
25
+ * simultaneous — all at once (default, no delay)
26
+ * wave-right — sweep left to right (by x)
27
+ * wave-left — sweep right to left (by x)
28
+ * wave-down — sweep top to bottom (by y, 2D only)
29
+ * wave-up — sweep bottom to top (by y, 2D only)
30
+ * diagonal-tl — cascade from top-left corner (2D only)
31
+ * diagonal-tr — cascade from top-right corner (2D only)
32
+ * diagonal-bl — cascade from bottom-left corner (2D only)
33
+ * diagonal-br — cascade from bottom-right corner (2D only)
34
+ * center-out — explode from center to edges
35
+ * outside-in — implode from edges to center
36
+ * random — random delay per display
37
+ *
38
+ * @module @xiboplayer/sync/choreography
39
+ */
40
+
41
+ /**
42
+ * Compute the stagger delay for a display.
43
+ *
44
+ * @param {Object} options
45
+ * @param {string} options.choreography — choreography name
46
+ * @param {number} [options.position] — 1D: this display's 0-indexed position
47
+ * @param {number} [options.totalDisplays] — 1D: total displays in the group
48
+ * @param {Object} [options.topology] — 2D: this display's topology { x, y, orientation? }
49
+ * @param {number} [options.gridCols] — 2D: grid width (columns)
50
+ * @param {number} [options.gridRows] — 2D: grid height (rows)
51
+ * @param {number} options.staggerMs — base delay between consecutive displays (ms)
52
+ * @returns {number} delay in ms before this display should execute its transition
53
+ *
54
+ * @example
55
+ * // 1D: 4 displays, wave-right with 150ms stagger
56
+ * computeStagger({ choreography: 'wave-right', position: 2, totalDisplays: 4, staggerMs: 150 })
57
+ * // → 300
58
+ *
59
+ * @example
60
+ * // 2D: 3×2 grid, diagonal from top-left
61
+ * computeStagger({
62
+ * choreography: 'diagonal-tl',
63
+ * topology: { x: 2, y: 1 },
64
+ * gridCols: 3, gridRows: 2,
65
+ * staggerMs: 100,
66
+ * })
67
+ * // → 300 (Manhattan distance 2+1=3, so 3×100)
68
+ */
69
+ export function computeStagger({ choreography, position, totalDisplays, topology, gridCols, gridRows, staggerMs }) {
70
+ if (!choreography || choreography === 'simultaneous' || !staggerMs) {
71
+ return 0;
72
+ }
73
+
74
+ // 2D mode: topology with grid dimensions
75
+ if (topology && gridCols != null && gridRows != null) {
76
+ if (gridCols <= 1 && gridRows <= 1) return 0;
77
+ return _computeStagger2D(choreography, topology, gridCols, gridRows, staggerMs);
78
+ }
79
+
80
+ // 1D mode: position-based
81
+ if (totalDisplays == null || totalDisplays <= 1) return 0;
82
+ return _computeStagger1D(choreography, position ?? 0, totalDisplays, staggerMs);
83
+ }
84
+
85
+ /** @private 1D stagger computation */
86
+ function _computeStagger1D(choreography, position, totalDisplays, staggerMs) {
87
+ const last = totalDisplays - 1;
88
+ const center = last / 2;
89
+
90
+ switch (choreography) {
91
+ case 'wave-right':
92
+ return position * staggerMs;
93
+
94
+ case 'wave-left':
95
+ return (last - position) * staggerMs;
96
+
97
+ case 'center-out':
98
+ return Math.round(Math.abs(position - center)) * staggerMs;
99
+
100
+ case 'outside-in': {
101
+ const maxDist = Math.round(center);
102
+ return (maxDist - Math.round(Math.abs(position - center))) * staggerMs;
103
+ }
104
+
105
+ case 'random':
106
+ return Math.floor(Math.random() * last * staggerMs);
107
+
108
+ default:
109
+ return 0;
110
+ }
111
+ }
112
+
113
+ /** @private 2D stagger computation */
114
+ function _computeStagger2D(choreography, topology, gridCols, gridRows, staggerMs) {
115
+ const { x, y } = topology;
116
+ const maxX = gridCols - 1;
117
+ const maxY = gridRows - 1;
118
+ const centerX = maxX / 2;
119
+ const centerY = maxY / 2;
120
+
121
+ switch (choreography) {
122
+ // Axis-aligned sweeps
123
+ case 'wave-right':
124
+ return x * staggerMs;
125
+
126
+ case 'wave-left':
127
+ return (maxX - x) * staggerMs;
128
+
129
+ case 'wave-down':
130
+ return y * staggerMs;
131
+
132
+ case 'wave-up':
133
+ return (maxY - y) * staggerMs;
134
+
135
+ // Corner diagonals (Manhattan distance from corner)
136
+ case 'diagonal-tl':
137
+ return (x + y) * staggerMs;
138
+
139
+ case 'diagonal-tr':
140
+ return ((maxX - x) + y) * staggerMs;
141
+
142
+ case 'diagonal-bl':
143
+ return (x + (maxY - y)) * staggerMs;
144
+
145
+ case 'diagonal-br':
146
+ return ((maxX - x) + (maxY - y)) * staggerMs;
147
+
148
+ // Radial patterns (Euclidean distance from center)
149
+ case 'center-out': {
150
+ const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
151
+ return Math.round(dist) * staggerMs;
152
+ }
153
+
154
+ case 'outside-in': {
155
+ const maxDist = Math.round(Math.sqrt(centerX ** 2 + centerY ** 2));
156
+ const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
157
+ return (maxDist - Math.round(dist)) * staggerMs;
158
+ }
159
+
160
+ case 'random': {
161
+ const maxSteps = maxX + maxY; // Manhattan extent
162
+ return Math.floor(Math.random() * maxSteps * staggerMs);
163
+ }
164
+
165
+ default:
166
+ return 0;
167
+ }
168
+ }
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
3
+ import { describe, it, expect } from 'vitest';
4
+ import { computeStagger } from './choreography.js';
5
+
6
+ // ── 1D helpers ────────────────────────────────────────────────────
7
+ const opts = (choreography, position, totalDisplays = 4, staggerMs = 150) =>
8
+ ({ choreography, position, totalDisplays, staggerMs });
9
+
10
+ // ── 2D helpers ────────────────────────────────────────────────────
11
+ const opts2D = (choreography, x, y, gridCols = 3, gridRows = 2, staggerMs = 100) =>
12
+ ({ choreography, topology: { x, y }, gridCols, gridRows, staggerMs });
13
+
14
+ // ══════════════════════════════════════════════════════════════════
15
+ // 1D MODE (backward compatible)
16
+ // ══════════════════════════════════════════════════════════════════
17
+ describe('computeStagger — 1D mode', () => {
18
+ describe('simultaneous (default)', () => {
19
+ it('returns 0 for simultaneous', () => {
20
+ expect(computeStagger(opts('simultaneous', 0))).toBe(0);
21
+ expect(computeStagger(opts('simultaneous', 3))).toBe(0);
22
+ });
23
+
24
+ it('returns 0 when no choreography', () => {
25
+ expect(computeStagger(opts(null, 2))).toBe(0);
26
+ expect(computeStagger(opts(undefined, 2))).toBe(0);
27
+ expect(computeStagger(opts('', 2))).toBe(0);
28
+ });
29
+
30
+ it('returns 0 for single display', () => {
31
+ expect(computeStagger(opts('wave-right', 0, 1, 150))).toBe(0);
32
+ });
33
+
34
+ it('returns 0 when staggerMs is 0', () => {
35
+ expect(computeStagger(opts('wave-right', 2, 4, 0))).toBe(0);
36
+ });
37
+ });
38
+
39
+ describe('wave-right', () => {
40
+ it('staggers left to right', () => {
41
+ expect(computeStagger(opts('wave-right', 0))).toBe(0);
42
+ expect(computeStagger(opts('wave-right', 1))).toBe(150);
43
+ expect(computeStagger(opts('wave-right', 2))).toBe(300);
44
+ expect(computeStagger(opts('wave-right', 3))).toBe(450);
45
+ });
46
+ });
47
+
48
+ describe('wave-left', () => {
49
+ it('staggers right to left', () => {
50
+ expect(computeStagger(opts('wave-left', 0))).toBe(450);
51
+ expect(computeStagger(opts('wave-left', 1))).toBe(300);
52
+ expect(computeStagger(opts('wave-left', 2))).toBe(150);
53
+ expect(computeStagger(opts('wave-left', 3))).toBe(0);
54
+ });
55
+ });
56
+
57
+ describe('center-out', () => {
58
+ it('explodes from center (even count)', () => {
59
+ // 4 displays: center = 1.5
60
+ // pos 0: |0-1.5| = 1.5 → round(1.5)*150 = 2*150 = 300
61
+ // pos 1: |1-1.5| = 0.5 → round(0.5)*150 = 1*150 = 150 (JS rounds .5 up)
62
+ // pos 2: |2-1.5| = 0.5 → round(0.5)*150 = 1*150 = 150
63
+ // pos 3: |3-1.5| = 1.5 → round(1.5)*150 = 2*150 = 300
64
+ expect(computeStagger(opts('center-out', 0))).toBe(300);
65
+ expect(computeStagger(opts('center-out', 1))).toBe(150);
66
+ expect(computeStagger(opts('center-out', 2))).toBe(150);
67
+ expect(computeStagger(opts('center-out', 3))).toBe(300);
68
+ });
69
+
70
+ it('explodes from center (odd count)', () => {
71
+ // 5 displays: center = 2
72
+ expect(computeStagger(opts('center-out', 0, 5, 100))).toBe(200);
73
+ expect(computeStagger(opts('center-out', 1, 5, 100))).toBe(100);
74
+ expect(computeStagger(opts('center-out', 2, 5, 100))).toBe(0);
75
+ expect(computeStagger(opts('center-out', 3, 5, 100))).toBe(100);
76
+ expect(computeStagger(opts('center-out', 4, 5, 100))).toBe(200);
77
+ });
78
+ });
79
+
80
+ describe('outside-in', () => {
81
+ it('implodes from edges (odd count)', () => {
82
+ expect(computeStagger(opts('outside-in', 0, 5, 100))).toBe(0);
83
+ expect(computeStagger(opts('outside-in', 1, 5, 100))).toBe(100);
84
+ expect(computeStagger(opts('outside-in', 2, 5, 100))).toBe(200);
85
+ expect(computeStagger(opts('outside-in', 3, 5, 100))).toBe(100);
86
+ expect(computeStagger(opts('outside-in', 4, 5, 100))).toBe(0);
87
+ });
88
+ });
89
+
90
+ describe('random', () => {
91
+ it('returns a value within range', () => {
92
+ const delay = computeStagger(opts('random', 2));
93
+ expect(delay).toBeGreaterThanOrEqual(0);
94
+ expect(delay).toBeLessThan(3 * 150); // last * staggerMs
95
+ });
96
+ });
97
+
98
+ describe('unknown choreography', () => {
99
+ it('returns 0 for unrecognized name', () => {
100
+ expect(computeStagger(opts('zigzag', 2))).toBe(0);
101
+ });
102
+ });
103
+ });
104
+
105
+ // ══════════════════════════════════════════════════════════════════
106
+ // 2D MODE (topology-based)
107
+ // ══════════════════════════════════════════════════════════════════
108
+ describe('computeStagger — 2D mode', () => {
109
+ // 3×2 grid:
110
+ // (0,0) (1,0) (2,0)
111
+ // (0,1) (1,1) (2,1)
112
+
113
+ describe('edge cases', () => {
114
+ it('returns 0 for 1×1 grid', () => {
115
+ expect(computeStagger(opts2D('wave-right', 0, 0, 1, 1, 100))).toBe(0);
116
+ });
117
+
118
+ it('returns 0 for simultaneous', () => {
119
+ expect(computeStagger(opts2D('simultaneous', 1, 1))).toBe(0);
120
+ });
121
+ });
122
+
123
+ describe('wave-right (by x only)', () => {
124
+ it('staggers by x coordinate', () => {
125
+ expect(computeStagger(opts2D('wave-right', 0, 0))).toBe(0);
126
+ expect(computeStagger(opts2D('wave-right', 1, 0))).toBe(100);
127
+ expect(computeStagger(opts2D('wave-right', 2, 0))).toBe(200);
128
+ // Same x = same delay regardless of y
129
+ expect(computeStagger(opts2D('wave-right', 1, 1))).toBe(100);
130
+ });
131
+ });
132
+
133
+ describe('wave-left (by x, reversed)', () => {
134
+ it('staggers right to left', () => {
135
+ expect(computeStagger(opts2D('wave-left', 0, 0))).toBe(200);
136
+ expect(computeStagger(opts2D('wave-left', 2, 0))).toBe(0);
137
+ });
138
+ });
139
+
140
+ describe('wave-down (by y only)', () => {
141
+ it('staggers by y coordinate', () => {
142
+ expect(computeStagger(opts2D('wave-down', 0, 0))).toBe(0);
143
+ expect(computeStagger(opts2D('wave-down', 0, 1))).toBe(100);
144
+ // Same y = same delay regardless of x
145
+ expect(computeStagger(opts2D('wave-down', 2, 0))).toBe(0);
146
+ });
147
+ });
148
+
149
+ describe('wave-up (by y, reversed)', () => {
150
+ it('staggers bottom to top', () => {
151
+ expect(computeStagger(opts2D('wave-up', 0, 0))).toBe(100);
152
+ expect(computeStagger(opts2D('wave-up', 0, 1))).toBe(0);
153
+ });
154
+ });
155
+
156
+ describe('diagonal-tl (from top-left)', () => {
157
+ it('staggers by Manhattan distance from (0,0)', () => {
158
+ // (0,0)→0, (1,0)→1, (2,0)→2, (0,1)→1, (1,1)→2, (2,1)→3
159
+ expect(computeStagger(opts2D('diagonal-tl', 0, 0))).toBe(0);
160
+ expect(computeStagger(opts2D('diagonal-tl', 1, 0))).toBe(100);
161
+ expect(computeStagger(opts2D('diagonal-tl', 0, 1))).toBe(100);
162
+ expect(computeStagger(opts2D('diagonal-tl', 2, 0))).toBe(200);
163
+ expect(computeStagger(opts2D('diagonal-tl', 1, 1))).toBe(200);
164
+ expect(computeStagger(opts2D('diagonal-tl', 2, 1))).toBe(300);
165
+ });
166
+ });
167
+
168
+ describe('diagonal-tr (from top-right)', () => {
169
+ it('staggers by Manhattan distance from (maxX,0)', () => {
170
+ // maxX=2: (2,0)→0, (1,0)→1, (0,0)→2, (2,1)→1, (1,1)→2, (0,1)→3
171
+ expect(computeStagger(opts2D('diagonal-tr', 2, 0))).toBe(0);
172
+ expect(computeStagger(opts2D('diagonal-tr', 1, 0))).toBe(100);
173
+ expect(computeStagger(opts2D('diagonal-tr', 0, 0))).toBe(200);
174
+ expect(computeStagger(opts2D('diagonal-tr', 0, 1))).toBe(300);
175
+ });
176
+ });
177
+
178
+ describe('diagonal-bl (from bottom-left)', () => {
179
+ it('staggers by Manhattan distance from (0,maxY)', () => {
180
+ // maxY=1: (0,1)→0, (1,1)→1, (0,0)→1, (2,1)→2, (1,0)→2, (2,0)→3
181
+ expect(computeStagger(opts2D('diagonal-bl', 0, 1))).toBe(0);
182
+ expect(computeStagger(opts2D('diagonal-bl', 0, 0))).toBe(100);
183
+ expect(computeStagger(opts2D('diagonal-bl', 2, 1))).toBe(200);
184
+ expect(computeStagger(opts2D('diagonal-bl', 2, 0))).toBe(300);
185
+ });
186
+ });
187
+
188
+ describe('diagonal-br (from bottom-right)', () => {
189
+ it('staggers by Manhattan distance from (maxX,maxY)', () => {
190
+ // (2,1)→0, (1,1)→1, (2,0)→1, (0,1)→2, (1,0)→2, (0,0)→3
191
+ expect(computeStagger(opts2D('diagonal-br', 2, 1))).toBe(0);
192
+ expect(computeStagger(opts2D('diagonal-br', 1, 1))).toBe(100);
193
+ expect(computeStagger(opts2D('diagonal-br', 2, 0))).toBe(100);
194
+ expect(computeStagger(opts2D('diagonal-br', 0, 0))).toBe(300);
195
+ });
196
+ });
197
+
198
+ describe('center-out (Euclidean from center)', () => {
199
+ it('radiates from center of 3×3 grid', () => {
200
+ // 3×3 grid, center = (1,1)
201
+ // (1,1)→0, (0,1)→1, (1,0)→1, (0,0)→√2≈1.41→round=1
202
+ const o = (x, y) => opts2D('center-out', x, y, 3, 3, 100);
203
+ expect(computeStagger(o(1, 1))).toBe(0); // center
204
+ expect(computeStagger(o(0, 1))).toBe(100); // dist 1
205
+ expect(computeStagger(o(1, 0))).toBe(100); // dist 1
206
+ expect(computeStagger(o(0, 0))).toBe(100); // dist √2 ≈ 1.41 → round = 1
207
+ expect(computeStagger(o(2, 2))).toBe(100); // dist √2 ≈ 1.41 → round = 1
208
+ });
209
+ });
210
+
211
+ describe('outside-in (reverse radial)', () => {
212
+ it('edges first, center last on 3×3', () => {
213
+ // 3×3: maxDist = round(√(1²+1²)) = round(1.41) = 1
214
+ // (0,0): dist=√2≈1.41→round=1 → (1-1)=0 → 0ms
215
+ // (1,1): dist=0→round=0 → (1-0)=1 → 100ms
216
+ const o = (x, y) => opts2D('outside-in', x, y, 3, 3, 100);
217
+ expect(computeStagger(o(0, 0))).toBe(0); // corner, starts first
218
+ expect(computeStagger(o(1, 1))).toBe(100); // center, last
219
+ });
220
+ });
221
+
222
+ describe('random (2D)', () => {
223
+ it('returns a value within range', () => {
224
+ const delay = computeStagger(opts2D('random', 1, 1));
225
+ // maxSteps = maxX + maxY = 2 + 1 = 3
226
+ expect(delay).toBeGreaterThanOrEqual(0);
227
+ expect(delay).toBeLessThan(3 * 100);
228
+ });
229
+ });
230
+
231
+ describe('unknown 2D choreography', () => {
232
+ it('returns 0', () => {
233
+ expect(computeStagger(opts2D('spiral', 1, 1))).toBe(0);
234
+ });
235
+ });
236
+
237
+ describe('topology with orientation', () => {
238
+ it('orientation does not affect stagger (cosmetic metadata)', () => {
239
+ const base = computeStagger(opts2D('wave-right', 1, 0));
240
+ const withOrientation = computeStagger({
241
+ choreography: 'wave-right',
242
+ topology: { x: 1, y: 0, orientation: 90 },
243
+ gridCols: 3, gridRows: 2, staggerMs: 100,
244
+ });
245
+ expect(withOrientation).toBe(base);
246
+ });
247
+ });
248
+ });
package/src/index.d.ts CHANGED
@@ -9,6 +9,24 @@ export interface SyncTransport {
9
9
  readonly connected: boolean;
10
10
  }
11
11
 
12
+ export type Choreography =
13
+ | 'simultaneous'
14
+ | 'wave-right' | 'wave-left'
15
+ | 'wave-down' | 'wave-up'
16
+ | 'diagonal-tl' | 'diagonal-tr' | 'diagonal-bl' | 'diagonal-br'
17
+ | 'center-out' | 'outside-in'
18
+ | 'random';
19
+
20
+ /** Display topology — position and orientation in the physical grid */
21
+ export interface DisplayTopology {
22
+ /** X coordinate in the grid (0-indexed, left to right) */
23
+ x: number;
24
+ /** Y coordinate in the grid (0-indexed, top to bottom) */
25
+ y: number;
26
+ /** Screen orientation in degrees clockwise (0=landscape, 90=portrait-right, 270=portrait-left) */
27
+ orientation?: number;
28
+ }
29
+
12
30
  export interface SyncConfig {
13
31
  syncGroup: string;
14
32
  syncPublisherPort: number;
@@ -16,6 +34,30 @@ export interface SyncConfig {
16
34
  syncVideoPauseDelay: number;
17
35
  isLead: boolean;
18
36
  relayUrl?: string;
37
+ /** Auth token for relay join validation (typically CMS server key) */
38
+ syncToken?: string;
39
+ /** Wall mode: map lead layoutId → this display's position-specific layoutId */
40
+ layoutMap?: Record<string, string | number>;
41
+
42
+ // ── Choreography (1D mode) ─────────────────────────────────────
43
+ /** This display's 0-indexed position in a row (1D choreography) */
44
+ position?: number;
45
+ /** Total displays in the group (auto-detected from relay if omitted) */
46
+ totalDisplays?: number;
47
+
48
+ // ── Choreography (2D mode) ─────────────────────────────────────
49
+ /** This display's topology { x, y, orientation } (enables 2D choreography) */
50
+ topology?: DisplayTopology;
51
+ /** Grid width in columns (required for 2D choreography) */
52
+ gridCols?: number;
53
+ /** Grid height in rows (required for 2D choreography) */
54
+ gridRows?: number;
55
+
56
+ // ── Choreography (common) ──────────────────────────────────────
57
+ /** Transition choreography pattern */
58
+ choreography?: Choreography;
59
+ /** Base delay between consecutive displays in ms (default: 150) */
60
+ staggerMs?: number;
19
61
  }
20
62
 
21
63
  export class BroadcastChannelTransport implements SyncTransport {
@@ -27,13 +69,30 @@ export class BroadcastChannelTransport implements SyncTransport {
27
69
  }
28
70
 
29
71
  export class WebSocketTransport implements SyncTransport {
30
- constructor(url: string);
72
+ constructor(url: string, options?: {
73
+ syncGroup?: string;
74
+ displayId?: string;
75
+ topology?: DisplayTopology;
76
+ token?: string;
77
+ });
31
78
  send(msg: any): void;
32
79
  onMessage(callback: (msg: any) => void): void;
33
80
  close(): void;
34
81
  readonly connected: boolean;
35
82
  }
36
83
 
84
+ export function computeStagger(options: {
85
+ choreography: string;
86
+ staggerMs: number;
87
+ // 1D mode
88
+ position?: number;
89
+ totalDisplays?: number;
90
+ // 2D mode
91
+ topology?: DisplayTopology;
92
+ gridCols?: number;
93
+ gridRows?: number;
94
+ }): number;
95
+
37
96
  export class SyncManager {
38
97
  constructor(options: {
39
98
  displayId: string;
@@ -46,6 +105,7 @@ export class SyncManager {
46
105
  onLogsReport?: (followerId: string, logsXml: string, ack: () => void) => void;
47
106
  onStatsAck?: (targetDisplayId: string) => void;
48
107
  onLogsAck?: (targetDisplayId: string) => void;
108
+ onGroupUpdate?: (totalDisplays: number, topology: Record<string, DisplayTopology>) => void;
49
109
  });
50
110
 
51
111
  displayId: string;
@@ -75,6 +75,7 @@ export class SyncManager {
75
75
  this.onLogsReport = options.onLogsReport || null;
76
76
  this.onStatsAck = options.onStatsAck || null;
77
77
  this.onLogsAck = options.onLogsAck || null;
78
+ this.onGroupUpdate = options.onGroupUpdate || null;
78
79
 
79
80
  // State
80
81
  this.transport = options.transport || null;
@@ -104,7 +105,12 @@ export class SyncManager {
104
105
  // Select transport if none injected
105
106
  if (!this.transport) {
106
107
  if (this.syncConfig.relayUrl) {
107
- this.transport = new WebSocketTransport(this.syncConfig.relayUrl);
108
+ this.transport = new WebSocketTransport(this.syncConfig.relayUrl, {
109
+ syncGroup: this.syncConfig.syncGroup,
110
+ displayId: this.displayId,
111
+ topology: this.syncConfig.topology,
112
+ token: this.syncConfig.syncToken,
113
+ });
108
114
  } else if (typeof BroadcastChannel !== 'undefined') {
109
115
  this.transport = new BroadcastChannelTransport();
110
116
  } else {
@@ -294,6 +300,15 @@ export class SyncManager {
294
300
 
295
301
  /** @private */
296
302
  _handleMessage(msg) {
303
+ // Relay-originated messages (no displayId)
304
+ if (msg.type === 'group-update') {
305
+ this._log.info(`Group update: ${msg.totalDisplays} displays`);
306
+ if (this.onGroupUpdate) {
307
+ this.onGroupUpdate(msg.totalDisplays, msg.topology || {});
308
+ }
309
+ return;
310
+ }
311
+
297
312
  // Ignore our own messages
298
313
  if (msg.displayId === this.displayId) return;
299
314
 
@@ -515,3 +530,4 @@ export class SyncManager {
515
530
 
516
531
  export { BroadcastChannelTransport } from './bc-transport.js';
517
532
  export { WebSocketTransport } from './ws-transport.js';
533
+ export { computeStagger } from './choreography.js';
@@ -558,4 +558,120 @@ describe('SyncManager', () => {
558
558
  expect(onLogsAck).toHaveBeenCalledWith('pwa-f1');
559
559
  });
560
560
  });
561
+
562
+ // ── Local sync config fallback ────────────────────────────────
563
+
564
+ describe('local sync config fallback pattern', () => {
565
+ // Tests the pattern used in main.ts: when CMS returns syncConfig: null
566
+ // but config.data.sync is set, the player emits 'sync-config' on
567
+ // register-complete. This tests the EventEmitter contract, not the DOM.
568
+
569
+ it('should initialize SyncManager from local config when CMS provides no syncConfig', () => {
570
+ // Simulate the pattern from main.ts:
571
+ // core.on('register-complete') → if no CMS sync → emit 'sync-config' from local
572
+ const { EventEmitter } = require('@xiboplayer/utils');
573
+ const core = new EventEmitter();
574
+
575
+ const localSyncConfig = {
576
+ syncGroup: 'lobby',
577
+ syncPublisherPort: 9590,
578
+ syncSwitchDelay: 500,
579
+ syncVideoPauseDelay: 100,
580
+ isLead: false,
581
+ };
582
+
583
+ const syncConfigReceived = vi.fn();
584
+ core.on('sync-config', syncConfigReceived);
585
+
586
+ // Simulate the fallback listener (mirrors main.ts logic)
587
+ core.on('register-complete', (regResult) => {
588
+ if (!regResult.syncConfig && localSyncConfig) {
589
+ core.emit('sync-config', localSyncConfig);
590
+ }
591
+ });
592
+
593
+ // CMS returns no syncConfig
594
+ core.emit('register-complete', { syncConfig: null, settings: {} });
595
+
596
+ expect(syncConfigReceived).toHaveBeenCalledWith(localSyncConfig);
597
+ });
598
+
599
+ it('should NOT use local config when CMS provides syncConfig', () => {
600
+ const { EventEmitter } = require('@xiboplayer/utils');
601
+ const core = new EventEmitter();
602
+
603
+ const localSyncConfig = { syncGroup: 'local', isLead: false };
604
+ const cmsSyncConfig = { syncGroup: 'cms-group', isLead: true };
605
+
606
+ const syncConfigReceived = vi.fn();
607
+ core.on('sync-config', syncConfigReceived);
608
+
609
+ // Fallback listener
610
+ core.on('register-complete', (regResult) => {
611
+ if (!regResult.syncConfig && localSyncConfig) {
612
+ core.emit('sync-config', localSyncConfig);
613
+ }
614
+ });
615
+
616
+ // CMS provides syncConfig — fallback should NOT fire
617
+ core.emit('register-complete', { syncConfig: cmsSyncConfig, settings: {} });
618
+
619
+ // The fallback listener should NOT have emitted sync-config
620
+ expect(syncConfigReceived).not.toHaveBeenCalled();
621
+ });
622
+
623
+ it('should NOT emit sync-config when neither CMS nor local provides config', () => {
624
+ const { EventEmitter } = require('@xiboplayer/utils');
625
+ const core = new EventEmitter();
626
+
627
+ const syncConfigReceived = vi.fn();
628
+ core.on('sync-config', syncConfigReceived);
629
+
630
+ // Fallback with null local config
631
+ const localSyncConfig = null;
632
+ core.on('register-complete', (regResult) => {
633
+ if (!regResult.syncConfig && localSyncConfig) {
634
+ core.emit('sync-config', localSyncConfig);
635
+ }
636
+ });
637
+
638
+ core.emit('register-complete', { syncConfig: null, settings: {} });
639
+
640
+ expect(syncConfigReceived).not.toHaveBeenCalled();
641
+ });
642
+
643
+ it('should create SyncManager with local config values', () => {
644
+ const localConfig = {
645
+ syncGroup: 'lobby',
646
+ syncPublisherPort: 9590,
647
+ syncSwitchDelay: 750,
648
+ syncVideoPauseDelay: 100,
649
+ isLead: true,
650
+ topology: { x: 0, y: 0, orientation: 0 },
651
+ choreography: 'wave-right',
652
+ staggerMs: 150,
653
+ gridCols: 2,
654
+ gridRows: 2,
655
+ };
656
+
657
+ const transport = {
658
+ send: vi.fn(),
659
+ onMessage: vi.fn(),
660
+ close: vi.fn(),
661
+ connected: true,
662
+ };
663
+
664
+ const manager = new SyncManager({
665
+ displayId: 'test-local',
666
+ syncConfig: localConfig,
667
+ transport,
668
+ });
669
+
670
+ expect(manager.displayId).toBe('test-local');
671
+ expect(manager.isLead).toBe(true);
672
+ expect(manager.syncConfig.topology).toEqual({ x: 0, y: 0, orientation: 0 });
673
+ expect(manager.syncConfig.choreography).toBe('wave-right');
674
+ manager.stop();
675
+ });
676
+ });
561
677
  });
@@ -23,9 +23,18 @@ const BACKOFF_FACTOR = 2;
23
23
  export class WebSocketTransport {
24
24
  /**
25
25
  * @param {string} url — WebSocket URL, e.g. ws://192.168.1.100:8765/sync
26
+ * @param {Object} [options]
27
+ * @param {string} [options.syncGroup] — group name for relay isolation
28
+ * @param {string} [options.displayId] — this display's unique ID
29
+ * @param {Object} [options.topology] — this display's topology { x, y, orientation? }
30
+ * @param {string} [options.token] — auth token for relay join validation
26
31
  */
27
- constructor(url) {
32
+ constructor(url, { syncGroup, displayId, topology, token } = {}) {
28
33
  this._url = url;
34
+ this._syncGroup = syncGroup || null;
35
+ this._displayId = displayId || null;
36
+ this._topology = topology || null;
37
+ this._token = token || null;
29
38
  this._callback = null;
30
39
  this._closed = false;
31
40
  this._retryMs = INITIAL_RETRY_MS;
@@ -87,12 +96,27 @@ export class WebSocketTransport {
87
96
  this.ws.onopen = () => {
88
97
  this._log.info(`Connected to ${this._url}`);
89
98
  this._retryMs = INITIAL_RETRY_MS; // Reset backoff on success
99
+
100
+ // Join sync group for relay isolation (+ topology for auto-detection)
101
+ if (this._syncGroup) {
102
+ const join = { type: 'join', syncGroup: this._syncGroup };
103
+ if (this._displayId) join.displayId = this._displayId;
104
+ if (this._topology) join.topology = this._topology;
105
+ if (this._token) join.token = this._token;
106
+ this.ws.send(JSON.stringify(join));
107
+ }
90
108
  };
91
109
 
92
- this.ws.onmessage = (event) => {
110
+ this.ws.onmessage = async (event) => {
93
111
  if (!this._callback) return;
94
112
  try {
95
- const msg = JSON.parse(event.data);
113
+ // Browser WebSocket delivers string; Node ws delivers Buffer;
114
+ // Node 22+ native WebSocket delivers Blob. Handle all three.
115
+ let raw = event.data;
116
+ if (typeof raw !== 'string') {
117
+ raw = (raw instanceof Blob) ? await raw.text() : String(raw);
118
+ }
119
+ const msg = JSON.parse(raw);
96
120
  this._callback(msg);
97
121
  } catch (e) {
98
122
  this._log.warn('Failed to parse message:', e.message);
@@ -0,0 +1,261 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * WebSocketTransport tests
5
+ *
6
+ * Focuses on message parsing — the onmessage handler must correctly parse
7
+ * JSON from three different event.data types:
8
+ * 1. string — browser WebSocket (standard)
9
+ * 2. Buffer — Node.js `ws` package
10
+ * 3. Blob — Node 22+ native WebSocket (undici)
11
+ *
12
+ * Uses a mock WebSocket to test the transport in isolation.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15
+
16
+ // ── Mock WebSocket ──────────────────────────────────────────────
17
+ // Captures onopen/onmessage/onclose/onerror handlers set by the transport
18
+
19
+ class MockWebSocket {
20
+ static OPEN = 1;
21
+ static instances = [];
22
+
23
+ constructor(url) {
24
+ this.url = url;
25
+ this.readyState = MockWebSocket.OPEN;
26
+ this.onopen = null;
27
+ this.onmessage = null;
28
+ this.onclose = null;
29
+ this.onerror = null;
30
+ this._sent = [];
31
+ MockWebSocket.instances.push(this);
32
+
33
+ // Auto-fire onopen in next microtask
34
+ queueMicrotask(() => {
35
+ if (this.onopen) this.onopen();
36
+ });
37
+ }
38
+
39
+ send(data) {
40
+ this._sent.push(data);
41
+ }
42
+
43
+ close() {
44
+ this.readyState = 3; // CLOSED
45
+ if (this.onclose) this.onclose();
46
+ }
47
+
48
+ // Test helper: simulate receiving a message with arbitrary event.data
49
+ simulateMessage(data) {
50
+ if (this.onmessage) {
51
+ this.onmessage({ data });
52
+ }
53
+ }
54
+ }
55
+
56
+ // Install mock before importing the transport
57
+ const originalWebSocket = globalThis.WebSocket;
58
+
59
+ beforeEach(() => {
60
+ MockWebSocket.instances = [];
61
+ globalThis.WebSocket = MockWebSocket;
62
+ });
63
+
64
+ afterEach(() => {
65
+ globalThis.WebSocket = originalWebSocket;
66
+ });
67
+
68
+ // Dynamic import so it picks up our mock WebSocket
69
+ async function createTransport(url = 'ws://localhost:9590/sync', opts = {}) {
70
+ const { WebSocketTransport } = await import('./ws-transport.js');
71
+ return new WebSocketTransport(url, opts);
72
+ }
73
+
74
+ // ── Tests ───────────────────────────────────────────────────────
75
+
76
+ describe('WebSocketTransport', () => {
77
+ describe('message parsing', () => {
78
+ const testMsg = { type: 'layout-change', layoutId: '42', showAt: 12345 };
79
+
80
+ it('parses string data (browser WebSocket)', async () => {
81
+ const transport = await createTransport();
82
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
83
+ const ws = MockWebSocket.instances[0];
84
+
85
+ const received = vi.fn();
86
+ transport.onMessage(received);
87
+
88
+ // Browser sends string
89
+ ws.simulateMessage(JSON.stringify(testMsg));
90
+
91
+ // Allow async onmessage to complete
92
+ await vi.waitFor(() => expect(received).toHaveBeenCalled());
93
+ expect(received).toHaveBeenCalledWith(testMsg);
94
+
95
+ transport.close();
96
+ });
97
+
98
+ it('parses Buffer data (Node ws package)', async () => {
99
+ const transport = await createTransport();
100
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
101
+ const ws = MockWebSocket.instances[0];
102
+
103
+ const received = vi.fn();
104
+ transport.onMessage(received);
105
+
106
+ // Node ws sends Buffer
107
+ const buffer = Buffer.from(JSON.stringify(testMsg));
108
+ ws.simulateMessage(buffer);
109
+
110
+ await vi.waitFor(() => expect(received).toHaveBeenCalled());
111
+ expect(received).toHaveBeenCalledWith(testMsg);
112
+
113
+ transport.close();
114
+ });
115
+
116
+ it('parses Blob data (Node 22+ native WebSocket)', async () => {
117
+ const transport = await createTransport();
118
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
119
+ const ws = MockWebSocket.instances[0];
120
+
121
+ const received = vi.fn();
122
+ transport.onMessage(received);
123
+
124
+ // Node 22+ native WS (undici) sends Blob
125
+ const blob = new Blob([JSON.stringify(testMsg)]);
126
+ ws.simulateMessage(blob);
127
+
128
+ await vi.waitFor(() => expect(received).toHaveBeenCalled());
129
+ expect(received).toHaveBeenCalledWith(testMsg);
130
+
131
+ transport.close();
132
+ });
133
+
134
+ it('warns on unparseable data without crashing', async () => {
135
+ const transport = await createTransport();
136
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
137
+ const ws = MockWebSocket.instances[0];
138
+
139
+ const received = vi.fn();
140
+ transport.onMessage(received);
141
+
142
+ // Send garbage
143
+ ws.simulateMessage('not json {{{');
144
+
145
+ // Give async handler a tick
146
+ await new Promise(r => setTimeout(r, 50));
147
+
148
+ expect(received).not.toHaveBeenCalled();
149
+
150
+ transport.close();
151
+ });
152
+
153
+ it('ignores messages when no callback registered', async () => {
154
+ const transport = await createTransport();
155
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
156
+ const ws = MockWebSocket.instances[0];
157
+
158
+ // No onMessage callback set — should not throw
159
+ ws.simulateMessage(JSON.stringify(testMsg));
160
+ await new Promise(r => setTimeout(r, 50));
161
+
162
+ transport.close();
163
+ });
164
+ });
165
+
166
+ describe('join message', () => {
167
+ it('sends join with syncGroup on connect', async () => {
168
+ const transport = await createTransport('ws://localhost:9590/sync', {
169
+ syncGroup: 'lobby',
170
+ });
171
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
172
+ const ws = MockWebSocket.instances[0];
173
+
174
+ // Wait for onopen to fire and send join
175
+ await vi.waitFor(() => expect(ws._sent.length).toBeGreaterThan(0));
176
+
177
+ const join = JSON.parse(ws._sent[0]);
178
+ expect(join.type).toBe('join');
179
+ expect(join.syncGroup).toBe('lobby');
180
+
181
+ transport.close();
182
+ });
183
+
184
+ it('includes displayId and topology in join', async () => {
185
+ const transport = await createTransport('ws://localhost:9590/sync', {
186
+ syncGroup: 'wall',
187
+ displayId: 'display-1',
188
+ topology: { x: 1, y: 0, orientation: 90 },
189
+ });
190
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
191
+ const ws = MockWebSocket.instances[0];
192
+
193
+ await vi.waitFor(() => expect(ws._sent.length).toBeGreaterThan(0));
194
+
195
+ const join = JSON.parse(ws._sent[0]);
196
+ expect(join.displayId).toBe('display-1');
197
+ expect(join.topology).toEqual({ x: 1, y: 0, orientation: 90 });
198
+
199
+ transport.close();
200
+ });
201
+
202
+ it('does not send join when no syncGroup', async () => {
203
+ const transport = await createTransport('ws://localhost:9590/sync', {});
204
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
205
+ const ws = MockWebSocket.instances[0];
206
+
207
+ // Give time for onopen
208
+ await new Promise(r => setTimeout(r, 50));
209
+
210
+ expect(ws._sent.length).toBe(0);
211
+
212
+ transport.close();
213
+ });
214
+ });
215
+
216
+ describe('send', () => {
217
+ it('serializes objects to JSON', async () => {
218
+ const transport = await createTransport();
219
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
220
+
221
+ transport.send({ type: 'heartbeat', displayId: 'test' });
222
+ const ws = MockWebSocket.instances[0];
223
+ const lastSent = ws._sent[ws._sent.length - 1];
224
+ expect(JSON.parse(lastSent)).toEqual({ type: 'heartbeat', displayId: 'test' });
225
+
226
+ transport.close();
227
+ });
228
+
229
+ it('does not send when connection is closed', async () => {
230
+ const transport = await createTransport();
231
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
232
+
233
+ transport.close();
234
+ transport.send({ type: 'heartbeat' });
235
+ // Should not throw
236
+ });
237
+ });
238
+
239
+ describe('lifecycle', () => {
240
+ it('reports connected when WebSocket is OPEN', async () => {
241
+ const transport = await createTransport();
242
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
243
+
244
+ expect(transport.connected).toBe(true);
245
+
246
+ transport.close();
247
+ expect(transport.connected).toBe(false);
248
+ });
249
+
250
+ it('close() prevents reconnection', async () => {
251
+ const transport = await createTransport();
252
+ await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
253
+
254
+ transport.close();
255
+
256
+ // Wait to ensure no reconnect
257
+ await new Promise(r => setTimeout(r, 200));
258
+ expect(MockWebSocket.instances.length).toBe(1); // no new instance
259
+ });
260
+ });
261
+ });