@sansavision/create-pulse 0.1.0-alpha.8 → 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 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.1.0-alpha.8",
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
- "name": "pulse-react-all-features",
3
- "private": true,
4
- "version": "0.1.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": "latest",
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
- }
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
- // Local loopback: since the relay doesn't echo back to the sender,
77
- // we directly update peerTime for the same-tab demo experience.
78
- // Also record the loopback delay so telemetry metrics are visible.
79
- if (action === 'tick') {
80
- setPeerTime(time);
81
- } else if (action === 'play') {
82
- setIsPlaying(true);
83
- setPeerTime(time);
84
- } else if (action === 'pause') {
85
- setIsPlaying(false);
86
- setPeerTime(time);
87
- } else if (action === 'seek') {
88
- setPeerTime(time);
89
- }
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];
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
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect, useState, useRef, useCallback } from 'react';
2
+ import { Clock } from 'lucide-react';
2
3
 
3
4
  // Web Crypto API: ECDH P-256 key agreement + AES-256-GCM symmetric encryption
4
5
  // This is production-grade E2E encryption — the relay never sees plaintext.
@@ -89,6 +90,10 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
89
90
  const [aliceDraft, setAliceDraft] = useState('');
90
91
  const [bobDraft, setBobDraft] = useState('');
91
92
 
93
+ // Delay metrics
94
+ const [lastDelay, setLastDelay] = useState<number | null>(null);
95
+ const delayHistoryRef = useRef<number[]>([]);
96
+
92
97
  // Generate ECDH keypairs and derive shared secrets on mount
93
98
  useEffect(() => {
94
99
  const setupCrypto = async () => {
@@ -151,6 +156,8 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
151
156
  const decryptKey = sender === 'Alice' ? keysRef.current.bobShared : keysRef.current.aliceShared;
152
157
  if (!encryptKey || !decryptKey) return;
153
158
 
159
+ const sendTs = Date.now();
160
+
154
161
  // 1) Encrypt the plaintext
155
162
  const encrypted = await WebCrypto.encrypt(encryptKey, text);
156
163
  const b64 = btoa(String.fromCharCode(...encrypted));
@@ -180,11 +187,19 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
180
187
  } else {
181
188
  setAliceMessages((prev: ChatMessage[]) => [...prev, { me: false, text: decrypted, encrypted: ciphertextPreview }]);
182
189
  }
190
+ // Record E2E loopback delay
191
+ const delay = Date.now() - sendTs;
192
+ setLastDelay(delay);
193
+ delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
183
194
  } catch (err) {
184
195
  console.error('Local decrypt failed:', err);
185
196
  }
186
197
  };
