@xiboplayer/sync 0.6.2 → 0.6.4
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 +194 -10
- package/package.json +2 -2
- package/src/bc-transport.js +51 -0
- package/src/index.d.ts +63 -6
- package/src/sync-manager.js +56 -32
- package/src/sync-manager.test.js +88 -5
- package/src/ws-transport.js +125 -0
- package/vitest.config.js +8 -0
package/README.md
CHANGED
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
# @xiboplayer/sync
|
|
2
2
|
|
|
3
|
-
**Multi-display synchronization for Xibo video walls.**
|
|
3
|
+
**Multi-display synchronization for Xibo video walls — same-machine and cross-device.**
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Coordinates layout transitions and video playback across multiple displays:
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **Layout sync** — all displays transition to the same layout simultaneously
|
|
12
|
-
- **Stats/logs delegation** — follower tabs delegate proof-of-play stats and log submission to the sync lead via BroadcastChannel, avoiding duplicate CMS traffic in video wall setups
|
|
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
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
Both modes share the same sync protocol — only the transport layer differs.
|
|
13
|
+
|
|
14
|
+
### Capabilities
|
|
15
|
+
|
|
16
|
+
- **Synchronized layout transitions** — lead signals followers to change layout, waits for all to be ready, then sends a simultaneous "show" signal
|
|
17
|
+
- **Coordinated video start** — video playback begins at the same moment on all displays
|
|
18
|
+
- **Stats/logs delegation** — followers delegate proof-of-play stats and log submission through the lead, avoiding duplicate CMS traffic
|
|
19
|
+
- **Automatic follower discovery** — heartbeats every 5s, stale detection after 15s
|
|
20
|
+
- **Graceful degradation** — if a follower is unresponsive, the lead proceeds after a 10s timeout
|
|
21
|
+
- **Auto-reconnect** — WebSocket transport reconnects with exponential backoff (1s → 30s)
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Same-machine (BroadcastChannel): Cross-device (WebSocket relay):
|
|
27
|
+
|
|
28
|
+
Tab 1 (Lead) Tab 2 (Follower) PC 1 (Lead) PC 2 (Follower)
|
|
29
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
30
|
+
│SyncMgr │ │SyncMgr │ │SyncMgr │ │SyncMgr │
|
|
31
|
+
│ └─BC │◄──►│ └─BC │ │ └─WS │ │ └─WS │
|
|
32
|
+
└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘
|
|
33
|
+
BroadcastChannel │ │
|
|
34
|
+
▼ │
|
|
35
|
+
┌────────────┐ │
|
|
36
|
+
│Proxy :8765 │◄────────────┘
|
|
37
|
+
│ └─SyncRelay│ (LAN WebSocket)
|
|
38
|
+
└────────────┘
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The relay is a dumb pipe — it broadcasts each message to all other connected clients. The sync protocol (heartbeats, ready-waits, layout changes) runs entirely in SyncManager.
|
|
15
42
|
|
|
16
43
|
## Installation
|
|
17
44
|
|
|
@@ -21,13 +48,170 @@ npm install @xiboplayer/sync
|
|
|
21
48
|
|
|
22
49
|
## Usage
|
|
23
50
|
|
|
51
|
+
### Same-machine sync (default)
|
|
52
|
+
|
|
53
|
+
No extra configuration needed. When multiple tabs/windows run on the same origin, BroadcastChannel handles message passing automatically.
|
|
54
|
+
|
|
24
55
|
```javascript
|
|
25
56
|
import { SyncManager } from '@xiboplayer/sync';
|
|
26
57
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
// Lead display
|
|
59
|
+
const lead = new SyncManager({
|
|
60
|
+
displayId: 'screen-1',
|
|
61
|
+
syncConfig: { isLead: true, syncGroup: 'lead', syncSwitchDelay: 750 },
|
|
62
|
+
onLayoutShow: (layoutId) => renderer.show(layoutId),
|
|
63
|
+
});
|
|
64
|
+
lead.start();
|
|
65
|
+
|
|
66
|
+
// Request synchronized layout change (waits for followers)
|
|
67
|
+
await lead.requestLayoutChange('42');
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
// Follower display (different tab)
|
|
72
|
+
const follower = new SyncManager({
|
|
73
|
+
displayId: 'screen-2',
|
|
74
|
+
syncConfig: { isLead: false, syncGroup: '192.168.1.100', syncSwitchDelay: 750 },
|
|
75
|
+
onLayoutChange: async (layoutId) => {
|
|
76
|
+
await renderer.prepareLayout(layoutId);
|
|
77
|
+
follower.reportReady(layoutId);
|
|
78
|
+
},
|
|
79
|
+
onLayoutShow: (layoutId) => renderer.show(layoutId),
|
|
80
|
+
});
|
|
81
|
+
follower.start();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Cross-device sync (LAN video wall)
|
|
85
|
+
|
|
86
|
+
When `syncGroup` is an IP address (not `"lead"`) and `syncPublisherPort` is set, the PWA automatically builds a WebSocket relay URL. The lead connects to its own proxy at `ws://localhost:<port>/sync`; followers connect to `ws://<lead-ip>:<port>/sync`.
|
|
87
|
+
|
|
88
|
+
**Lead config.json** (e.g. `~/.config/xiboplayer/electron/config.json`):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"cmsUrl": "https://cms.example.com",
|
|
93
|
+
"cmsKey": "yourKey",
|
|
94
|
+
"displayName": "videowall-lead",
|
|
95
|
+
"listenAddress": "0.0.0.0"
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The `listenAddress: "0.0.0.0"` makes the proxy reachable from the LAN. The CMS sync settings (`syncGroup`, `syncPublisherPort`) are sent via the RegisterDisplay response.
|
|
100
|
+
|
|
101
|
+
**CMS Display Settings:**
|
|
102
|
+
|
|
103
|
+
| Setting | Lead | Follower |
|
|
104
|
+
|---------|------|----------|
|
|
105
|
+
| Sync Group | `lead` | `192.168.1.100` (lead's IP) |
|
|
106
|
+
| Sync Publisher Port | `8765` | `8765` |
|
|
107
|
+
|
|
108
|
+
The SyncManager detects this configuration and selects the WebSocket transport:
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
// This happens automatically in packages/pwa/src/main.ts:
|
|
112
|
+
if (syncConfig.syncPublisherPort && syncConfig.syncGroup !== 'lead') {
|
|
113
|
+
const host = syncConfig.isLead ? 'localhost' : syncConfig.syncGroup;
|
|
114
|
+
syncConfig.relayUrl = `ws://${host}:${syncConfig.syncPublisherPort}/sync`;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Injecting a custom transport
|
|
119
|
+
|
|
120
|
+
For testing or custom setups, you can inject any object that implements the transport interface:
|
|
121
|
+
|
|
122
|
+
```javascript
|
|
123
|
+
const transport = {
|
|
124
|
+
send(msg) { /* ... */ },
|
|
125
|
+
onMessage(callback) { /* ... */ },
|
|
126
|
+
close() { /* ... */ },
|
|
127
|
+
get connected() { return true; },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const sync = new SyncManager({
|
|
131
|
+
displayId: 'test-1',
|
|
132
|
+
syncConfig: { isLead: true },
|
|
133
|
+
transport,
|
|
134
|
+
});
|
|
135
|
+
sync.start();
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Transport Interface
|
|
139
|
+
|
|
140
|
+
Both `BroadcastChannelTransport` and `WebSocketTransport` implement:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
interface SyncTransport {
|
|
144
|
+
send(msg: any): void; // Send message to peers
|
|
145
|
+
onMessage(cb: (msg) => void); // Register message handler
|
|
146
|
+
close(): void; // Clean up resources
|
|
147
|
+
readonly connected: boolean; // Connection status
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Sync Protocol
|
|
152
|
+
|
|
30
153
|
```
|
|
154
|
+
Lead Follower(s)
|
|
155
|
+
──── ──────────
|
|
156
|
+
heartbeat (every 5s) → discovers peers
|
|
157
|
+
layout-change(layoutId, showAt) → loads layout, prepares DOM
|
|
158
|
+
← layout-ready(layoutId, displayId)
|
|
159
|
+
(waits for all or timeout 10s)
|
|
160
|
+
layout-show(layoutId) → shows layout simultaneously
|
|
161
|
+
video-start(layoutId, regionId) → unpauses video
|
|
162
|
+
stats-report / logs-report ← delegates stats to lead
|
|
163
|
+
stats-ack / logs-ack → confirms submission
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Example: 4-screen video wall
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
┌─────────────┬─────────────┐
|
|
170
|
+
│ Screen 1 │ Screen 2 │
|
|
171
|
+
│ (LEAD) │ (follower) │
|
|
172
|
+
│ 192.168.1.10│ 192.168.1.11│
|
|
173
|
+
├─────────────┼─────────────┤
|
|
174
|
+
│ Screen 3 │ Screen 4 │
|
|
175
|
+
│ (follower) │ (follower) │
|
|
176
|
+
│ 192.168.1.12│ 192.168.1.13│
|
|
177
|
+
└─────────────┴─────────────┘
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**CMS setup:** Create 4 displays. Set Screen 1's sync group to `lead`. Set Screens 2-4's sync group to `192.168.1.10`. Set sync publisher port to `8765` on all four.
|
|
181
|
+
|
|
182
|
+
**Screen 1 config.json:** Add `"listenAddress": "0.0.0.0"` so the proxy listens on all interfaces.
|
|
183
|
+
|
|
184
|
+
All four screens run the same Electron/Chromium player. The lead drives layout transitions; followers load content in parallel and show simultaneously when all are ready.
|
|
185
|
+
|
|
186
|
+
## API
|
|
187
|
+
|
|
188
|
+
### `new SyncManager(options)`
|
|
189
|
+
|
|
190
|
+
| Option | Type | Description |
|
|
191
|
+
|--------|------|-------------|
|
|
192
|
+
| `displayId` | string | This display's unique hardware key |
|
|
193
|
+
| `syncConfig` | SyncConfig | Sync configuration from CMS RegisterDisplay |
|
|
194
|
+
| `transport` | SyncTransport? | Optional pre-built transport (for testing) |
|
|
195
|
+
| `onLayoutChange` | Function? | Called when lead requests layout change |
|
|
196
|
+
| `onLayoutShow` | Function? | Called when lead gives show signal |
|
|
197
|
+
| `onVideoStart` | Function? | Called when lead gives video start signal |
|
|
198
|
+
| `onStatsReport` | Function? | (Lead) Called when follower sends stats |
|
|
199
|
+
| `onLogsReport` | Function? | (Lead) Called when follower sends logs |
|
|
200
|
+
| `onStatsAck` | Function? | (Follower) Called when lead confirms stats |
|
|
201
|
+
| `onLogsAck` | Function? | (Follower) Called when lead confirms logs |
|
|
202
|
+
|
|
203
|
+
### Methods
|
|
204
|
+
|
|
205
|
+
| Method | Role | Description |
|
|
206
|
+
|--------|------|-------------|
|
|
207
|
+
| `start()` | Both | Opens transport, begins heartbeats |
|
|
208
|
+
| `stop()` | Both | Closes transport, clears timers |
|
|
209
|
+
| `requestLayoutChange(layoutId)` | Lead | Sends layout-change, waits for ready, sends show |
|
|
210
|
+
| `requestVideoStart(layoutId, regionId)` | Lead | Signals synchronized video start |
|
|
211
|
+
| `reportReady(layoutId)` | Follower | Reports layout is loaded and ready |
|
|
212
|
+
| `reportStats(statsXml)` | Follower | Delegates stats submission to lead |
|
|
213
|
+
| `reportLogs(logsXml)` | Follower | Delegates logs submission to lead |
|
|
214
|
+
| `getStatus()` | Both | Returns sync status including follower details |
|
|
31
215
|
|
|
32
216
|
---
|
|
33
217
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/sync",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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.
|
|
15
|
+
"@xiboplayer/utils": "0.6.4"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BroadcastChannelTransport — same-machine sync transport
|
|
3
|
+
*
|
|
4
|
+
* Wraps the browser BroadcastChannel API behind the sync transport interface.
|
|
5
|
+
* Used for multi-tab / multi-window sync on a single device.
|
|
6
|
+
*
|
|
7
|
+
* Transport interface: { send(msg), onMessage(callback), close(), get connected() }
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CHANNEL = 'xibo-sync';
|
|
11
|
+
|
|
12
|
+
export class BroadcastChannelTransport {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} [channelName='xibo-sync']
|
|
15
|
+
*/
|
|
16
|
+
constructor(channelName = DEFAULT_CHANNEL) {
|
|
17
|
+
this.channel = new BroadcastChannel(channelName);
|
|
18
|
+
this._connected = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Send a message to all other tabs/windows on this channel.
|
|
23
|
+
* @param {Object} msg — plain object (structured-cloned by BroadcastChannel)
|
|
24
|
+
*/
|
|
25
|
+
send(msg) {
|
|
26
|
+
if (!this.channel) return;
|
|
27
|
+
this.channel.postMessage(msg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register a callback for incoming messages.
|
|
32
|
+
* @param {Function} callback — receives the message data (already deserialized)
|
|
33
|
+
*/
|
|
34
|
+
onMessage(callback) {
|
|
35
|
+
this.channel.onmessage = (e) => callback(e.data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Close the channel. */
|
|
39
|
+
close() {
|
|
40
|
+
if (this.channel) {
|
|
41
|
+
this.channel.close();
|
|
42
|
+
this.channel = null;
|
|
43
|
+
}
|
|
44
|
+
this._connected = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {boolean} Whether the channel is open */
|
|
48
|
+
get connected() {
|
|
49
|
+
return this._connected && !!this.channel;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,22 +1,79 @@
|
|
|
1
1
|
export const VERSION: string;
|
|
2
2
|
|
|
3
|
+
export interface SyncTransport {
|
|
4
|
+
send(msg: any): void;
|
|
5
|
+
onMessage(callback: (msg: any) => void): void;
|
|
6
|
+
close(): void;
|
|
7
|
+
readonly connected: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export interface SyncConfig {
|
|
4
11
|
syncGroup: string;
|
|
5
12
|
syncPublisherPort: number;
|
|
6
13
|
syncSwitchDelay: number;
|
|
7
14
|
syncVideoPauseDelay: number;
|
|
8
15
|
isLead: boolean;
|
|
16
|
+
relayUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class BroadcastChannelTransport implements SyncTransport {
|
|
20
|
+
constructor(channelName?: string);
|
|
21
|
+
send(msg: any): void;
|
|
22
|
+
onMessage(callback: (msg: any) => void): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
readonly connected: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class WebSocketTransport implements SyncTransport {
|
|
28
|
+
constructor(url: string);
|
|
29
|
+
send(msg: any): void;
|
|
30
|
+
onMessage(callback: (msg: any) => void): void;
|
|
31
|
+
close(): void;
|
|
32
|
+
readonly connected: boolean;
|
|
9
33
|
}
|
|
10
34
|
|
|
11
35
|
export class SyncManager {
|
|
12
|
-
constructor(
|
|
13
|
-
|
|
36
|
+
constructor(options: {
|
|
37
|
+
displayId: string;
|
|
38
|
+
syncConfig: SyncConfig;
|
|
39
|
+
transport?: SyncTransport;
|
|
40
|
+
onLayoutChange?: (layoutId: string, showAt: number) => void;
|
|
41
|
+
onLayoutShow?: (layoutId: string) => void;
|
|
42
|
+
onVideoStart?: (layoutId: string, regionId: string) => void;
|
|
43
|
+
onStatsReport?: (followerId: string, statsXml: string, ack: () => void) => void;
|
|
44
|
+
onLogsReport?: (followerId: string, logsXml: string, ack: () => void) => void;
|
|
45
|
+
onStatsAck?: (targetDisplayId: string) => void;
|
|
46
|
+
onLogsAck?: (targetDisplayId: string) => void;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
displayId: string;
|
|
50
|
+
syncConfig: SyncConfig;
|
|
14
51
|
isLead: boolean;
|
|
52
|
+
transport: SyncTransport | null;
|
|
53
|
+
/** Backward-compatible alias for transport */
|
|
54
|
+
channel: SyncTransport | null;
|
|
55
|
+
followers: Map<string, any>;
|
|
15
56
|
|
|
16
57
|
start(): void;
|
|
17
58
|
stop(): void;
|
|
18
|
-
requestLayoutChange(layoutId:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
59
|
+
requestLayoutChange(layoutId: string | number): Promise<void>;
|
|
60
|
+
requestVideoStart(layoutId: string | number, regionId: string): Promise<void>;
|
|
61
|
+
reportReady(layoutId: string | number): void;
|
|
62
|
+
reportStats(statsXml: string): void;
|
|
63
|
+
reportLogs(logsXml: string): void;
|
|
64
|
+
getStatus(): {
|
|
65
|
+
started: boolean;
|
|
66
|
+
isLead: boolean;
|
|
67
|
+
displayId: string;
|
|
68
|
+
followers: number;
|
|
69
|
+
pendingLayoutId: string | null;
|
|
70
|
+
transport: 'websocket' | 'broadcast-channel';
|
|
71
|
+
followerDetails: Array<{
|
|
72
|
+
displayId: string;
|
|
73
|
+
lastSeen: number;
|
|
74
|
+
ready: boolean;
|
|
75
|
+
readyLayoutId: string | null;
|
|
76
|
+
stale: boolean;
|
|
77
|
+
}>;
|
|
78
|
+
};
|
|
22
79
|
}
|
package/src/sync-manager.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SyncManager - Multi-display synchronization
|
|
2
|
+
* SyncManager - Multi-display synchronization
|
|
3
3
|
*
|
|
4
4
|
* Coordinates layout transitions across multiple browser tabs/windows
|
|
5
|
-
*
|
|
5
|
+
* (same machine via BroadcastChannel) or across devices on a LAN
|
|
6
|
+
* (via WebSocket relay on the lead's proxy server).
|
|
6
7
|
*
|
|
7
8
|
* Protocol:
|
|
8
9
|
* Lead Follower(s)
|
|
@@ -17,21 +18,28 @@
|
|
|
17
18
|
* Lead tracks active followers. If a follower goes silent for 15s,
|
|
18
19
|
* it's considered offline and excluded from ready-wait.
|
|
19
20
|
*
|
|
21
|
+
* Transport:
|
|
22
|
+
* Pluggable — BroadcastChannelTransport (same-machine) or
|
|
23
|
+
* WebSocketTransport (cross-device via relay). Selected automatically
|
|
24
|
+
* based on syncConfig.relayUrl.
|
|
25
|
+
*
|
|
20
26
|
* @module @xiboplayer/sync
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* @typedef {Object} SyncConfig
|
|
25
31
|
* @property {string} syncGroup - "lead" or leader's LAN IP
|
|
26
|
-
* @property {number} syncPublisherPort - TCP port (
|
|
32
|
+
* @property {number} syncPublisherPort - TCP port (used for WebSocket relay URL)
|
|
27
33
|
* @property {number} syncSwitchDelay - Delay in ms before showing new content
|
|
28
34
|
* @property {number} syncVideoPauseDelay - Delay in ms before unpausing video
|
|
29
35
|
* @property {boolean} isLead - Whether this display is the leader
|
|
36
|
+
* @property {string} [relayUrl] - WebSocket relay URL for cross-device sync
|
|
30
37
|
*/
|
|
31
38
|
|
|
32
39
|
import { createLogger } from '@xiboplayer/utils';
|
|
40
|
+
import { BroadcastChannelTransport } from './bc-transport.js';
|
|
41
|
+
import { WebSocketTransport } from './ws-transport.js';
|
|
33
42
|
|
|
34
|
-
const CHANNEL_NAME = 'xibo-sync';
|
|
35
43
|
const HEARTBEAT_INTERVAL = 5000; // Send heartbeat every 5s
|
|
36
44
|
const FOLLOWER_TIMEOUT = 15000; // Consider follower offline after 15s silence
|
|
37
45
|
const READY_TIMEOUT = 10000; // Max wait for followers to be ready
|
|
@@ -41,6 +49,7 @@ export class SyncManager {
|
|
|
41
49
|
* @param {Object} options
|
|
42
50
|
* @param {string} options.displayId - This display's unique hardware key
|
|
43
51
|
* @param {SyncConfig} options.syncConfig - Sync configuration from RegisterDisplay
|
|
52
|
+
* @param {Object} [options.transport] - Optional pre-built transport (for testing)
|
|
44
53
|
* @param {Function} [options.onLayoutChange] - Called when lead requests layout change
|
|
45
54
|
* @param {Function} [options.onLayoutShow] - Called when lead gives show signal
|
|
46
55
|
* @param {Function} [options.onVideoStart] - Called when lead gives video start signal
|
|
@@ -66,7 +75,7 @@ export class SyncManager {
|
|
|
66
75
|
this.onLogsAck = options.onLogsAck || null;
|
|
67
76
|
|
|
68
77
|
// State
|
|
69
|
-
this.
|
|
78
|
+
this.transport = options.transport || null;
|
|
70
79
|
this.followers = new Map(); // displayId → { lastSeen, ready }
|
|
71
80
|
this._heartbeatTimer = null;
|
|
72
81
|
this._cleanupTimer = null;
|
|
@@ -79,20 +88,30 @@ export class SyncManager {
|
|
|
79
88
|
this._log = createLogger(this.isLead ? 'Sync:LEAD' : 'Sync:FOLLOW');
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
/** Backward-compatible alias for transport */
|
|
92
|
+
get channel() { return this.transport; }
|
|
93
|
+
set channel(v) { this.transport = v; }
|
|
94
|
+
|
|
82
95
|
/**
|
|
83
|
-
* Start the sync manager
|
|
96
|
+
* Start the sync manager — selects transport, begins heartbeats.
|
|
84
97
|
*/
|
|
85
98
|
start() {
|
|
86
99
|
if (this._started) return;
|
|
87
100
|
this._started = true;
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
// Select transport if none injected
|
|
103
|
+
if (!this.transport) {
|
|
104
|
+
if (this.syncConfig.relayUrl) {
|
|
105
|
+
this.transport = new WebSocketTransport(this.syncConfig.relayUrl);
|
|
106
|
+
} else if (typeof BroadcastChannel !== 'undefined') {
|
|
107
|
+
this.transport = new BroadcastChannelTransport();
|
|
108
|
+
} else {
|
|
109
|
+
this._log.warn('No transport available — sync disabled');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
92
112
|
}
|
|
93
113
|
|
|
94
|
-
this.
|
|
95
|
-
this.channel.onmessage = (event) => this._handleMessage(event.data);
|
|
114
|
+
this.transport.onMessage((msg) => this._handleMessage(msg));
|
|
96
115
|
|
|
97
116
|
// Start heartbeat
|
|
98
117
|
this._heartbeatTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL);
|
|
@@ -103,7 +122,8 @@ export class SyncManager {
|
|
|
103
122
|
this._cleanupTimer = setInterval(() => this._cleanupStaleFollowers(), HEARTBEAT_INTERVAL);
|
|
104
123
|
}
|
|
105
124
|
|
|
106
|
-
this._log.info(
|
|
125
|
+
this._log.info('Started. DisplayId:', this.displayId,
|
|
126
|
+
this.syncConfig.relayUrl ? `(relay: ${this.syncConfig.relayUrl})` : '(BroadcastChannel)');
|
|
107
127
|
}
|
|
108
128
|
|
|
109
129
|
/**
|
|
@@ -121,13 +141,13 @@ export class SyncManager {
|
|
|
121
141
|
clearInterval(this._cleanupTimer);
|
|
122
142
|
this._cleanupTimer = null;
|
|
123
143
|
}
|
|
124
|
-
if (this.
|
|
125
|
-
this.
|
|
126
|
-
this.
|
|
144
|
+
if (this.transport) {
|
|
145
|
+
this.transport.close();
|
|
146
|
+
this.transport = null;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
this.followers.clear();
|
|
130
|
-
this._log.info(
|
|
150
|
+
this._log.info('Stopped');
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
// ── Lead API ──────────────────────────────────────────────────────
|
|
@@ -141,7 +161,7 @@ export class SyncManager {
|
|
|
141
161
|
*/
|
|
142
162
|
async requestLayoutChange(layoutId) {
|
|
143
163
|
if (!this.isLead) {
|
|
144
|
-
this._log.warn(
|
|
164
|
+
this._log.warn('requestLayoutChange called on follower — ignoring');
|
|
145
165
|
return;
|
|
146
166
|
}
|
|
147
167
|
|
|
@@ -156,7 +176,7 @@ export class SyncManager {
|
|
|
156
176
|
|
|
157
177
|
const showAt = Date.now() + this.switchDelay;
|
|
158
178
|
|
|
159
|
-
this._log.info(
|
|
179
|
+
this._log.info(`Requesting layout change: ${layoutId} (show at ${new Date(showAt).toISOString()}, ${this.followers.size} followers)`);
|
|
160
180
|
|
|
161
181
|
// Broadcast layout-change to all followers
|
|
162
182
|
this._send({
|
|
@@ -178,7 +198,7 @@ export class SyncManager {
|
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
// Send show signal
|
|
181
|
-
this._log.info(
|
|
201
|
+
this._log.info(`Sending layout-show: ${layoutId}`);
|
|
182
202
|
this._send({
|
|
183
203
|
type: 'layout-show',
|
|
184
204
|
layoutId,
|
|
@@ -225,7 +245,7 @@ export class SyncManager {
|
|
|
225
245
|
reportReady(layoutId) {
|
|
226
246
|
layoutId = String(layoutId);
|
|
227
247
|
|
|
228
|
-
this._log.info(
|
|
248
|
+
this._log.info(`Reporting ready for layout ${layoutId}`);
|
|
229
249
|
|
|
230
250
|
this._send({
|
|
231
251
|
type: 'layout-ready',
|
|
@@ -283,7 +303,7 @@ export class SyncManager {
|
|
|
283
303
|
case 'layout-change':
|
|
284
304
|
// Follower: lead is requesting a layout change
|
|
285
305
|
if (!this.isLead) {
|
|
286
|
-
this._log.info(
|
|
306
|
+
this._log.info(`Layout change requested: ${msg.layoutId}`);
|
|
287
307
|
this.onLayoutChange(msg.layoutId, msg.showAt);
|
|
288
308
|
}
|
|
289
309
|
break;
|
|
@@ -298,7 +318,7 @@ export class SyncManager {
|
|
|
298
318
|
case 'layout-show':
|
|
299
319
|
// Follower: lead says show now
|
|
300
320
|
if (!this.isLead) {
|
|
301
|
-
this._log.info(
|
|
321
|
+
this._log.info(`Layout show signal: ${msg.layoutId}`);
|
|
302
322
|
this.onLayoutShow(msg.layoutId);
|
|
303
323
|
}
|
|
304
324
|
break;
|
|
@@ -306,7 +326,7 @@ export class SyncManager {
|
|
|
306
326
|
case 'video-start':
|
|
307
327
|
// Follower: lead says start video
|
|
308
328
|
if (!this.isLead) {
|
|
309
|
-
this._log.info(
|
|
329
|
+
this._log.info(`Video start signal: ${msg.layoutId} region ${msg.regionId}`);
|
|
310
330
|
this.onVideoStart(msg.layoutId, msg.regionId);
|
|
311
331
|
}
|
|
312
332
|
break;
|
|
@@ -344,7 +364,7 @@ export class SyncManager {
|
|
|
344
364
|
break;
|
|
345
365
|
|
|
346
366
|
default:
|
|
347
|
-
this._log.warn(
|
|
367
|
+
this._log.warn('Unknown message type:', msg.type);
|
|
348
368
|
}
|
|
349
369
|
}
|
|
350
370
|
|
|
@@ -361,7 +381,7 @@ export class SyncManager {
|
|
|
361
381
|
readyLayoutId: null,
|
|
362
382
|
role: msg.role || 'unknown',
|
|
363
383
|
});
|
|
364
|
-
this._log.info(
|
|
384
|
+
this._log.info(`Follower joined: ${msg.displayId} (${this.followers.size} total)`);
|
|
365
385
|
}
|
|
366
386
|
}
|
|
367
387
|
|
|
@@ -381,12 +401,12 @@ export class SyncManager {
|
|
|
381
401
|
follower.lastSeen = Date.now();
|
|
382
402
|
}
|
|
383
403
|
|
|
384
|
-
this._log.info(
|
|
404
|
+
this._log.info(`Follower ${msg.displayId} ready for layout ${msg.layoutId}`);
|
|
385
405
|
|
|
386
406
|
// Check if all followers are now ready
|
|
387
407
|
if (this._pendingLayoutId === msg.layoutId && this._readyResolve) {
|
|
388
408
|
if (this._allFollowersReady(msg.layoutId)) {
|
|
389
|
-
this._log.info(
|
|
409
|
+
this._log.info('All followers ready');
|
|
390
410
|
this._readyResolve();
|
|
391
411
|
this._readyResolve = null;
|
|
392
412
|
}
|
|
@@ -425,7 +445,7 @@ export class SyncManager {
|
|
|
425
445
|
notReady.push(id);
|
|
426
446
|
}
|
|
427
447
|
}
|
|
428
|
-
this._log.warn(
|
|
448
|
+
this._log.warn(`Ready timeout — proceeding without: ${notReady.join(', ')}`);
|
|
429
449
|
this._readyResolve = null;
|
|
430
450
|
resolve();
|
|
431
451
|
}
|
|
@@ -450,7 +470,7 @@ export class SyncManager {
|
|
|
450
470
|
const now = Date.now();
|
|
451
471
|
for (const [id, follower] of this.followers) {
|
|
452
472
|
if (now - follower.lastSeen > FOLLOWER_TIMEOUT) {
|
|
453
|
-
this._log.info(
|
|
473
|
+
this._log.info(`Removing stale follower: ${id} (last seen ${Math.round((now - follower.lastSeen) / 1000)}s ago)`);
|
|
454
474
|
this.followers.delete(id);
|
|
455
475
|
}
|
|
456
476
|
}
|
|
@@ -458,11 +478,11 @@ export class SyncManager {
|
|
|
458
478
|
|
|
459
479
|
/** @private */
|
|
460
480
|
_send(msg) {
|
|
461
|
-
if (!this.
|
|
481
|
+
if (!this.transport) return;
|
|
462
482
|
try {
|
|
463
|
-
this.
|
|
483
|
+
this.transport.send(msg);
|
|
464
484
|
} catch (e) {
|
|
465
|
-
this._log.error(
|
|
485
|
+
this._log.error('Failed to send:', e);
|
|
466
486
|
}
|
|
467
487
|
}
|
|
468
488
|
|
|
@@ -479,6 +499,7 @@ export class SyncManager {
|
|
|
479
499
|
displayId: this.displayId,
|
|
480
500
|
followers: this.followers.size,
|
|
481
501
|
pendingLayoutId: this._pendingLayoutId,
|
|
502
|
+
transport: this.syncConfig.relayUrl ? 'websocket' : 'broadcast-channel',
|
|
482
503
|
followerDetails: Array.from(this.followers.entries()).map(([id, f]) => ({
|
|
483
504
|
displayId: id,
|
|
484
505
|
lastSeen: f.lastSeen,
|
|
@@ -489,3 +510,6 @@ export class SyncManager {
|
|
|
489
510
|
};
|
|
490
511
|
}
|
|
491
512
|
}
|
|
513
|
+
|
|
514
|
+
export { BroadcastChannelTransport } from './bc-transport.js';
|
|
515
|
+
export { WebSocketTransport } from './ws-transport.js';
|
package/src/sync-manager.test.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SyncManager unit tests
|
|
3
3
|
*
|
|
4
|
-
* Tests multi-display sync coordination via
|
|
5
|
-
* Uses a simple BroadcastChannel mock for
|
|
4
|
+
* Tests multi-display sync coordination via pluggable transports.
|
|
5
|
+
* Uses a simple BroadcastChannel mock for the default transport path
|
|
6
|
+
* and a mock transport for the transport-injection path.
|
|
6
7
|
*/
|
|
7
8
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
8
|
-
import { SyncManager } from './sync-manager.js';
|
|
9
|
+
import { SyncManager, BroadcastChannelTransport, WebSocketTransport } from './sync-manager.js';
|
|
9
10
|
|
|
10
11
|
// ── BroadcastChannel mock ──────────────────────────────────────────
|
|
11
12
|
// Simulates same-origin message passing between instances
|
|
@@ -51,6 +52,24 @@ class MockBroadcastChannel {
|
|
|
51
52
|
// Install mock globally
|
|
52
53
|
globalThis.BroadcastChannel = MockBroadcastChannel;
|
|
53
54
|
|
|
55
|
+
// ── Mock Transport (for transport-injection tests) ──────────────────
|
|
56
|
+
class MockTransport {
|
|
57
|
+
constructor() {
|
|
58
|
+
this._callback = null;
|
|
59
|
+
this._sent = [];
|
|
60
|
+
this._connected = true;
|
|
61
|
+
}
|
|
62
|
+
send(msg) { this._sent.push(msg); }
|
|
63
|
+
onMessage(callback) { this._callback = callback; }
|
|
64
|
+
close() { this._connected = false; }
|
|
65
|
+
get connected() { return this._connected; }
|
|
66
|
+
|
|
67
|
+
/** Simulate receiving a message from remote */
|
|
68
|
+
_receive(msg) {
|
|
69
|
+
if (this._callback) this._callback(msg);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
// ── Helper to flush microtasks ──────────────────────────────────────
|
|
55
74
|
const tick = (ms = 10) => new Promise(r => setTimeout(r, ms));
|
|
56
75
|
|
|
@@ -100,7 +119,7 @@ describe('SyncManager', () => {
|
|
|
100
119
|
expect(follower1.isLead).toBe(false);
|
|
101
120
|
});
|
|
102
121
|
|
|
103
|
-
it('should start and open BroadcastChannel', () => {
|
|
122
|
+
it('should start and open BroadcastChannel transport', () => {
|
|
104
123
|
lead = new SyncManager({
|
|
105
124
|
displayId: 'pwa-lead',
|
|
106
125
|
syncConfig: makeSyncConfig(true),
|
|
@@ -108,10 +127,11 @@ describe('SyncManager', () => {
|
|
|
108
127
|
lead.start();
|
|
109
128
|
|
|
110
129
|
expect(lead.channel).not.toBeNull();
|
|
130
|
+
expect(lead.transport).not.toBeNull();
|
|
111
131
|
expect(lead.getStatus().started).toBe(true);
|
|
112
132
|
});
|
|
113
133
|
|
|
114
|
-
it('should stop and close
|
|
134
|
+
it('should stop and close transport', () => {
|
|
115
135
|
lead = new SyncManager({
|
|
116
136
|
displayId: 'pwa-lead',
|
|
117
137
|
syncConfig: makeSyncConfig(true),
|
|
@@ -120,10 +140,73 @@ describe('SyncManager', () => {
|
|
|
120
140
|
lead.stop();
|
|
121
141
|
|
|
122
142
|
expect(lead.channel).toBeNull();
|
|
143
|
+
expect(lead.transport).toBeNull();
|
|
123
144
|
expect(lead.getStatus().started).toBe(false);
|
|
124
145
|
});
|
|
125
146
|
});
|
|
126
147
|
|
|
148
|
+
describe('Transport injection', () => {
|
|
149
|
+
it('should use injected transport instead of BroadcastChannel', () => {
|
|
150
|
+
const transport = new MockTransport();
|
|
151
|
+
lead = new SyncManager({
|
|
152
|
+
displayId: 'pwa-lead',
|
|
153
|
+
syncConfig: makeSyncConfig(true),
|
|
154
|
+
transport,
|
|
155
|
+
});
|
|
156
|
+
lead.start();
|
|
157
|
+
|
|
158
|
+
expect(lead.transport).toBe(transport);
|
|
159
|
+
// Should have sent initial heartbeat
|
|
160
|
+
expect(transport._sent.length).toBe(1);
|
|
161
|
+
expect(transport._sent[0].type).toBe('heartbeat');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should handle messages from injected transport', () => {
|
|
165
|
+
const onLayoutChange = vi.fn();
|
|
166
|
+
const transport = new MockTransport();
|
|
167
|
+
|
|
168
|
+
follower1 = new SyncManager({
|
|
169
|
+
displayId: 'pwa-f1',
|
|
170
|
+
syncConfig: makeSyncConfig(false),
|
|
171
|
+
transport,
|
|
172
|
+
onLayoutChange,
|
|
173
|
+
});
|
|
174
|
+
follower1.start();
|
|
175
|
+
|
|
176
|
+
// Simulate a layout-change message from lead
|
|
177
|
+
transport._receive({
|
|
178
|
+
type: 'layout-change',
|
|
179
|
+
layoutId: '42',
|
|
180
|
+
showAt: Date.now() + 1000,
|
|
181
|
+
displayId: 'pwa-lead',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(onLayoutChange).toHaveBeenCalledWith('42', expect.any(Number));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should report websocket transport type in status when relayUrl set', () => {
|
|
188
|
+
const transport = new MockTransport();
|
|
189
|
+
lead = new SyncManager({
|
|
190
|
+
displayId: 'pwa-lead',
|
|
191
|
+
syncConfig: { ...makeSyncConfig(true), relayUrl: 'ws://localhost:8765/sync' },
|
|
192
|
+
transport,
|
|
193
|
+
});
|
|
194
|
+
lead.start();
|
|
195
|
+
|
|
196
|
+
expect(lead.getStatus().transport).toBe('websocket');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should report broadcast-channel transport type by default', () => {
|
|
200
|
+
lead = new SyncManager({
|
|
201
|
+
displayId: 'pwa-lead',
|
|
202
|
+
syncConfig: makeSyncConfig(true),
|
|
203
|
+
});
|
|
204
|
+
lead.start();
|
|
205
|
+
|
|
206
|
+
expect(lead.getStatus().transport).toBe('broadcast-channel');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
127
210
|
describe('Heartbeat', () => {
|
|
128
211
|
it('should discover followers via heartbeat', async () => {
|
|
129
212
|
lead = new SyncManager({
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocketTransport — cross-device sync transport
|
|
3
|
+
*
|
|
4
|
+
* Connects to the lead player's proxy WebSocket relay at /sync.
|
|
5
|
+
* Used for LAN video walls where each screen is a separate device.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Auto-reconnect with exponential backoff (1s → 2s → 4s → max 30s)
|
|
9
|
+
* - JSON serialization (WebSocket sends strings, not structured clones)
|
|
10
|
+
* - Same transport interface as BroadcastChannelTransport
|
|
11
|
+
*
|
|
12
|
+
* Transport interface: { send(msg), onMessage(callback), close(), get connected() }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
16
|
+
|
|
17
|
+
const INITIAL_RETRY_MS = 1000;
|
|
18
|
+
const MAX_RETRY_MS = 30000;
|
|
19
|
+
const BACKOFF_FACTOR = 2;
|
|
20
|
+
|
|
21
|
+
export class WebSocketTransport {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} url — WebSocket URL, e.g. ws://192.168.1.100:8765/sync
|
|
24
|
+
*/
|
|
25
|
+
constructor(url) {
|
|
26
|
+
this._url = url;
|
|
27
|
+
this._callback = null;
|
|
28
|
+
this._closed = false;
|
|
29
|
+
this._retryMs = INITIAL_RETRY_MS;
|
|
30
|
+
this._retryTimer = null;
|
|
31
|
+
this._log = createLogger('WS-Sync');
|
|
32
|
+
this.ws = null;
|
|
33
|
+
|
|
34
|
+
this._connect();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send a message to the relay (which broadcasts to other clients).
|
|
39
|
+
* @param {Object} msg — plain object (JSON-serialized for WebSocket)
|
|
40
|
+
*/
|
|
41
|
+
send(msg) {
|
|
42
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
43
|
+
this.ws.send(JSON.stringify(msg));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register a callback for incoming messages.
|
|
49
|
+
* @param {Function} callback — receives the parsed message object
|
|
50
|
+
*/
|
|
51
|
+
onMessage(callback) {
|
|
52
|
+
this._callback = callback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Close the connection and stop reconnecting. */
|
|
56
|
+
close() {
|
|
57
|
+
this._closed = true;
|
|
58
|
+
if (this._retryTimer) {
|
|
59
|
+
clearTimeout(this._retryTimer);
|
|
60
|
+
this._retryTimer = null;
|
|
61
|
+
}
|
|
62
|
+
if (this.ws) {
|
|
63
|
+
this.ws.close();
|
|
64
|
+
this.ws = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {boolean} Whether the WebSocket is open */
|
|
69
|
+
get connected() {
|
|
70
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @private */
|
|
74
|
+
_connect() {
|
|
75
|
+
if (this._closed) return;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
this.ws = new WebSocket(this._url);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
this._log.error('WebSocket creation failed:', e.message);
|
|
81
|
+
this._scheduleReconnect();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.ws.onopen = () => {
|
|
86
|
+
this._log.info(`Connected to ${this._url}`);
|
|
87
|
+
this._retryMs = INITIAL_RETRY_MS; // Reset backoff on success
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
this.ws.onmessage = (event) => {
|
|
91
|
+
if (!this._callback) return;
|
|
92
|
+
try {
|
|
93
|
+
const msg = JSON.parse(event.data);
|
|
94
|
+
this._callback(msg);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
this._log.warn('Failed to parse message:', e.message);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.ws.onclose = () => {
|
|
101
|
+
if (!this._closed) {
|
|
102
|
+
this._log.info('Connection closed — will reconnect');
|
|
103
|
+
this._scheduleReconnect();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
this.ws.onerror = (e) => {
|
|
108
|
+
// onclose will fire after onerror, triggering reconnect
|
|
109
|
+
this._log.warn('WebSocket error');
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @private */
|
|
114
|
+
_scheduleReconnect() {
|
|
115
|
+
if (this._closed || this._retryTimer) return;
|
|
116
|
+
|
|
117
|
+
this._log.info(`Reconnecting in ${this._retryMs}ms...`);
|
|
118
|
+
this._retryTimer = setTimeout(() => {
|
|
119
|
+
this._retryTimer = null;
|
|
120
|
+
this._connect();
|
|
121
|
+
}, this._retryMs);
|
|
122
|
+
|
|
123
|
+
this._retryMs = Math.min(this._retryMs * BACKOFF_FACTOR, MAX_RETRY_MS);
|
|
124
|
+
}
|
|
125
|
+
}
|