@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.
Files changed (97) hide show
  1. package/dist/index.js +2 -0
  2. package/package.json +2 -2
  3. package/templates/aurora-auth-node-demo/README.md +43 -0
  4. package/templates/aurora-auth-node-demo/aurora.config.ts +15 -0
  5. package/templates/aurora-auth-node-demo/bun.lock +679 -0
  6. package/templates/aurora-auth-node-demo/drizzle.config.ts +9 -0
  7. package/templates/aurora-auth-node-demo/package.json +39 -0
  8. package/templates/aurora-auth-node-demo/postcss.config.mjs +7 -0
  9. package/templates/aurora-auth-node-demo/server.mjs +46 -0
  10. package/templates/aurora-auth-node-demo/src/actions/createMessage.action.server.ts +31 -0
  11. package/templates/aurora-auth-node-demo/src/aurora.auth.ts +65 -0
  12. package/templates/aurora-auth-node-demo/src/lib/auth-client.ts +30 -0
  13. package/templates/aurora-auth-node-demo/src/lib/auth.server.ts +11 -0
  14. package/templates/aurora-auth-node-demo/src/lib/auth.ts +30 -0
  15. package/templates/aurora-auth-node-demo/src/lib/db.ts +6 -0
  16. package/templates/aurora-auth-node-demo/src/lib/pulse.ts +45 -0
  17. package/templates/aurora-auth-node-demo/src/lib/schema.ts +107 -0
  18. package/templates/aurora-auth-node-demo/src/queries/listMessages.server.ts +25 -0
  19. package/templates/aurora-auth-node-demo/src/routes/api/auth/[...slug]/handler.ts +14 -0
  20. package/templates/aurora-auth-node-demo/src/routes/api/pulse/verify/handler.ts +55 -0
  21. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.client.tsx +132 -0
  22. package/templates/aurora-auth-node-demo/src/routes/auth/sign-in/page.tsx +5 -0
  23. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.client.tsx +154 -0
  24. package/templates/aurora-auth-node-demo/src/routes/auth/sign-up/page.tsx +5 -0
  25. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.client.tsx +640 -0
  26. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/arena-game/page.tsx +5 -0
  27. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.client.tsx +349 -0
  28. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/chat/page.tsx +5 -0
  29. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.client.tsx +472 -0
  30. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/encrypted-chat/page.tsx +5 -0
  31. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.client.tsx +375 -0
  32. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/game-sync/page.tsx +5 -0
  33. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.client.tsx +423 -0
  34. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/queues/page.tsx +5 -0
  35. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.client.tsx +840 -0
  36. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/video-call/page.tsx +5 -0
  37. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.client.tsx +722 -0
  38. package/templates/aurora-auth-node-demo/src/routes/dashboard/demos/watch-together/page.tsx +5 -0
  39. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.client.tsx +113 -0
  40. package/templates/aurora-auth-node-demo/src/routes/dashboard/layout.tsx +5 -0
  41. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.client.tsx +195 -0
  42. package/templates/aurora-auth-node-demo/src/routes/dashboard/page.tsx +5 -0
  43. package/templates/aurora-auth-node-demo/src/routes/favicon.ico +0 -0
  44. package/templates/aurora-auth-node-demo/src/routes/layout.tsx +18 -0
  45. package/templates/aurora-auth-node-demo/src/routes/page.client.tsx +263 -0
  46. package/templates/aurora-auth-node-demo/src/routes/page.tsx +5 -0
  47. package/templates/aurora-auth-node-demo/src/styles/app.css +96 -0
  48. package/templates/aurora-auth-node-demo/tsconfig.json +27 -0
  49. package/templates/aurora-auth-node-demo/tsconfig.tsbuildinfo +1 -0
  50. package/templates/nextjs-auth-demo/next-env.d.ts +1 -1
  51. package/templates/nextjs-auth-demo/package.json +8 -7
  52. package/templates/nextjs-auth-demo/src/app/dashboard/demos/arena-game/page.tsx +20 -3
  53. package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +108 -23
  54. package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +278 -217
  55. package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +66 -35
  56. package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +213 -87
  57. package/templates/nextjs-auth-demo/src/app/dashboard/demos/video-call/page.tsx +106 -6
  58. package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +415 -262
  59. package/templates/nextjs-auth-node-demo/.env.example +10 -0
  60. package/templates/nextjs-auth-node-demo/Dockerfile +19 -0
  61. package/templates/nextjs-auth-node-demo/README.md +159 -0
  62. package/templates/nextjs-auth-node-demo/_gitignore +33 -0
  63. package/templates/nextjs-auth-node-demo/drizzle.config.ts +10 -0
  64. package/templates/nextjs-auth-node-demo/eslint.config.mjs +18 -0
  65. package/templates/nextjs-auth-node-demo/next-env.d.ts +6 -0
  66. package/templates/nextjs-auth-node-demo/next.config.ts +7 -0
  67. package/templates/nextjs-auth-node-demo/package.json +38 -0
  68. package/templates/nextjs-auth-node-demo/postcss.config.mjs +7 -0
  69. package/templates/nextjs-auth-node-demo/public/file.svg +1 -0
  70. package/templates/nextjs-auth-node-demo/public/globe.svg +1 -0
  71. package/templates/nextjs-auth-node-demo/public/next.svg +1 -0
  72. package/templates/nextjs-auth-node-demo/public/vercel.svg +1 -0
  73. package/templates/nextjs-auth-node-demo/public/window.svg +1 -0
  74. package/templates/nextjs-auth-node-demo/server.mjs +45 -0
  75. package/templates/nextjs-auth-node-demo/src/app/api/auth/[...all]/route.ts +4 -0
  76. package/templates/nextjs-auth-node-demo/src/app/api/pulse/verify/route.ts +54 -0
  77. package/templates/nextjs-auth-node-demo/src/app/auth/sign-in/page.tsx +131 -0
  78. package/templates/nextjs-auth-node-demo/src/app/auth/sign-up/page.tsx +153 -0
  79. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/arena-game/page.tsx +640 -0
  80. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/chat/page.tsx +349 -0
  81. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +472 -0
  82. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/game-sync/page.tsx +375 -0
  83. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/queues/page.tsx +423 -0
  84. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/video-call/page.tsx +840 -0
  85. package/templates/nextjs-auth-node-demo/src/app/dashboard/demos/watch-together/page.tsx +724 -0
  86. package/templates/nextjs-auth-node-demo/src/app/dashboard/layout.tsx +113 -0
  87. package/templates/nextjs-auth-node-demo/src/app/dashboard/page.tsx +195 -0
  88. package/templates/nextjs-auth-node-demo/src/app/favicon.ico +0 -0
  89. package/templates/nextjs-auth-node-demo/src/app/globals.css +96 -0
  90. package/templates/nextjs-auth-node-demo/src/app/layout.tsx +27 -0
  91. package/templates/nextjs-auth-node-demo/src/app/page.tsx +254 -0
  92. package/templates/nextjs-auth-node-demo/src/lib/auth-client.ts +15 -0
  93. package/templates/nextjs-auth-node-demo/src/lib/auth.ts +14 -0
  94. package/templates/nextjs-auth-node-demo/src/lib/db.ts +6 -0
  95. package/templates/nextjs-auth-node-demo/src/lib/pulse.ts +45 -0
  96. package/templates/nextjs-auth-node-demo/src/lib/schema.ts +107 -0
  97. 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 streamNameRef = useRef<string>("");
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 || !session) return;
82
+ if (!connRef.current || !sessionRef.current) return;
83
+ const sess = sessionRef.current;
76
84
 
