@gwakko/shared-websocket 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,9 @@ Share ONE WebSocket connection across multiple browser tabs. Zero dependencies.
32
32
  - [Lifecycle Hooks](#lifecycle-hooks)
33
33
  - [Private Channels](#private-channels--chat-rooms-tenant-notifications)
34
34
  - [Server-side channel handling](#server-side-channel-handling)
35
+ - [Topics](#topics--server-side-filtered-subscriptions)
36
+ - [Push Notifications](#push-notifications)
37
+ - [Server-Side Implementation Guide](#server-side-implementation-guide)
35
38
  - [Exported Types](#exported-types)
36
39
  - [Browser Support](#browser-support)
37
40
  - [License](#license)
@@ -1074,6 +1077,430 @@ wss.on('connection', (ws) => {
1074
1077
  });
1075
1078
  ```
1076
1079
 
1080
+ ## Topics — Server-Side Filtered Subscriptions
1081
+
1082
+ Subscribe to specific topics so the server only sends relevant events:
1083
+
1084
+ ```typescript
1085
+ // Vanilla
1086
+ ws.subscribe('notifications:orders');
1087
+ ws.subscribe('notifications:payments');
1088
+ ws.subscribe(`user:${userId}:mentions`);
1089
+
1090
+ // Later — unsubscribe
1091
+ ws.unsubscribe('notifications:orders');
1092
+ ```
1093
+
1094
+ ```tsx
1095
+ // React — auto-subscribe on mount, unsubscribe on unmount
1096
+ function OrdersDashboard() {
1097
+ useTopics(['notifications:orders', 'notifications:payments']);
1098
+
1099
+ const order = useSocketEvent('notifications:orders:new');
1100
+ return order ? <div>New order #{order.id}</div> : null;
1101
+ }
1102
+
1103
+ // Dynamic topics
1104
+ function UserMentions({ userId }: { userId: string }) {
1105
+ useTopics([`user:${userId}:mentions`]);
1106
+ useSocketCallback(`user:${userId}:mentions:mention`, showMentionToast);
1107
+ return null;
1108
+ }
1109
+ ```
1110
+
1111
+ ```vue
1112
+ <!-- Vue — same pattern -->
1113
+ <script setup>
1114
+ const props = defineProps<{ userId: string }>();
1115
+ useTopics([`user:${props.userId}:mentions`]);
1116
+ useSocketEvent(`user:${props.userId}:mentions:mention`, showToast);
1117
+ </script>
1118
+ ```
1119
+
1120
+ Server receives `$topic:subscribe` / `$topic:unsubscribe` events (configurable via `events.topicSubscribe`).
1121
+
1122
+ ## Push Notifications
1123
+
1124
+ Two modes: **custom render** (sonner, react-hot-toast, your UI) and/or **browser Notification API**.
1125
+
1126
+ `target` controls which tab(s) show the notification:
1127
+
1128
+ | Target | Behavior | Default for |
1129
+ |--------|----------|-------------|
1130
+ | `'active'` | Only the currently visible/focused tab | render (toasts) |
1131
+ | `'leader'` | Only the leader tab | browser Notification |
1132
+ | `'all'` | Every tab (critical alerts) | — |
1133
+
1134
+ ### Custom Render — you control the display
1135
+
1136
+ ```typescript
1137
+ // Vanilla — sonner toast (default: target 'active' — visible tab only)
1138
+ import { toast } from 'sonner';
1139
+
1140
+ ws.push('notification', {
1141
+ render: (n) => toast(n.title, { description: n.body }),
1142
+ // target: 'active' — implicit default
1143
+ });
1144
+
1145
+ ws.push('order.created', {
1146
+ render: (order) => toast.success(`New Order #${order.id}`, {
1147
+ description: `$${order.total} from ${order.customer}`,
1148
+ action: { label: 'View', onClick: () => navigate(`/orders/${order.id}`) },
1149
+ }),
1150
+ });
1151
+ ```
1152
+
1153
+ ```tsx
1154
+ // React — sonner
1155
+ import { toast } from 'sonner';
1156
+
1157
+ function NotificationSetup() {
1158
+ usePush('notification', {
1159
+ render: (n) => toast(n.title, { description: n.body }),
1160
+ });
1161
+
1162
+ usePush('order.created', {
1163
+ render: (order) => toast.success(`Order #${order.id} — $${order.total}`),
1164
+ });
1165
+
1166
+ return null;
1167
+ }
1168
+
1169
+ // React — react-hot-toast
1170
+ import hotToast from 'react-hot-toast';
1171
+
1172
+ function NotificationSetup() {
1173
+ usePush('notification', {
1174
+ render: (n) => hotToast(n.title),
1175
+ });
1176
+ return null;
1177
+ }
1178
+ ```
1179
+
1180
+ ```vue
1181
+ <!-- Vue — sonner-vue -->
1182
+ <script setup>
1183
+ import { toast } from 'sonner-vue';
1184
+
1185
+ usePush('notification', {
1186
+ render: (n) => toast(n.title, { description: n.body }),
1187
+ });
1188
+
1189
+ usePush('order.created', {
1190
+ render: (order) => toast.success(`Order #${order.id} — $${order.total}`),
1191
+ });
1192
+ </script>
1193
+ ```
1194
+
1195
+ ### Browser Notification API — native OS notifications
1196
+
1197
+ ```typescript
1198
+ // Vanilla — browser native (default: target 'leader' — one notification, not N)
1199
+ ws.push('notification', {
1200
+ title: (n) => n.title,
1201
+ body: (n) => n.body,
1202
+ icon: '/icons/bell.png',
1203
+ tag: (n) => `notif-${n.id}`, // deduplication
1204
+ onClick: (n) => window.open(n.url),
1205
+ // target: 'leader' — implicit default for native notifications
1206
+ });
1207
+ ```
1208
+
1209
+ ```tsx
1210
+ // React
1211
+ usePush('order.created', {
1212
+ title: (order) => `New Order #${order.id}`,
1213
+ body: (order) => `$${order.total}`,
1214
+ icon: '/icons/order.png',
1215
+ onClick: (order) => navigate(`/orders/${order.id}`),
1216
+ });
1217
+ ```
1218
+
1219
+ ```vue
1220
+ <!-- Vue -->
1221
+ <script setup>
1222
+ usePush('order.created', {
1223
+ title: (order) => `New Order #${order.id}`,
1224
+ body: (order) => `$${order.total}`,
1225
+ });
1226
+ </script>
1227
+ ```
1228
+
1229
+ ### Critical alerts — show in ALL tabs
1230
+
1231
+ ```typescript
1232
+ // Vanilla — payment failed: show toast in EVERY tab
1233
+ ws.push('payment.failed', {
1234
+ render: (err) => toast.error(`Payment failed: ${err.message}`),
1235
+ target: 'all', // every tab sees it
1236
+ });
1237
+ ```
1238
+
1239
+ ```tsx
1240
+ // React
1241
+ usePush('payment.failed', {
1242
+ render: (err) => toast.error(`Payment failed: ${err.message}`),
1243
+ target: 'all',
1244
+ });
1245
+ ```
1246
+
1247
+ ```vue
1248
+ <!-- Vue -->
1249
+ <script setup>
1250
+ usePush('payment.failed', {
1251
+ render: (err) => toast.error(`Payment failed: ${err.message}`),
1252
+ target: 'all',
1253
+ });
1254
+ </script>
1255
+ ```
1256
+
1257
+ ### Both — toast in UI + browser notification
1258
+
1259
+ ```typescript
1260
+ // Active tab gets sonner toast, leader sends native notification
1261
+ ws.push('order.created', {
1262
+ render: (order) => toast.success(`Order #${order.id}`), // in-app toast
1263
+ title: (order) => `New Order #${order.id}`, // + native notification
1264
+ body: (order) => `$${order.total}`,
1265
+ });
1266
+ ```
1267
+
1268
+ ## Server-Side Implementation Guide
1269
+
1270
+ Complete server reference — what events to listen for and how to respond.
1271
+
1272
+ ### Message Format
1273
+
1274
+ All messages are JSON with two fields (configurable via `events` option):
1275
+
1276
+ ```
1277
+ Client → Server: { "event": "event.name", "data": { ... } }
1278
+ Server → Client: { "event": "event.name", "data": { ... } }
1279
+ ```
1280
+
1281
+ ### System Events (sent by client automatically)
1282
+
1283
+ | Event | When | Payload | Your Server Should |
1284
+ |-------|------|---------|-------------------|
1285
+ | `ping` | Every 30s (heartbeat) | `{ "type": "ping" }` | Respond with `{ "type": "pong" }` or ignore |
1286
+ | `$channel:join` | `ws.channel('name')` | `{ "channel": "chat:room_1" }` | Track which channels this connection belongs to |
1287
+ | `$channel:leave` | `channel.leave()` | `{ "channel": "chat:room_1" }` | Remove connection from channel |
1288
+ | `$topic:subscribe` | `ws.subscribe('topic')` | `{ "topic": "notifications:orders" }` | Start sending events for this topic to this connection |
1289
+ | `$topic:unsubscribe` | `ws.unsubscribe('topic')` | `{ "topic": "notifications:orders" }` | Stop sending events for this topic |
1290
+
1291
+ ### Node.js (ws) — Complete Server Example
1292
+
1293
+ ```typescript
1294
+ import { WebSocketServer, WebSocket } from 'ws';
1295
+
1296
+ const wss = new WebSocketServer({ port: 8080 });
1297
+
1298
+ // Track per-connection state
1299
+ interface ClientState {
1300
+ userId?: string;
1301
+ channels: Set<string>;
1302
+ topics: Set<string>;
1303
+ }
1304
+
1305
+ const clients = new Map<WebSocket, ClientState>();
1306
+
1307
+ wss.on('connection', (ws, req) => {
1308
+ // Extract auth token from URL
1309
+ const url = new URL(req.url!, `http://${req.headers.host}`);
1310
+ const token = url.searchParams.get('token');
1311
+ const userId = verifyToken(token); // your auth logic
1312
+
1313
+ const state: ClientState = {
1314
+ userId,
1315
+ channels: new Set(),
1316
+ topics: new Set(),
1317
+ };
1318
+ clients.set(ws, state);
1319
+
1320
+ // Send welcome
1321
+ send(ws, 'welcome', { userId, timestamp: Date.now() });
1322
+
1323
+ ws.on('message', (raw) => {
1324
+ const msg = JSON.parse(raw.toString());
1325
+ const { event, data } = msg;
1326
+
1327
+ switch (event) {
1328
+ // ─── System Events ───────────────────────
1329
+
1330
+ case 'ping':
1331
+ // Respond to heartbeat (optional — some servers ignore pings)
1332
+ ws.send(JSON.stringify({ type: 'pong' }));
1333
+ break;
1334
+
1335
+ // ─── Channel Events ──────────────────────
1336
+
1337
+ case '$channel:join':
1338
+ state.channels.add(data.channel);
1339
+ console.log(`${userId} joined ${data.channel}`);
1340
+ // Send channel history, presence, etc.
1341
+ break;
1342
+
1343
+ case '$channel:leave':
1344
+ state.channels.delete(data.channel);
1345
+ console.log(`${userId} left ${data.channel}`);
1346
+ break;
1347
+
1348
+ // ─── Topic Events ────────────────────────
1349
+
1350
+ case '$topic:subscribe':
1351
+ state.topics.add(data.topic);
1352
+ console.log(`${userId} subscribed to ${data.topic}`);
1353
+ break;
1354
+
1355
+ case '$topic:unsubscribe':
1356
+ state.topics.delete(data.topic);
1357
+ break;
1358
+
1359
+ // ─── App Events ──────────────────────────
1360
+
1361
+ case 'chat.send':
1362
+ // Broadcast to all clients in the same channel
1363
+ const channel = data.roomId ? `chat:${data.roomId}` : null;
1364
+ broadcastToChannel(channel, 'chat.message', {
1365
+ id: crypto.randomUUID(),
1366
+ userId: state.userId,
1367
+ text: data.text,
1368
+ timestamp: Date.now(),
1369
+ });
1370
+ break;
1371
+
1372
+ case 'chat.typing':
1373
+ broadcastToChannel(`chat:${data.roomId}`, 'chat.typing', {
1374
+ userId: state.userId,
1375
+ }, ws); // exclude sender
1376
+ break;
1377
+
1378
+ default:
1379
+ console.log('Unknown event:', event, data);
1380
+ }
1381
+ });
1382
+
1383
+ ws.on('close', () => {
1384
+ clients.delete(ws);
1385
+ });
1386
+ });
1387
+
1388
+ // ─── Helpers ─────────────────────────────────────
1389
+
1390
+ function send(ws: WebSocket, event: string, data: unknown) {
1391
+ if (ws.readyState === WebSocket.OPEN) {
1392
+ ws.send(JSON.stringify({ event, data }));
1393
+ }
1394
+ }
1395
+
1396
+ function broadcastToChannel(
1397
+ channel: string | null,
1398
+ event: string,
1399
+ data: unknown,
1400
+ exclude?: WebSocket,
1401
+ ) {
1402
+ for (const [ws, state] of clients) {
1403
+ if (ws === exclude) continue;
1404
+ if (channel && !state.channels.has(channel)) continue;
1405
+ send(ws, event, data);
1406
+ }
1407
+ }
1408
+
1409
+ function broadcastToTopic(topic: string, event: string, data: unknown) {
1410
+ for (const [ws, state] of clients) {
1411
+ if (!state.topics.has(topic)) continue;
1412
+ send(ws, `${topic}:${event}`, data);
1413
+ }
1414
+ }
1415
+
1416
+ // ─── Example: Send notifications by topic ────────
1417
+
1418
+ function notifyNewOrder(order: Order) {
1419
+ broadcastToTopic('notifications:orders', 'new', {
1420
+ id: order.id,
1421
+ total: order.total,
1422
+ customer: order.customerName,
1423
+ });
1424
+ // Only clients who called ws.subscribe('notifications:orders') receive this
1425
+ }
1426
+ ```
1427
+
1428
+ ### Go — Server Example
1429
+
1430
+ ```go
1431
+ // Message format
1432
+ type Message struct {
1433
+ Event string `json:"event"`
1434
+ Data json.RawMessage `json:"data"`
1435
+ }
1436
+
1437
+ // Handle incoming messages
1438
+ func handleMessage(conn *websocket.Conn, state *ClientState, msg Message) {
1439
+ switch msg.Event {
1440
+ case "$channel:join":
1441
+ var payload struct{ Channel string `json:"channel"` }
1442
+ json.Unmarshal(msg.Data, &payload)
1443
+ state.Channels[payload.Channel] = true
1444
+
1445
+ case "$channel:leave":
1446
+ var payload struct{ Channel string `json:"channel"` }
1447
+ json.Unmarshal(msg.Data, &payload)
1448
+ delete(state.Channels, payload.Channel)
1449
+
1450
+ case "$topic:subscribe":
1451
+ var payload struct{ Topic string `json:"topic"` }
1452
+ json.Unmarshal(msg.Data, &payload)
1453
+ state.Topics[payload.Topic] = true
1454
+
1455
+ case "$topic:unsubscribe":
1456
+ var payload struct{ Topic string `json:"topic"` }
1457
+ json.Unmarshal(msg.Data, &payload)
1458
+ delete(state.Topics, payload.Topic)
1459
+
1460
+ case "chat.send":
1461
+ // broadcast to channel...
1462
+
1463
+ case "ping":
1464
+ conn.WriteJSON(Message{Event: "pong"})
1465
+ }
1466
+ }
1467
+ ```
1468
+
1469
+ ### PHP (Laravel + Ratchet/Swoole) — Server Example
1470
+
1471
+ ```php
1472
+ // Handle incoming WebSocket message
1473
+ public function onMessage(ConnectionInterface $conn, $msg): void
1474
+ {
1475
+ $data = json_decode($msg, true);
1476
+ $event = $data['event'] ?? 'message';
1477
+ $payload = $data['data'] ?? [];
1478
+
1479
+ match ($event) {
1480
+ '$channel:join' => $this->joinChannel($conn, $payload['channel']),
1481
+ '$channel:leave' => $this->leaveChannel($conn, $payload['channel']),
1482
+ '$topic:subscribe' => $this->subscribeTopic($conn, $payload['topic']),
1483
+ '$topic:unsubscribe' => $this->unsubscribeTopic($conn, $payload['topic']),
1484
+ 'chat.send' => $this->handleChatMessage($conn, $payload),
1485
+ 'ping' => $conn->send(json_encode(['type' => 'pong'])),
1486
+ default => logger()->warning("Unknown event: {$event}"),
1487
+ };
1488
+ }
1489
+
1490
+ // Send to topic subscribers
1491
+ public function notifyTopic(string $topic, string $event, array $data): void
1492
+ {
1493
+ foreach ($this->connections as $conn) {
1494
+ if (in_array($topic, $this->topics[$conn->resourceId] ?? [])) {
1495
+ $conn->send(json_encode([
1496
+ 'event' => "{$topic}:{$event}",
1497
+ 'data' => $data,
1498
+ ]));
1499
+ }
1500
+ }
1501
+ }
1502
+ ```
1503
+
1077
1504
  ## Exported Types
1078
1505
 
1079
1506
  All types are available for import in your projects:
@@ -1116,9 +1543,9 @@ import {
1116
1543
  useSocketStatus,
1117
1544
  useSocketLifecycle,
1118
1545
  useChannel,
1546
+ useTopics,
1547
+ usePush,
1119
1548
  } from '@gwakko/shared-websocket/react';
1120
-
1121
- import type { SharedWebSocketProviderProps } from '@gwakko/shared-websocket/react';
1122
1549
  ```
1123
1550
 
1124
1551
  ```typescript
@@ -1133,7 +1560,9 @@ import {
1133
1560
  useSocketStatus,
1134
1561
  useSocketLifecycle,
1135
1562
  useChannel,
1136
- SharedWebSocketKey, // InjectionKey for custom provide/inject
1563
+ useTopics,
1564
+ usePush,
1565
+ SharedWebSocketKey,
1137
1566
  } from '@gwakko/shared-websocket/vue';
1138
1567
  ```
1139
1568
 
@@ -95,6 +95,78 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
95
95
  * notifications.on('alert', (alert) => showToast(alert));
96
96
  */
97
97
  channel(name: string): Channel;
98
+ /**
99
+ * Subscribe to a server-side topic. Server will start sending events for this topic.
100
+ * Sends topicSubscribe event (default: "$topic:subscribe").
101
+ *
102
+ * @example
103
+ * ws.subscribe('notifications:orders');
104
+ * ws.subscribe('notifications:payments');
105
+ * ws.subscribe(`user:${userId}:mentions`);
106
+ */
107
+ subscribe(topic: string): void;
108
+ /**
109
+ * Unsubscribe from a server-side topic.
110
+ * Sends topicUnsubscribe event (default: "$topic:unsubscribe").
111
+ */
112
+ unsubscribe(topic: string): void;
113
+ /**
114
+ * Subscribe to an event and show notifications.
115
+ *
116
+ * **target** controls which tab(s) display the notification:
117
+ * - `'active'` — only the currently visible tab (default for render)
118
+ * - `'leader'` — only the leader tab (default for browser Notification)
119
+ * - `'all'` — every tab (for critical alerts)
120
+ *
121
+ * @example
122
+ * // Custom render — sonner toast on active tab only
123
+ * ws.push('notification', {
124
+ * render: (n) => toast(n.title),
125
+ * target: 'active', // default for render
126
+ * });
127
+ *
128
+ * @example
129
+ * // Critical alert — show in ALL tabs
130
+ * ws.push('payment.failed', {
131
+ * render: (n) => toast.error('Payment failed!'),
132
+ * target: 'all',
133
+ * });
134
+ *
135
+ * @example
136
+ * // Browser Notification — only from leader
137
+ * ws.push('order.created', {
138
+ * title: (order) => `New Order #${order.id}`,
139
+ * target: 'leader', // default for browser Notification
140
+ * });
141
+ *
142
+ * @example
143
+ * // Both render + native with different targets
144
+ * ws.push('order.created', {
145
+ * render: (order) => toast(`Order #${order.id}`), // active tab
146
+ * title: (order) => `New Order #${order.id}`, // leader → native
147
+ * });
148
+ */
149
+ push<T = unknown>(event: string, config: {
150
+ /** Custom render function — you decide how to display. */
151
+ render?: (data: T) => void;
152
+ /** Title for browser Notification API. */
153
+ title?: string | ((data: T) => string);
154
+ /** Body for browser Notification API. */
155
+ body?: string | ((data: T) => string);
156
+ /** Icon URL for browser Notification. */
157
+ icon?: string;
158
+ /** Tag for browser Notification deduplication. */
159
+ tag?: string | ((data: T) => string);
160
+ /**
161
+ * Which tab(s) show the notification:
162
+ * - `'active'` — only the visible/focused tab (default for render)
163
+ * - `'leader'` — only the leader tab (default for browser Notification)
164
+ * - `'all'` — every tab (critical alerts)
165
+ */
166
+ target?: 'active' | 'leader' | 'all';
167
+ /** Called when browser Notification is clicked. */
168
+ onClick?: (data: T) => void;
169
+ }): Unsubscribe;
98
170
  disconnect(): void;
99
171
  private createSocket;
100
172
  private handleBecomeLeader;
@@ -164,3 +164,37 @@ export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): v
164
164
  * useSocketCallback(`tenant:${tenantId}:notifications:alert`, showToast);
165
165
  */
166
166
  export declare function useChannel(name: string): import("..").Channel;
167
+ /**
168
+ * Subscribe to server-side topics. Auto-unsubscribes on unmount.
169
+ *
170
+ * @example
171
+ * useTopics(['notifications:orders', 'notifications:payments']);
172
+ * useTopics([`user:${userId}:mentions`]);
173
+ */
174
+ export declare function useTopics(topics: string[]): void;
175
+ /**
176
+ * Enable browser push notifications for an event. Auto-cleanup on unmount.
177
+ *
178
+ * @example
179
+ * usePush('notification', {
180
+ * title: (n) => n.title,
181
+ * body: (n) => n.body,
182
+ * icon: '/icon.png',
183
+ * });
184
+ *
185
+ * @example
186
+ * usePush('order.created', {
187
+ * title: (order) => `New Order #${order.id}`,
188
+ * body: (order) => `$${order.total}`,
189
+ * onClick: (order) => navigate(`/orders/${order.id}`),
190
+ * });
191
+ */
192
+ export declare function usePush<T = unknown>(event: string, config: {
193
+ title: string | ((data: T) => string);
194
+ body?: string | ((data: T) => string);
195
+ icon?: string;
196
+ tag?: string | ((data: T) => string);
197
+ leaderOnly?: boolean;
198
+ onlyWhenHidden?: boolean;
199
+ onClick?: (data: T) => void;
200
+ }): void;
@@ -123,3 +123,29 @@ export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): v
123
123
  * // Send via chat.send('message', { text: 'Hello' })
124
124
  */
125
125
  export declare function useChannel(name: string): import("..").Channel;
126
+ /**
127
+ * Subscribe to server-side topics. Auto-unsubscribes on unmount.
128
+ *
129
+ * @example
130
+ * useTopics(['notifications:orders', 'notifications:payments']);
131
+ */
132
+ export declare function useTopics(topics: string[]): void;
133
+ /**
134
+ * Enable browser push notifications for an event. Auto-cleanup on unmount.
135
+ *
136
+ * @example
137
+ * usePush('notification', {
138
+ * title: (n) => n.title,
139
+ * body: (n) => n.body,
140
+ * icon: '/icon.png',
141
+ * });
142
+ */
143
+ export declare function usePush<T = unknown>(event: string, config: {
144
+ title: string | ((data: T) => string);
145
+ body?: string | ((data: T) => string);
146
+ icon?: string;
147
+ tag?: string | ((data: T) => string);
148
+ leaderOnly?: boolean;
149
+ onlyWhenHidden?: boolean;
150
+ onClick?: (data: T) => void;
151
+ }): void;