187
198
 
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
+
188
203
  return (
189
204
  <div className="space-y-6 max-w-5xl mx-auto">
190
205
  {/* Crypto status banner */}
@@ -199,6 +214,22 @@ export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
199
214
  </div>
200
215
  </div>
201
216
 
217
+ {/* Live Metrics */}
218
+ <div className="grid grid-cols-2 gap-4">
219
+ <div className="glass-panel px-4 py-3 flex items-center justify-between">
220
+ <div className="flex items-center gap-2 text-xs text-textMuted"><Clock className="w-3.5 h-3.5" />Msg Delay</div>
221
+ <span className={`text-sm font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
222
+ {lastDelay !== null ? `${lastDelay}ms` : '—'}
223
+ </span>
224
+ </div>
225
+ <div className="glass-panel px-4 py-3 flex items-center justify-between">
226
+ <div className="flex items-center gap-2 text-xs text-textMuted"><Clock className="w-3.5 h-3.5" />Relay RTT</div>
227
+ <span className="text-sm font-bold font-mono text-textMain">
228
+ {avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
229
+ </span>
230
+ </div>
231
+ </div>
232
+
202
233
  <div className="grid grid-cols-2 gap-8">
203
234
  {/* Alice Panel */}
204
235
  <div className="glass-panel overflow-hidden border-brandPurple/30">
@@ -11,6 +11,10 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
11
11
 
12
12
  const [metrics, setMetrics] = useState({ ups: 0, bytes: 0 });
13
13
 
14
+ // Delay tracking
15
+ const [lastDelay, setLastDelay] = useState<number | null>(null);
16
+ const delayHistoryRef = useRef<number[]>([]);
17
+
14
18
  const localPlayerRef = useRef({ x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' });
15
19
  const remotePlayerRef = useRef({ x: 0.7, y: 0.5, color: '#10B981', label: 'P2' });
16
20
  const remoteViewRef = useRef({
@@ -58,6 +62,13 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
58
62
  remoteViewRef.current.p1.y += (state.p1.y - remoteViewRef.current.p1.y) * 0.7;
59
63
  remoteViewRef.current.p2.x += (state.p2.x - remoteViewRef.current.p2.x) * 0.7;
60
64
  remoteViewRef.current.p2.y += (state.p2.y - remoteViewRef.current.p2.y) * 0.7;
65
+
66
+ // Track delay from received messages
67
+ if (msg.ts) {
68
+ const delay = Date.now() - msg.ts;
69
+ setLastDelay(delay);
70
+ delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
71
+ }
61
72
  }
62
73
  }, []);
63
74
 
@@ -107,7 +118,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
107
118
  ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
108
119
  }
109
120
 
110
- collectiblesRef.current.forEach((c) => {
121
+ collectiblesRef.current.forEach((c: any) => {
111
122
  if (c.collected) return;
112
123
  ctx.beginPath();
113
124
  ctx.arc(c.x * w, c.y * h, 8, 0, Math.PI * 2);
@@ -131,6 +142,11 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
131
142
  }
132
143
  };
133
144
 
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
+
134
150
  // Game loop
135
151
  useEffect(() => {
136
152
  let animationId: number;
@@ -155,7 +171,7 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
155
171
  remoteP.x = 0.5 + Math.cos(aiRef.current.angle) * 0.3;
156
172
  remoteP.y = 0.5 + Math.sin(aiRef.current.angle * 1.3) * 0.25;
157
173
 
158
- collectiblesRef.current.forEach((c) => {
174
+ collectiblesRef.current.forEach((c: any) => {
159
175
  if (c.collected) return;
160
176
  const d1 = Math.hypot(localP.x - c.x, localP.y - c.y);
161
177
  const d2 = Math.hypot(remoteP.x - c.x, remoteP.y - c.y);
@@ -163,8 +179,8 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
163
179
  if (d2 < 0.04) { c.collected = true; scoreRef.current.p2++; }
164
180
  });
165
181
 
166
- if (collectiblesRef.current.every((c) => c.collected)) {
167
- collectiblesRef.current.forEach((c) => {
182
+ if (collectiblesRef.current.every((c: any) => c.collected)) {
183
+ collectiblesRef.current.forEach((c: any) => {
168
184
  c.x = 0.1 + Math.random() * 0.8;
169
185
  c.y = 0.1 + Math.random() * 0.8;
170
186
  c.collected = false;
@@ -176,11 +192,13 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
176
192
  // Sync state at ~60fps
177
193
  statsRef.current.frameCount++;
178
194
  statsRef.current.updatesThisSecond++;
195
+ const sendTs = Date.now();
179
196
  const payload = {
180
197
  p1: { x: localP.x, y: localP.y },
181
198
  p2: { x: remoteP.x, y: remoteP.y },
182
199
  score: scoreRef.current,
183
200
  seq: statsRef.current.frameCount,
201
+ ts: sendTs,
184
202
  };
185
203
 
186
204
  const payloadStr = JSON.stringify(payload);
@@ -189,6 +207,14 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
189
207
  // Send to peers
190
208
  broadcast(payload);
191
209
 
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);
217
+
192
218
  // Interpolate fallback for when broadcast doesn't echo locally fast enough
193
219
  const rv = remoteViewRef.current;
194
220
  rv.p1.x += (localP.x - rv.p1.x) * 0.1;
@@ -249,18 +275,23 @@ export function GameSync({ useChannel, logEvent }: GameSyncProps) {
249
275
  <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Updates/sec</div>
250
276
  </div>
251
277
  <div className="glass-panel p-4 text-center">
252
- <div className="text-2xl font-bold font-mono text-brandPurple">60Hz</div>
253
- <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Target Sync</div>
278
+ <div className="text-2xl font-bold font-mono text-brandPurple">{metrics.bytes}</div>
279
+ <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Bytes/sec</div>
254
280
  </div>
255
281
  <div className="glass-panel p-4 text-center">
256
- <div className="text-2xl font-bold font-mono text-brandEmerald">{metrics.bytes}</div>
257
- <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Bytes/sec</div>
282
+ <div className={`text-2xl font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
283
+ {lastDelay !== null ? `${lastDelay}ms` : '—'}
284
+ </div>
285
+ <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Msg Delay</div>
258
286
  </div>
259
287
  <div className="glass-panel p-4 text-center">
260
- <div className="text-2xl font-bold font-mono text-brandAmber">ON</div>
261
- <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Interpolation</div>
288
+ <div className="text-2xl font-bold font-mono text-brandEmerald">
289
+ {avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
290
+ </div>
291
+ <div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Relay RTT</div>
262
292
  </div>
263
293
  </div>
264
294
  </div>
265
295
  );
266
296
  }
297
+
@@ -10,6 +10,10 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
10
10
  const cpuCanvas = useRef<HTMLCanvasElement>(null);
11
11
  const memCanvas = useRef<HTMLCanvasElement>(null);
12
12
 
13
+ // Delay tracking
14
+ const [lastDelay, setLastDelay] = useState<number | null>(null);
15
+ const delayHistoryRef = useRef<number[]>([]);
16
+
13
17
  const historyRef = useRef({
14
18
  cpu: Array(60).fill(0),
15
19
  mem: Array(60).fill(0),
@@ -66,6 +70,13 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
66
70
  if (memCanvas.current) drawChart(memCanvas.current, memHist, '#7C3AED'); // Purple
67
71
 
68
72
  logEvent(`RECV [Metrics]: CPU=${metrics.cpu.toFixed(1)}% MEM=${metrics.mem.toFixed(1)}%`);
73
+
74
+ // Track delay from timestamps
75
+ if (metrics.ts) {
76
+ const delay = Date.now() - metrics.ts;
77
+ setLastDelay(delay);
78
+ delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
79
+ }
69
80
  }
70
81
  }, [logEvent]);
