@gwakko/shared-websocket 0.6.1 → 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 +430 -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
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
Share ONE WebSocket connection across multiple browser tabs. Zero dependencies. React and Vue adapters included.
|
|
4
4
|
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Problem](#problem)
|
|
8
|
+
- [Solution](#solution)
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Usage — Vanilla TypeScript](#usage--vanilla-typescript)
|
|
11
|
+
- [Scoped Lifecycle — withSocket()](#scoped-lifecycle--withsocket)
|
|
12
|
+
- [Usage — React](#usage--react)
|
|
13
|
+
- [Usage — Vue 3](#usage--vue-3)
|
|
14
|
+
- [API Reference](#api-reference)
|
|
15
|
+
- [Options](#options)
|
|
16
|
+
- [Authentication](#authentication)
|
|
17
|
+
- [React Hooks](#react-hooks-react-19-useeffectevent-for-stable-refs)
|
|
18
|
+
- [Vue Composables](#vue-composables)
|
|
19
|
+
- [How It Works](#how-it-works)
|
|
20
|
+
- [When to Use `useWorker: true`](#when-to-use-useworker-true)
|
|
21
|
+
- [Typed Events](#typed-events)
|
|
22
|
+
- [Type narrowing](#type-narrowing-for-untyped-events)
|
|
23
|
+
- [Runtime validation with Zod](#runtime-validation-with-zod)
|
|
24
|
+
- [Middleware](#middleware)
|
|
25
|
+
- [Debug Mode & Custom Logger](#debug-mode--custom-logger)
|
|
26
|
+
- [Custom Event Protocol](#custom-event-protocol)
|
|
27
|
+
- [Advanced Examples](#advanced-examples)
|
|
28
|
+
- [Stream](#stream--consume-events-as-async-iterator)
|
|
29
|
+
- [Request](#request--requestresponse-through-server)
|
|
30
|
+
- [Protocols](#protocols--websocket-subprotocols)
|
|
31
|
+
- [Worker URL](#worker-url--custom-worker-file)
|
|
32
|
+
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
33
|
+
- [Private Channels](#private-channels--chat-rooms-tenant-notifications)
|
|
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)
|
|
38
|
+
- [Exported Types](#exported-types)
|
|
39
|
+
- [Browser Support](#browser-support)
|
|
40
|
+
- [License](#license)
|
|
41
|
+
|
|
5
42
|
## Problem
|
|
6
43
|
|
|
7
44
|
5 tabs open = 5 WebSocket connections = 5x server resources for the same user.
|
|
@@ -1040,6 +1077,394 @@ wss.on('connection', (ws) => {
|
|
|
1040
1077
|
});
|
|
1041
1078
|
```
|
|
1042
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
|
+
|
|
1043
1468
|
## Exported Types
|
|
1044
1469
|
|
|
1045
1470
|
All types are available for import in your projects:
|
|
@@ -1082,9 +1507,9 @@ import {
|
|
|
1082
1507
|
useSocketStatus,
|
|
1083
1508
|
useSocketLifecycle,
|
|
1084
1509
|
useChannel,
|
|
1510
|
+
useTopics,
|
|
1511
|
+
usePush,
|
|
1085
1512
|
} from '@gwakko/shared-websocket/react';
|
|
1086
|
-
|
|
1087
|
-
import type { SharedWebSocketProviderProps } from '@gwakko/shared-websocket/react';
|
|
1088
1513
|
```
|
|
1089
1514
|
|
|
1090
1515
|
```typescript
|
|
@@ -1099,7 +1524,9 @@ import {
|
|
|
1099
1524
|
useSocketStatus,
|
|
1100
1525
|
useSocketLifecycle,
|
|
1101
1526
|
useChannel,
|
|
1102
|
-
|
|
1527
|
+
useTopics,
|
|
1528
|
+
usePush,
|
|
1529
|
+
SharedWebSocketKey,
|
|
1103
1530
|
} from '@gwakko/shared-websocket/vue';
|
|
1104
1531
|
```
|
|
1105
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;
|