@sansavision/create-pulse 0.4.4 → 0.4.6
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 +2 -0
- package/package.json +2 -2
- package/templates/aurora-auth-node-demo/README.md +43 -0
- package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
- package/templates/aurora-auth-node-demo/bun.lock +679 -0
- package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
- package/templates/aurora-auth-node-demo/package.json +39 -0
- package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/aurora-auth-node-demo/server.mjs +46 -0
- package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
- package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
- package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
- package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
- package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
- package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
- package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
- package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
- package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
- package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
- package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
- package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
- package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
- package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
- package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
- package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
- package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
- package/templates/nextjs-auth-demo/package.json +8 -7
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +20 -3
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +108 -23
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +278 -217
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +66 -35
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +106 -6
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +415 -262
- package/templates/nextjs-auth-node-demo/.env.example +10 -0
- package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
- package/templates/nextjs-auth-node-demo/README.md +159 -0
- package/templates/nextjs-auth-node-demo/_gitignore +33 -0
- package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-node-demo/package.json +38 -0
- package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-node-demo/server.mjs +45 -0
- package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
- package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
- package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
- package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
- package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
- package/templates/nextjs-auth-node-demo/tsconfig.json +34 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
4
|
import { useSession } from "@/lib/auth-client";
|
|
5
5
|
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
-
import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw, Copy, Users, LogIn } from "lucide-react";
|
|
6
|
+
import { Gamepad2, Shield, Wifi, WifiOff, Loader2, RotateCcw, Copy, Users, LogIn, AlertCircle } from "lucide-react";
|
|
7
7
|
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
8
8
|
|
|
9
9
|
type Cell = "X" | "O" | null;
|
|
@@ -22,9 +22,16 @@ export default function GameSyncPage() {
|
|
|
22
22
|
// Room management
|
|
23
23
|
const [roomCode, setRoomCode] = useState<string>("");
|
|
24
24
|
const [joinCode, setJoinCode] = useState("");
|
|
25
|
+
const [error, setError] = useState("");
|
|
25
26
|
const [inRoom, setInRoom] = useState(false);
|
|
26
27
|
const [copied, setCopied] = useState(false);
|
|
27
|
-
const
|
|
28
|
+
const streamRef = useRef<ReturnType<PulseConnection["stream"]> | null>(null);
|
|
29
|
+
const mySymbolRef = useRef<"X" | "O" | null>(null);
|
|
30
|
+
const sessionRef = useRef(session);
|
|
31
|
+
|
|
32
|
+
// Keep refs in sync with state
|
|
33
|
+
useEffect(() => { sessionRef.current = session; }, [session]);
|
|
34
|
+
useEffect(() => { mySymbolRef.current = mySymbol; }, [mySymbol]);
|
|
28
35
|
|
|
29
36
|
const checkWinner = useCallback((b: Cell[]): Cell => {
|
|
30
37
|
const lines = [
|
|
@@ -72,27 +79,32 @@ export default function GameSyncPage() {
|
|
|
72
79
|
}, [session]);
|
|
73
80
|
|
|
74
81
|
function joinRoom(code: string) {
|
|
75
|
-
if (!connRef.current || !
|
|
82
|
+
if (!connRef.current || !sessionRef.current) return;
|
|
83
|
+
const sess = sessionRef.current;
|
|
76
84
|
|
|
77
85
|
const streamName = `ttt-${code}`;
|
|
78
|
-
|
|
86
|
+
const stream = connRef.current.stream(streamName);
|
|
87
|
+
streamRef.current = stream;
|
|
79
88
|
setRoomCode(code);
|
|
80
89
|
setInRoom(true);
|
|
81
90
|
|
|
82
|
-
const stream = connRef.current.stream(streamName);
|
|
83
|
-
|
|
84
91
|
// Send join message
|
|
85
92
|
stream.send(JSON.stringify({
|
|
86
93
|
type: "join",
|
|
87
|
-
name:
|
|
88
|
-
userId:
|
|
94
|
+
name: sess.user.name,
|
|
95
|
+
userId: sess.user.id,
|
|
89
96
|
}));
|
|
90
97
|
|
|
91
|
-
// Listen for game events
|
|
98
|
+
// Listen for game events — uses refs to avoid stale closures
|
|
92
99
|
stream.on("data", (data: Uint8Array) => {
|
|
93
100
|
try {
|
|
94
101
|
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
95
|
-
|
|
102
|
+
const currentSession = sessionRef.current;
|
|
103
|
+
if (!currentSession) return;
|
|
104
|
+
const selfId = currentSession.user.id;
|
|
105
|
+
const selfName = currentSession.user.name;
|
|
106
|
+
|
|
107
|
+
if (msg.type === "move" && msg.userId !== selfId) {
|
|
96
108
|
setBoard((prev) => {
|
|
97
109
|
const next = [...prev];
|
|
98
110
|
next[msg.cell] = msg.symbol;
|
|
@@ -102,18 +114,23 @@ export default function GameSyncPage() {
|
|
|
102
114
|
return next;
|
|
103
115
|
});
|
|
104
116
|
setCurrentTurn(msg.symbol === "X" ? "O" : "X");
|
|
105
|
-
} else if (msg.type === "join" && msg.userId !==
|
|
106
|
-
setPlayers((prev) =>
|
|
107
|
-
prev.includes(msg.name)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
} else if (msg.type === "join" && msg.userId !== selfId) {
|
|
118
|
+
setPlayers((prev) => {
|
|
119
|
+
if (!prev.includes(msg.name)) {
|
|
120
|
+
// Announce presence back with current symbol from ref
|
|
121
|
+
stream.send(JSON.stringify({
|
|
122
|
+
type: "presence",
|
|
123
|
+
name: selfName,
|
|
124
|
+
userId: selfId,
|
|
125
|
+
symbol: mySymbolRef.current
|
|
126
|
+
}));
|
|
127
|
+
return [...prev, msg.name];
|
|
128
|
+
}
|
|
129
|
+
return prev;
|
|
130
|
+
});
|
|
131
|
+
} else if (msg.type === "presence" && msg.userId !== selfId) {
|
|
132
|
+
setPlayers((prev) => prev.includes(msg.name) ? prev : [...prev, msg.name]);
|
|
133
|
+
} else if (msg.type === "reset" && msg.userId !== selfId) {
|
|
117
134
|
setBoard(Array(9).fill(null));
|
|
118
135
|
setCurrentTurn("X");
|
|
119
136
|
setWinner(null);
|
|
@@ -123,7 +140,8 @@ export default function GameSyncPage() {
|
|
|
123
140
|
|
|
124
141
|
// First player is X
|
|
125
142
|
setMySymbol("X");
|
|
126
|
-
|
|
143
|
+
mySymbolRef.current = "X";
|
|
144
|
+
setPlayers([sess.user.name]);
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
function createRoom() {
|
|
@@ -132,10 +150,17 @@ export default function GameSyncPage() {
|
|
|
132
150
|
}
|
|
133
151
|
|
|
134
152
|
function handleJoinExisting() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
153
|
+
const cleaned = joinCode.trim().toUpperCase();
|
|
154
|
+
if (cleaned) {
|
|
155
|
+
if (!/^[A-Z0-9]{6}$/.test(cleaned)) {
|
|
156
|
+
setError("Invalid room code format. Must be exactly 6 alphanumeric characters.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
setError("");
|
|
160
|
+
joinRoom(cleaned);
|
|
161
|
+
// Second player joining gets O — set both state AND ref
|
|
138
162
|
setMySymbol("O");
|
|
163
|
+
mySymbolRef.current = "O";
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
166
|
|
|
@@ -146,11 +171,10 @@ export default function GameSyncPage() {
|
|
|
146
171
|
}
|
|
147
172
|
|
|
148
173
|
function handleCellClick(index: number) {
|
|
149
|
-
if (board[index] || winner || !
|
|
174
|
+
if (board[index] || winner || !streamRef.current || !mySymbol) return;
|
|
150
175
|
if (currentTurn !== mySymbol) return;
|
|
151
176
|
|
|
152
|
-
|
|
153
|
-
stream.send(JSON.stringify({
|
|
177
|
+
streamRef.current.send(JSON.stringify({
|
|
154
178
|
type: "move",
|
|
155
179
|
cell: index,
|
|
156
180
|
symbol: mySymbol,
|
|
@@ -158,7 +182,7 @@ export default function GameSyncPage() {
|
|
|
158
182
|
userId: session?.user?.id,
|
|
159
183
|
}));
|
|
160
184
|
|
|
161
|
-
// Apply move locally
|
|
185
|
+
// Apply move locally
|
|
162
186
|
setBoard((prev) => {
|
|
163
187
|
const next = [...prev];
|
|
164
188
|
next[index] = mySymbol;
|
|
@@ -171,9 +195,8 @@ export default function GameSyncPage() {
|
|
|
171
195
|
}
|
|
172
196
|
|
|
173
197
|
function handleReset() {
|
|
174
|
-
if (!
|
|
175
|
-
|
|
176
|
-
stream.send(JSON.stringify({
|
|
198
|
+
if (!streamRef.current) return;
|
|
199
|
+
streamRef.current.send(JSON.stringify({
|
|
177
200
|
type: "reset",
|
|
178
201
|
userId: session?.user?.id,
|
|
179
202
|
}));
|
|
@@ -188,9 +211,10 @@ export default function GameSyncPage() {
|
|
|
188
211
|
setBoard(Array(9).fill(null));
|
|
189
212
|
setCurrentTurn("X");
|
|
190
213
|
setMySymbol(null);
|
|
214
|
+
mySymbolRef.current = null;
|
|
191
215
|
setWinner(null);
|
|
192
216
|
setPlayers([]);
|
|
193
|
-
|
|
217
|
+
streamRef.current = null;
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
return (
|
|
@@ -251,11 +275,18 @@ export default function GameSyncPage() {
|
|
|
251
275
|
</div>
|
|
252
276
|
</div>
|
|
253
277
|
|
|
278
|
+
{error && (
|
|
279
|
+
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2 text-red-400 text-sm text-left">
|
|
280
|
+
<AlertCircle className="w-4 h-4 shrink-0" />
|
|
281
|
+
<p>{error}</p>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
254
285
|
<div className="flex gap-2">
|
|
255
286
|
<input
|
|
256
287
|
type="text"
|
|
257
288
|
value={joinCode}
|
|
258
|
-
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
|
289
|
+
onChange={(e) => { setJoinCode(e.target.value.toUpperCase()); setError(""); }}
|
|
259
290
|
placeholder="Enter room code..."
|
|
260
291
|
maxLength={6}
|
|
261
292
|
className="flex-1 px-4 py-3 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-green-500 focus:ring-1 focus:ring-green-500 outline-none text-center text-lg font-mono tracking-wider placeholder:text-slate-600 transition-colors"
|
|
@@ -4,17 +4,8 @@ import { useState, useEffect, useRef } from "react";
|
|
|
4
4
|
import { useSession } from "@/lib/auth-client";
|
|
5
5
|
import { connectWithAuth } from "@/lib/pulse";
|
|
6
6
|
import {
|
|
7
|
-
Database,
|
|
8
|
-
|
|
9
|
-
Download,
|
|
10
|
-
Check,
|
|
11
|
-
WifiOff,
|
|
12
|
-
Wifi,
|
|
13
|
-
Shield,
|
|
14
|
-
Loader2,
|
|
15
|
-
Trash2,
|
|
16
|
-
Clock,
|
|
17
|
-
RefreshCw,
|
|
7
|
+
Database, Send, Download, Check, WifiOff, Wifi, Shield,
|
|
8
|
+
Loader2, Trash2, Clock, RefreshCw, Layers, Lock
|
|
18
9
|
} from "lucide-react";
|
|
19
10
|
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
20
11
|
import { PulseQueue } from "@sansavision/pulse-sdk";
|
|
@@ -33,10 +24,44 @@ export default function QueueDemoPage() {
|
|
|
33
24
|
const [connecting, setConnecting] = useState(true);
|
|
34
25
|
const [simulatedOffline, setSimulatedOffline] = useState(false);
|
|
35
26
|
const [publishInput, setPublishInput] = useState("");
|
|
36
|
-
const [
|
|
37
|
-
const [
|
|
27
|
+
const [queueType, setQueueType] = useState<"public" | "private">("public");
|
|
28
|
+
const [publishedMessages, setPublishedMessages] = useState<QueueMsg[]>(() => {
|
|
29
|
+
if (typeof window !== 'undefined') {
|
|
30
|
+
const saved = localStorage.getItem('pulse-queue-published');
|
|
31
|
+
if (saved) return JSON.parse(saved);
|
|
32
|
+
}
|
|
33
|
+
return [];
|
|
34
|
+
});
|
|
35
|
+
const [receivedMessages, setReceivedMessages] = useState<QueueMsg[]>(() => {
|
|
36
|
+
if (typeof window !== 'undefined') {
|
|
37
|
+
const saved = localStorage.getItem('pulse-queue-received');
|
|
38
|
+
if (saved) return JSON.parse(saved);
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
});
|
|
38
42
|
const connRef = useRef<PulseConnection | null>(null);
|
|
39
|
-
const
|
|
43
|
+
const [outbox, setOutbox] = useState<{ payload: string; queueName: string; timestamp: number }[]>(() => {
|
|
44
|
+
if (typeof window !== 'undefined') {
|
|
45
|
+
const saved = localStorage.getItem('pulse-queue-outbox');
|
|
46
|
+
if (saved) return JSON.parse(saved);
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const getQueueName = () => {
|
|
52
|
+
if (queueType === "public") return "demo-queue-public";
|
|
53
|
+
return `demo-queue-private-${session?.user?.id || "unknown"}`;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const getQueue = () => {
|
|
57
|
+
if (!connRef.current) return null;
|
|
58
|
+
return new PulseQueue(connRef.current, getQueueName());
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Persist outbox
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
localStorage.setItem('pulse-queue-outbox', JSON.stringify(outbox));
|
|
64
|
+
}, [outbox]);
|
|
40
65
|
|
|
41
66
|
useEffect(() => {
|
|
42
67
|
if (!session) return;
|
|
@@ -48,7 +73,6 @@ export default function QueueDemoPage() {
|
|
|
48
73
|
if (cancelled) return;
|
|
49
74
|
|
|
50
75
|
connRef.current = connection;
|
|
51
|
-
queueRef.current = new PulseQueue(connection, "demo-queue");
|
|
52
76
|
setConnected(true);
|
|
53
77
|
setConnecting(false);
|
|
54
78
|
|
|
@@ -57,7 +81,11 @@ export default function QueueDemoPage() {
|
|
|
57
81
|
if (user) setAuthUser(user);
|
|
58
82
|
|
|
59
83
|
connection.on("disconnect", () => setConnected(false));
|
|
60
|
-
connection.on("reconnected", () =>
|
|
84
|
+
connection.on("reconnected", () => {
|
|
85
|
+
setConnected(true);
|
|
86
|
+
// Auto-flush outbox on reconnect
|
|
87
|
+
flushOutbox(connection);
|
|
88
|
+
});
|
|
61
89
|
} catch (err) {
|
|
62
90
|
console.error("Failed to connect:", err);
|
|
63
91
|
setConnecting(false);
|
|
@@ -72,18 +100,89 @@ export default function QueueDemoPage() {
|
|
|
72
100
|
};
|
|
73
101
|
}, [session]);
|
|
74
102
|
|
|
103
|
+
// Flush outbox when connection becomes available
|
|
104
|
+
async function flushOutbox(conn?: PulseConnection) {
|
|
105
|
+
const connection = conn || connRef.current;
|
|
106
|
+
if (!connection) return;
|
|
107
|
+
const pending = JSON.parse(localStorage.getItem('pulse-queue-outbox') || '[]');
|
|
108
|
+
if (pending.length === 0) return;
|
|
109
|
+
|
|
110
|
+
const flushed: number[] = [];
|
|
111
|
+
for (let i = 0; i < pending.length; i++) {
|
|
112
|
+
const item = pending[i];
|
|
113
|
+
try {
|
|
114
|
+
const q = new PulseQueue(connection, item.queueName);
|
|
115
|
+
const result = await q.publish(item.payload, { ttlSecs: 300 });
|
|
116
|
+
setPublishedMessages((prev) => [
|
|
117
|
+
...prev,
|
|
118
|
+
{
|
|
119
|
+
id: result.sequence || Date.now(),
|
|
120
|
+
payload: item.payload,
|
|
121
|
+
timestamp: item.timestamp,
|
|
122
|
+
acked: false,
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
flushed.push(i);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error('Outbox flush failed for item:', err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Remove flushed items
|
|
131
|
+
const remaining = pending.filter((_: unknown, idx: number) => !flushed.includes(idx));
|
|
132
|
+
setOutbox(remaining);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Auto-flush outbox when we first connect
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (connected && outbox.length > 0) {
|
|
138
|
+
flushOutbox();
|
|
139
|
+
}
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, [connected]);
|
|
142
|
+
|
|
143
|
+
// Persist messages
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
localStorage.setItem('pulse-queue-published', JSON.stringify(publishedMessages.slice(-50)));
|
|
146
|
+
}, [publishedMessages]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
localStorage.setItem('pulse-queue-received', JSON.stringify(receivedMessages.slice(-50)));
|
|
150
|
+
}, [receivedMessages]);
|
|
151
|
+
|
|
75
152
|
async function handlePublish(e: React.FormEvent) {
|
|
76
153
|
e.preventDefault();
|
|
77
|
-
if (!publishInput.trim()
|
|
154
|
+
if (!publishInput.trim()) return;
|
|
155
|
+
|
|
156
|
+
const payload = publishInput.trim();
|
|
157
|
+
const qName = getQueueName();
|
|
158
|
+
|
|
159
|
+
// If offline, queue locally
|
|
160
|
+
if (!connected || !connRef.current) {
|
|
161
|
+
setOutbox((prev) => [...prev, { payload, queueName: qName, timestamp: Date.now() }]);
|
|
162
|
+
setPublishedMessages((prev) => [
|
|
163
|
+
...prev,
|
|
164
|
+
{
|
|
165
|
+
id: -(Date.now()), // negative ID = pending/local
|
|
166
|
+
payload: `⏳ ${payload}`,
|
|
167
|
+
timestamp: Date.now(),
|
|
168
|
+
acked: false,
|
|
169
|
+
},
|
|
170
|
+
]);
|
|
171
|
+
setPublishInput("");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const q = getQueue();
|
|
176
|
+
if (!q) return;
|
|
78
177
|
|
|
79
178
|
try {
|
|
80
|
-
const result = await
|
|
179
|
+
const result = await q.publish(payload, { ttlSecs: 300 });
|
|
81
180
|
|
|
82
181
|
setPublishedMessages((prev) => [
|
|
83
182
|
...prev,
|
|
84
183
|
{
|
|
85
184
|
id: result.sequence || Date.now(),
|
|
86
|
-
payload
|
|
185
|
+
payload,
|
|
87
186
|
timestamp: Date.now(),
|
|
88
187
|
acked: false,
|
|
89
188
|
},
|
|
@@ -95,20 +194,24 @@ export default function QueueDemoPage() {
|
|
|
95
194
|
}
|
|
96
195
|
|
|
97
196
|
async function handleConsume() {
|
|
98
|
-
|
|
197
|
+
const q = getQueue();
|
|
198
|
+
if (!q) return;
|
|
99
199
|
|
|
100
200
|
try {
|
|
101
|
-
const msg = await
|
|
201
|
+
const msg = await q.pull();
|
|
102
202
|
if (msg) {
|
|
103
|
-
setReceivedMessages((prev) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
203
|
+
setReceivedMessages((prev) => {
|
|
204
|
+
if (prev.find(m => m.id === msg.sequence)) return prev;
|
|
205
|
+
return [
|
|
206
|
+
...prev,
|
|
207
|
+
{
|
|
208
|
+
id: msg.sequence,
|
|
209
|
+
payload: msg.payload,
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
acked: false,
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
});
|
|
112
215
|
}
|
|
113
216
|
} catch (err) {
|
|
114
217
|
console.error("Consume failed:", err);
|
|
@@ -116,32 +219,42 @@ export default function QueueDemoPage() {
|
|
|
116
219
|
}
|
|
117
220
|
|
|
118
221
|
async function handleDrainAll() {
|
|
119
|
-
|
|
222
|
+
const q = getQueue();
|
|
223
|
+
if (!q) return;
|
|
120
224
|
|
|
121
225
|
try {
|
|
122
|
-
const msgs = await
|
|
226
|
+
const msgs = await q.drain(50);
|
|
123
227
|
const mapped = msgs.map((m) => ({
|
|
124
228
|
id: m.sequence,
|
|
125
229
|
payload: m.payload,
|
|
126
230
|
timestamp: Date.now(),
|
|
127
231
|
acked: false,
|
|
128
232
|
}));
|
|
129
|
-
|
|
233
|
+
|
|
234
|
+
setReceivedMessages((prev) => {
|
|
235
|
+
const map = new Map(prev.map(p => [p.id, p]));
|
|
236
|
+
for (const m of mapped) { map.set(m.id, m); }
|
|
237
|
+
return Array.from(map.values()).sort((a, b) => a.id - b.id);
|
|
238
|
+
});
|
|
130
239
|
} catch (err) {
|
|
131
240
|
console.error("Drain failed:", err);
|
|
132
241
|
}
|
|
133
242
|
}
|
|
134
243
|
|
|
135
244
|
async function handleAck(sequence: number) {
|
|
136
|
-
|
|
245
|
+
const q = getQueue();
|
|
246
|
+
if (!q) return;
|
|
137
247
|
|
|
138
248
|
try {
|
|
139
|
-
await
|
|
249
|
+
await q.ack(sequence);
|
|
140
250
|
setReceivedMessages((prev) =>
|
|
141
251
|
prev.map((m) => (m.id === sequence ? { ...m, acked: true } : m))
|
|
142
252
|
);
|
|
143
|
-
} catch
|
|
144
|
-
|
|
253
|
+
} catch {
|
|
254
|
+
// Message expired or no longer in relay — mark as stale in UI
|
|
255
|
+
setReceivedMessages((prev) =>
|
|
256
|
+
prev.map((m) => (m.id === sequence ? { ...m, acked: true, payload: m.payload + " (expired)" } : m))
|
|
257
|
+
);
|
|
145
258
|
}
|
|
146
259
|
}
|
|
147
260
|
|
|
@@ -152,11 +265,7 @@ export default function QueueDemoPage() {
|
|
|
152
265
|
try {
|
|
153
266
|
const connection = await connectWithAuth();
|
|
154
267
|
connRef.current = connection;
|
|
155
|
-
queueRef.current = new PulseQueue(connection, "demo-queue");
|
|
156
268
|
setConnected(true);
|
|
157
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
-
const user = (connection as any).user;
|
|
159
|
-
if (user) setAuthUser(user);
|
|
160
269
|
} catch {
|
|
161
270
|
console.error("Reconnect failed");
|
|
162
271
|
}
|
|
@@ -179,7 +288,7 @@ export default function QueueDemoPage() {
|
|
|
179
288
|
<div>
|
|
180
289
|
<h1 className="text-2xl font-bold">Durable Queues</h1>
|
|
181
290
|
<p className="text-sm text-slate-500">
|
|
182
|
-
Persistent store-and-forward with
|
|
291
|
+
Persistent store-and-forward with multiple topologies
|
|
183
292
|
</p>
|
|
184
293
|
</div>
|
|
185
294
|
</div>
|
|
@@ -207,84 +316,101 @@ export default function QueueDemoPage() {
|
|
|
207
316
|
</div>
|
|
208
317
|
) : (
|
|
209
318
|
<>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
319
|
+
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
|
320
|
+
<div className="glass rounded-xl p-5 flex-1 flex items-center justify-between">
|
|
321
|
+
<div>
|
|
322
|
+
<h3 className="text-sm font-semibold mb-1 flex items-center gap-2">
|
|
323
|
+
{queueType === 'public' ? <Layers className="w-4 h-4 text-cyan-400" /> : <Lock className="w-4 h-4 text-amber-400" />}
|
|
324
|
+
Queue Topology
|
|
325
|
+
</h3>
|
|
326
|
+
<p className="text-xs text-slate-400">
|
|
327
|
+
{queueType === 'public' ? "Public queue: all demo users can push and pull." : "Private queue: only you can access these messages."}
|
|
328
|
+
</p>
|
|
329
|
+
</div>
|
|
330
|
+
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
|
|
331
|
+
<button onClick={() => { setQueueType("public"); setReceivedMessages([]); setPublishedMessages([]); }} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${queueType === 'public' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'}`}>Public</button>
|
|
332
|
+
<button onClick={() => { setQueueType("private"); setReceivedMessages([]); setPublishedMessages([]); }} className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${queueType === 'private' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'}`}>Private</button>
|
|
333
|
+
</div>
|
|
219
334
|
</div>
|
|
220
|
-
<
|
|
221
|
-
|
|
335
|
+
<div className="glass rounded-xl p-5 flex-1 flex items-center justify-between">
|
|
336
|
+
<div>
|
|
337
|
+
<h3 className="text-sm font-semibold mb-1">🧪 Offline Simulation</h3>
|
|
338
|
+
<p className="text-xs text-slate-400">
|
|
339
|
+
Simulate a network disconnect. Messages will queue on the relay.
|
|
340
|
+
</p>
|
|
341
|
+
</div>
|
|
342
|
+
<button onClick={toggleOffline}
|
|
343
|
+
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium text-sm transition-all ${simulatedOffline
|
|
222
344
|
? "bg-green-600 hover:bg-green-500 text-white"
|
|
223
345
|
: "bg-red-600/20 border border-red-500/30 text-red-400 hover:bg-red-600/30"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
346
|
+
}`}>
|
|
347
|
+
{simulatedOffline ? (<><Wifi className="w-4 h-4" />Resume</>) : (<><WifiOff className="w-4 h-4" />Go Offline</>)}
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
227
350
|
</div>
|
|
228
351
|
|
|
229
352
|
<div className="grid lg:grid-cols-2 gap-8">
|
|
230
353
|
{/* Publisher */}
|
|
231
|
-
<div className="glass rounded-2xl p-6">
|
|
354
|
+
<div className="glass rounded-2xl p-6 border-t border-slate-700/50">
|
|
232
355
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
233
|
-
<Send className="w-4 h-4 text-amber-400" />Publisher
|
|
356
|
+
<Send className="w-4 h-4 text-amber-400" />Publisher <span className="text-xs font-mono text-slate-500 ml-auto bg-slate-900 px-2 py-1 rounded">{getQueueName()}</span>
|
|
234
357
|
</h2>
|
|
235
358
|
<form onSubmit={handlePublish} className="flex gap-2 mb-4">
|
|
236
359
|
<input type="text" value={publishInput} onChange={(e) => setPublishInput(e.target.value)}
|
|
237
|
-
placeholder="Message to publish..."
|
|
238
|
-
className="flex-1 px-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none text-sm transition-colors placeholder:text-slate-600
|
|
239
|
-
<button type="submit" disabled={!
|
|
240
|
-
className=
|
|
241
|
-
Publish
|
|
360
|
+
placeholder={connected ? "Message to publish..." : "Type to queue offline..."}
|
|
361
|
+
className="flex-1 px-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none text-sm transition-colors placeholder:text-slate-600" />
|
|
362
|
+
<button type="submit" disabled={!publishInput.trim()}
|
|
363
|
+
className={`px-4 py-2.5 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium shadow-lg ${connected ? 'bg-amber-600 hover:bg-amber-500 shadow-amber-600/20' : 'bg-slate-700 hover:bg-slate-600 shadow-none text-slate-300'}`}>
|
|
364
|
+
{connected ? 'Publish' : `Queue (${outbox.length})`}
|
|
242
365
|
</button>
|
|
243
366
|
</form>
|
|
244
|
-
<div className="space-y-2 max-h-
|
|
367
|
+
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
|
|
245
368
|
{publishedMessages.length === 0 && <p className="text-sm text-slate-500 text-center py-8">No messages published yet</p>}
|
|
246
|
-
{publishedMessages.map((msg) => (
|
|
247
|
-
<div key={msg.id} className="flex items-center gap-3 p-3 rounded-lg bg-slate-
|
|
248
|
-
<
|
|
249
|
-
<span className="text-sm flex-1 truncate">{msg.payload}</span>
|
|
250
|
-
<span className="text-xs text-slate-500 font-mono">seq:{msg.id}</span>
|
|
251
|
-
<span className="text-xs text-slate-500">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
|
369
|
+
{[...publishedMessages].reverse().map((msg) => (
|
|
370
|
+
<div key={`pub-${msg.id}-${msg.timestamp}`} className="flex items-center gap-3 p-3 rounded-lg bg-slate-900/50 border border-slate-700/50">
|
|
371
|
+
<Check className="w-3.5 h-3.5 text-green-500 shrink-0" />
|
|
372
|
+
<span className="text-sm flex-1 truncate font-medium text-slate-300">{msg.payload}</span>
|
|
373
|
+
<span className="text-xs text-slate-500 font-mono tracking-widest bg-slate-800 px-1.5 py-0.5 rounded">seq:{msg.id}</span>
|
|
252
374
|
</div>
|
|
253
375
|
))}
|
|
254
376
|
</div>
|
|
255
377
|
</div>
|
|
256
378
|
|
|
257
379
|
{/* Consumer */}
|
|
258
|
-
<div className="glass rounded-2xl p-6">
|
|
380
|
+
<div className="glass rounded-2xl p-6 border-t border-slate-700/50">
|
|
259
381
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
260
|
-
<Download className="w-4 h-4 text-cyan-400" />Consumer
|
|
382
|
+
<Download className="w-4 h-4 text-cyan-400" />Consumer <span className="text-xs font-mono text-slate-500 ml-auto bg-slate-900 px-2 py-1 rounded">{getQueueName()}</span>
|
|
261
383
|
</h2>
|
|
262
384
|
<div className="flex gap-2 mb-4">
|
|
263
385
|
<button onClick={handleConsume} disabled={!connected}
|
|
264
|
-
className="flex items-center gap-2 px-4 py-2.5 bg-cyan-600 hover:bg-cyan-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium">
|
|
265
|
-
<RefreshCw className="w-3.5 h-3.5" />Pull
|
|
386
|
+
className="flex items-center gap-2 px-4 py-2.5 bg-cyan-600 hover:bg-cyan-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium shadow-lg shadow-cyan-600/20 w-1/3 justify-center">
|
|
387
|
+
<RefreshCw className="w-3.5 h-3.5" />Pull 1
|
|
266
388
|
</button>
|
|
267
389
|
<button onClick={handleDrainAll} disabled={!connected}
|
|
268
|
-
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium">
|
|
390
|
+
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium shadow-lg shadow-indigo-600/20 w-1/3 justify-center">
|
|
269
391
|
<Download className="w-3.5 h-3.5" />Drain All
|
|
270
392
|
</button>
|
|
271
393
|
<button onClick={() => setReceivedMessages([])}
|
|
272
|
-
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-slate-700 hover:bg-slate-800/50 transition-all text-sm text-slate-400">
|
|
273
|
-
<Trash2 className="w-3.5 h-3.5" />Clear
|
|
394
|
+
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-slate-700 hover:bg-slate-800/50 transition-all text-sm text-slate-400 w-1/3 justify-center">
|
|
395
|
+
<Trash2 className="w-3.5 h-3.5" />Clear UI
|
|
274
396
|
</button>
|
|
275
397
|
</div>
|
|
276
|
-
<div className="space-y-2 max-h-
|
|
277
|
-
{receivedMessages.length === 0 && <p className="text-sm text-slate-500 text-center py-8">
|
|
278
|
-
{receivedMessages.map((msg) => (
|
|
279
|
-
<div key={msg.id}
|
|
280
|
-
className={`flex items-center gap-3 p-3 rounded-lg border ${msg.acked ? "bg-green-500/5 border-green-500/20" : "bg-slate-
|
|
281
|
-
{msg.acked ? <Check className="w-
|
|
282
|
-
<
|
|
283
|
-
|
|
398
|
+
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
|
|
399
|
+
{receivedMessages.length === 0 && <p className="text-sm text-slate-500 text-center py-8 italic">Inbox zero. Click Pull or Drain.</p>}
|
|
400
|
+
{[...receivedMessages].reverse().map((msg) => (
|
|
401
|
+
<div key={`rec-${msg.id}-${msg.timestamp}`}
|
|
402
|
+
className={`flex items-center gap-3 p-3 rounded-lg border ${msg.acked ? "bg-green-500/5 border-green-500/20" : "bg-slate-900 border-indigo-500/30 shadow-inner shadow-indigo-500/5"}`}>
|
|
403
|
+
{msg.acked ? <Check className="w-4 h-4 text-green-400 shrink-0" /> : <Clock className="w-4 h-4 text-amber-400 shrink-0 animate-pulse" />}
|
|
404
|
+
<div className="flex-1 min-w-0">
|
|
405
|
+
<div className="text-sm font-medium text-white truncate">{msg.payload}</div>
|
|
406
|
+
<div className="text-[10px] text-slate-500 font-mono tracking-widest mt-0.5">SEQ:{msg.id} | {new Date(msg.timestamp).toLocaleTimeString()}</div>
|
|
407
|
+
</div>
|
|
408
|
+
{!msg.acked ? (
|
|
284
409
|
<button onClick={() => handleAck(msg.id)} disabled={!connected}
|
|
285
|
-
className="text-xs px-
|
|
410
|
+
className="text-xs px-3 py-1.5 font-bold tracking-widest bg-green-600 hover:bg-green-500 rounded-md transition-all disabled:opacity-50">ACK</button>
|
|
411
|
+
) : (
|
|
412
|
+
<span className="text-[10px] font-bold text-green-500 uppercase tracking-widest mr-2">Acked</span>
|
|
286
413
|
)}
|
|
287
|
-
<span className="text-xs text-slate-500">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
|
288
414
|
</div>
|
|
289
415
|
))}
|
|
290
416
|
</div>
|