71
82
 
@@ -78,21 +89,33 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
78
89
  // Check if we haven't received real data in the last tick
79
90
  // For the sake of the demo, if we want this React app to just work standalone:
80
91
  // We simulate that the server is broadcasting.
92
+ const sendTs = Date.now();
81
93
  const fakeMetrics = {
82
94
  type: "server_metrics",
83
95
  cpu: 10 + Math.random() * 20 + Math.sin(Date.now() / 1000) * 10,
84
96
  mem: 40 + Math.random() * 5 + Math.cos(Date.now() / 2000) * 10,
85
97
  net: Math.random() * 100,
86
- disk: Math.random() * 5
98
+ disk: Math.random() * 5,
99
+ ts: sendTs,
87
100
  };
88
101
  // Normally the server does this, here we do it so the demo "just works" locally
89
102
  broadcast(fakeMetrics);
90
- // We handle it directly in handleMessage too (since broadcast goes up to the relay and back)
103
+
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);
91
110
  }, 1000);
92
111
 
93
112
  return () => clearInterval(interval);
94
113
  }, [broadcast]);
95
114
 
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
+
96
119
  return (
97
120
  <div className="space-y-6 max-w-5xl mx-auto">
98
121
  <div className="grid grid-cols-4 gap-4">
@@ -105,12 +128,16 @@ export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
105
128
  <div className="text-3xl font-bold text-brandPurple font-mono">{stats.mem.toFixed(1)}%</div>
106
129
  </div>
107
130
  <div className="glass-panel p-6 text-center">
108
- <div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Network</div>
109
- <div className="text-3xl font-bold text-brandEmerald font-mono">{stats.net.toFixed(0)} Mbps</div>
131
+ <div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Msg Delay</div>
132
+ <div className={`text-3xl font-bold font-mono ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
133
+ {lastDelay !== null ? `${lastDelay}ms` : '—'}
134
+ </div>
110
135
  </div>
111
136
  <div className="glass-panel p-6 text-center">
112
- <div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Disk I/O</div>
113
- <div className="text-3xl font-bold text-brandAmber font-mono">{stats.disk.toFixed(1)}%</div>
137
+ <div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Relay RTT</div>
138
+ <div className="text-3xl font-bold text-brandEmerald font-mono">
139
+ {avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
140
+ </div>
114
141
  </div>
115
142
  </div>
116
143
 
@@ -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,4 @@
1
+ import { createRoot } from 'react-dom/client'
2
+ import App from './App'
3
+
4
+ createRoot(document.getElementById('root')!).render(<App />)
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ })
@@ -1,27 +1,27 @@
1
1
  {
2
- "name": "pulse-react-watch-together",
3
- "private": true,
4
- "version": "0.1.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": "latest",
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
- }
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
+ }
@@ -1,9 +1,14 @@
1
1
  import { useState, useCallback, useRef } from 'react';
2
2
  import { usePulse } from './hooks/usePulse';
3
3
  import { VideoPlayer } from './components/VideoPlayer';
4
- import { Activity, Users, Video } from 'lucide-react';
4
+ import { Activity, Users, Video, Settings } from 'lucide-react';
5
5
 
6
- const VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
6
+ // Quality options for the video
7
+ const QUALITY_OPTIONS = [
8
+ { label: '1080p', value: '1080p', src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4' },
9
+ { label: '720p', value: '720p', src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4' },
10
+ { label: '480p', value: '480p', src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4' },
11
+ ];
7
12
  const STREAM_NAME = "watch-together-demo";
8
13
 
9
14
  export default function App() {
@@ -17,6 +22,10 @@ export default function App() {
17
22
  const [isPlaying, setIsPlaying] = useState(false);
18
23
  const [events, setEvents] = useState<{ id: number, action: string, time: string }[]>([]);
19
24
 
25
+ // Video quality
26
+ const [selectedQuality, setSelectedQuality] = useState(QUALITY_OPTIONS[0]);
27
+ const [showQualityMenu, setShowQualityMenu] = useState(false);
28
+
20
29
  // Track message round-trip delay
21
30
  const [lastDelay, setLastDelay] = useState<number | null>(null);
22
31
  const delayHistoryRef = useRef<number[]>([]);
@@ -54,6 +63,12 @@ export default function App() {
54
63
  case 'tick':
55
64
  if (msg.time !== undefined) setPeerTime(msg.time);
56
65
  break;
66
+ case 'quality':
67
+ if (msg.quality) {
68
+ const q = QUALITY_OPTIONS.find((o: any) => o.value === msg.quality);
69
+ if (q) setSelectedQuality(q);
70
+ }
71
+ break;
57
72
  }
58
73
  }, [logEvent]);
59
74
 
@@ -66,25 +81,43 @@ export default function App() {
66
81
  const msg = { action, time, ts: sendTs };
67
82
  broadcast(msg);
68
83
 
69
- // Local loopback: since the relay doesn't echo back to the sender,
70
- // we directly update peerTime to simulate the round-trip for the demo.
71
- // For a real multi-user scenario, the relay delivers to other connections.
72
- if (action === 'tick') {
73
- setPeerTime(time);
74
- } else if (action === 'play') {
75
- setIsPlaying(true);
76
- setPeerTime(time);
77
- } else if (action === 'pause') {
78
- setIsPlaying(false);
79
- setPeerTime(time);
80
- } else if (action === 'seek') {
81
- setPeerTime(time);
82
- }
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);
83
106
 
84
- // Record loopback delay so telemetry metrics are visible
85
- const loopbackDelay = Date.now() - sendTs;
86
- setLastDelay(loopbackDelay);
87
- delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), loopbackDelay];
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);
114
+ }, [broadcast, logEvent]);
115
+
116
+ const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
117
+ setSelectedQuality(quality);
118
+ setShowQualityMenu(false);
119
+ logEvent(`QUALITY: ${quality.label}`);
120
+ broadcast({ action: 'quality', quality: quality.value, ts: Date.now() });
88
121
  }, [broadcast, logEvent]);
89
122
 
90
123
  // Compute average delay
@@ -142,10 +175,46 @@ export default function App() {
142
175
 
143
176
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
144
177
  <div className="lg:col-span-2 space-y-8">
178
+ {/* Quality selector */}
179
+ <div className="flex items-center justify-between">
180
+ <div className="flex items-center gap-2">
181
+ <span className="px-2.5 py-1 rounded-lg bg-brandPurple/20 border border-brandPurple/30 text-brandPurple text-[11px] font-bold uppercase tracking-wider">
182
+ {selectedQuality.label}
183
+ </span>
184
+ <span className="text-xs text-textDim">Current Quality</span>
185
+ </div>
186
+ <div className="relative">
187
+ <button
188
+ onClick={() => setShowQualityMenu(!showQualityMenu)}
189
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg glass-panel text-xs text-textMuted hover:text-textMain transition-colors"
190
+ >
191
+ <Settings className="w-3.5 h-3.5" />
192
+ Quality
193
+ </button>
194
+ {showQualityMenu && (
195
+ <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: any) => (
197
+ <button
198
+ key={q.value}
199
+ onClick={() => handleQualityChange(q)}
200
+ className={`w-full px-4 py-2.5 text-left text-xs font-mono transition-colors ${selectedQuality.value === q.value
201
+ ? 'bg-brandPurple/20 text-brandPurple font-bold'
202
+ : 'text-textMuted hover:bg-white/5 hover:text-textMain'
203
+ }`}
204
+ >
205
+ {q.label}
206
+ {selectedQuality.value === q.value && ' ✓'}
207
+ </button>
208
+ ))}
209
+ </div>
210
+ )}
211
+ </div>
212
+ </div>
213
+
145
214
  <VideoPlayer
146
215
  role="host"
147
216
  label="Host Player (You control this)"
148
- src={VIDEO_URL}
217
+ src={selectedQuality.src}
149
218
  currentTime={hostTime}
150
219
  isPlaying={isPlaying}
151
220
  onPlay={() => { setIsPlaying(true); broadcastAction('play', hostTime); }}
@@ -153,15 +222,17 @@ export default function App() {
153
222
  onSeek={(t: number) => { setHostTime(t); broadcastAction('seek', t); }}
154
223
  onTick={(t: number) => { setHostTime(t); broadcastAction('tick', t); }}
155
224
  accentColor="#a5b4fc"
225
+ quality={selectedQuality.label}
156
226
  />
157
227
 
158
228
  <VideoPlayer
159
229
  role="peer"
160
230
  label="Peer Viewer (Remote representation)"
161
- src={VIDEO_URL}
231
+ src={selectedQuality.src}
162
232
  currentTime={peerTime}
163
233
  isPlaying={isPlaying}
164
234
  accentColor="#67e8f9"
235
+ quality={selectedQuality.label}
165
236
  />
166
237
  </div>
167
238
 
@@ -11,6 +11,7 @@ interface VideoPlayerProps {
11
11
  onSeek?: (time: number) => void;
12
12
  onTick?: (time: number) => void;
13
13
  accentColor: string;
14
+ quality?: string;
14
15
  }
15
16
 
16
17
  export function VideoPlayer({
@@ -23,7 +24,8 @@ export function VideoPlayer({
23
24
  onPause,
24
25
  onSeek,
25
26
  onTick,
26
- accentColor
27
+ accentColor,
28
+ quality
27
29
  }: VideoPlayerProps) {
28
30
  const videoRef = useRef<HTMLVideoElement>(null);
29
31
 
@@ -100,6 +102,11 @@ export function VideoPlayer({
100
102
  </div>
101
103
 
102
104
  <div className="relative rounded-2xl overflow-hidden border border-pulseBorder bg-black shadow-xl ring-1 ring-white/5 h-64 sm:h-80 md:h-96">
105
+ {quality && (
106
+ <div className="absolute top-3 right-3 z-10 px-2 py-1 rounded-md bg-black/70 border border-white/10 backdrop-blur-sm text-[10px] font-bold font-mono text-white/80 uppercase tracking-wider">
107
+ {quality}
108
+ </div>
109
+ )}
103
110
  <video
104
111
  ref={videoRef}
105
112
  controls={role === 'host'}