77
85
  const streamName = `ttt-${code}`;
78
- streamNameRef.current = streamName;
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: session.user.name,
88
- userId: session.user.id,
94
+ name: sess.user.name,
95
+ userId: sess.user.id,
89
96
  }));
90
97
 
91
- // Listen for game events (skip self-echoes for moves)
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
- if (msg.type === "move" && msg.userId !== session.user.id) {
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 !== session.user.id) {
106
- setPlayers((prev) =>
107
- prev.includes(msg.name) ? prev : [...prev, msg.name]
108
- );
109
- // Second player gets O
110
- setMySymbol((prev) => prev || "X");
111
- } else if (msg.type === "assign") {
112
- // Server-side symbol assignment echo
113
- if (msg.userId === session.user.id && msg.symbol) {
114
- setMySymbol(msg.symbol);
115
- }
116
- } else if (msg.type === "reset" && msg.userId !== session.user.id) {
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
- setPlayers([session.user.name]);
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
- if (joinCode.trim()) {
136
- joinRoom(joinCode.trim().toUpperCase());
137
- // Second player joining gets O
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 || !connRef.current || !mySymbol) return;
174
+ if (board[index] || winner || !streamRef.current || !mySymbol) return;
150
175
  if (currentTurn !== mySymbol) return;
151
176
 
152
- const stream = connRef.current.stream(streamNameRef.current);
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 (skip self-echoes in on("data"))
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 (!connRef.current) return;
175
- const stream = connRef.current.stream(streamNameRef.current);
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
- streamNameRef.current = "";
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
- Send,
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 [publishedMessages, setPublishedMessages] = useState<QueueMsg[]>([]);
37
- const [receivedMessages, setReceivedMessages] = useState<QueueMsg[]>([]);
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 queueRef = useRef<PulseQueue | null>(null);
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", () => setConnected(true));
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() || !queueRef.current) return;
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 queueRef.current.publish(publishInput.trim(), { ttlSecs: 300 });
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: publishInput.trim(),
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
- if (!queueRef.current) return;
197
+ const q = getQueue();
198
+ if (!q) return;
99
199
 
