@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 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.9",
3
+ "version": "0.4.1",
4
4
  "description": "Scaffold a new Pulse application",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
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
- }
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: any) => {
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
- // 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.
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
- // Computed delay stats
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` : avgDelay !== null ? `~${avgDelay * 2}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
- {avgDelay !== null && <span className="text-textDim text-[10px] ml-1">avg {avgDelay}ms</span>}
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: any;
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 (msg: any) => {
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
- {avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
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: any; // from usePulse
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((msg: any) => {
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: any, p2: any, isRemote: boolean) => {
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: any) => {
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: any) => {
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: any) => c.collected)) {
183
- collectiblesRef.current.forEach((c: any) => {
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
- // Record loopback delay so metrics are visible
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
- {avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
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 React, { useEffect, useState, useRef, useCallback } from 'react';
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: any; // from usePulse
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((metrics: any) => {
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
- // Record loopback delay
105
- const loopbackDelay = Date.now() - sendTs;
106
- setLastDelay(loopbackDelay);
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
- {avgDelay !== null ? `~${avgDelay * 2}ms` : '—'}
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,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
- }
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: any) => {
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: any) => o.value === msg.quality);
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
- // Local loopback: since the relay doesn't echo back to the sender,
85
- // we directly update peerTime to simulate the round-trip for the demo.
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: any) => (
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` : avgDelay !== null ? `~${avgDelay * 2}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
- {avgDelay !== null && <span className="text-textDim text-[10px] ml-1">avg {avgDelay}ms</span>}
250
+ <span className="text-textDim text-[10px] ml-1">(relay echo)</span>
262
251
  </span>
263
252
  </div>
264
253
  </div>