@sansavision/create-pulse 0.1.0-alpha.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/templates/react-all-features/package.json +26 -26
- package/templates/react-all-features/src/App.tsx +29 -19
- package/templates/react-all-features/src/components/GameSync.tsx +7 -4
- package/templates/react-all-features/src/components/ServerMetrics.tsx +6 -4
- 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 +172 -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 +26 -26
- package/templates/react-watch-together/src/App.tsx +29 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansavision/create-pulse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
}
|
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
|
-
|
|
27
|
-
}
|
|
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.3.0",
|
|
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
|
+
}
|
|
@@ -73,25 +73,35 @@ export default function App() {
|
|
|
73
73
|
const msg = { action, time, ts: sendTs };
|
|
74
74
|
broadcast(msg);
|
|
75
75
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
76
|
+
// Single-tab simulation: defer peerTime update by the measured relay
|
|
77
|
+
// round-trip so that drift is visible and realistic. In a real
|
|
78
|
+
// multi-tab setup the relay echo handler (handleSyncMessage) updates
|
|
79
|
+
// peerTime — but since the relay does not echo back to the sender,
|
|
80
|
+
// we simulate it here with the actual measured delay.
|
|
81
|
+
const simulatedDelay = delayHistoryRef.current.length > 0
|
|
82
|
+
? delayHistoryRef.current[delayHistoryRef.current.length - 1]
|
|
83
|
+
: 4; // sensible default ~4ms first message
|
|
84
|
+
|
|
85
|
+
setTimeout(() => {
|
|
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
|
+
}
|
|
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);
|
|
95
105
|
}, [broadcast, logEvent]);
|
|
96
106
|
|
|
97
107
|
// Computed delay stats
|
|
@@ -207,10 +207,13 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
|
207
207
|
// Send to peers
|
|
208
208
|
broadcast(payload);
|
|
209
209
|
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
210
|
+
// 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);
|
|
214
217
|
|
|
215
218
|
// Interpolate fallback for when broadcast doesn't echo locally fast enough
|
|
216
219
|
const rv = remoteViewRef.current;
|
|
@@ -101,10 +101,12 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
|
101
101
|
// Normally the server does this, here we do it so the demo "just works" locally
|
|
102
102
|
broadcast(fakeMetrics);
|
|
103
103
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Measure delay via deferred setTimeout for single-tab demos
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
const delay = Date.now() - sendTs;
|
|
107
|
+
setLastDelay(delay);
|
|
108
|
+
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
109
|
+
}, 0);
|
|
108
110
|
}, 1000);
|
|
109
111
|
|
|
110
112
|
return () => clearInterval(interval);
|
|
@@ -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.0",
|
|
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,172 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Pulse } from '@sansavision/pulse-sdk'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pulse v0.4.0 — Durable Queue Demo
|
|
6
|
+
*
|
|
7
|
+
* This template demonstrates the persistent message queue system with:
|
|
8
|
+
* - Publisher → Queue → Consumer flow over PLP
|
|
9
|
+
* - At-least-once delivery semantics
|
|
10
|
+
* - Dead-letter queue visibility
|
|
11
|
+
* - Real-time metrics (RTT, packet loss)
|
|
12
|
+
*
|
|
13
|
+
* Server-side queue backends (configured via PULSE_QUEUE_BACKEND env var):
|
|
14
|
+
* - memory: In-memory (default, ephemeral)
|
|
15
|
+
* - wal: Write-Ahead Log (crash-resilient, local filesystem)
|
|
16
|
+
* - postgres: PostgreSQL (cloud/HA)
|
|
17
|
+
* - redis: Redis (shared state / cache)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface QueueMessage {
|
|
21
|
+
id: string
|
|
22
|
+
payload: string
|
|
23
|
+
timestamp: number
|
|
24
|
+
state: 'pending' | 'in_flight' | 'acked' | 'dead_lettered'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function App() {
|
|
28
|
+
const [connected, setConnected] = useState(false)
|
|
29
|
+
const [messages, setMessages] = useState<QueueMessage[]>([])
|
|
30
|
+
const [input, setInput] = useState('')
|
|
31
|
+
const [rtt, setRtt] = useState<number | null>(null)
|
|
32
|
+
const [conn, setConn] = useState<any>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const pulse = new Pulse({ apiKey: 'dev' })
|
|
36
|
+
|
|
37
|
+
pulse.connect('ws://localhost:4001').then((connection) => {
|
|
38
|
+
setConn(connection)
|
|
39
|
+
setConnected(true)
|
|
40
|
+
|
|
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
|
+
// Live metrics
|
|
49
|
+
connection.on('metrics', (m: any) => setRtt(m.rtt))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
conn?.close()
|
|
54
|
+
}
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const publish = useCallback(async () => {
|
|
58
|
+
if (!conn || !input.trim()) return
|
|
59
|
+
|
|
60
|
+
const msg: QueueMessage = {
|
|
61
|
+
id: crypto.randomUUID(),
|
|
62
|
+
payload: input.trim(),
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
state: 'pending',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stream = conn.stream('task-queue', { qos: 'durable' })
|
|
68
|
+
stream.send(new TextEncoder().encode(JSON.stringify(msg)))
|
|
69
|
+
setMessages((prev) => [...prev, msg])
|
|
70
|
+
setInput('')
|
|
71
|
+
}, [conn, input])
|
|
72
|
+
|
|
73
|
+
const ackMessage = useCallback(async (id: string) => {
|
|
74
|
+
if (!conn) return
|
|
75
|
+
await conn.service('queue', 'ack', { queue: 'task-queue', message_id: id })
|
|
76
|
+
setMessages((prev) =>
|
|
77
|
+
prev.map((m) => (m.id === id ? { ...m, state: 'acked' as const } : m))
|
|
78
|
+
)
|
|
79
|
+
}, [conn])
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', maxWidth: 720, margin: '0 auto', padding: 32 }}>
|
|
83
|
+
<h1>🔄 Pulse Queue Demo</h1>
|
|
84
|
+
<p style={{ color: '#888' }}>
|
|
85
|
+
Durable message queues with at-least-once delivery over PLP.
|
|
86
|
+
{connected ? (
|
|
87
|
+
<span style={{ color: '#4ade80' }}> ● Connected</span>
|
|
88
|
+
) : (
|
|
89
|
+
<span style={{ color: '#f87171' }}> ○ Connecting...</span>
|
|
90
|
+
)}
|
|
91
|
+
{rtt !== null && <span style={{ marginLeft: 12 }}>RTT: {rtt}ms</span>}
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
|
95
|
+
<input
|
|
96
|
+
value={input}
|
|
97
|
+
onChange={(e) => setInput(e.target.value)}
|
|
98
|
+
onKeyDown={(e) => e.key === 'Enter' && publish()}
|
|
99
|
+
placeholder="Enter a task message..."
|
|
100
|
+
style={{
|
|
101
|
+
flex: 1, padding: '10px 16px', borderRadius: 8,
|
|
102
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 14
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
<button
|
|
106
|
+
onClick={publish}
|
|
107
|
+
disabled={!connected}
|
|
108
|
+
style={{
|
|
109
|
+
padding: '10px 20px', borderRadius: 8, border: 'none',
|
|
110
|
+
background: '#7c3aed', color: '#fff', fontWeight: 600, cursor: 'pointer'
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
Publish
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<h2>Messages ({messages.length})</h2>
|
|
118
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
119
|
+
{messages.map((msg) => (
|
|
120
|
+
<div
|
|
121
|
+
key={msg.id}
|
|
122
|
+
style={{
|
|
123
|
+
padding: 16, borderRadius: 8,
|
|
124
|
+
background: msg.state === 'acked' ? '#064e3b' : msg.state === 'dead_lettered' ? '#7f1d1d' : '#1e1b4b',
|
|
125
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<div>
|
|
129
|
+
<strong>{msg.payload}</strong>
|
|
130
|
+
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
131
|
+
{msg.id.slice(0, 8)} · {new Date(msg.timestamp).toLocaleTimeString()} ·{' '}
|
|
132
|
+
<span style={{
|
|
133
|
+
color: msg.state === 'acked' ? '#4ade80' : msg.state === 'dead_lettered' ? '#f87171' : '#facc15'
|
|
134
|
+
}}>
|
|
135
|
+
{msg.state}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
{msg.state === 'pending' && (
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => ackMessage(msg.id)}
|
|
142
|
+
style={{
|
|
143
|
+
padding: '6px 12px', borderRadius: 6, border: 'none',
|
|
144
|
+
background: '#22c55e', color: '#000', fontWeight: 600, cursor: 'pointer'
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
ACK
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div style={{ marginTop: 32, padding: 16, borderRadius: 8, background: '#111827', fontSize: 13, color: '#9ca3af' }}>
|
|
155
|
+
<strong style={{ color: '#e5e7eb' }}>Server Configuration</strong>
|
|
156
|
+
<pre style={{ marginTop: 8 }}>
|
|
157
|
+
{`# Start Pulse with WAL queue persistence:
|
|
158
|
+
PULSE_QUEUE_BACKEND=wal PULSE_QUEUE_WAL_DIR=./data pulse dev
|
|
159
|
+
|
|
160
|
+
# Or with PostgreSQL:
|
|
161
|
+
PULSE_QUEUE_BACKEND=postgres PULSE_QUEUE_POSTGRES_URL=postgres://... pulse dev
|
|
162
|
+
|
|
163
|
+
# Or with Redis:
|
|
164
|
+
PULSE_QUEUE_BACKEND=redis PULSE_QUEUE_REDIS_URL=redis://localhost:6379 pulse dev
|
|
165
|
+
|
|
166
|
+
# Enable encryption at rest (any backend):
|
|
167
|
+
PULSE_QUEUE_KEY=$(openssl rand -hex 32) pulse dev`}
|
|
168
|
+
</pre>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -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
|
-
|
|
27
|
-
}
|
|
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.3.0",
|
|
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
|
+
}
|
|
@@ -81,25 +81,36 @@ export default function App() {
|
|
|
81
81
|
const msg = { action, time, ts: sendTs };
|
|
82
82
|
broadcast(msg);
|
|
83
83
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
84
|
+
// Single-tab simulation: defer peerTime update by the measured relay
|
|
85
|
+
// round-trip so that drift is visible and realistic. In a real
|
|
86
|
+
// multi-tab setup the relay echo handler (handleSyncMessage) updates
|
|
87
|
+
// peerTime — but since the relay does not echo back to the sender,
|
|
88
|
+
// we simulate it here with the actual measured delay.
|
|
89
|
+
const simulatedDelay = delayHistoryRef.current.length > 0
|
|
90
|
+
? delayHistoryRef.current[delayHistoryRef.current.length - 1]
|
|
91
|
+
: 4; // sensible default ~4ms first message
|
|
92
|
+
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
if (action === 'tick') {
|
|
95
|
+
setPeerTime(time);
|
|
96
|
+
} else if (action === 'play') {
|
|
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);
|
|
98
106
|
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
103
114
|
}, [broadcast, logEvent]);
|
|
104
115
|
|
|
105
116
|
const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
|