100
200
  try {
101
- const msg = await queueRef.current.pull();
201
+ const msg = await q.pull();
102
202
  if (msg) {
103
- setReceivedMessages((prev) => [
104
- ...prev,
105
- {
106
- id: msg.sequence,
107
- payload: msg.payload,
108
- timestamp: Date.now(),
109
- acked: false,
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
- if (!queueRef.current) return;
222
+ const q = getQueue();
223
+ if (!q) return;
120
224
 
121
225
  try {
122
- const msgs = await queueRef.current.drain(50);
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
- setReceivedMessages((prev) => [...prev, ...mapped]);
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
- if (!queueRef.current) return;
245
+ const q = getQueue();
246
+ if (!q) return;
137
247
 
138
248
  try {
139
- await queueRef.current.ack(sequence);
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 (err) {
144
- console.error("ACK failed:", err);
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 offline simulation
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
- {/* Offline simulation */}
211
- <div className="glass rounded-xl p-5 mb-8 flex items-center justify-between">
212
- <div>
213
- <h3 className="text-sm font-semibold mb-1">🧪 Offline Simulation</h3>
214
- <p className="text-xs text-slate-400">
215
- {simulatedOffline
216
- ? "You are offline. Messages published by others will queue. Click 'Come Back Online' then 'Drain All' to receive them."
217
- : "Click 'Go Offline' to simulate a network disconnect. The relay will queue messages for you."}
218
- </p>
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
- <button onClick={toggleOffline}
221
- className={`flex items-center gap-2 px-5 py-2.5 rounded-lg font-medium text-sm transition-all ${simulatedOffline
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
- {simulatedOffline ? (<><Wifi className="w-4 h-4" />Come Back Online</>) : (<><WifiOff className="w-4 h-4" />Go Offline</>)}
226
- </button>
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..." disabled={!connected}
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 disabled:opacity-50" />
239
- <button type="submit" disabled={!connected || !publishInput.trim()}
240
- className="px-4 py-2.5 bg-amber-600 hover:bg-amber-500 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium">
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-80 overflow-y-auto">
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-800/30 border border-slate-700/50">
248
- <Clock className="w-3.5 h-3.5 text-slate-500 shrink-0" />
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 Next
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-80 overflow-y-auto">
277
- {receivedMessages.length === 0 && <p className="text-sm text-slate-500 text-center py-8">No messages consumed yet</p>}
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-800/30 border-slate-700/50"}`}>
281
- {msg.acked ? <Check className="w-3.5 h-3.5 text-green-400 shrink-0" /> : <Clock className="w-3.5 h-3.5 text-amber-400 shrink-0" />}
282
- <span className="text-sm flex-1 truncate">{msg.payload}</span>
283
- {!msg.acked && (
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-2 py-1 bg-green-600 hover:bg-green-500 rounded transition-all disabled:opacity-50">ACK</button>
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>