@sansavision/create-pulse 0.1.0-alpha.9 → 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/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/templates/react-all-features/package.json +25 -25
- package/templates/react-all-features/src/App.tsx +9 -18
- package/templates/react-all-features/src/components/EncryptedChat.tsx +8 -8
- package/templates/react-all-features/src/components/GameSync.tsx +39 -21
- package/templates/react-all-features/src/components/ServerMetrics.tsx +20 -13
- package/templates/react-queue-demo/README.md +44 -0
- package/templates/react-queue-demo/index.html +15 -0
- package/templates/react-queue-demo/package.json +22 -0
- package/templates/react-queue-demo/src/App.tsx +318 -0
- package/templates/react-queue-demo/src/main.tsx +4 -0
- package/templates/react-queue-demo/tsconfig.json +16 -0
- package/templates/react-queue-demo/vite.config.ts +6 -0
- package/templates/react-watch-together/package.json +25 -25
- package/templates/react-watch-together/src/App.tsx +7 -18
package/dist/index.js
CHANGED
|
@@ -53,6 +53,7 @@ async function main() {
|
|
|
53
53
|
options: [
|
|
54
54
|
{ value: "react-watch-together", label: "Watch Together (React + TS)", hint: "Synchronized video playback" },
|
|
55
55
|
{ value: "react-all-features", label: "All Features (React + TS)", hint: "Chat, Video, Audio, RPC" },
|
|
56
|
+
{ value: "react-queue-demo", label: "Durable Queues (React + TS)", hint: "Persistent queues with WAL/Postgres/Redis" },
|
|
56
57
|
{ value: "vanilla-basic", label: "Vanilla JS (Basic)", hint: "Minimal setup" }
|
|
57
58
|
]
|
|
58
59
|
});
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ async function main() {
|
|
|
35
35
|
options: [
|
|
36
36
|
{ value: 'react-watch-together', label: 'Watch Together (React + TS)', hint: 'Synchronized video playback' },
|
|
37
37
|
{ value: 'react-all-features', label: 'All Features (React + TS)', hint: 'Chat, Video, Audio, RPC' },
|
|
38
|
+
{ value: 'react-queue-demo', label: 'Durable Queues (React + TS)', hint: 'Persistent queues with WAL/Postgres/Redis' },
|
|
38
39
|
{ value: 'vanilla-basic', label: 'Vanilla JS (Basic)', hint: 'Minimal setup' }
|
|
39
40
|
]
|
|
40
41
|
});
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"name": "pulse-react-all-features",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@sansavision/pulse-sdk": "^0.4.1",
|
|
13
|
+
"react": "^18.3.1",
|
|
14
|
+
"react-dom": "^18.3.1",
|
|
15
|
+
"lucide-react": "^0.412.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.3",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
21
|
+
"autoprefixer": "^10.4.19",
|
|
22
|
+
"postcss": "^8.4.39",
|
|
23
|
+
"tailwindcss": "^3.4.6",
|
|
24
|
+
"typescript": "^5.5.3",
|
|
25
|
+
"vite": "^5.3.4"
|
|
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,9 +73,8 @@ export default function App() {
|
|
|
73
73
|
const msg = { action, time, ts: sendTs };
|
|
74
74
|
broadcast(msg);
|
|
75
75
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// Also record the loopback delay so telemetry metrics are visible.
|
|
76
|
+
// Single-tab simulation: update peerTime immediately (in a real
|
|
77
|
+
// multi-tab setup the relay echo handler updates peerTime).
|
|
79
78
|
if (action === 'tick') {
|
|
80
79
|
setPeerTime(time);
|
|
81
80
|
} else if (action === 'play') {
|
|
@@ -87,17 +86,9 @@ export default function App() {
|
|
|
87
86
|
} else if (action === 'seek') {
|
|
88
87
|
setPeerTime(time);
|
|
89
88
|
}
|
|
90
|
-
|
|
91
|
-
// Record loopback delay (measures local processing overhead)
|
|
92
|
-
const loopbackDelay = Date.now() - sendTs;
|
|
93
|
-
setLastDelay(loopbackDelay);
|
|
94
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
95
89
|
}, [broadcast, logEvent]);
|
|
96
90
|
|
|
97
|
-
|
|
98
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
99
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
100
|
-
: null;
|
|
91
|
+
|
|
101
92
|
|
|
102
93
|
return (
|
|
103
94
|
<div className="min-h-screen">
|
|
@@ -305,14 +296,14 @@ export default function App() {
|
|
|
305
296
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
306
297
|
<span className="text-textMuted">Relay RTT</span>
|
|
307
298
|
<span className="text-textMain font-semibold tracking-wider">
|
|
308
|
-
{metrics ? `${Math.round(metrics.rtt)}ms` :
|
|
299
|
+
{metrics ? `${Math.round(metrics.rtt)}ms` : '—'}
|
|
309
300
|
</span>
|
|
310
301
|
</div>
|
|
311
302
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
312
303
|
<span className="text-textMuted">Msg Delay</span>
|
|
313
304
|
<span className={`font-semibold tracking-wider ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
314
305
|
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
315
|
-
|
|
306
|
+
<span className="text-textDim text-[10px] ml-1">(relay echo)</span>
|
|
316
307
|
</span>
|
|
317
308
|
</div>
|
|
318
309
|
</div>
|
|
@@ -354,7 +345,7 @@ export default function App() {
|
|
|
354
345
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Encrypted Chat</h2>
|
|
355
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>
|
|
356
347
|
|
|
357
|
-
<EncryptedChat useChannel={useChannel} logEvent={logEvent} />
|
|
348
|
+
<EncryptedChat useChannel={useChannel} logEvent={logEvent} relayRtt={metrics?.rtt} />
|
|
358
349
|
</div>
|
|
359
350
|
)}
|
|
360
351
|
|
|
@@ -369,7 +360,7 @@ export default function App() {
|
|
|
369
360
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Server Metrics Dashboard</h2>
|
|
370
361
|
<div className="text-textMuted mb-6 text-sm">Receiving live server telemetry via Pulse STREAM_DATA and rendering realtime charts.</div>
|
|
371
362
|
|
|
372
|
-
<ServerMetrics useChannel={useChannel} logEvent={logEvent} />
|
|
363
|
+
<ServerMetrics useChannel={useChannel} logEvent={logEvent} relayRtt={metrics?.rtt} />
|
|
373
364
|
</div>
|
|
374
365
|
)}
|
|
375
366
|
|
|
@@ -384,7 +375,7 @@ export default function App() {
|
|
|
384
375
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Game State Sync</h2>
|
|
385
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>
|
|
386
377
|
|
|
387
|
-
<GameSync useChannel={useChannel} logEvent={logEvent} />
|
|
378
|
+
<GameSync useChannel={useChannel} logEvent={logEvent} relayRtt={metrics?.rtt} />
|
|
388
379
|
</div>
|
|
389
380
|
)}
|
|
390
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;
|
|
@@ -207,10 +228,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
207
228
|
// Send to peers
|
|
208
229
|
broadcast(payload);
|
|
209
230
|
|
|
210
|
-
//
|
|
211
|
-
const loopbackDelay = Date.now() - sendTs;
|
|
212
|
-
setLastDelay(loopbackDelay);
|
|
213
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
231
|
+
// Delay is measured in handleSyncMessage when the relay echoes back.
|
|
214
232
|
|
|
215
233
|
// Interpolate fallback for when broadcast doesn't echo locally fast enough
|
|
216
234
|
const rv = remoteViewRef.current;
|
|
@@ -283,7 +301,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
283
301
|
</div>
|
|
284
302
|
<div className="glass-panel p-4 text-center">
|
|
285
303
|
<div className="text-2xl font-bold font-mono text-brandEmerald">
|
|
286
|
-
{
|
|
304
|
+
{relayRtt !== undefined ? `${Math.round(relayRtt)}ms` : '—'}
|
|
287
305
|
</div>
|
|
288
306
|
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Relay RTT</div>
|
|
289
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,19 +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
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
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.
|
|
108
119
|
}, 1000);
|
|
109
120
|
|
|
110
121
|
return () => clearInterval(interval);
|
|
111
122
|
}, [broadcast]);
|
|
112
123
|
|
|
113
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
114
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
115
|
-
: null;
|
|
116
|
-
|
|
117
124
|
return (
|
|
118
125
|
<div className="space-y-6 max-w-5xl mx-auto">
|
|
119
126
|
<div className="grid grid-cols-4 gap-4">
|
|
@@ -134,7 +141,7 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
134
141
|
<div className="glass-panel p-6 text-center">
|
|
135
142
|
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Relay RTT</div>
|
|
136
143
|
<div className="text-3xl font-bold text-brandEmerald font-mono">
|
|
137
|
-
{
|
|
144
|
+
{relayRtt !== undefined ? `${Math.round(relayRtt)}ms` : '—'}
|
|
138
145
|
</div>
|
|
139
146
|
</div>
|
|
140
147
|
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Pulse Queue Demo
|
|
2
|
+
|
|
3
|
+
A demo app showcasing **Pulse v0.4.0** durable message queues.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Publish → Queue → Consumer flow over PLP
|
|
8
|
+
- At-least-once delivery with ACK/NACK
|
|
9
|
+
- Dead-letter queue for failed messages
|
|
10
|
+
- Real-time RTT metrics
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm run dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
In a separate terminal, start Pulse with your preferred queue backend:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# In-memory (default, ephemeral)
|
|
23
|
+
pulse dev
|
|
24
|
+
|
|
25
|
+
# WAL (Write-Ahead Log — crash-resilient)
|
|
26
|
+
PULSE_QUEUE_BACKEND=wal pulse dev
|
|
27
|
+
|
|
28
|
+
# PostgreSQL
|
|
29
|
+
PULSE_QUEUE_BACKEND=postgres PULSE_QUEUE_POSTGRES_URL=postgres://user:pass@localhost/pulse pulse dev
|
|
30
|
+
|
|
31
|
+
# Redis
|
|
32
|
+
PULSE_QUEUE_BACKEND=redis PULSE_QUEUE_REDIS_URL=redis://localhost:6379 pulse dev
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Encryption at Rest
|
|
36
|
+
|
|
37
|
+
Add encryption to any backend:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export PULSE_QUEUE_KEY=$(openssl rand -hex 32)
|
|
41
|
+
PULSE_QUEUE_BACKEND=wal pulse dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
All payloads are encrypted with ChaCha20-Poly1305 before reaching storage.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Pulse Queue Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin: 0; background: #0f0f23; color: #e5e7eb; }
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pulse-queue-demo",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "tsc && vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sansavision/pulse-sdk": "^0.4.1",
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^19.0.0",
|
|
17
|
+
"@types/react-dom": "^19.0.0",
|
|
18
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
19
|
+
"typescript": "^5.7.0",
|
|
20
|
+
"vite": "^6.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
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'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pulse v0.4.4 — Durable Queue Demo
|
|
7
|
+
*
|
|
8
|
+
* This template demonstrates the persistent message queue system with:
|
|
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)
|
|
14
|
+
*
|
|
15
|
+
* Server-side queue backends (configured via PULSE_QUEUE_BACKEND env var):
|
|
16
|
+
* - memory: In-memory (default, ephemeral)
|
|
17
|
+
* - wal: Write-Ahead Log (crash-resilient, local filesystem)
|
|
18
|
+
* - postgres: PostgreSQL (cloud/HA)
|
|
19
|
+
* - redis: Redis (shared state / cache)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface DisplayMessage extends QueueMessage {
|
|
23
|
+
state: 'pending' | 'acked'
|
|
24
|
+
}
|
|
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
|
+
|
|
38
|
+
export default function App() {
|
|
39
|
+
const [connected, setConnected] = useState(false)
|
|
40
|
+
const [messages, setMessages] = useState<DisplayMessage[]>([])
|
|
41
|
+
const [input, setInput] = useState('')
|
|
42
|
+
const [rtt, setRtt] = useState<number | null>(null)
|
|
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)
|
|
49
|
+
|
|
50
|
+
// Connect + auto-fetch existing messages on mount
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const pulse = new Pulse({ apiKey: 'dev' })
|
|
53
|
+
|
|
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
|
|
58
|
+
setConnected(true)
|
|
59
|
+
|
|
60
|
+
// Live metrics
|
|
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)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
connRef.current?.disconnect()
|
|
77
|
+
}
|
|
78
|
+
}, [])
|
|
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
|
|
94
|
+
const publish = useCallback(async () => {
|
|
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])
|
|
121
|
+
|
|
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)
|
|
140
|
+
}
|
|
141
|
+
}, [])
|
|
142
|
+
|
|
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
|
+
}
|
|
151
|
+
|
|
152
|
+
const applyCustomTtl = () => {
|
|
153
|
+
const val = parseInt(customTtl, 10)
|
|
154
|
+
if (!isNaN(val) && val > 0) {
|
|
155
|
+
setTtlSecs(val)
|
|
156
|
+
setShowCustomTtl(false)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', maxWidth: 720, margin: '0 auto', padding: 32 }}>
|
|
162
|
+
<h1>🔄 Pulse Queue Demo</h1>
|
|
163
|
+
<p style={{ color: '#888' }}>
|
|
164
|
+
Durable message queues with at-least-once delivery over PLP.
|
|
165
|
+
{connected ? (
|
|
166
|
+
<span style={{ color: '#4ade80' }}> ● Connected</span>
|
|
167
|
+
) : (
|
|
168
|
+
<span style={{ color: '#f87171' }}> ○ Connecting...</span>
|
|
169
|
+
)}
|
|
170
|
+
{rtt !== null && <span style={{ marginLeft: 12 }}>RTT: {rtt}ms</span>}
|
|
171
|
+
<span style={{ marginLeft: 12, color: '#60a5fa' }}>
|
|
172
|
+
Depth: {queueDepth}
|
|
173
|
+
</span>
|
|
174
|
+
</p>
|
|
175
|
+
|
|
176
|
+
{/* Message input + TTL controls */}
|
|
177
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
178
|
+
<input
|
|
179
|
+
value={input}
|
|
180
|
+
onChange={(e) => setInput(e.target.value)}
|
|
181
|
+
onKeyDown={(e) => e.key === 'Enter' && publish()}
|
|
182
|
+
placeholder="Enter a message..."
|
|
183
|
+
style={{
|
|
184
|
+
flex: 1, padding: '10px 16px', borderRadius: 8,
|
|
185
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 14
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
<button
|
|
189
|
+
onClick={publish}
|
|
190
|
+
disabled={!connected}
|
|
191
|
+
style={{
|
|
192
|
+
padding: '10px 20px', borderRadius: 8, border: 'none',
|
|
193
|
+
background: '#7c3aed', color: '#fff', fontWeight: 600, cursor: 'pointer'
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
Publish
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
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
|
+
|
|
251
|
+
<h2>Messages ({messages.length})</h2>
|
|
252
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
253
|
+
{messages.map((msg) => (
|
|
254
|
+
<div
|
|
255
|
+
key={msg.sequence}
|
|
256
|
+
style={{
|
|
257
|
+
padding: 16, borderRadius: 8,
|
|
258
|
+
background: msg.state === 'acked' ? '#064e3b' : '#1e1b4b',
|
|
259
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<div>
|
|
263
|
+
<strong>{msg.payload}</strong>
|
|
264
|
+
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
265
|
+
seq:{msg.sequence} · deliveries:{msg.delivery_count} ·{' '}
|
|
266
|
+
<span style={{
|
|
267
|
+
color: msg.state === 'acked' ? '#4ade80' : '#facc15'
|
|
268
|
+
}}>
|
|
269
|
+
{msg.state}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
{msg.state === 'pending' && (
|
|
274
|
+
<button
|
|
275
|
+
onClick={() => ackMessage(msg.sequence)}
|
|
276
|
+
style={{
|
|
277
|
+
padding: '6px 12px', borderRadius: 6, border: 'none',
|
|
278
|
+
background: '#22c55e', color: '#000', fontWeight: 600, cursor: 'pointer'
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
ACK
|
|
282
|
+
</button>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
))}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div style={{ marginTop: 32, padding: 16, borderRadius: 8, background: '#111827', fontSize: 13, color: '#9ca3af' }}>
|
|
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>
|
|
299
|
+
<pre style={{ marginTop: 8 }}>
|
|
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')
|
|
304
|
+
|
|
305
|
+
// Publish with TTL
|
|
306
|
+
await queue.publish('hello world', { ttlSecs: 60 })
|
|
307
|
+
|
|
308
|
+
// Pull + Ack
|
|
309
|
+
const msg = await queue.pull()
|
|
310
|
+
if (msg) await queue.ack(msg.sequence)
|
|
311
|
+
|
|
312
|
+
// Drain all pending (useful after reconnect)
|
|
313
|
+
const recovered = await queue.drain()`}
|
|
314
|
+
</pre>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"strict": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"name": "pulse-react-watch-together",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.4.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@sansavision/pulse-sdk": "^0.4.1",
|
|
13
|
+
"react": "^18.3.1",
|
|
14
|
+
"react-dom": "^18.3.1",
|
|
15
|
+
"lucide-react": "^0.412.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/react": "^18.3.3",
|
|
19
|
+
"@types/react-dom": "^18.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
21
|
+
"autoprefixer": "^10.4.19",
|
|
22
|
+
"postcss": "^8.4.39",
|
|
23
|
+
"tailwindcss": "^3.4.6",
|
|
24
|
+
"typescript": "^5.5.3",
|
|
25
|
+
"vite": "^5.3.4"
|
|
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,9 +81,8 @@ export default function App() {
|
|
|
81
81
|
const msg = { action, time, ts: sendTs };
|
|
82
82
|
broadcast(msg);
|
|
83
83
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// For a real multi-user scenario, the relay delivers to other connections.
|
|
84
|
+
// Single-tab simulation: update peerTime immediately (in a real
|
|
85
|
+
// multi-tab setup the relay echo handler updates peerTime).
|
|
87
86
|
if (action === 'tick') {
|
|
88
87
|
setPeerTime(time);
|
|
89
88
|
} else if (action === 'play') {
|
|
@@ -95,11 +94,6 @@ export default function App() {
|
|
|
95
94
|
} else if (action === 'seek') {
|
|
96
95
|
setPeerTime(time);
|
|
97
96
|
}
|
|
98
|
-
|
|
99
|
-
// Record loopback delay so telemetry metrics are visible
|
|
100
|
-
const loopbackDelay = Date.now() - sendTs;
|
|
101
|
-
setLastDelay(loopbackDelay);
|
|
102
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
|
|
103
97
|
}, [broadcast, logEvent]);
|
|
104
98
|
|
|
105
99
|
const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
|
|
@@ -109,11 +103,6 @@ export default function App() {
|
|
|
109
103
|
broadcast({ action: 'quality', quality: quality.value, ts: Date.now() });
|
|
110
104
|
}, [broadcast, logEvent]);
|
|
111
105
|
|
|
112
|
-
// Compute average delay
|
|
113
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
114
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
115
|
-
: null;
|
|
116
|
-
|
|
117
106
|
const drift = Math.abs(hostTime - peerTime);
|
|
118
107
|
const driftColor = drift > 0.15 ? 'text-brandAmber' : 'text-brandEmerald';
|
|
119
108
|
|
|
@@ -182,7 +171,7 @@ export default function App() {
|
|
|
182
171
|
</button>
|
|
183
172
|
{showQualityMenu && (
|
|
184
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]">
|
|
185
|
-
{QUALITY_OPTIONS.map((q
|
|
174
|
+
{QUALITY_OPTIONS.map((q) => (
|
|
186
175
|
<button
|
|
187
176
|
key={q.value}
|
|
188
177
|
onClick={() => handleQualityChange(q)}
|
|
@@ -251,14 +240,14 @@ export default function App() {
|
|
|
251
240
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
252
241
|
<span className="text-textMuted">Relay RTT</span>
|
|
253
242
|
<span className="text-textMain font-semibold tracking-wider">
|
|
254
|
-
{metrics ? `${Math.round(metrics.rtt)}ms` :
|
|
243
|
+
{metrics ? `${Math.round(metrics.rtt)}ms` : '—'}
|
|
255
244
|
</span>
|
|
256
245
|
</div>
|
|
257
246
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
258
247
|
<span className="text-textMuted">Msg Delay</span>
|
|
259
248
|
<span className={`font-semibold tracking-wider ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
260
249
|
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
261
|
-
|
|
250
|
+
<span className="text-textDim text-[10px] ml-1">(relay echo)</span>
|
|
262
251
|
</span>
|
|
263
252
|
</div>
|
|
264
253
|
</div>
|