@gwakko/shared-websocket 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +396 -3
- package/dist/SharedWebSocket.d.ts +72 -0
- package/dist/adapters/react.d.ts +34 -0
- package/dist/adapters/vue.d.ts +26 -0
- package/dist/{chunk-UEOFAFLV.cjs → chunk-PH7NVGUX.cjs} +97 -2
- package/dist/chunk-PH7NVGUX.cjs.map +1 -0
- package/dist/{chunk-4D2ZDCA6.js → chunk-YRJ23OMG.js} +97 -2
- package/dist/chunk-YRJ23OMG.js.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.cjs +19 -3
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +18 -2
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/vue.cjs +17 -3
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +16 -2
- package/dist/vue.js.map +1 -1
- package/package.json +7 -3
- package/src/SharedWebSocket.ts +130 -0
- package/src/adapters/react.ts +53 -0
- package/src/adapters/vue.ts +44 -0
- package/src/index.ts +1 -0
- package/src/types.ts +16 -0
- package/dist/chunk-4D2ZDCA6.js.map +0 -1
- package/dist/chunk-UEOFAFLV.cjs.map +0 -1
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,394 @@ 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**. Both respect `leaderOnly` + `onlyWhenHidden` to prevent duplicates across tabs.
|
|
1125
|
+
|
|
1126
|
+
### Custom Render — you control the display
|
|
1127
|
+
|
|
1128
|
+
```typescript
|
|
1129
|
+
// Vanilla — sonner toast
|
|
1130
|
+
import { toast } from 'sonner';
|
|
1131
|
+
|
|
1132
|
+
ws.push('notification', {
|
|
1133
|
+
render: (n) => toast(n.title, { description: n.body }),
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
ws.push('order.created', {
|
|
1137
|
+
render: (order) => toast.success(`New Order #${order.id}`, {
|
|
1138
|
+
description: `$${order.total} from ${order.customer}`,
|
|
1139
|
+
action: { label: 'View', onClick: () => navigate(`/orders/${order.id}`) },
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
```tsx
|
|
1145
|
+
// React — sonner
|
|
1146
|
+
import { toast } from 'sonner';
|
|
1147
|
+
|
|
1148
|
+
function NotificationSetup() {
|
|
1149
|
+
usePush('notification', {
|
|
1150
|
+
render: (n) => toast(n.title, { description: n.body }),
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
usePush('order.created', {
|
|
1154
|
+
render: (order) => toast.success(`Order #${order.id} — $${order.total}`),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// React — react-hot-toast
|
|
1161
|
+
import hotToast from 'react-hot-toast';
|
|
1162
|
+
|
|
1163
|
+
function NotificationSetup() {
|
|
1164
|
+
usePush('notification', {
|
|
1165
|
+
render: (n) => hotToast(n.title),
|
|
1166
|
+
});
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
```vue
|
|
1172
|
+
<!-- Vue — sonner-vue -->
|
|
1173
|
+
<script setup>
|
|
1174
|
+
import { toast } from 'sonner-vue';
|
|
1175
|
+
|
|
1176
|
+
usePush('notification', {
|
|
1177
|
+
render: (n) => toast(n.title, { description: n.body }),
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
usePush('order.created', {
|
|
1181
|
+
render: (order) => toast.success(`Order #${order.id} — $${order.total}`),
|
|
1182
|
+
});
|
|
1183
|
+
</script>
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
### Browser Notification API — native OS notifications
|
|
1187
|
+
|
|
1188
|
+
```typescript
|
|
1189
|
+
// Vanilla — browser native (no render needed)
|
|
1190
|
+
ws.push('notification', {
|
|
1191
|
+
title: (n) => n.title,
|
|
1192
|
+
body: (n) => n.body,
|
|
1193
|
+
icon: '/icons/bell.png',
|
|
1194
|
+
tag: (n) => `notif-${n.id}`, // deduplication
|
|
1195
|
+
onClick: (n) => window.open(n.url),
|
|
1196
|
+
leaderOnly: true, // default: true
|
|
1197
|
+
onlyWhenHidden: true, // default: true
|
|
1198
|
+
});
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
```tsx
|
|
1202
|
+
// React
|
|
1203
|
+
usePush('order.created', {
|
|
1204
|
+
title: (order) => `New Order #${order.id}`,
|
|
1205
|
+
body: (order) => `$${order.total}`,
|
|
1206
|
+
icon: '/icons/order.png',
|
|
1207
|
+
onClick: (order) => navigate(`/orders/${order.id}`),
|
|
1208
|
+
});
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
```vue
|
|
1212
|
+
<!-- Vue -->
|
|
1213
|
+
<script setup>
|
|
1214
|
+
usePush('order.created', {
|
|
1215
|
+
title: (order) => `New Order #${order.id}`,
|
|
1216
|
+
body: (order) => `$${order.total}`,
|
|
1217
|
+
});
|
|
1218
|
+
</script>
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
### Both — toast in UI + browser notification
|
|
1222
|
+
|
|
1223
|
+
```typescript
|
|
1224
|
+
// Show sonner toast AND browser notification
|
|
1225
|
+
ws.push('order.created', {
|
|
1226
|
+
render: (order) => toast.success(`Order #${order.id}`), // in-app toast
|
|
1227
|
+
title: (order) => `New Order #${order.id}`, // + native notification
|
|
1228
|
+
body: (order) => `$${order.total}`,
|
|
1229
|
+
});
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
## Server-Side Implementation Guide
|
|
1233
|
+
|
|
1234
|
+
Complete server reference — what events to listen for and how to respond.
|
|
1235
|
+
|
|
1236
|
+
### Message Format
|
|
1237
|
+
|
|
1238
|
+
All messages are JSON with two fields (configurable via `events` option):
|
|
1239
|
+
|
|
1240
|
+
```
|
|
1241
|
+
Client → Server: { "event": "event.name", "data": { ... } }
|
|
1242
|
+
Server → Client: { "event": "event.name", "data": { ... } }
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
### System Events (sent by client automatically)
|
|
1246
|
+
|
|
1247
|
+
| Event | When | Payload | Your Server Should |
|
|
1248
|
+
|-------|------|---------|-------------------|
|
|
1249
|
+
| `ping` | Every 30s (heartbeat) | `{ "type": "ping" }` | Respond with `{ "type": "pong" }` or ignore |
|
|
1250
|
+
| `$channel:join` | `ws.channel('name')` | `{ "channel": "chat:room_1" }` | Track which channels this connection belongs to |
|
|
1251
|
+
| `$channel:leave` | `channel.leave()` | `{ "channel": "chat:room_1" }` | Remove connection from channel |
|
|
1252
|
+
| `$topic:subscribe` | `ws.subscribe('topic')` | `{ "topic": "notifications:orders" }` | Start sending events for this topic to this connection |
|
|
1253
|
+
| `$topic:unsubscribe` | `ws.unsubscribe('topic')` | `{ "topic": "notifications:orders" }` | Stop sending events for this topic |
|
|
1254
|
+
|
|
1255
|
+
### Node.js (ws) — Complete Server Example
|
|
1256
|
+
|
|
1257
|
+
```typescript
|
|
1258
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
1259
|
+
|
|
1260
|
+
const wss = new WebSocketServer({ port: 8080 });
|
|
1261
|
+
|
|
1262
|
+
// Track per-connection state
|
|
1263
|
+
interface ClientState {
|
|
1264
|
+
userId?: string;
|
|
1265
|
+
channels: Set<string>;
|
|
1266
|
+
topics: Set<string>;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const clients = new Map<WebSocket, ClientState>();
|
|
1270
|
+
|
|
1271
|
+
wss.on('connection', (ws, req) => {
|
|
1272
|
+
// Extract auth token from URL
|
|
1273
|
+
const url = new URL(req.url!, `http://${req.headers.host}`);
|
|
1274
|
+
const token = url.searchParams.get('token');
|
|
1275
|
+
const userId = verifyToken(token); // your auth logic
|
|
1276
|
+
|
|
1277
|
+
const state: ClientState = {
|
|
1278
|
+
userId,
|
|
1279
|
+
channels: new Set(),
|
|
1280
|
+
topics: new Set(),
|
|
1281
|
+
};
|
|
1282
|
+
clients.set(ws, state);
|
|
1283
|
+
|
|
1284
|
+
// Send welcome
|
|
1285
|
+
send(ws, 'welcome', { userId, timestamp: Date.now() });
|
|
1286
|
+
|
|
1287
|
+
ws.on('message', (raw) => {
|
|
1288
|
+
const msg = JSON.parse(raw.toString());
|
|
1289
|
+
const { event, data } = msg;
|
|
1290
|
+
|
|
1291
|
+
switch (event) {
|
|
1292
|
+
// ─── System Events ───────────────────────
|
|
1293
|
+
|
|
1294
|
+
case 'ping':
|
|
1295
|
+
// Respond to heartbeat (optional — some servers ignore pings)
|
|
1296
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
1297
|
+
break;
|
|
1298
|
+
|
|
1299
|
+
// ─── Channel Events ──────────────────────
|
|
1300
|
+
|
|
1301
|
+
case '$channel:join':
|
|
1302
|
+
state.channels.add(data.channel);
|
|
1303
|
+
console.log(`${userId} joined ${data.channel}`);
|
|
1304
|
+
// Send channel history, presence, etc.
|
|
1305
|
+
break;
|
|
1306
|
+
|
|
1307
|
+
case '$channel:leave':
|
|
1308
|
+
state.channels.delete(data.channel);
|
|
1309
|
+
console.log(`${userId} left ${data.channel}`);
|
|
1310
|
+
break;
|
|
1311
|
+
|
|
1312
|
+
// ─── Topic Events ────────────────────────
|
|
1313
|
+
|
|
1314
|
+
case '$topic:subscribe':
|
|
1315
|
+
state.topics.add(data.topic);
|
|
1316
|
+
console.log(`${userId} subscribed to ${data.topic}`);
|
|
1317
|
+
break;
|
|
1318
|
+
|
|
1319
|
+
case '$topic:unsubscribe':
|
|
1320
|
+
state.topics.delete(data.topic);
|
|
1321
|
+
break;
|
|
1322
|
+
|
|
1323
|
+
// ─── App Events ──────────────────────────
|
|
1324
|
+
|
|
1325
|
+
case 'chat.send':
|
|
1326
|
+
// Broadcast to all clients in the same channel
|
|
1327
|
+
const channel = data.roomId ? `chat:${data.roomId}` : null;
|
|
1328
|
+
broadcastToChannel(channel, 'chat.message', {
|
|
1329
|
+
id: crypto.randomUUID(),
|
|
1330
|
+
userId: state.userId,
|
|
1331
|
+
text: data.text,
|
|
1332
|
+
timestamp: Date.now(),
|
|
1333
|
+
});
|
|
1334
|
+
break;
|
|
1335
|
+
|
|
1336
|
+
case 'chat.typing':
|
|
1337
|
+
broadcastToChannel(`chat:${data.roomId}`, 'chat.typing', {
|
|
1338
|
+
userId: state.userId,
|
|
1339
|
+
}, ws); // exclude sender
|
|
1340
|
+
break;
|
|
1341
|
+
|
|
1342
|
+
default:
|
|
1343
|
+
console.log('Unknown event:', event, data);
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
ws.on('close', () => {
|
|
1348
|
+
clients.delete(ws);
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// ─── Helpers ─────────────────────────────────────
|
|
1353
|
+
|
|
1354
|
+
function send(ws: WebSocket, event: string, data: unknown) {
|
|
1355
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1356
|
+
ws.send(JSON.stringify({ event, data }));
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function broadcastToChannel(
|
|
1361
|
+
channel: string | null,
|
|
1362
|
+
event: string,
|
|
1363
|
+
data: unknown,
|
|
1364
|
+
exclude?: WebSocket,
|
|
1365
|
+
) {
|
|
1366
|
+
for (const [ws, state] of clients) {
|
|
1367
|
+
if (ws === exclude) continue;
|
|
1368
|
+
if (channel && !state.channels.has(channel)) continue;
|
|
1369
|
+
send(ws, event, data);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function broadcastToTopic(topic: string, event: string, data: unknown) {
|
|
1374
|
+
for (const [ws, state] of clients) {
|
|
1375
|
+
if (!state.topics.has(topic)) continue;
|
|
1376
|
+
send(ws, `${topic}:${event}`, data);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ─── Example: Send notifications by topic ────────
|
|
1381
|
+
|
|
1382
|
+
function notifyNewOrder(order: Order) {
|
|
1383
|
+
broadcastToTopic('notifications:orders', 'new', {
|
|
1384
|
+
id: order.id,
|
|
1385
|
+
total: order.total,
|
|
1386
|
+
customer: order.customerName,
|
|
1387
|
+
});
|
|
1388
|
+
// Only clients who called ws.subscribe('notifications:orders') receive this
|
|
1389
|
+
}
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
### Go — Server Example
|
|
1393
|
+
|
|
1394
|
+
```go
|
|
1395
|
+
// Message format
|
|
1396
|
+
type Message struct {
|
|
1397
|
+
Event string `json:"event"`
|
|
1398
|
+
Data json.RawMessage `json:"data"`
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Handle incoming messages
|
|
1402
|
+
func handleMessage(conn *websocket.Conn, state *ClientState, msg Message) {
|
|
1403
|
+
switch msg.Event {
|
|
1404
|
+
case "$channel:join":
|
|
1405
|
+
var payload struct{ Channel string `json:"channel"` }
|
|
1406
|
+
json.Unmarshal(msg.Data, &payload)
|
|
1407
|
+
state.Channels[payload.Channel] = true
|
|
1408
|
+
|
|
1409
|
+
case "$channel:leave":
|
|
1410
|
+
var payload struct{ Channel string `json:"channel"` }
|
|
1411
|
+
json.Unmarshal(msg.Data, &payload)
|
|
1412
|
+
delete(state.Channels, payload.Channel)
|
|
1413
|
+
|
|
1414
|
+
case "$topic:subscribe":
|
|
1415
|
+
var payload struct{ Topic string `json:"topic"` }
|
|
1416
|
+
json.Unmarshal(msg.Data, &payload)
|
|
1417
|
+
state.Topics[payload.Topic] = true
|
|
1418
|
+
|
|
1419
|
+
case "$topic:unsubscribe":
|
|
1420
|
+
var payload struct{ Topic string `json:"topic"` }
|
|
1421
|
+
json.Unmarshal(msg.Data, &payload)
|
|
1422
|
+
delete(state.Topics, payload.Topic)
|
|
1423
|
+
|
|
1424
|
+
case "chat.send":
|
|
1425
|
+
// broadcast to channel...
|
|
1426
|
+
|
|
1427
|
+
case "ping":
|
|
1428
|
+
conn.WriteJSON(Message{Event: "pong"})
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
### PHP (Laravel + Ratchet/Swoole) — Server Example
|
|
1434
|
+
|
|
1435
|
+
```php
|
|
1436
|
+
// Handle incoming WebSocket message
|
|
1437
|
+
public function onMessage(ConnectionInterface $conn, $msg): void
|
|
1438
|
+
{
|
|
1439
|
+
$data = json_decode($msg, true);
|
|
1440
|
+
$event = $data['event'] ?? 'message';
|
|
1441
|
+
$payload = $data['data'] ?? [];
|
|
1442
|
+
|
|
1443
|
+
match ($event) {
|
|
1444
|
+
'$channel:join' => $this->joinChannel($conn, $payload['channel']),
|
|
1445
|
+
'$channel:leave' => $this->leaveChannel($conn, $payload['channel']),
|
|
1446
|
+
'$topic:subscribe' => $this->subscribeTopic($conn, $payload['topic']),
|
|
1447
|
+
'$topic:unsubscribe' => $this->unsubscribeTopic($conn, $payload['topic']),
|
|
1448
|
+
'chat.send' => $this->handleChatMessage($conn, $payload),
|
|
1449
|
+
'ping' => $conn->send(json_encode(['type' => 'pong'])),
|
|
1450
|
+
default => logger()->warning("Unknown event: {$event}"),
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Send to topic subscribers
|
|
1455
|
+
public function notifyTopic(string $topic, string $event, array $data): void
|
|
1456
|
+
{
|
|
1457
|
+
foreach ($this->connections as $conn) {
|
|
1458
|
+
if (in_array($topic, $this->topics[$conn->resourceId] ?? [])) {
|
|
1459
|
+
$conn->send(json_encode([
|
|
1460
|
+
'event' => "{$topic}:{$event}",
|
|
1461
|
+
'data' => $data,
|
|
1462
|
+
]));
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1077
1468
|
## Exported Types
|
|
1078
1469
|
|
|
1079
1470
|
All types are available for import in your projects:
|
|
@@ -1116,9 +1507,9 @@ import {
|
|
|
1116
1507
|
useSocketStatus,
|
|
1117
1508
|
useSocketLifecycle,
|
|
1118
1509
|
useChannel,
|
|
1510
|
+
useTopics,
|
|
1511
|
+
usePush,
|
|
1119
1512
|
} from '@gwakko/shared-websocket/react';
|
|
1120
|
-
|
|
1121
|
-
import type { SharedWebSocketProviderProps } from '@gwakko/shared-websocket/react';
|
|
1122
1513
|
```
|
|
1123
1514
|
|
|
1124
1515
|
```typescript
|
|
@@ -1133,7 +1524,9 @@ import {
|
|
|
1133
1524
|
useSocketStatus,
|
|
1134
1525
|
useSocketLifecycle,
|
|
1135
1526
|
useChannel,
|
|
1136
|
-
|
|
1527
|
+
useTopics,
|
|
1528
|
+
usePush,
|
|
1529
|
+
SharedWebSocketKey,
|
|
1137
1530
|
} from '@gwakko/shared-websocket/vue';
|
|
1138
1531
|
```
|
|
1139
1532
|
|
|
@@ -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
|
+
* Two modes:
|
|
117
|
+
* - **render** — you control how to display (sonner, react-hot-toast, custom UI)
|
|
118
|
+
* - **browser Notification API** — native OS notifications (title/body/icon)
|
|
119
|
+
*
|
|
120
|
+
* Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* // Custom render — sonner toast
|
|
124
|
+
* import { toast } from 'sonner';
|
|
125
|
+
* ws.push('notification', {
|
|
126
|
+
* render: (data) => toast(data.title, { description: data.body }),
|
|
127
|
+
* });
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* // Custom render — react-hot-toast
|
|
131
|
+
* import toast from 'react-hot-toast';
|
|
132
|
+
* ws.push('order.created', {
|
|
133
|
+
* render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),
|
|
134
|
+
* });
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* // Browser Notification API (no render — uses title/body/icon)
|
|
138
|
+
* ws.push('notification', {
|
|
139
|
+
* title: (data) => data.title,
|
|
140
|
+
* body: (data) => data.body,
|
|
141
|
+
* icon: '/icons/bell.png',
|
|
142
|
+
* });
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* // Both — toast in UI + browser notification
|
|
146
|
+
* ws.push('order.created', {
|
|
147
|
+
* render: (order) => toast(`Order #${order.id}`),
|
|
148
|
+
* title: (order) => `New Order #${order.id}`,
|
|
149
|
+
* body: (order) => `$${order.total}`,
|
|
150
|
+
* });
|
|
151
|
+
*/
|
|
152
|
+
push<T = unknown>(event: string, config: {
|
|
153
|
+
/** Custom render function — you decide how to display. Called for every matching event. */
|
|
154
|
+
render?: (data: T) => void;
|
|
155
|
+
/** Title for browser Notification API (ignored if only render is used). */
|
|
156
|
+
title?: string | ((data: T) => string);
|
|
157
|
+
/** Body for browser Notification API. */
|
|
158
|
+
body?: string | ((data: T) => string);
|
|
159
|
+
/** Icon URL for browser Notification. */
|
|
160
|
+
icon?: string;
|
|
161
|
+
/** Tag for browser Notification deduplication. */
|
|
162
|
+
tag?: string | ((data: T) => string);
|
|
163
|
+
/** Only trigger from leader tab (default: true). Prevents N duplicates for N tabs. */
|
|
164
|
+
leaderOnly?: boolean;
|
|
165
|
+
/** Only trigger when tab is hidden/background (default: true). */
|
|
166
|
+
onlyWhenHidden?: boolean;
|
|
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;
|
package/dist/adapters/react.d.ts
CHANGED
|
@@ -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;
|
package/dist/adapters/vue.d.ts
CHANGED
|
@@ -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;
|
|
@@ -583,7 +583,9 @@ var DEFAULT_PROTOCOL = {
|
|
|
583
583
|
channelJoin: "$channel:join",
|
|
584
584
|
channelLeave: "$channel:leave",
|
|
585
585
|
ping: { type: "ping" },
|
|
586
|
-
defaultEvent: "message"
|
|
586
|
+
defaultEvent: "message",
|
|
587
|
+
topicSubscribe: "$topic:subscribe",
|
|
588
|
+
topicUnsubscribe: "$topic:unsubscribe"
|
|
587
589
|
};
|
|
588
590
|
var NOOP_LOGGER = {
|
|
589
591
|
debug() {
|
|
@@ -821,6 +823,99 @@ var SharedWebSocket = (_class6 = class {
|
|
|
821
823
|
}
|
|
822
824
|
};
|
|
823
825
|
}
|
|
826
|
+
// ─── Topics ──────────────────────────────────────────
|
|
827
|
+
/**
|
|
828
|
+
* Subscribe to a server-side topic. Server will start sending events for this topic.
|
|
829
|
+
* Sends topicSubscribe event (default: "$topic:subscribe").
|
|
830
|
+
*
|
|
831
|
+
* @example
|
|
832
|
+
* ws.subscribe('notifications:orders');
|
|
833
|
+
* ws.subscribe('notifications:payments');
|
|
834
|
+
* ws.subscribe(`user:${userId}:mentions`);
|
|
835
|
+
*/
|
|
836
|
+
subscribe(topic) {
|
|
837
|
+
this.send(this.proto.topicSubscribe, { topic });
|
|
838
|
+
this.log.debug("[SharedWS] \u{1F4CC} subscribe topic", topic);
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Unsubscribe from a server-side topic.
|
|
842
|
+
* Sends topicUnsubscribe event (default: "$topic:unsubscribe").
|
|
843
|
+
*/
|
|
844
|
+
unsubscribe(topic) {
|
|
845
|
+
this.send(this.proto.topicUnsubscribe, { topic });
|
|
846
|
+
this.log.debug("[SharedWS] \u{1F4CC} unsubscribe topic", topic);
|
|
847
|
+
}
|
|
848
|
+
// ─── Push Notifications ─────────────────────────────
|
|
849
|
+
/**
|
|
850
|
+
* Subscribe to an event and show notifications.
|
|
851
|
+
*
|
|
852
|
+
* Two modes:
|
|
853
|
+
* - **render** — you control how to display (sonner, react-hot-toast, custom UI)
|
|
854
|
+
* - **browser Notification API** — native OS notifications (title/body/icon)
|
|
855
|
+
*
|
|
856
|
+
* Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.
|
|
857
|
+
*
|
|
858
|
+
* @example
|
|
859
|
+
* // Custom render — sonner toast
|
|
860
|
+
* import { toast } from 'sonner';
|
|
861
|
+
* ws.push('notification', {
|
|
862
|
+
* render: (data) => toast(data.title, { description: data.body }),
|
|
863
|
+
* });
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* // Custom render — react-hot-toast
|
|
867
|
+
* import toast from 'react-hot-toast';
|
|
868
|
+
* ws.push('order.created', {
|
|
869
|
+
* render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),
|
|
870
|
+
* });
|
|
871
|
+
*
|
|
872
|
+
* @example
|
|
873
|
+
* // Browser Notification API (no render — uses title/body/icon)
|
|
874
|
+
* ws.push('notification', {
|
|
875
|
+
* title: (data) => data.title,
|
|
876
|
+
* body: (data) => data.body,
|
|
877
|
+
* icon: '/icons/bell.png',
|
|
878
|
+
* });
|
|
879
|
+
*
|
|
880
|
+
* @example
|
|
881
|
+
* // Both — toast in UI + browser notification
|
|
882
|
+
* ws.push('order.created', {
|
|
883
|
+
* render: (order) => toast(`Order #${order.id}`),
|
|
884
|
+
* title: (order) => `New Order #${order.id}`,
|
|
885
|
+
* body: (order) => `$${order.total}`,
|
|
886
|
+
* });
|
|
887
|
+
*/
|
|
888
|
+
push(event, config) {
|
|
889
|
+
const leaderOnly = _nullishCoalesce(config.leaderOnly, () => ( true));
|
|
890
|
+
const onlyWhenHidden = _nullishCoalesce(config.onlyWhenHidden, () => ( true));
|
|
891
|
+
const useNativeNotification = !!config.title;
|
|
892
|
+
if (useNativeNotification && typeof Notification !== "undefined" && Notification.permission === "default") {
|
|
893
|
+
Notification.requestPermission();
|
|
894
|
+
}
|
|
895
|
+
return this.on(event, ((data) => {
|
|
896
|
+
const typed = data;
|
|
897
|
+
if (leaderOnly && this.tabRole !== "leader") return;
|
|
898
|
+
if (onlyWhenHidden && typeof document !== "undefined" && !document.hidden) return;
|
|
899
|
+
if (config.render) {
|
|
900
|
+
config.render(typed);
|
|
901
|
+
this.log.debug("[SharedWS] \u{1F514} push render", event);
|
|
902
|
+
}
|
|
903
|
+
if (useNativeNotification && typeof Notification !== "undefined" && Notification.permission === "granted") {
|
|
904
|
+
const title = typeof config.title === "function" ? config.title(typed) : config.title;
|
|
905
|
+
const body = typeof config.body === "function" ? config.body(typed) : config.body;
|
|
906
|
+
const tag = typeof config.tag === "function" ? config.tag(typed) : config.tag;
|
|
907
|
+
const notif = new Notification(title, { body, icon: config.icon, tag });
|
|
908
|
+
if (config.onClick) {
|
|
909
|
+
const handler = config.onClick;
|
|
910
|
+
notif.onclick = () => {
|
|
911
|
+
handler(typed);
|
|
912
|
+
window.focus();
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
this.log.debug("[SharedWS] \u{1F514} push native", title);
|
|
916
|
+
}
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
824
919
|
disconnect() {
|
|
825
920
|
this[Symbol.dispose]();
|
|
826
921
|
}
|
|
@@ -924,4 +1019,4 @@ var SharedWebSocket = (_class6 = class {
|
|
|
924
1019
|
|
|
925
1020
|
|
|
926
1021
|
exports.MessageBus = MessageBus; exports.TabCoordinator = TabCoordinator; exports.SharedSocket = SharedSocket; exports.WorkerSocket = WorkerSocket; exports.SubscriptionManager = SubscriptionManager; exports.SharedWebSocket = SharedWebSocket;
|
|
927
|
-
//# sourceMappingURL=chunk-
|
|
1022
|
+
//# sourceMappingURL=chunk-PH7NVGUX.cjs.map
|