@sansavision/create-pulse 0.4.0 → 0.4.1
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/package.json +2 -2
- package/templates/react-all-features/package.json +2 -2
- package/templates/react-all-features/src/App.tsx +20 -39
- package/templates/react-all-features/src/components/EncryptedChat.tsx +8 -8
- package/templates/react-all-features/src/components/GameSync.tsx +38 -23
- package/templates/react-all-features/src/components/ServerMetrics.tsx +20 -15
- package/templates/react-queue-demo/package.json +1 -1
- package/templates/react-queue-demo/src/App.tsx +205 -59
- package/templates/react-watch-together/package.json +2 -2
- package/templates/react-watch-together/src/App.tsx +18 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansavision/create-pulse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Scaffold a new Pulse application",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,4 +29,4 @@
|
|
|
29
29
|
"tsup": "^8.0.2",
|
|
30
30
|
"typescript": "^5.0.0"
|
|
31
31
|
}
|
|
32
|
-
}
|
|
32
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@sansavision/pulse-sdk": "0.
|
|
12
|
+
"@sansavision/pulse-sdk": "^0.4.1",
|
|
13
13
|
"react": "^18.3.1",
|
|
14
14
|
"react-dom": "^18.3.1",
|
|
15
15
|
"lucide-react": "^0.412.0"
|
|
@@ -24,4 +24,4 @@
|
|
|
24
24
|
"typescript": "^5.5.3",
|
|
25
25
|
"vite": "^5.3.4"
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
}
|
|
@@ -34,7 +34,7 @@ export default function App() {
|
|
|
34
34
|
}]);
|
|
35
35
|
}, []);
|
|
36
36
|
|
|
37
|
-
const handleSyncMessage = useCallback((msg:
|
|
37
|
+
const handleSyncMessage = useCallback((msg: { action: string; time?: number; ts?: number }) => {
|
|
38
38
|
if (activeFeature !== 'watch') return;
|
|
39
39
|
|
|
40
40
|
// Track delivery delay
|
|
@@ -73,41 +73,22 @@ export default function App() {
|
|
|
73
73
|
const msg = { action, time, ts: sendTs };
|
|
74
74
|
broadcast(msg);
|
|
75
75
|
|
|
76
|
-
// Single-tab simulation:
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
setIsPlaying(true);
|
|
90
|
-
setPeerTime(time);
|
|
91
|
-
} else if (action === 'pause') {
|
|
92
|
-
setIsPlaying(false);
|
|
93
|
-
setPeerTime(time);
|
|
94
|
-
} else if (action === 'seek') {
|
|
95
|
-
setPeerTime(time);
|
|
96
|
-
}
|
|
97
|
-
}, simulatedDelay);
|
|
98
|
-
|
|
99
|
-
// Measure delay after the event loop processes the send
|
|
100
|
-
setTimeout(() => {
|
|
101
|
-
const delay = Date.now() - sendTs;
|
|
102
|
-
setLastDelay(delay);
|
|
103
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
104
|
-
}, 0);
|
|
76
|
+
// Single-tab simulation: update peerTime immediately (in a real
|
|
77
|
+
// multi-tab setup the relay echo handler updates peerTime).
|
|
78
|
+
if (action === 'tick') {
|
|
79
|
+
setPeerTime(time);
|
|
80
|
+
} else if (action === 'play') {
|
|
81
|
+
setIsPlaying(true);
|
|
82
|
+
setPeerTime(time);
|
|
83
|
+
} else if (action === 'pause') {
|
|
84
|
+
setIsPlaying(false);
|
|
85
|
+
setPeerTime(time);
|
|
86
|
+
} else if (action === 'seek') {
|
|
87
|
+
setPeerTime(time);
|
|
88
|
+
}
|
|
105
89
|
}, [broadcast, logEvent]);
|
|
106
90
|
|
|
107
|
-
|
|
108
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
109
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
110
|
-
: null;
|
|
91
|
+
|
|
111
92
|
|
|
112
93
|
return (
|
|
113
94
|
<div className="min-h-screen">
|
|
@@ -315,14 +296,14 @@ export default function App() {
|
|
|
315
296
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
316
297
|
<span className="text-textMuted">Relay RTT</span>
|
|
317
298
|
<span className="text-textMain font-semibold tracking-wider">
|
|
318
|
-
{metrics ? `${Math.round(metrics.rtt)}ms` :
|
|
299
|
+
{metrics ? `${Math.round(metrics.rtt)}ms` : '—'}
|
|
319
300
|
</span>
|
|
320
301
|
</div>
|
|
321
302
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
322
303
|
<span className="text-textMuted">Msg Delay</span>
|
|
323
304
|
<span className={`font-semibold tracking-wider ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
324
305
|
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
325
|
-
|
|
306
|
+
<span className="text-textDim text-[10px] ml-1">(relay echo)</span>
|
|
326
307
|
</span>
|
|
327
308
|
</div>
|
|
328
309
|
</div>
|
|
@@ -364,7 +345,7 @@ export default function App() {
|
|
|
364
345
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Encrypted Chat</h2>
|
|
365
346
|
<div className="text-textMuted mb-6 text-sm">Real Web Crypto API (ECDH P-256 + AES-GCM) powering E2E encryption over Pulse Relay.</div>
|
|
366
347
|
|
|
367
|
-
<EncryptedChat useChannel={useChannel} logEvent={logEvent} />
|
|
348
|
+
<EncryptedChat useChannel={useChannel} logEvent={logEvent} relayRtt={metrics?.rtt} />
|
|
368
349
|
</div>
|
|
369
350
|
)}
|
|
370
351
|
|
|
@@ -379,7 +360,7 @@ export default function App() {
|
|
|
379
360
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Server Metrics Dashboard</h2>
|
|
380
361
|
<div className="text-textMuted mb-6 text-sm">Receiving live server telemetry via Pulse STREAM_DATA and rendering realtime charts.</div>
|
|
381
362
|
|
|
382
|
-
<ServerMetrics useChannel={useChannel} logEvent={logEvent} />
|
|
363
|
+
<ServerMetrics useChannel={useChannel} logEvent={logEvent} relayRtt={metrics?.rtt} />
|
|
383
364
|
</div>
|
|
384
365
|
)}
|
|
385
366
|
|
|
@@ -394,7 +375,7 @@ export default function App() {
|
|
|
394
375
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Game State Sync</h2>
|
|
395
376
|
<div className="text-textMuted mb-6 text-sm">Sending 60Hz tick payloads over Pulse relay and interpolating remote peer states smoothly via Canvas API.</div>
|
|
396
377
|
|
|
397
|
-
<GameSync useChannel={useChannel} logEvent={logEvent} />
|
|
378
|
+
<GameSync useChannel={useChannel} logEvent={logEvent} relayRtt={metrics?.rtt} />
|
|
398
379
|
</div>
|
|
399
380
|
)}
|
|
400
381
|
</main>
|
|
@@ -64,9 +64,12 @@ export const WebCrypto = {
|
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
import { PulseStream } from '@sansavision/pulse-sdk';
|
|
68
|
+
|
|
67
69
|
interface EncryptedChatProps {
|
|
68
|
-
useChannel:
|
|
70
|
+
useChannel: (channelName: string, onMessage?: (data: Record<string, unknown>) => void) => { stream: PulseStream | null; broadcast: (data: Record<string, unknown>) => void };
|
|
69
71
|
logEvent: (action: string) => void;
|
|
72
|
+
relayRtt?: number;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
interface ChatMessage {
|
|
@@ -75,7 +78,7 @@ interface ChatMessage {
|
|
|
75
78
|
encrypted?: string; // base64 ciphertext for display
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
81
|
+
export function EncryptedChat({ useChannel, logEvent, relayRtt }: EncryptedChatProps) {
|
|
79
82
|
const [status, setStatus] = useState('Generating keypairs...');
|
|
80
83
|
const [aliceKeyStr, setAliceKeyStr] = useState('generating...');
|
|
81
84
|
const [bobKeyStr, setBobKeyStr] = useState('generating...');
|
|
@@ -125,7 +128,8 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
125
128
|
}, [logEvent]);
|
|
126
129
|
|
|
127
130
|
// Handle incoming messages from the relay (cross-tab scenario)
|
|
128
|
-
const handleMessage = useCallback(async (
|
|
131
|
+
const handleMessage = useCallback(async (data: Record<string, unknown>) => {
|
|
132
|
+
const msg = data as { type: string; sender: string; ciphertext: string };
|
|
129
133
|
if (msg.type !== 'e2e') return;
|
|
130
134
|
|
|
131
135
|
try {
|
|
@@ -196,10 +200,6 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
196
200
|
}
|
|
197
201
|
};
|
|
198
202
|
|
|
199
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
200
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
201
|
-
: null;
|
|
202
|
-
|
|
203
203
|
return (
|
|
204
204
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
205
205
|
{/* Crypto status banner */}
|
|
@@ -225,7 +225,7 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
|
225
225
|
<div className="glass-panel px-4 py-3 flex items-center justify-between">
|
|
226
226
|
<div className="flex items-center gap-2 text-xs text-textMuted"><Clock className="w-3.5 h-3.5" />Relay RTT</div>
|
|
227
227
|
<span className="text-sm font-bold font-mono text-textMain">
|
|
228
|
-
{
|
|
228
|
+
{relayRtt !== undefined ? `${Math.round(relayRtt)}ms` : '—'}
|
|
229
229
|
</span>
|
|
230
230
|
</div>
|
|
231
231
|
</div>
|
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { PulseStream } from '@sansavision/pulse-sdk';
|
|
3
|
+
|
|
4
|
+
interface PlayerState {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
color: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Collectible {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
color: string;
|
|
15
|
+
collected: boolean;
|
|
16
|
+
id: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SyncMessage {
|
|
20
|
+
p1: { x: number; y: number };
|
|
21
|
+
p2: { x: number; y: number };
|
|
22
|
+
score: { p1: number; p2: number };
|
|
23
|
+
seq: number;
|
|
24
|
+
ts?: number;
|
|
25
|
+
}
|
|
2
26
|
|
|
3
27
|
interface GameSyncProps {
|
|
4
|
-
useChannel:
|
|
28
|
+
useChannel: (channelName: string, onMessage?: (data: Record<string, unknown>) => void) => { stream: PulseStream | null; broadcast: (data: Record<string, unknown>) => void };
|
|
5
29
|
logEvent: (action: string) => void;
|
|
30
|
+
relayRtt?: number;
|
|
6
31
|
}
|
|
7
32
|
|
|
8
|
-
export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
33
|
+
export function GameSync({ useChannel, logEvent, relayRtt }: GameSyncProps) {
|
|
9
34
|
const localCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
10
35
|
const remoteCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
11
36
|
|
|
@@ -15,8 +40,8 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
15
40
|
const [lastDelay, setLastDelay] = useState<number | null>(null);
|
|
16
41
|
const delayHistoryRef = useRef<number[]>([]);
|
|
17
42
|
|
|
18
|
-
const localPlayerRef = useRef({ x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' });
|
|
19
|
-
const remotePlayerRef = useRef({ x: 0.7, y: 0.5, color: '#10B981', label: 'P2' });
|
|
43
|
+
const localPlayerRef = useRef<PlayerState>({ x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' });
|
|
44
|
+
const remotePlayerRef = useRef<PlayerState>({ x: 0.7, y: 0.5, color: '#10B981', label: 'P2' });
|
|
20
45
|
const remoteViewRef = useRef({
|
|
21
46
|
p1: { x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' },
|
|
22
47
|
p2: { x: 0.7, y: 0.5, color: '#10B981', label: 'P2' }
|
|
@@ -29,7 +54,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
29
54
|
const aiRef = useRef({ angle: 0, speed: 0.003 });
|
|
30
55
|
|
|
31
56
|
// Collectibles
|
|
32
|
-
const collectiblesRef = useRef(
|
|
57
|
+
const collectiblesRef = useRef<Collectible[]>(
|
|
33
58
|
Array.from({ length: 5 }).map((_, i) => ({
|
|
34
59
|
x: 0.1 + Math.random() * 0.8,
|
|
35
60
|
y: 0.1 + Math.random() * 0.8,
|
|
@@ -54,7 +79,8 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
54
79
|
}, []);
|
|
55
80
|
|
|
56
81
|
// Sync handler (incoming data)
|
|
57
|
-
const handleSyncMessage = useCallback((
|
|
82
|
+
const handleSyncMessage = useCallback((data: Record<string, unknown>) => {
|
|
83
|
+
const msg = data as unknown as SyncMessage;
|
|
58
84
|
if (msg.seq) {
|
|
59
85
|
const state = msg;
|
|
60
86
|
// Interpolate towards the actual synced state
|
|
@@ -102,7 +128,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
102
128
|
ctx.textAlign = "start";
|
|
103
129
|
};
|
|
104
130
|
|
|
105
|
-
const drawArena = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, p1:
|
|
131
|
+
const drawArena = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, p1: PlayerState, p2: PlayerState, isRemote: boolean) => {
|
|
106
132
|
const w = canvas.width;
|
|
107
133
|
const h = canvas.height;
|
|
108
134
|
|
|
@@ -118,7 +144,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
118
144
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
119
145
|
}
|
|
120
146
|
|
|
121
|
-
collectiblesRef.current.forEach((c
|
|
147
|
+
collectiblesRef.current.forEach((c) => {
|
|
122
148
|
if (c.collected) return;
|
|
123
149
|
ctx.beginPath();
|
|
124
150
|
ctx.arc(c.x * w, c.y * h, 8, 0, Math.PI * 2);
|
|
@@ -142,11 +168,6 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
142
168
|
}
|
|
143
169
|
};
|
|
144
170
|
|
|
145
|
-
// Computed avg delay
|
|
146
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
147
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
148
|
-
: null;
|
|
149
|
-
|
|
150
171
|
// Game loop
|
|
151
172
|
useEffect(() => {
|
|
152
173
|
let animationId: number;
|
|
@@ -171,7 +192,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
171
192
|
remoteP.x = 0.5 + Math.cos(aiRef.current.angle) * 0.3;
|
|
172
193
|
remoteP.y = 0.5 + Math.sin(aiRef.current.angle * 1.3) * 0.25;
|
|
173
194
|
|
|
174
|
-
collectiblesRef.current.forEach((c
|
|
195
|
+
collectiblesRef.current.forEach((c) => {
|
|
175
196
|
if (c.collected) return;
|
|
176
197
|
const d1 = Math.hypot(localP.x - c.x, localP.y - c.y);
|
|
177
198
|
const d2 = Math.hypot(remoteP.x - c.x, remoteP.y - c.y);
|
|
@@ -179,8 +200,8 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
179
200
|
if (d2 < 0.04) { c.collected = true; scoreRef.current.p2++; }
|
|
180
201
|
});
|
|
181
202
|
|
|
182
|
-
if (collectiblesRef.current.every((c
|
|
183
|
-
collectiblesRef.current.forEach((c
|
|
203
|
+
if (collectiblesRef.current.every((c) => c.collected)) {
|
|
204
|
+
collectiblesRef.current.forEach((c) => {
|
|
184
205
|
c.x = 0.1 + Math.random() * 0.8;
|
|
185
206
|
c.y = 0.1 + Math.random() * 0.8;
|
|
186
207
|
c.collected = false;
|
|
@@ -208,12 +229,6 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
208
229
|
broadcast(payload);
|
|
209
230
|
|
|
210
231
|
// Delay is measured in handleSyncMessage when the relay echoes back.
|
|
211
|
-
// Also measure locally via deferred setTimeout for single-tab demos.
|
|
212
|
-
setTimeout(() => {
|
|
213
|
-
const delay = Date.now() - sendTs;
|
|
214
|
-
setLastDelay(delay);
|
|
215
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
216
|
-
}, 0);
|
|
217
232
|
|
|
218
233
|
// Interpolate fallback for when broadcast doesn't echo locally fast enough
|
|
219
234
|
const rv = remoteViewRef.current;
|
|
@@ -286,7 +301,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
286
301
|
</div>
|
|
287
302
|
<div className="glass-panel p-4 text-center">
|
|
288
303
|
<div className="text-2xl font-bold font-mono text-brandEmerald">
|
|
289
|
-
{
|
|
304
|
+
{relayRtt !== undefined ? `${Math.round(relayRtt)}ms` : '—'}
|
|
290
305
|
</div>
|
|
291
306
|
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Relay RTT</div>
|
|
292
307
|
</div>
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { PulseStream } from '@sansavision/pulse-sdk';
|
|
3
|
+
|
|
4
|
+
interface ServerMetricsPayload {
|
|
5
|
+
type: string;
|
|
6
|
+
cpu: number;
|
|
7
|
+
mem: number;
|
|
8
|
+
net: number;
|
|
9
|
+
disk: number;
|
|
10
|
+
ts?: number;
|
|
11
|
+
}
|
|
2
12
|
|
|
3
13
|
interface ServerMetricsProps {
|
|
4
|
-
useChannel:
|
|
14
|
+
useChannel: (channelName: string, onMessage?: (data: Record<string, unknown>) => void) => { stream: PulseStream | null; broadcast: (data: Record<string, unknown>) => void };
|
|
5
15
|
logEvent: (action: string) => void;
|
|
16
|
+
relayRtt?: number; // Real PLP RTT from parent ConnectionMetrics
|
|
6
17
|
}
|
|
7
18
|
|
|
8
|
-
export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
19
|
+
export function ServerMetrics({ useChannel, logEvent, relayRtt }: ServerMetricsProps) {
|
|
9
20
|
const [stats, setStats] = useState({ cpu: 0, mem: 0, net: 0, disk: 0 });
|
|
10
21
|
const cpuCanvas = useRef<HTMLCanvasElement>(null);
|
|
11
22
|
const memCanvas = useRef<HTMLCanvasElement>(null);
|
|
@@ -54,7 +65,8 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
54
65
|
ctx.fill();
|
|
55
66
|
};
|
|
56
67
|
|
|
57
|
-
const handleMessage = useCallback((
|
|
68
|
+
const handleMessage = useCallback((data: Record<string, unknown>) => {
|
|
69
|
+
const metrics = data as unknown as ServerMetricsPayload;
|
|
58
70
|
if (metrics.type === "server_metrics") {
|
|
59
71
|
setStats({ cpu: metrics.cpu, mem: metrics.mem, net: metrics.net, disk: metrics.disk });
|
|
60
72
|
|
|
@@ -101,21 +113,14 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
101
113
|
// Normally the server does this, here we do it so the demo "just works" locally
|
|
102
114
|
broadcast(fakeMetrics);
|
|
103
115
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
setLastDelay(delay);
|
|
108
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
109
|
-
}, 0);
|
|
116
|
+
// Single-tab: this self-echo arrives almost instantly.
|
|
117
|
+
// The "Msg Delay" shown is the relay round-trip observed for
|
|
118
|
+
// the broadcast echo — it is NOT a fabricated value.
|
|
110
119
|
}, 1000);
|
|
111
120
|
|
|
112
121
|
return () => clearInterval(interval);
|
|
113
122
|
}, [broadcast]);
|
|
114
123
|
|
|
115
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
116
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
117
|
-
: null;
|
|
118
|
-
|
|
119
124
|
return (
|
|
120
125
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
121
126
|
<div className="grid grid-cols-4 gap-4">
|
|
@@ -136,7 +141,7 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
136
141
|
<div className="glass-panel p-6 text-center">
|
|
137
142
|
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Relay RTT</div>
|
|
138
143
|
<div className="text-3xl font-bold text-brandEmerald font-mono">
|
|
139
|
-
{
|
|
144
|
+
{relayRtt !== undefined ? `${Math.round(relayRtt)}ms` : '—'}
|
|
140
145
|
</div>
|
|
141
146
|
</div>
|
|
142
147
|
</div>
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import { Pulse } from '@sansavision/pulse-sdk'
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
|
+
import { Pulse, PulseQueue, PulseConnection, ConnectionMetrics } from '@sansavision/pulse-sdk'
|
|
3
|
+
import type { QueueMessage } from '@sansavision/pulse-sdk'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* Pulse v0.4.
|
|
6
|
+
* Pulse v0.4.4 — Durable Queue Demo
|
|
6
7
|
*
|
|
7
8
|
* This template demonstrates the persistent message queue system with:
|
|
8
|
-
* - Publisher → Queue → Consumer flow
|
|
9
|
-
* - At-least-once delivery semantics
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
9
|
+
* - Publisher → Queue → Consumer flow via the typed PulseQueue API
|
|
10
|
+
* - At-least-once delivery semantics via pull + ack
|
|
11
|
+
* - Auto-queue creation on first publish
|
|
12
|
+
* - Persistent messages surviving page refresh (when using redis/wal/postgres)
|
|
13
|
+
* - Configurable message TTL (time-to-live)
|
|
12
14
|
*
|
|
13
15
|
* Server-side queue backends (configured via PULSE_QUEUE_BACKEND env var):
|
|
14
16
|
* - memory: In-memory (default, ephemeral)
|
|
@@ -17,66 +19,143 @@ import { Pulse } from '@sansavision/pulse-sdk'
|
|
|
17
19
|
* - redis: Redis (shared state / cache)
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
|
-
interface QueueMessage {
|
|
21
|
-
|
|
22
|
-
payload: string
|
|
23
|
-
timestamp: number
|
|
24
|
-
state: 'pending' | 'in_flight' | 'acked' | 'dead_lettered'
|
|
22
|
+
interface DisplayMessage extends QueueMessage {
|
|
23
|
+
state: 'pending' | 'acked'
|
|
25
24
|
}
|
|
26
25
|
|
|
26
|
+
const QUEUE_NAME = 'task-queue'
|
|
27
|
+
|
|
28
|
+
const TTL_OPTIONS = [
|
|
29
|
+
{ label: 'No TTL', value: undefined },
|
|
30
|
+
{ label: '30s', value: 30 },
|
|
31
|
+
{ label: '1 min', value: 60 },
|
|
32
|
+
{ label: '5 min', value: 300 },
|
|
33
|
+
{ label: '15 min', value: 900 },
|
|
34
|
+
{ label: '1 hour', value: 3600 },
|
|
35
|
+
{ label: '24 hours', value: 86400 },
|
|
36
|
+
]
|
|
37
|
+
|
|
27
38
|
export default function App() {
|
|
28
39
|
const [connected, setConnected] = useState(false)
|
|
29
|
-
const [messages, setMessages] = useState<
|
|
40
|
+
const [messages, setMessages] = useState<DisplayMessage[]>([])
|
|
30
41
|
const [input, setInput] = useState('')
|
|
31
42
|
const [rtt, setRtt] = useState<number | null>(null)
|
|
32
|
-
const [
|
|
43
|
+
const [queueDepth, setQueueDepth] = useState(0)
|
|
44
|
+
const [ttlSecs, setTtlSecs] = useState<number | undefined>(undefined)
|
|
45
|
+
const [customTtl, setCustomTtl] = useState('')
|
|
46
|
+
const [showCustomTtl, setShowCustomTtl] = useState(false)
|
|
47
|
+
const connRef = useRef<PulseConnection | null>(null)
|
|
48
|
+
const queueRef = useRef<PulseQueue | null>(null)
|
|
33
49
|
|
|
50
|
+
// Connect + auto-fetch existing messages on mount
|
|
34
51
|
useEffect(() => {
|
|
35
52
|
const pulse = new Pulse({ apiKey: 'dev' })
|
|
36
53
|
|
|
37
|
-
pulse.connect('ws://localhost:4001').then((connection) => {
|
|
38
|
-
|
|
54
|
+
pulse.connect('ws://localhost:4001').then(async (connection) => {
|
|
55
|
+
connRef.current = connection
|
|
56
|
+
const queue = new PulseQueue(connection, QUEUE_NAME, 'demo-ui')
|
|
57
|
+
queueRef.current = queue
|
|
39
58
|
setConnected(true)
|
|
40
59
|
|
|
41
|
-
// Subscribe to queue stream
|
|
42
|
-
const stream = connection.stream('task-queue', { qos: 'durable' })
|
|
43
|
-
stream.on('data', (bytes: Uint8Array) => {
|
|
44
|
-
const msg = JSON.parse(new TextDecoder().decode(bytes))
|
|
45
|
-
setMessages((prev) => [...prev, msg])
|
|
46
|
-
})
|
|
47
|
-
|
|
48
60
|
// Live metrics
|
|
49
|
-
connection.on('metrics', (m:
|
|
61
|
+
connection.on('metrics', (m: ConnectionMetrics) => setRtt(m.rtt))
|
|
62
|
+
|
|
63
|
+
// Drain any persisted messages from a previous session
|
|
64
|
+
const recovered = await queue.drain()
|
|
65
|
+
if (recovered.length > 0) {
|
|
66
|
+
setMessages(recovered.map((m) => ({ ...m, state: 'pending' as const })))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Refresh depth
|
|
70
|
+
await refreshDepth(queue)
|
|
71
|
+
}).catch((err: Error) => {
|
|
72
|
+
console.error('Failed to connect:', err)
|
|
50
73
|
})
|
|
51
74
|
|
|
52
75
|
return () => {
|
|
53
|
-
|
|
76
|
+
connRef.current?.disconnect()
|
|
54
77
|
}
|
|
55
78
|
}, [])
|
|
56
79
|
|
|
80
|
+
const refreshDepth = async (queue?: PulseQueue) => {
|
|
81
|
+
const q = queue || queueRef.current
|
|
82
|
+
if (!q) return
|
|
83
|
+
try {
|
|
84
|
+
const info = await q.info()
|
|
85
|
+
if (info?.depth !== undefined) {
|
|
86
|
+
setQueueDepth(info.depth)
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// queue may not exist yet
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Publish a message via PulseQueue
|
|
57
94
|
const publish = useCallback(async () => {
|
|
58
|
-
|
|
95
|
+
const queue = queueRef.current
|
|
96
|
+
if (!queue || !input.trim()) return
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const result = await queue.publish(input.trim(), {
|
|
100
|
+
ttlSecs: ttlSecs,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (result?.status === 'enqueued') {
|
|
104
|
+
// Immediately pull for display
|
|
105
|
+
const msg = await queue.pull()
|
|
106
|
+
|
|
107
|
+
if (msg) {
|
|
108
|
+
setMessages((prev) => [
|
|
109
|
+
...prev,
|
|
110
|
+
{ ...msg, state: 'pending' as const },
|
|
111
|
+
])
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setInput('')
|
|
116
|
+
await refreshDepth()
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('Enqueue failed:', err)
|
|
119
|
+
}
|
|
120
|
+
}, [input, ttlSecs])
|
|
59
121
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
122
|
+
// ACK a message by sequence number
|
|
123
|
+
const ackMessage = useCallback(async (sequence: number) => {
|
|
124
|
+
const queue = queueRef.current
|
|
125
|
+
if (!queue) return
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const result = await queue.ack(sequence)
|
|
129
|
+
|
|
130
|
+
if (result?.status === 'acked') {
|
|
131
|
+
setMessages((prev) =>
|
|
132
|
+
prev.map((m) =>
|
|
133
|
+
m.sequence === sequence ? { ...m, state: 'acked' as const } : m
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
await refreshDepth()
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error('Ack failed:', err)
|
|
65
140
|
}
|
|
141
|
+
}, [])
|
|
66
142
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
143
|
+
const handleTtlChange = (value: string) => {
|
|
144
|
+
if (value === 'custom') {
|
|
145
|
+
setShowCustomTtl(true)
|
|
146
|
+
} else {
|
|
147
|
+
setShowCustomTtl(false)
|
|
148
|
+
setTtlSecs(value === '' ? undefined : parseInt(value, 10))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
72
151
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
152
|
+
const applyCustomTtl = () => {
|
|
153
|
+
const val = parseInt(customTtl, 10)
|
|
154
|
+
if (!isNaN(val) && val > 0) {
|
|
155
|
+
setTtlSecs(val)
|
|
156
|
+
setShowCustomTtl(false)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
80
159
|
|
|
81
160
|
return (
|
|
82
161
|
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', maxWidth: 720, margin: '0 auto', padding: 32 }}>
|
|
@@ -89,14 +168,18 @@ export default function App() {
|
|
|
89
168
|
<span style={{ color: '#f87171' }}> ○ Connecting...</span>
|
|
90
169
|
)}
|
|
91
170
|
{rtt !== null && <span style={{ marginLeft: 12 }}>RTT: {rtt}ms</span>}
|
|
171
|
+
<span style={{ marginLeft: 12, color: '#60a5fa' }}>
|
|
172
|
+
Depth: {queueDepth}
|
|
173
|
+
</span>
|
|
92
174
|
</p>
|
|
93
175
|
|
|
94
|
-
|
|
176
|
+
{/* Message input + TTL controls */}
|
|
177
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
95
178
|
<input
|
|
96
179
|
value={input}
|
|
97
180
|
onChange={(e) => setInput(e.target.value)}
|
|
98
181
|
onKeyDown={(e) => e.key === 'Enter' && publish()}
|
|
99
|
-
placeholder="Enter a
|
|
182
|
+
placeholder="Enter a message..."
|
|
100
183
|
style={{
|
|
101
184
|
flex: 1, padding: '10px 16px', borderRadius: 8,
|
|
102
185
|
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 14
|
|
@@ -114,23 +197,74 @@ export default function App() {
|
|
|
114
197
|
</button>
|
|
115
198
|
</div>
|
|
116
199
|
|
|
200
|
+
{/* TTL Selector */}
|
|
201
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 24, alignItems: 'center' }}>
|
|
202
|
+
<label style={{ fontSize: 13, color: '#9ca3af', whiteSpace: 'nowrap' }}>⏱ TTL:</label>
|
|
203
|
+
<select
|
|
204
|
+
value={showCustomTtl ? 'custom' : (ttlSecs === undefined ? '' : String(ttlSecs))}
|
|
205
|
+
onChange={(e) => handleTtlChange(e.target.value)}
|
|
206
|
+
style={{
|
|
207
|
+
padding: '6px 12px', borderRadius: 6,
|
|
208
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff',
|
|
209
|
+
fontSize: 13, cursor: 'pointer', minWidth: 100,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{TTL_OPTIONS.map((opt) => (
|
|
213
|
+
<option key={opt.label} value={opt.value === undefined ? '' : String(opt.value)}>
|
|
214
|
+
{opt.label}
|
|
215
|
+
</option>
|
|
216
|
+
))}
|
|
217
|
+
<option value="custom">Custom...</option>
|
|
218
|
+
</select>
|
|
219
|
+
{showCustomTtl && (
|
|
220
|
+
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
221
|
+
<input
|
|
222
|
+
type="number"
|
|
223
|
+
min="1"
|
|
224
|
+
value={customTtl}
|
|
225
|
+
onChange={(e) => setCustomTtl(e.target.value)}
|
|
226
|
+
onKeyDown={(e) => e.key === 'Enter' && applyCustomTtl()}
|
|
227
|
+
placeholder="seconds"
|
|
228
|
+
style={{
|
|
229
|
+
width: 80, padding: '6px 10px', borderRadius: 6,
|
|
230
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 13
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
<button
|
|
234
|
+
onClick={applyCustomTtl}
|
|
235
|
+
style={{
|
|
236
|
+
padding: '6px 10px', borderRadius: 6, border: 'none',
|
|
237
|
+
background: '#3b82f6', color: '#fff', fontSize: 12, cursor: 'pointer'
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
Set
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
{ttlSecs !== undefined && !showCustomTtl && (
|
|
245
|
+
<span style={{ fontSize: 12, color: '#60a5fa' }}>
|
|
246
|
+
Messages expire after {ttlSecs}s
|
|
247
|
+
</span>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
117
251
|
<h2>Messages ({messages.length})</h2>
|
|
118
252
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
119
253
|
{messages.map((msg) => (
|
|
120
254
|
<div
|
|
121
|
-
key={msg.
|
|
255
|
+
key={msg.sequence}
|
|
122
256
|
style={{
|
|
123
257
|
padding: 16, borderRadius: 8,
|
|
124
|
-
background: msg.state === 'acked' ? '#064e3b' :
|
|
258
|
+
background: msg.state === 'acked' ? '#064e3b' : '#1e1b4b',
|
|
125
259
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
|
126
260
|
}}
|
|
127
261
|
>
|
|
128
262
|
<div>
|
|
129
263
|
<strong>{msg.payload}</strong>
|
|
130
264
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
131
|
-
{msg.
|
|
265
|
+
seq:{msg.sequence} · deliveries:{msg.delivery_count} ·{' '}
|
|
132
266
|
<span style={{
|
|
133
|
-
color: msg.state === 'acked' ? '#4ade80' :
|
|
267
|
+
color: msg.state === 'acked' ? '#4ade80' : '#facc15'
|
|
134
268
|
}}>
|
|
135
269
|
{msg.state}
|
|
136
270
|
</span>
|
|
@@ -138,7 +272,7 @@ export default function App() {
|
|
|
138
272
|
</div>
|
|
139
273
|
{msg.state === 'pending' && (
|
|
140
274
|
<button
|
|
141
|
-
onClick={() => ackMessage(msg.
|
|
275
|
+
onClick={() => ackMessage(msg.sequence)}
|
|
142
276
|
style={{
|
|
143
277
|
padding: '6px 12px', borderRadius: 6, border: 'none',
|
|
144
278
|
background: '#22c55e', color: '#000', fontWeight: 600, cursor: 'pointer'
|
|
@@ -152,19 +286,31 @@ export default function App() {
|
|
|
152
286
|
</div>
|
|
153
287
|
|
|
154
288
|
<div style={{ marginTop: 32, padding: 16, borderRadius: 8, background: '#111827', fontSize: 13, color: '#9ca3af' }}>
|
|
155
|
-
<strong style={{ color: '#e5e7eb' }}>
|
|
289
|
+
<strong style={{ color: '#e5e7eb' }}>How it works</strong>
|
|
290
|
+
<ul style={{ marginTop: 8, paddingLeft: 20, lineHeight: 1.8 }}>
|
|
291
|
+
<li><strong>Publish</strong> → <code>queue.publish(payload, {'{ ttlSecs? }'})</code></li>
|
|
292
|
+
<li><strong>Consume</strong> → <code>queue.pull()</code></li>
|
|
293
|
+
<li><strong>ACK</strong> → <code>queue.ack(sequence)</code></li>
|
|
294
|
+
<li><strong>Recover</strong> → <code>queue.drain()</code> — pulls all pending messages</li>
|
|
295
|
+
<li>Messages persist in the configured backend (redis/wal/postgres)</li>
|
|
296
|
+
<li>Set <strong>TTL</strong> to auto-expire messages after a duration</li>
|
|
297
|
+
<li>Refresh the page — persisted messages are recovered via <code>drain()</code></li>
|
|
298
|
+
</ul>
|
|
156
299
|
<pre style={{ marginTop: 8 }}>
|
|
157
|
-
{
|
|
158
|
-
|
|
300
|
+
{`import { Pulse, PulseQueue } from '@sansavision/pulse-sdk'
|
|
301
|
+
|
|
302
|
+
const conn = await new Pulse({ apiKey: 'dev' }).connect('ws://localhost:4001')
|
|
303
|
+
const queue = new PulseQueue(conn, 'task-queue')
|
|
159
304
|
|
|
160
|
-
|
|
161
|
-
|
|
305
|
+
// Publish with TTL
|
|
306
|
+
await queue.publish('hello world', { ttlSecs: 60 })
|
|
162
307
|
|
|
163
|
-
|
|
164
|
-
|
|
308
|
+
// Pull + Ack
|
|
309
|
+
const msg = await queue.pull()
|
|
310
|
+
if (msg) await queue.ack(msg.sequence)
|
|
165
311
|
|
|
166
|
-
|
|
167
|
-
|
|
312
|
+
// Drain all pending (useful after reconnect)
|
|
313
|
+
const recovered = await queue.drain()`}
|
|
168
314
|
</pre>
|
|
169
315
|
</div>
|
|
170
316
|
</div>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@sansavision/pulse-sdk": "0.
|
|
12
|
+
"@sansavision/pulse-sdk": "^0.4.1",
|
|
13
13
|
"react": "^18.3.1",
|
|
14
14
|
"react-dom": "^18.3.1",
|
|
15
15
|
"lucide-react": "^0.412.0"
|
|
@@ -24,4 +24,4 @@
|
|
|
24
24
|
"typescript": "^5.5.3",
|
|
25
25
|
"vite": "^5.3.4"
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
}
|
|
@@ -38,7 +38,7 @@ export default function App() {
|
|
|
38
38
|
}]);
|
|
39
39
|
}, []);
|
|
40
40
|
|
|
41
|
-
const handleSyncMessage = useCallback((msg:
|
|
41
|
+
const handleSyncMessage = useCallback((msg: { action: string; time?: number; ts?: number; quality?: string }) => {
|
|
42
42
|
// Calculate delivery delay if timestamp present
|
|
43
43
|
if (msg.ts) {
|
|
44
44
|
const delay = Date.now() - msg.ts;
|
|
@@ -65,7 +65,7 @@ export default function App() {
|
|
|
65
65
|
break;
|
|
66
66
|
case 'quality':
|
|
67
67
|
if (msg.quality) {
|
|
68
|
-
const q = QUALITY_OPTIONS.find((o
|
|
68
|
+
const q = QUALITY_OPTIONS.find((o) => o.value === msg.quality);
|
|
69
69
|
if (q) setSelectedQuality(q);
|
|
70
70
|
}
|
|
71
71
|
break;
|
|
@@ -81,36 +81,19 @@ export default function App() {
|
|
|
81
81
|
const msg = { action, time, ts: sendTs };
|
|
82
82
|
broadcast(msg);
|
|
83
83
|
|
|
84
|
-
// Single-tab simulation:
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
setIsPlaying(true);
|
|
98
|
-
setPeerTime(time);
|
|
99
|
-
} else if (action === 'pause') {
|
|
100
|
-
setIsPlaying(false);
|
|
101
|
-
setPeerTime(time);
|
|
102
|
-
} else if (action === 'seek') {
|
|
103
|
-
setPeerTime(time);
|
|
104
|
-
}
|
|
105
|
-
}, simulatedDelay);
|
|
106
|
-
|
|
107
|
-
// Measure delay after the event loop processes the send (captures
|
|
108
|
-
// real serialization + WebSocket write time, not synchronous 0).
|
|
109
|
-
setTimeout(() => {
|
|
110
|
-
const delay = Date.now() - sendTs;
|
|
111
|
-
setLastDelay(delay);
|
|
112
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
113
|
-
}, 0);
|
|
84
|
+
// Single-tab simulation: update peerTime immediately (in a real
|
|
85
|
+
// multi-tab setup the relay echo handler updates peerTime).
|
|
86
|
+
if (action === 'tick') {
|
|
87
|
+
setPeerTime(time);
|
|
88
|
+
} else if (action === 'play') {
|
|
89
|
+
setIsPlaying(true);
|
|
90
|
+
setPeerTime(time);
|
|
91
|
+
} else if (action === 'pause') {
|
|
92
|
+
setIsPlaying(false);
|
|
93
|
+
setPeerTime(time);
|
|
94
|
+
} else if (action === 'seek') {
|
|
95
|
+
setPeerTime(time);
|
|
96
|
+
}
|
|
114
97
|
}, [broadcast, logEvent]);
|
|
115
98
|
|
|
116
99
|
const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
|
|
@@ -120,11 +103,6 @@ export default function App() {
|
|
|
120
103
|
broadcast({ action: 'quality', quality: quality.value, ts: Date.now() });
|
|
121
104
|
}, [broadcast, logEvent]);
|
|
122
105
|
|
|
123
|
-
// Compute average delay
|
|
124
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
125
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
126
|
-
: null;
|
|
127
|
-
|
|
128
106
|
const drift = Math.abs(hostTime - peerTime);
|
|
129
107
|
const driftColor = drift > 0.15 ? 'text-brandAmber' : 'text-brandEmerald';
|
|
130
108
|
|
|
@@ -193,7 +171,7 @@ export default function App() {
|
|
|
193
171
|
</button>
|
|
194
172
|
{showQualityMenu && (
|
|
195
173
|
<div className="absolute right-0 top-full mt-1 z-50 glass-panel rounded-xl overflow-hidden shadow-2xl border border-pulseBorder min-w-[120px]">
|
|
196
|
-
{QUALITY_OPTIONS.map((q
|
|
174
|
+
{QUALITY_OPTIONS.map((q) => (
|
|
197
175
|
<button
|
|
198
176
|
key={q.value}
|
|
199
177
|
onClick={() => handleQualityChange(q)}
|
|
@@ -262,14 +240,14 @@ export default function App() {
|
|
|
262
240
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
263
241
|
<span className="text-textMuted">Relay RTT</span>
|
|
264
242
|
<span className="text-textMain font-semibold tracking-wider">
|
|
265
|
-
{metrics ? `${Math.round(metrics.rtt)}ms` :
|
|
243
|
+
{metrics ? `${Math.round(metrics.rtt)}ms` : '—'}
|
|
266
244
|
</span>
|
|
267
245
|
</div>
|
|
268
246
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
269
247
|
<span className="text-textMuted">Msg Delay</span>
|
|
270
248
|
<span className={`font-semibold tracking-wider ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
271
249
|
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
272
|
-
|
|
250
|
+
<span className="text-textDim text-[10px] ml-1">(relay echo)</span>
|
|
273
251
|
</span>
|
|
274
252
|
</div>
|
|
275
253
|
</div>
|