@sansavision/create-pulse 0.4.0 → 0.4.2
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/README.md +27 -3
- package/dist/index.js +3 -1
- package/package.json +2 -2
- package/templates/nextjs-auth-demo/.env.example +6 -0
- package/templates/nextjs-auth-demo/README.md +74 -0
- package/templates/nextjs-auth-demo/_gitignore +33 -0
- package/templates/nextjs-auth-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-demo/package.json +34 -0
- package/templates/nextjs-auth-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +248 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +198 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +192 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +297 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +258 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +109 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +147 -0
- package/templates/nextjs-auth-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-demo/src/lib/auth.ts +13 -0
- package/templates/nextjs-auth-demo/src/lib/db.ts +5 -0
- package/templates/nextjs-auth-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-demo/tsconfig.json +34 -0
- package/templates/react-all-features/package.json +2 -2
- package/templates/react-all-features/src/App.tsx +20 -39
- package/templates/react-all-features/src/components/EncryptedChat.tsx +8 -8
- package/templates/react-all-features/src/components/GameSync.tsx +38 -23
- package/templates/react-all-features/src/components/ServerMetrics.tsx +20 -15
- package/templates/react-queue-demo/README.md +6 -7
- package/templates/react-queue-demo/package.json +1 -1
- package/templates/react-queue-demo/src/App.tsx +229 -62
- package/templates/react-watch-together/package.json +2 -2
- package/templates/react-watch-together/src/App.tsx +18 -40
- package/src/index.ts +0 -115
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import { Pulse } from '@sansavision/pulse-sdk'
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
|
+
import { Pulse, PulseQueue, PulseConnection, ConnectionMetrics } from '@sansavision/pulse-sdk'
|
|
3
|
+
import type { QueueMessage } from '@sansavision/pulse-sdk'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* Pulse v0.4.
|
|
6
|
+
* Pulse v0.4.4 — Durable Queue Demo
|
|
6
7
|
*
|
|
7
8
|
* This template demonstrates the persistent message queue system with:
|
|
8
|
-
* - Publisher → Queue → Consumer flow
|
|
9
|
-
* - At-least-once delivery semantics
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
9
|
+
* - Publisher → Queue → Consumer flow via the typed PulseQueue API
|
|
10
|
+
* - At-least-once delivery semantics via pull + ack
|
|
11
|
+
* - Auto-queue creation on first publish
|
|
12
|
+
* - Persistent messages surviving page refresh (when using redis/wal/postgres)
|
|
13
|
+
* - Configurable message TTL (time-to-live)
|
|
12
14
|
*
|
|
13
15
|
* Server-side queue backends (configured via PULSE_QUEUE_BACKEND env var):
|
|
14
16
|
* - memory: In-memory (default, ephemeral)
|
|
@@ -17,86 +19,188 @@ import { Pulse } from '@sansavision/pulse-sdk'
|
|
|
17
19
|
* - redis: Redis (shared state / cache)
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
|
-
interface QueueMessage {
|
|
21
|
-
|
|
22
|
-
payload: string
|
|
23
|
-
timestamp: number
|
|
24
|
-
state: 'pending' | 'in_flight' | 'acked' | 'dead_lettered'
|
|
22
|
+
interface DisplayMessage extends QueueMessage {
|
|
23
|
+
state: 'pending' | 'acked'
|
|
25
24
|
}
|
|
26
25
|
|
|
26
|
+
const QUEUE_NAME = 'task-queue'
|
|
27
|
+
|
|
28
|
+
const TTL_OPTIONS = [
|
|
29
|
+
{ label: 'No TTL', value: undefined },
|
|
30
|
+
{ label: '30s', value: 30 },
|
|
31
|
+
{ label: '1 min', value: 60 },
|
|
32
|
+
{ label: '5 min', value: 300 },
|
|
33
|
+
{ label: '15 min', value: 900 },
|
|
34
|
+
{ label: '1 hour', value: 3600 },
|
|
35
|
+
{ label: '24 hours', value: 86400 },
|
|
36
|
+
]
|
|
37
|
+
|
|
27
38
|
export default function App() {
|
|
28
|
-
const [
|
|
29
|
-
const [messages, setMessages] = useState<
|
|
39
|
+
const [connectionState, setConnectionState] = useState<'connecting' | 'connected' | 'reconnecting' | 'disconnected'>('connecting')
|
|
40
|
+
const [messages, setMessages] = useState<DisplayMessage[]>([])
|
|
30
41
|
const [input, setInput] = useState('')
|
|
31
42
|
const [rtt, setRtt] = useState<number | null>(null)
|
|
32
|
-
const [
|
|
43
|
+
const [queueDepth, setQueueDepth] = useState(0)
|
|
44
|
+
const [ttlSecs, setTtlSecs] = useState<number | undefined>(undefined)
|
|
45
|
+
const [customTtl, setCustomTtl] = useState('')
|
|
46
|
+
const [showCustomTtl, setShowCustomTtl] = useState(false)
|
|
47
|
+
const connRef = useRef<PulseConnection | null>(null)
|
|
48
|
+
const queueRef = useRef<PulseQueue | null>(null)
|
|
49
|
+
|
|
50
|
+
const connected = connectionState === 'connected'
|
|
33
51
|
|
|
52
|
+
// Helper: drain persisted messages and refresh depth
|
|
53
|
+
const recoverMessages = useCallback(async (queue: PulseQueue) => {
|
|
54
|
+
try {
|
|
55
|
+
const recovered = await queue.drain()
|
|
56
|
+
if (recovered.length > 0) {
|
|
57
|
+
setMessages(recovered.map((m) => ({ ...m, state: 'pending' as const })))
|
|
58
|
+
}
|
|
59
|
+
await refreshDepth(queue)
|
|
60
|
+
} catch {
|
|
61
|
+
// Queue may not exist yet — that's fine, it'll be auto-created
|
|
62
|
+
}
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
// Connect + auto-fetch existing messages on mount
|
|
66
|
+
// Auto-reconnect is enabled by default in the SDK — the protocol "just works"
|
|
34
67
|
useEffect(() => {
|
|
35
68
|
const pulse = new Pulse({ apiKey: 'dev' })
|
|
36
69
|
|
|
37
|
-
|
|
38
|
-
setConn(connection)
|
|
39
|
-
setConnected(true)
|
|
70
|
+
setConnectionState('connecting')
|
|
40
71
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})
|
|
72
|
+
pulse.connect('ws://localhost:4001').then(async (connection) => {
|
|
73
|
+
connRef.current = connection
|
|
74
|
+
const queue = new PulseQueue(connection, QUEUE_NAME, 'demo-ui')
|
|
75
|
+
queueRef.current = queue
|
|
76
|
+
setConnectionState('connected')
|
|
47
77
|
|
|
48
78
|
// Live metrics
|
|
49
|
-
connection.on('metrics', (m:
|
|
79
|
+
connection.on('metrics', (m: ConnectionMetrics) => setRtt(m.rtt))
|
|
80
|
+
|
|
81
|
+
// Connection lifecycle events
|
|
82
|
+
connection.on('disconnect', () => setConnectionState('reconnecting'))
|
|
83
|
+
connection.on('reconnecting', () => setConnectionState('reconnecting'))
|
|
84
|
+
connection.on('reconnected', async () => {
|
|
85
|
+
setConnectionState('connected')
|
|
86
|
+
// Re-drain persisted messages after reconnect
|
|
87
|
+
await recoverMessages(queue)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Drain any persisted messages from a previous session
|
|
91
|
+
await recoverMessages(queue)
|
|
50
92
|
})
|
|
51
93
|
|
|
52
94
|
return () => {
|
|
53
|
-
|
|
95
|
+
connRef.current?.disconnect()
|
|
54
96
|
}
|
|
55
|
-
}, [])
|
|
97
|
+
}, [recoverMessages])
|
|
98
|
+
|
|
99
|
+
const refreshDepth = async (queue?: PulseQueue) => {
|
|
100
|
+
const q = queue || queueRef.current
|
|
101
|
+
if (!q) return
|
|
102
|
+
try {
|
|
103
|
+
const info = await q.info()
|
|
104
|
+
if (info?.depth !== undefined) {
|
|
105
|
+
setQueueDepth(info.depth)
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// queue may not exist yet
|
|
109
|
+
}
|
|
110
|
+
}
|
|
56
111
|
|
|
112
|
+
// Publish a message via PulseQueue
|
|
57
113
|
const publish = useCallback(async () => {
|
|
58
|
-
|
|
114
|
+
const queue = queueRef.current
|
|
115
|
+
if (!queue || !input.trim()) return
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await queue.publish(input.trim(), {
|
|
119
|
+
ttlSecs: ttlSecs,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if (result?.status === 'enqueued') {
|
|
123
|
+
// Immediately pull for display
|
|
124
|
+
const msg = await queue.pull()
|
|
125
|
+
|
|
126
|
+
if (msg) {
|
|
127
|
+
setMessages((prev) => [
|
|
128
|
+
...prev,
|
|
129
|
+
{ ...msg, state: 'pending' as const },
|
|
130
|
+
])
|
|
131
|
+
}
|
|
132
|
+
}
|
|
59
133
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
state: 'pending',
|
|
134
|
+
setInput('')
|
|
135
|
+
await refreshDepth()
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error('Enqueue failed:', err)
|
|
65
138
|
}
|
|
139
|
+
}, [input, ttlSecs])
|
|
66
140
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}, [conn, input])
|
|
141
|
+
// ACK a message by sequence number
|
|
142
|
+
const ackMessage = useCallback(async (sequence: number) => {
|
|
143
|
+
const queue = queueRef.current
|
|
144
|
+
if (!queue) return
|
|
72
145
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
146
|
+
try {
|
|
147
|
+
const result = await queue.ack(sequence)
|
|
148
|
+
|
|
149
|
+
if (result?.status === 'acked') {
|
|
150
|
+
setMessages((prev) =>
|
|
151
|
+
prev.map((m) =>
|
|
152
|
+
m.sequence === sequence ? { ...m, state: 'acked' as const } : m
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
await refreshDepth()
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('Ack failed:', err)
|
|
159
|
+
}
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
const handleTtlChange = (value: string) => {
|
|
163
|
+
if (value === 'custom') {
|
|
164
|
+
setShowCustomTtl(true)
|
|
165
|
+
} else {
|
|
166
|
+
setShowCustomTtl(false)
|
|
167
|
+
setTtlSecs(value === '' ? undefined : parseInt(value, 10))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const applyCustomTtl = () => {
|
|
172
|
+
const val = parseInt(customTtl, 10)
|
|
173
|
+
if (!isNaN(val) && val > 0) {
|
|
174
|
+
setTtlSecs(val)
|
|
175
|
+
setShowCustomTtl(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
80
178
|
|
|
81
179
|
return (
|
|
82
180
|
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', maxWidth: 720, margin: '0 auto', padding: 32 }}>
|
|
83
181
|
<h1>🔄 Pulse Queue Demo</h1>
|
|
84
182
|
<p style={{ color: '#888' }}>
|
|
85
183
|
Durable message queues with at-least-once delivery over PLP.
|
|
86
|
-
{connected ? (
|
|
184
|
+
{connectionState === 'connected' ? (
|
|
87
185
|
<span style={{ color: '#4ade80' }}> ● Connected</span>
|
|
186
|
+
) : connectionState === 'reconnecting' ? (
|
|
187
|
+
<span style={{ color: '#fbbf24' }}> ◌ Reconnecting...</span>
|
|
88
188
|
) : (
|
|
89
189
|
<span style={{ color: '#f87171' }}> ○ Connecting...</span>
|
|
90
190
|
)}
|
|
91
191
|
{rtt !== null && <span style={{ marginLeft: 12 }}>RTT: {rtt}ms</span>}
|
|
192
|
+
<span style={{ marginLeft: 12, color: '#60a5fa' }}>
|
|
193
|
+
Depth: {queueDepth}
|
|
194
|
+
</span>
|
|
92
195
|
</p>
|
|
93
196
|
|
|
94
|
-
|
|
197
|
+
{/* Message input + TTL controls */}
|
|
198
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
95
199
|
<input
|
|
96
200
|
value={input}
|
|
97
201
|
onChange={(e) => setInput(e.target.value)}
|
|
98
202
|
onKeyDown={(e) => e.key === 'Enter' && publish()}
|
|
99
|
-
placeholder="Enter a
|
|
203
|
+
placeholder="Enter a message..."
|
|
100
204
|
style={{
|
|
101
205
|
flex: 1, padding: '10px 16px', borderRadius: 8,
|
|
102
206
|
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 14
|
|
@@ -114,23 +218,74 @@ export default function App() {
|
|
|
114
218
|
</button>
|
|
115
219
|
</div>
|
|
116
220
|
|
|
221
|
+
{/* TTL Selector */}
|
|
222
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 24, alignItems: 'center' }}>
|
|
223
|
+
<label style={{ fontSize: 13, color: '#9ca3af', whiteSpace: 'nowrap' }}>⏱ TTL:</label>
|
|
224
|
+
<select
|
|
225
|
+
value={showCustomTtl ? 'custom' : (ttlSecs === undefined ? '' : String(ttlSecs))}
|
|
226
|
+
onChange={(e) => handleTtlChange(e.target.value)}
|
|
227
|
+
style={{
|
|
228
|
+
padding: '6px 12px', borderRadius: 6,
|
|
229
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff',
|
|
230
|
+
fontSize: 13, cursor: 'pointer', minWidth: 100,
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{TTL_OPTIONS.map((opt) => (
|
|
234
|
+
<option key={opt.label} value={opt.value === undefined ? '' : String(opt.value)}>
|
|
235
|
+
{opt.label}
|
|
236
|
+
</option>
|
|
237
|
+
))}
|
|
238
|
+
<option value="custom">Custom...</option>
|
|
239
|
+
</select>
|
|
240
|
+
{showCustomTtl && (
|
|
241
|
+
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
242
|
+
<input
|
|
243
|
+
type="number"
|
|
244
|
+
min="1"
|
|
245
|
+
value={customTtl}
|
|
246
|
+
onChange={(e) => setCustomTtl(e.target.value)}
|
|
247
|
+
onKeyDown={(e) => e.key === 'Enter' && applyCustomTtl()}
|
|
248
|
+
placeholder="seconds"
|
|
249
|
+
style={{
|
|
250
|
+
width: 80, padding: '6px 10px', borderRadius: 6,
|
|
251
|
+
border: '1px solid #333', background: '#1a1a2e', color: '#fff', fontSize: 13
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
<button
|
|
255
|
+
onClick={applyCustomTtl}
|
|
256
|
+
style={{
|
|
257
|
+
padding: '6px 10px', borderRadius: 6, border: 'none',
|
|
258
|
+
background: '#3b82f6', color: '#fff', fontSize: 12, cursor: 'pointer'
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
Set
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
{ttlSecs !== undefined && !showCustomTtl && (
|
|
266
|
+
<span style={{ fontSize: 12, color: '#60a5fa' }}>
|
|
267
|
+
Messages expire after {ttlSecs}s
|
|
268
|
+
</span>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
117
272
|
<h2>Messages ({messages.length})</h2>
|
|
118
273
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
119
274
|
{messages.map((msg) => (
|
|
120
275
|
<div
|
|
121
|
-
key={msg.
|
|
276
|
+
key={msg.sequence}
|
|
122
277
|
style={{
|
|
123
278
|
padding: 16, borderRadius: 8,
|
|
124
|
-
background: msg.state === 'acked' ? '#064e3b' :
|
|
279
|
+
background: msg.state === 'acked' ? '#064e3b' : '#1e1b4b',
|
|
125
280
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
|
|
126
281
|
}}
|
|
127
282
|
>
|
|
128
283
|
<div>
|
|
129
284
|
<strong>{msg.payload}</strong>
|
|
130
285
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
131
|
-
{msg.
|
|
286
|
+
seq:{msg.sequence} · deliveries:{msg.delivery_count} ·{' '}
|
|
132
287
|
<span style={{
|
|
133
|
-
color: msg.state === 'acked' ? '#4ade80' :
|
|
288
|
+
color: msg.state === 'acked' ? '#4ade80' : '#facc15'
|
|
134
289
|
}}>
|
|
135
290
|
{msg.state}
|
|
136
291
|
</span>
|
|
@@ -138,7 +293,7 @@ export default function App() {
|
|
|
138
293
|
</div>
|
|
139
294
|
{msg.state === 'pending' && (
|
|
140
295
|
<button
|
|
141
|
-
onClick={() => ackMessage(msg.
|
|
296
|
+
onClick={() => ackMessage(msg.sequence)}
|
|
142
297
|
style={{
|
|
143
298
|
padding: '6px 12px', borderRadius: 6, border: 'none',
|
|
144
299
|
background: '#22c55e', color: '#000', fontWeight: 600, cursor: 'pointer'
|
|
@@ -152,19 +307,31 @@ export default function App() {
|
|
|
152
307
|
</div>
|
|
153
308
|
|
|
154
309
|
<div style={{ marginTop: 32, padding: 16, borderRadius: 8, background: '#111827', fontSize: 13, color: '#9ca3af' }}>
|
|
155
|
-
<strong style={{ color: '#e5e7eb' }}>
|
|
310
|
+
<strong style={{ color: '#e5e7eb' }}>How it works</strong>
|
|
311
|
+
<ul style={{ marginTop: 8, paddingLeft: 20, lineHeight: 1.8 }}>
|
|
312
|
+
<li><strong>Publish</strong> → <code>queue.publish(payload, {'{ ttlSecs? }'})</code></li>
|
|
313
|
+
<li><strong>Consume</strong> → <code>queue.pull()</code></li>
|
|
314
|
+
<li><strong>ACK</strong> → <code>queue.ack(sequence)</code></li>
|
|
315
|
+
<li><strong>Recover</strong> → <code>queue.drain()</code> — pulls all pending messages</li>
|
|
316
|
+
<li>Messages persist in the configured backend (redis/wal/postgres)</li>
|
|
317
|
+
<li>Set <strong>TTL</strong> to auto-expire messages after a duration</li>
|
|
318
|
+
<li>Refresh the page — persisted messages are recovered via <code>drain()</code></li>
|
|
319
|
+
</ul>
|
|
156
320
|
<pre style={{ marginTop: 8 }}>
|
|
157
|
-
{
|
|
158
|
-
|
|
321
|
+
{`import { Pulse, PulseQueue } from '@sansavision/pulse-sdk'
|
|
322
|
+
|
|
323
|
+
const conn = await new Pulse({ apiKey: 'dev' }).connect('ws://localhost:4001')
|
|
324
|
+
const queue = new PulseQueue(conn, 'task-queue')
|
|
159
325
|
|
|
160
|
-
|
|
161
|
-
|
|
326
|
+
// Publish with TTL
|
|
327
|
+
await queue.publish('hello world', { ttlSecs: 60 })
|
|
162
328
|
|
|
163
|
-
|
|
164
|
-
|
|
329
|
+
// Pull + Ack
|
|
330
|
+
const msg = await queue.pull()
|
|
331
|
+
if (msg) await queue.ack(msg.sequence)
|
|
165
332
|
|
|
166
|
-
|
|
167
|
-
|
|
333
|
+
// Drain all pending (useful after reconnect)
|
|
334
|
+
const recovered = await queue.drain()`}
|
|
168
335
|
</pre>
|
|
169
336
|
</div>
|
|
170
337
|
</div>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@sansavision/pulse-sdk": "0.
|
|
12
|
+
"@sansavision/pulse-sdk": "^0.4.1",
|
|
13
13
|
"react": "^18.3.1",
|
|
14
14
|
"react-dom": "^18.3.1",
|
|
15
15
|
"lucide-react": "^0.412.0"
|
|
@@ -24,4 +24,4 @@
|
|
|
24
24
|
"typescript": "^5.5.3",
|
|
25
25
|
"vite": "^5.3.4"
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
}
|
|
@@ -38,7 +38,7 @@ export default function App() {
|
|
|
38
38
|
}]);
|
|
39
39
|
}, []);
|
|
40
40
|
|
|
41
|
-
const handleSyncMessage = useCallback((msg:
|
|
41
|
+
const handleSyncMessage = useCallback((msg: { action: string; time?: number; ts?: number; quality?: string }) => {
|
|
42
42
|
// Calculate delivery delay if timestamp present
|
|
43
43
|
if (msg.ts) {
|
|
44
44
|
const delay = Date.now() - msg.ts;
|
|
@@ -65,7 +65,7 @@ export default function App() {
|
|
|
65
65
|
break;
|
|
66
66
|
case 'quality':
|
|
67
67
|
if (msg.quality) {
|
|
68
|
-
const q = QUALITY_OPTIONS.find((o
|
|
68
|
+
const q = QUALITY_OPTIONS.find((o) => o.value === msg.quality);
|
|
69
69
|
if (q) setSelectedQuality(q);
|
|
70
70
|
}
|
|
71
71
|
break;
|
|
@@ -81,36 +81,19 @@ export default function App() {
|
|
|
81
81
|
const msg = { action, time, ts: sendTs };
|
|
82
82
|
broadcast(msg);
|
|
83
83
|
|
|
84
|
-
// Single-tab simulation:
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
setIsPlaying(true);
|
|
98
|
-
setPeerTime(time);
|
|
99
|
-
} else if (action === 'pause') {
|
|
100
|
-
setIsPlaying(false);
|
|
101
|
-
setPeerTime(time);
|
|
102
|
-
} else if (action === 'seek') {
|
|
103
|
-
setPeerTime(time);
|
|
104
|
-
}
|
|
105
|
-
}, simulatedDelay);
|
|
106
|
-
|
|
107
|
-
// Measure delay after the event loop processes the send (captures
|
|
108
|
-
// real serialization + WebSocket write time, not synchronous 0).
|
|
109
|
-
setTimeout(() => {
|
|
110
|
-
const delay = Date.now() - sendTs;
|
|
111
|
-
setLastDelay(delay);
|
|
112
|
-
delayHistoryRef.current = [...delayHistoryRef.current.slice(-29), delay];
|
|
113
|
-
}, 0);
|
|
84
|
+
// Single-tab simulation: update peerTime immediately (in a real
|
|
85
|
+
// multi-tab setup the relay echo handler updates peerTime).
|
|
86
|
+
if (action === 'tick') {
|
|
87
|
+
setPeerTime(time);
|
|
88
|
+
} else if (action === 'play') {
|
|
89
|
+
setIsPlaying(true);
|
|
90
|
+
setPeerTime(time);
|
|
91
|
+
} else if (action === 'pause') {
|
|
92
|
+
setIsPlaying(false);
|
|
93
|
+
setPeerTime(time);
|
|
94
|
+
} else if (action === 'seek') {
|
|
95
|
+
setPeerTime(time);
|
|
96
|
+
}
|
|
114
97
|
}, [broadcast, logEvent]);
|
|
115
98
|
|
|
116
99
|
const handleQualityChange = useCallback((quality: typeof QUALITY_OPTIONS[0]) => {
|
|
@@ -120,11 +103,6 @@ export default function App() {
|
|
|
120
103
|
broadcast({ action: 'quality', quality: quality.value, ts: Date.now() });
|
|
121
104
|
}, [broadcast, logEvent]);
|
|
122
105
|
|
|
123
|
-
// Compute average delay
|
|
124
|
-
const avgDelay = delayHistoryRef.current.length > 0
|
|
125
|
-
? Math.round(delayHistoryRef.current.reduce((a: number, b: number) => a + b, 0) / delayHistoryRef.current.length)
|
|
126
|
-
: null;
|
|
127
|
-
|
|
128
106
|
const drift = Math.abs(hostTime - peerTime);
|
|
129
107
|
const driftColor = drift > 0.15 ? 'text-brandAmber' : 'text-brandEmerald';
|
|
130
108
|
|
|
@@ -193,7 +171,7 @@ export default function App() {
|
|
|
193
171
|
</button>
|
|
194
172
|
{showQualityMenu && (
|
|
195
173
|
<div className="absolute right-0 top-full mt-1 z-50 glass-panel rounded-xl overflow-hidden shadow-2xl border border-pulseBorder min-w-[120px]">
|
|
196
|
-
{QUALITY_OPTIONS.map((q
|
|
174
|
+
{QUALITY_OPTIONS.map((q) => (
|
|
197
175
|
<button
|
|
198
176
|
key={q.value}
|
|
199
177
|
onClick={() => handleQualityChange(q)}
|
|
@@ -262,14 +240,14 @@ export default function App() {
|
|
|
262
240
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
263
241
|
<span className="text-textMuted">Relay RTT</span>
|
|
264
242
|
<span className="text-textMain font-semibold tracking-wider">
|
|
265
|
-
{metrics ? `${Math.round(metrics.rtt)}ms` :
|
|
243
|
+
{metrics ? `${Math.round(metrics.rtt)}ms` : '—'}
|
|
266
244
|
</span>
|
|
267
245
|
</div>
|
|
268
246
|
<div className="flex justify-between items-center p-3 rounded-lg bg-black/30 border border-pulseBorder">
|
|
269
247
|
<span className="text-textMuted">Msg Delay</span>
|
|
270
248
|
<span className={`font-semibold tracking-wider ${lastDelay !== null && lastDelay > 50 ? 'text-brandAmber' : 'text-brandEmerald'}`}>
|
|
271
249
|
{lastDelay !== null ? `${lastDelay}ms` : '—'}
|
|
272
|
-
|
|
250
|
+
<span className="text-textDim text-[10px] ml-1">(relay echo)</span>
|
|
273
251
|
</span>
|
|
274
252
|
</div>
|
|
275
253
|
</div>
|
package/src/index.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import * as p from '@clack/prompts';
|
|
3
|
-
import pc from 'picocolors';
|
|
4
|
-
import mri from 'mri';
|
|
5
|
-
import fs from 'fs';
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import { fileURLToPath } from 'url';
|
|
8
|
-
|
|
9
|
-
const argv = mri(process.argv.slice(2));
|
|
10
|
-
|
|
11
|
-
// In CommonJS, __dirname is available, but since we compile with tsup to CJS, we can just use __dirname.
|
|
12
|
-
const TEMPLATES_DIR = path.join(__dirname, '../templates');
|
|
13
|
-
|
|
14
|
-
async function main() {
|
|
15
|
-
p.intro(pc.bgMagenta(pc.white(' Create Pulse App ')));
|
|
16
|
-
|
|
17
|
-
let targetDir = argv._[0];
|
|
18
|
-
|
|
19
|
-
const project = await p.group(
|
|
20
|
-
{
|
|
21
|
-
path: () => {
|
|
22
|
-
if (targetDir) return Promise.resolve(targetDir);
|
|
23
|
-
return p.text({
|
|
24
|
-
message: 'Project name:',
|
|
25
|
-
initialValue: 'pulse-app',
|
|
26
|
-
validate: (val) => {
|
|
27
|
-
if (!val || val.trim() === '') return 'Project name is required';
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
},
|
|
32
|
-
template: () => {
|
|
33
|
-
return p.select({
|
|
34
|
-
message: 'Pick a template:',
|
|
35
|
-
options: [
|
|
36
|
-
{ value: 'react-watch-together', label: 'Watch Together (React + TS)', hint: 'Synchronized video playback' },
|
|
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' },
|
|
39
|
-
{ value: 'vanilla-basic', label: 'Vanilla JS (Basic)', hint: 'Minimal setup' }
|
|
40
|
-
]
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
onCancel: () => {
|
|
46
|
-
p.cancel('Operation cancelled.');
|
|
47
|
-
process.exit(0);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
const destDir = path.resolve(process.cwd(), project.path);
|
|
53
|
-
if (!fs.existsSync(destDir)) {
|
|
54
|
-
fs.mkdirSync(destDir, { recursive: true });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const s = p.spinner();
|
|
58
|
-
s.start(`Scaffolding project in ${pc.cyan(project.path)}...`);
|
|
59
|
-
|
|
60
|
-
const templateDir = path.join(TEMPLATES_DIR, project.template as string);
|
|
61
|
-
|
|
62
|
-
if (!fs.existsSync(templateDir)) {
|
|
63
|
-
s.stop(pc.red(`Template ${project.template} not found at ${templateDir}`));
|
|
64
|
-
process.exit(1);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
copyDir(templateDir, destDir);
|
|
68
|
-
|
|
69
|
-
// Rename _gitignore to .gitignore
|
|
70
|
-
const gitignorePath = path.join(destDir, '_gitignore');
|
|
71
|
-
if (fs.existsSync(gitignorePath)) {
|
|
72
|
-
fs.renameSync(gitignorePath, path.join(destDir, '.gitignore'));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Update package.json name to match the project path
|
|
76
|
-
const pkgPath = path.join(destDir, 'package.json');
|
|
77
|
-
if (fs.existsSync(pkgPath)) {
|
|
78
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
79
|
-
pkg.name = path.basename(destDir);
|
|
80
|
-
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
s.stop(`Scaffolded ${pc.cyan(project.template)} template in ${pc.cyan(project.path)}`);
|
|
84
|
-
|
|
85
|
-
p.note(
|
|
86
|
-
`cd ${project.path}\n` +
|
|
87
|
-
`npm install\n` +
|
|
88
|
-
`npm run dev`,
|
|
89
|
-
'Next steps'
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
p.outro(pc.magenta('Pulse is ready! 🚀'));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function copyDir(src: string, dest: string) {
|
|
96
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
97
|
-
|
|
98
|
-
for (const entry of entries) {
|
|
99
|
-
const srcPath = path.join(src, entry.name);
|
|
100
|
-
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
101
|
-
|
|
102
|
-
const destPath = path.join(dest, entry.name);
|
|
103
|
-
|
|
104
|
-
if (entry.isDirectory()) {
|
|
105
|
-
if (!fs.existsSync(destPath)) {
|
|
106
|
-
fs.mkdirSync(destPath);
|
|
107
|
-
}
|
|
108
|
-
copyDir(srcPath, destPath);
|
|
109
|
-
} else {
|
|
110
|
-
fs.copyFileSync(srcPath, destPath);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
main().catch(console.error);
|