@gwakko/shared-websocket 0.2.1 → 0.6.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 +731 -4
- package/dist/SharedSocket.d.ts +5 -0
- package/dist/SharedWebSocket.d.ts +71 -13
- package/dist/adapters/react.d.ts +29 -2
- package/dist/adapters/vue.d.ts +23 -1
- package/dist/{chunk-TNEMKPGP.js → chunk-4D2ZDCA6.js} +215 -25
- package/dist/chunk-4D2ZDCA6.js.map +1 -0
- package/dist/{chunk-SMH3X34N.cjs → chunk-UEOFAFLV.cjs} +216 -26
- package/dist/chunk-UEOFAFLV.cjs.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/react.cjs +33 -3
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +31 -1
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +78 -2
- package/dist/vue.cjs +33 -11
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +31 -9
- package/dist/vue.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedSocket.ts +35 -9
- package/src/SharedWebSocket.ts +239 -25
- package/src/adapters/react.ts +63 -4
- package/src/adapters/vue.ts +57 -11
- package/src/index.ts +21 -2
- package/src/types.ts +84 -2
- package/dist/chunk-SMH3X34N.cjs.map +0 -1
- package/dist/chunk-TNEMKPGP.js.map +0 -1
package/README.md
CHANGED
|
@@ -57,8 +57,9 @@ npm run build # outputs ESM + CJS + types to dist/
|
|
|
57
57
|
import { SharedWebSocket } from '@gwakko/shared-websocket';
|
|
58
58
|
|
|
59
59
|
const ws = new SharedWebSocket('wss://api.example.com/ws', {
|
|
60
|
-
auth: () => localStorage.getItem('token')!,
|
|
61
|
-
|
|
60
|
+
auth: () => localStorage.getItem('token')!, // or authToken: 'static-token'
|
|
61
|
+
authParam: 'token', // default — query param name (?token=xxx)
|
|
62
|
+
useWorker: true, // optional — offload WebSocket to Web Worker
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
await ws.connect();
|
|
@@ -221,7 +222,10 @@ import { createSharedWebSocketPlugin } from '@gwakko/shared-websocket/vue';
|
|
|
221
222
|
import App from './App.vue';
|
|
222
223
|
|
|
223
224
|
const app = createApp(App);
|
|
224
|
-
app.use(createSharedWebSocketPlugin('wss://api.example.com/ws'
|
|
225
|
+
app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
|
|
226
|
+
auth: () => localStorage.getItem('token')!,
|
|
227
|
+
useWorker: true,
|
|
228
|
+
}));
|
|
225
229
|
app.mount('#app');
|
|
226
230
|
</script>
|
|
227
231
|
```
|
|
@@ -294,13 +298,48 @@ Callback receives `{ ws, signal }` — destructure what you need. Signal aborts
|
|
|
294
298
|
| `reconnectMaxDelay` | `number` | `30000` | Max reconnect backoff (ms) |
|
|
295
299
|
| `heartbeatInterval` | `number` | `30000` | Ping interval (ms) |
|
|
296
300
|
| `sendBuffer` | `number` | `100` | Max buffered messages during reconnect |
|
|
297
|
-
| `auth` | `() => string` | — |
|
|
301
|
+
| `auth` | `() => string` | — | Token provider callback (called on each connect) |
|
|
302
|
+
| `authToken` | `string` | — | Static token (alternative to `auth` callback) |
|
|
303
|
+
| `authParam` | `string` | `"token"` | Query parameter name for token |
|
|
298
304
|
| **`useWorker`** | **`boolean`** | **`false`** | **Run WebSocket in Web Worker** |
|
|
299
305
|
| `workerUrl` | `string \| URL` | — | Custom worker URL (if useWorker) |
|
|
300
306
|
| `electionTimeout` | `number` | `200` | Leader election timeout (ms) |
|
|
301
307
|
| `leaderHeartbeat` | `number` | `2000` | Leader heartbeat interval (ms) |
|
|
302
308
|
| `leaderTimeout` | `number` | `5000` | Leader absence timeout (ms) |
|
|
303
309
|
|
|
310
|
+
### Authentication
|
|
311
|
+
|
|
312
|
+
Three ways to pass a token. Token is appended as a query parameter (default `?token=xxx`):
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
// 1. Callback — fresh token on every connect/reconnect
|
|
316
|
+
{ auth: () => localStorage.getItem('token')! }
|
|
317
|
+
// → wss://api.example.com/ws?token=eyJhb...
|
|
318
|
+
|
|
319
|
+
// 2. Static token — simple, no callback
|
|
320
|
+
{ authToken: 'eyJhbGciOiJIUzI1NiIs...' }
|
|
321
|
+
// → wss://api.example.com/ws?token=eyJhb...
|
|
322
|
+
|
|
323
|
+
// 3. Custom parameter name
|
|
324
|
+
{ auth: () => getToken(), authParam: 'access_token' }
|
|
325
|
+
// → wss://api.example.com/ws?access_token=eyJhb...
|
|
326
|
+
|
|
327
|
+
// 4. No auth
|
|
328
|
+
{} // connects without token
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Priority: `auth` callback > `authToken` static > no token.
|
|
332
|
+
Default parameter name: `"token"`. Override with `authParam`.
|
|
333
|
+
|
|
334
|
+
URL with existing query parameters is safe — token is appended without breaking anything (uses `URL` + `searchParams.set()`):
|
|
335
|
+
```typescript
|
|
336
|
+
// URL already has params — works fine
|
|
337
|
+
new SharedWebSocket('wss://api.example.com/ws?room=general&lang=en', {
|
|
338
|
+
auth: () => getToken(),
|
|
339
|
+
})
|
|
340
|
+
// → wss://api.example.com/ws?room=general&lang=en&token=eyJhb...
|
|
341
|
+
```
|
|
342
|
+
|
|
304
343
|
### Properties
|
|
305
344
|
|
|
306
345
|
| Property | Type | Description |
|
|
@@ -320,6 +359,8 @@ All hooks use context internally — no need to pass `ws`. Every hook accepts an
|
|
|
320
359
|
| `useSocketSync<T>(key, init, cb?)` | Returns `[T, setter]` | `cb(value)` — side effects on sync |
|
|
321
360
|
| `useSocketCallback<T>(event, cb)` | — | Fire-and-forget (no state) |
|
|
322
361
|
| `useSocketStatus()` | `{ connected, tabRole }` | — |
|
|
362
|
+
| `useSocketLifecycle(handlers)` | — | onConnect, onDisconnect, onReconnecting, onLeaderChange, onError |
|
|
363
|
+
| `useChannel(name)` | `Channel` handle | Auto-join/leave on mount/unmount |
|
|
323
364
|
|
|
324
365
|
```tsx
|
|
325
366
|
// Without callback — reactive state
|
|
@@ -400,6 +441,692 @@ const ws = new SharedWebSocket(url, { useWorker: true });
|
|
|
400
441
|
// API is identical — only internal transport changes
|
|
401
442
|
```
|
|
402
443
|
|
|
444
|
+
## Typed Events
|
|
445
|
+
|
|
446
|
+
Define your event map for full type safety across on/send/stream:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
type Events = {
|
|
450
|
+
'chat.message': { text: string; userId: string; timestamp: number };
|
|
451
|
+
'chat.typing': { userId: string };
|
|
452
|
+
'order.created': { id: string; total: number; items: string[] };
|
|
453
|
+
'notification': { title: string; body: string; type: 'info' | 'error' };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const ws = new SharedWebSocket<Events>('wss://api.example.com/ws');
|
|
457
|
+
|
|
458
|
+
// ✅ Type-safe — msg is { text, userId, timestamp }
|
|
459
|
+
ws.on('chat.message', (msg) => {
|
|
460
|
+
console.log(msg.text); // string
|
|
461
|
+
console.log(msg.userId); // string
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ✅ Type-safe send
|
|
465
|
+
ws.send('chat.message', { text: 'hi', userId: '1', timestamp: Date.now() });
|
|
466
|
+
|
|
467
|
+
// ❌ TypeScript error — wrong payload type
|
|
468
|
+
ws.send('chat.message', { wrong: 'field' });
|
|
469
|
+
|
|
470
|
+
// ✅ Type-safe stream
|
|
471
|
+
for await (const order of ws.stream('order.created')) {
|
|
472
|
+
console.log(order.id); // string
|
|
473
|
+
console.log(order.total); // number
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Still works with untyped events
|
|
477
|
+
ws.on('any.custom.event', (data) => { /* data: any */ });
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
```tsx
|
|
481
|
+
// React — pass type to hooks
|
|
482
|
+
const msg = useSocketEvent<Events['chat.message']>('chat.message');
|
|
483
|
+
// msg: { text, userId, timestamp } | undefined
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Type narrowing for untyped events
|
|
487
|
+
|
|
488
|
+
When working without EventMap, data is `unknown`. Use narrowing:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
// Type guard
|
|
492
|
+
function isChatMessage(data: unknown): data is { text: string; userId: string } {
|
|
493
|
+
return typeof data === 'object' && data !== null && 'text' in data && 'userId' in data;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Vanilla
|
|
497
|
+
ws.on('chat.message', (data) => {
|
|
498
|
+
if (isChatMessage(data)) {
|
|
499
|
+
console.log(data.text); // ← now typed as string
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
```tsx
|
|
505
|
+
// React
|
|
506
|
+
useSocketEvent('chat.message', (data) => {
|
|
507
|
+
if (isChatMessage(data)) renderMessage(data);
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
```vue
|
|
512
|
+
<!-- Vue -->
|
|
513
|
+
<script setup>
|
|
514
|
+
useSocketEvent('chat.message', (data) => {
|
|
515
|
+
if (isChatMessage(data)) renderMessage(data);
|
|
516
|
+
});
|
|
517
|
+
</script>
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### Runtime validation with Zod
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { z } from 'zod';
|
|
524
|
+
|
|
525
|
+
const ChatMessageSchema = z.object({
|
|
526
|
+
text: z.string(),
|
|
527
|
+
userId: z.string(),
|
|
528
|
+
timestamp: z.number(),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
|
532
|
+
|
|
533
|
+
// Validate on receive — drop invalid messages via middleware
|
|
534
|
+
ws.use('incoming', (raw) => {
|
|
535
|
+
const msg = raw as Record<string, unknown>;
|
|
536
|
+
const data = msg?.data;
|
|
537
|
+
const result = ChatMessageSchema.safeParse(data);
|
|
538
|
+
if (!result.success) {
|
|
539
|
+
console.warn('Invalid message:', result.error.issues);
|
|
540
|
+
return null; // drop
|
|
541
|
+
}
|
|
542
|
+
return raw; // pass through
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Or validate in handler
|
|
546
|
+
ws.on('chat.message', (data) => {
|
|
547
|
+
const result = ChatMessageSchema.safeParse(data);
|
|
548
|
+
if (!result.success) return;
|
|
549
|
+
|
|
550
|
+
const msg: ChatMessage = result.data;
|
|
551
|
+
console.log(msg.text); // fully typed and validated
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Zod middleware factory (reusable)
|
|
555
|
+
function zodValidate<T>(schema: z.ZodType<T>): Middleware {
|
|
556
|
+
return (raw) => {
|
|
557
|
+
const msg = raw as Record<string, unknown>;
|
|
558
|
+
const result = schema.safeParse(msg?.data ?? msg);
|
|
559
|
+
return result.success ? raw : null;
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
ws.use('incoming', zodValidate(ChatMessageSchema));
|
|
564
|
+
ws.use('incoming', zodValidate(OrderSchema));
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
// React — Zod validated hook
|
|
569
|
+
function useSafeSocketEvent<T>(event: string, schema: z.ZodType<T>): T | undefined {
|
|
570
|
+
const [value, setValue] = useState<T>();
|
|
571
|
+
|
|
572
|
+
useSocketEvent(event, (data) => {
|
|
573
|
+
const result = schema.safeParse(data);
|
|
574
|
+
if (result.success) setValue(result.data);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Usage
|
|
581
|
+
const msg = useSafeSocketEvent('chat.message', ChatMessageSchema);
|
|
582
|
+
// msg: ChatMessage | undefined — guaranteed valid
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
```vue
|
|
586
|
+
<!-- Vue — Zod validated composable -->
|
|
587
|
+
<script setup lang="ts">
|
|
588
|
+
import { z } from 'zod';
|
|
589
|
+
|
|
590
|
+
const ChatMessageSchema = z.object({
|
|
591
|
+
text: z.string(),
|
|
592
|
+
userId: z.string(),
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Composable with validation
|
|
596
|
+
function useSafeSocketEvent<T>(event: string, schema: z.ZodType<T>) {
|
|
597
|
+
const value = ref<T>();
|
|
598
|
+
useSocketEvent(event, (data) => {
|
|
599
|
+
const result = schema.safeParse(data);
|
|
600
|
+
if (result.success) value.value = result.data as T;
|
|
601
|
+
});
|
|
602
|
+
return readonly(value);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const msg = useSafeSocketEvent('chat.message', ChatMessageSchema);
|
|
606
|
+
// msg.value: ChatMessage | undefined — guaranteed valid
|
|
607
|
+
</script>
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
## Middleware
|
|
611
|
+
|
|
612
|
+
Transform or inspect messages before send / after receive:
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
const ws = new SharedWebSocket(url);
|
|
616
|
+
|
|
617
|
+
// Add timestamp to every outgoing message
|
|
618
|
+
ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
|
|
619
|
+
|
|
620
|
+
// Decrypt incoming messages
|
|
621
|
+
ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));
|
|
622
|
+
|
|
623
|
+
// Drop messages from blocked users (return null to drop)
|
|
624
|
+
ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);
|
|
625
|
+
|
|
626
|
+
// Log everything
|
|
627
|
+
ws.use('incoming', (msg) => { console.log('← recv', msg); return msg; });
|
|
628
|
+
ws.use('outgoing', (msg) => { console.log('→ send', msg); return msg; });
|
|
629
|
+
|
|
630
|
+
// Chain multiple — executed in order
|
|
631
|
+
ws.use('outgoing', addTimestamp)
|
|
632
|
+
.use('outgoing', addRequestId)
|
|
633
|
+
.use('incoming', decryptPayload)
|
|
634
|
+
.use('incoming', validateSchema);
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
// React — configure middleware in Provider
|
|
639
|
+
function App() {
|
|
640
|
+
const wsRef = useRef<SharedWebSocket>();
|
|
641
|
+
|
|
642
|
+
return (
|
|
643
|
+
<SharedWebSocketProvider
|
|
644
|
+
url="wss://api.example.com/ws"
|
|
645
|
+
options={{ debug: true }}
|
|
646
|
+
ref={(provider) => {
|
|
647
|
+
// Access ws instance after mount to add middleware
|
|
648
|
+
}}
|
|
649
|
+
>
|
|
650
|
+
<SetupMiddleware />
|
|
651
|
+
<Dashboard />
|
|
652
|
+
</SharedWebSocketProvider>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Or setup middleware in a component
|
|
657
|
+
function SetupMiddleware() {
|
|
658
|
+
const ws = useSharedWebSocket();
|
|
659
|
+
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
|
|
662
|
+
ws.use('incoming', zodValidate(MessageSchema));
|
|
663
|
+
}, [ws]);
|
|
664
|
+
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
```vue
|
|
670
|
+
<!-- Vue — configure middleware after plugin install -->
|
|
671
|
+
<script setup>
|
|
672
|
+
// In any component
|
|
673
|
+
const ws = useSharedWebSocket();
|
|
674
|
+
ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
|
|
675
|
+
ws.use('incoming', zodValidate(MessageSchema));
|
|
676
|
+
</script>
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
## Debug Mode & Custom Logger
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
// Debug mode — logs all events to console
|
|
683
|
+
new SharedWebSocket(url, { debug: true });
|
|
684
|
+
// [SharedWS] init { tabId: "abc-123", url: "wss://..." }
|
|
685
|
+
// [SharedWS] 👑 became leader
|
|
686
|
+
// [SharedWS] ✓ connected
|
|
687
|
+
// [SharedWS] → send chat.message { text: "hi" }
|
|
688
|
+
// [SharedWS] ← recv chat.message { text: "hello" }
|
|
689
|
+
// [SharedWS] 🔄 reconnecting
|
|
690
|
+
|
|
691
|
+
// Custom logger (pino, winston, bunyan, etc.)
|
|
692
|
+
import pino from 'pino';
|
|
693
|
+
new SharedWebSocket(url, {
|
|
694
|
+
debug: true,
|
|
695
|
+
logger: pino({ name: 'ws' }),
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// Sentry integration — errors + breadcrumbs
|
|
699
|
+
import * as Sentry from '@sentry/browser';
|
|
700
|
+
new SharedWebSocket(url, {
|
|
701
|
+
debug: true,
|
|
702
|
+
logger: {
|
|
703
|
+
debug: (msg, ...args) => Sentry.addBreadcrumb({
|
|
704
|
+
category: 'websocket',
|
|
705
|
+
message: msg,
|
|
706
|
+
data: args[0] as Record<string, unknown>,
|
|
707
|
+
level: 'debug',
|
|
708
|
+
}),
|
|
709
|
+
info: (msg, ...args) => Sentry.addBreadcrumb({
|
|
710
|
+
category: 'websocket',
|
|
711
|
+
message: msg,
|
|
712
|
+
level: 'info',
|
|
713
|
+
}),
|
|
714
|
+
warn: (msg, ...args) => Sentry.addBreadcrumb({
|
|
715
|
+
category: 'websocket',
|
|
716
|
+
message: msg,
|
|
717
|
+
level: 'warning',
|
|
718
|
+
}),
|
|
719
|
+
error: (msg, ...args) => {
|
|
720
|
+
Sentry.captureException(args[0] instanceof Error ? args[0] : new Error(msg));
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Logger interface — implement debug/info/warn/error
|
|
726
|
+
import type { Logger } from '@gwakko/shared-websocket';
|
|
727
|
+
const myLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
```tsx
|
|
731
|
+
// React — debug + Sentry in Provider
|
|
732
|
+
<SharedWebSocketProvider
|
|
733
|
+
url="wss://api.example.com/ws"
|
|
734
|
+
options={{
|
|
735
|
+
debug: process.env.NODE_ENV === 'development',
|
|
736
|
+
logger: sentryLogger, // your Sentry logger object
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
<App />
|
|
740
|
+
</SharedWebSocketProvider>
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
```vue
|
|
744
|
+
<!-- Vue — debug + Sentry in plugin -->
|
|
745
|
+
<script>
|
|
746
|
+
// main.ts
|
|
747
|
+
app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
|
|
748
|
+
debug: import.meta.env.DEV,
|
|
749
|
+
logger: sentryLogger,
|
|
750
|
+
}));
|
|
751
|
+
</script>
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
## Custom Event Protocol
|
|
755
|
+
|
|
756
|
+
Override event/field names when your server uses different conventions.
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
// Default: { event: 'chat.message', data: { text: 'hi' } }
|
|
760
|
+
new SharedWebSocket(url);
|
|
761
|
+
|
|
762
|
+
// Socket.IO style: { type: 'chat.message', payload: { text: 'hi' } }
|
|
763
|
+
new SharedWebSocket(url, {
|
|
764
|
+
events: {
|
|
765
|
+
eventField: 'type', // message field for event name
|
|
766
|
+
dataField: 'payload', // message field for payload
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Phoenix/Elixir style: join/leave events + custom ping
|
|
771
|
+
new SharedWebSocket(url, {
|
|
772
|
+
events: {
|
|
773
|
+
channelJoin: 'phx_join',
|
|
774
|
+
channelLeave: 'phx_leave',
|
|
775
|
+
ping: { event: 'heartbeat', payload: {} },
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// Laravel Echo / Pusher style
|
|
780
|
+
new SharedWebSocket(url, {
|
|
781
|
+
events: {
|
|
782
|
+
eventField: 'event',
|
|
783
|
+
dataField: 'data',
|
|
784
|
+
channelJoin: 'pusher:subscribe',
|
|
785
|
+
channelLeave: 'pusher:unsubscribe',
|
|
786
|
+
ping: { event: 'pusher:ping', data: {} },
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Action Cable (Rails) style
|
|
791
|
+
new SharedWebSocket(url, {
|
|
792
|
+
events: {
|
|
793
|
+
eventField: 'type',
|
|
794
|
+
dataField: 'message',
|
|
795
|
+
channelJoin: 'subscribe',
|
|
796
|
+
channelLeave: 'unsubscribe',
|
|
797
|
+
ping: { type: 'ping' },
|
|
798
|
+
defaultEvent: 'message',
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
All fields in `events` are optional — override only what differs from defaults.
|
|
804
|
+
|
|
805
|
+
| Field | Default | Description |
|
|
806
|
+
|-------|---------|-------------|
|
|
807
|
+
| `eventField` | `"event"` | Message field name for event type |
|
|
808
|
+
| `dataField` | `"data"` | Message field name for payload |
|
|
809
|
+
| `channelJoin` | `"$channel:join"` | Event sent when joining a channel |
|
|
810
|
+
| `channelLeave` | `"$channel:leave"` | Event sent when leaving a channel |
|
|
811
|
+
| `ping` | `{ type: "ping" }` | Heartbeat payload |
|
|
812
|
+
| `defaultEvent` | `"message"` | Fallback event when message has no event field |
|
|
813
|
+
|
|
814
|
+
## Advanced Examples
|
|
815
|
+
|
|
816
|
+
### Stream — consume events as async iterator
|
|
817
|
+
|
|
818
|
+
```typescript
|
|
819
|
+
// Vanilla
|
|
820
|
+
await withSocket(url, async ({ ws, signal }) => {
|
|
821
|
+
for await (const tick of ws.stream('trading.tick', signal)) {
|
|
822
|
+
updateChart(tick); // yields one event at a time
|
|
823
|
+
}
|
|
824
|
+
// auto-cleanup: unsubscribes when signal aborts or loop breaks
|
|
825
|
+
});
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
```tsx
|
|
829
|
+
// React — stream into state with limit
|
|
830
|
+
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
831
|
+
useSocketStream<LogEntry>('server.log', (entry) => {
|
|
832
|
+
setLogs(prev => [...prev, entry].slice(-500));
|
|
833
|
+
});
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
```vue
|
|
837
|
+
<!-- Vue — stream into ref -->
|
|
838
|
+
<script setup>
|
|
839
|
+
const logs = ref<LogEntry[]>([]);
|
|
840
|
+
useSocketStream<LogEntry>('server.log', (entry) => {
|
|
841
|
+
logs.value = [...logs.value, entry].slice(-500);
|
|
842
|
+
});
|
|
843
|
+
</script>
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
### Request — request/response through server
|
|
847
|
+
|
|
848
|
+
```typescript
|
|
849
|
+
// Vanilla — request user profile via server
|
|
850
|
+
await withSocket(url, async ({ ws }) => {
|
|
851
|
+
const user = await ws.request<User>('user.profile', { id: 123 }, 5000);
|
|
852
|
+
console.log(user.name); // response from server, 5s timeout
|
|
853
|
+
});
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
```tsx
|
|
857
|
+
// React
|
|
858
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
859
|
+
const ws = useSharedWebSocket();
|
|
860
|
+
const [user, setUser] = useState<User | null>(null);
|
|
861
|
+
|
|
862
|
+
useEffect(() => {
|
|
863
|
+
ws.request<User>('user.profile', { id: userId }).then(setUser);
|
|
864
|
+
}, [userId]);
|
|
865
|
+
|
|
866
|
+
return user ? <div>{user.name}</div> : <div>Loading...</div>;
|
|
867
|
+
}
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### Protocols — WebSocket subprotocols
|
|
871
|
+
|
|
872
|
+
```typescript
|
|
873
|
+
// Pass subprotocols for server-side protocol negotiation
|
|
874
|
+
new SharedWebSocket('wss://api.example.com/ws', {
|
|
875
|
+
protocols: ['graphql-ws', 'graphql-transport-ws'],
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// Common protocols:
|
|
879
|
+
// 'graphql-ws' — GraphQL over WebSocket
|
|
880
|
+
// 'mqtt' — MQTT over WebSocket
|
|
881
|
+
// 'wamp.2.json' — WAMP v2
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
### Worker URL — custom worker file
|
|
885
|
+
|
|
886
|
+
```typescript
|
|
887
|
+
// Default: inline blob worker (no extra files needed)
|
|
888
|
+
new SharedWebSocket(url, { useWorker: true });
|
|
889
|
+
|
|
890
|
+
// Custom worker file (for CSP restrictions or custom logic):
|
|
891
|
+
new SharedWebSocket(url, {
|
|
892
|
+
useWorker: true,
|
|
893
|
+
workerUrl: '/workers/socket.worker.js', // your own worker file
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// Or as URL object:
|
|
897
|
+
new SharedWebSocket(url, {
|
|
898
|
+
useWorker: true,
|
|
899
|
+
workerUrl: new URL('./socket.worker.ts', import.meta.url), // Vite handles this
|
|
900
|
+
});
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
### Lifecycle Hooks
|
|
904
|
+
|
|
905
|
+
```typescript
|
|
906
|
+
// Vanilla
|
|
907
|
+
await withSocket(url, async ({ ws }) => {
|
|
908
|
+
ws.onConnect(() => console.log('Connected!'));
|
|
909
|
+
ws.onDisconnect(() => showOfflineBanner());
|
|
910
|
+
ws.onReconnecting(() => showSpinner());
|
|
911
|
+
ws.onLeaderChange((isLeader) => console.log('Leader:', isLeader));
|
|
912
|
+
ws.onError((err) => reportToSentry(err));
|
|
913
|
+
});
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
```tsx
|
|
917
|
+
// React
|
|
918
|
+
useSocketLifecycle({
|
|
919
|
+
onConnect: () => toast.success('Connected'),
|
|
920
|
+
onDisconnect: () => toast.error('Connection lost'),
|
|
921
|
+
onReconnecting: () => toast.loading('Reconnecting...'),
|
|
922
|
+
onLeaderChange: (isLeader) => {
|
|
923
|
+
if (isLeader) console.log('This tab is now the leader');
|
|
924
|
+
},
|
|
925
|
+
onError: (err) => Sentry.captureException(err),
|
|
926
|
+
});
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
```vue
|
|
930
|
+
<!-- Vue -->
|
|
931
|
+
<script setup>
|
|
932
|
+
useSocketLifecycle({
|
|
933
|
+
onConnect: () => toast.success('Connected'),
|
|
934
|
+
onDisconnect: () => toast.error('Connection lost'),
|
|
935
|
+
onReconnecting: () => toast.loading('Reconnecting...'),
|
|
936
|
+
onError: (err) => reportError(err),
|
|
937
|
+
});
|
|
938
|
+
</script>
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
### Private Channels — chat rooms, tenant notifications
|
|
942
|
+
|
|
943
|
+
The `channel()` method creates a scoped handle. Events are prefixed with the channel name. Server receives `$channel:join` / `$channel:leave` events.
|
|
944
|
+
|
|
945
|
+
```typescript
|
|
946
|
+
// Vanilla — private chat room
|
|
947
|
+
await withSocket(url, { auth: () => getToken() }, async ({ ws }) => {
|
|
948
|
+
const chat = ws.channel('chat:room_42');
|
|
949
|
+
|
|
950
|
+
chat.on('message', (msg) => renderMessage(msg));
|
|
951
|
+
chat.on('typing', (user) => showTyping(user));
|
|
952
|
+
chat.send('message', { text: 'Hello room!' });
|
|
953
|
+
|
|
954
|
+
// When done:
|
|
955
|
+
chat.leave(); // sends $channel:leave to server, unsubscribes all
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
// Tenant-scoped notifications
|
|
959
|
+
await withSocket(url, { auth: () => getToken() }, async ({ ws }) => {
|
|
960
|
+
const notifs = ws.channel(`tenant:${tenantId}:notifications`);
|
|
961
|
+
notifs.on('alert', (alert) => showToast(alert));
|
|
962
|
+
notifs.on('update', (update) => refreshDashboard(update));
|
|
963
|
+
|
|
964
|
+
// User's private channel
|
|
965
|
+
const user = ws.channel(`user:${userId}`);
|
|
966
|
+
user.on('message', (dm) => showDirectMessage(dm));
|
|
967
|
+
user.on('mention', (mention) => highlightMention(mention));
|
|
968
|
+
});
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
```tsx
|
|
972
|
+
// React — auto join/leave on mount/unmount
|
|
973
|
+
function ChatRoom({ roomId }: { roomId: string }) {
|
|
974
|
+
const chat = useChannel(`chat:${roomId}`);
|
|
975
|
+
|
|
976
|
+
// Events are prefixed: 'chat:room_42:message'
|
|
977
|
+
const message = useSocketEvent<Message>(`chat:${roomId}:message`);
|
|
978
|
+
const typing = useSocketEvent<User>(`chat:${roomId}:typing`);
|
|
979
|
+
|
|
980
|
+
function send(text: string) {
|
|
981
|
+
chat.send('message', { text });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return (/* ... */);
|
|
985
|
+
}
|
|
986
|
+
// When ChatRoom unmounts → chat.leave() called automatically
|
|
987
|
+
|
|
988
|
+
// Tenant notifications
|
|
989
|
+
function TenantAlerts({ tenantId }: { tenantId: string }) {
|
|
990
|
+
const channel = useChannel(`tenant:${tenantId}:notifications`);
|
|
991
|
+
|
|
992
|
+
useSocketCallback(`tenant:${tenantId}:notifications:alert`, (alert) => {
|
|
993
|
+
showToast(alert);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
```vue
|
|
1001
|
+
<!-- Vue — private channel -->
|
|
1002
|
+
<script setup>
|
|
1003
|
+
const props = defineProps<{ roomId: string }>();
|
|
1004
|
+
|
|
1005
|
+
const chat = useChannel(`chat:${props.roomId}`);
|
|
1006
|
+
const message = useSocketEvent<Message>(`chat:${props.roomId}:message`);
|
|
1007
|
+
|
|
1008
|
+
function send(text: string) {
|
|
1009
|
+
chat.send('message', { text });
|
|
1010
|
+
}
|
|
1011
|
+
// Auto-leave on unmount
|
|
1012
|
+
</script>
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### Server-side channel handling
|
|
1016
|
+
|
|
1017
|
+
```typescript
|
|
1018
|
+
// Node.js — handle channel join/leave
|
|
1019
|
+
wss.on('connection', (ws) => {
|
|
1020
|
+
const channels = new Set<string>();
|
|
1021
|
+
|
|
1022
|
+
ws.on('message', (raw) => {
|
|
1023
|
+
const msg = JSON.parse(raw.toString());
|
|
1024
|
+
|
|
1025
|
+
if (msg.event === '$channel:join') {
|
|
1026
|
+
channels.add(msg.data.channel);
|
|
1027
|
+
console.log(`Client joined ${msg.data.channel}`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (msg.event === '$channel:leave') {
|
|
1032
|
+
channels.delete(msg.data.channel);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Route channel messages
|
|
1037
|
+
// msg.event = 'chat:room_42:message'
|
|
1038
|
+
// Extract channel: 'chat:room_42'
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
## Exported Types
|
|
1044
|
+
|
|
1045
|
+
All types are available for import in your projects:
|
|
1046
|
+
|
|
1047
|
+
```typescript
|
|
1048
|
+
import type {
|
|
1049
|
+
// Core
|
|
1050
|
+
SharedWebSocketOptions, // constructor options
|
|
1051
|
+
SocketState, // 'connecting' | 'connected' | 'reconnecting' | 'closed'
|
|
1052
|
+
TabRole, // 'leader' | 'follower'
|
|
1053
|
+
Unsubscribe, // () => void
|
|
1054
|
+
EventHandler, // (data: any) => void
|
|
1055
|
+
|
|
1056
|
+
// Channels
|
|
1057
|
+
Channel, // scoped channel handle from ws.channel()
|
|
1058
|
+
EventProtocol, // custom event/field names
|
|
1059
|
+
|
|
1060
|
+
// Lifecycle
|
|
1061
|
+
SocketLifecycleHandlers, // { onConnect?, onDisconnect?, onReconnecting?, ... }
|
|
1062
|
+
|
|
1063
|
+
// withSocket
|
|
1064
|
+
SocketScope, // { ws, signal } — callback argument
|
|
1065
|
+
WithSocketOptions, // extends SharedWebSocketOptions + signal
|
|
1066
|
+
WithSocketCallback, // (scope: SocketScope) => void | Promise<void>
|
|
1067
|
+
|
|
1068
|
+
// Internal (advanced)
|
|
1069
|
+
BusMessage, // BroadcastChannel message envelope
|
|
1070
|
+
} from '@gwakko/shared-websocket';
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
```tsx
|
|
1074
|
+
// React — all hooks + types
|
|
1075
|
+
import {
|
|
1076
|
+
SharedWebSocketProvider,
|
|
1077
|
+
useSharedWebSocket,
|
|
1078
|
+
useSocketEvent,
|
|
1079
|
+
useSocketStream,
|
|
1080
|
+
useSocketSync,
|
|
1081
|
+
useSocketCallback,
|
|
1082
|
+
useSocketStatus,
|
|
1083
|
+
useSocketLifecycle,
|
|
1084
|
+
useChannel,
|
|
1085
|
+
} from '@gwakko/shared-websocket/react';
|
|
1086
|
+
|
|
1087
|
+
import type { SharedWebSocketProviderProps } from '@gwakko/shared-websocket/react';
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
```typescript
|
|
1091
|
+
// Vue — all composables + types
|
|
1092
|
+
import {
|
|
1093
|
+
createSharedWebSocketPlugin,
|
|
1094
|
+
useSharedWebSocket,
|
|
1095
|
+
useSocketEvent,
|
|
1096
|
+
useSocketStream,
|
|
1097
|
+
useSocketSync,
|
|
1098
|
+
useSocketCallback,
|
|
1099
|
+
useSocketStatus,
|
|
1100
|
+
useSocketLifecycle,
|
|
1101
|
+
useChannel,
|
|
1102
|
+
SharedWebSocketKey, // InjectionKey for custom provide/inject
|
|
1103
|
+
} from '@gwakko/shared-websocket/vue';
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
### Usage with custom types
|
|
1107
|
+
|
|
1108
|
+
```typescript
|
|
1109
|
+
import type { Channel, SocketLifecycleHandlers, EventProtocol } from '@gwakko/shared-websocket';
|
|
1110
|
+
|
|
1111
|
+
// Type your channel
|
|
1112
|
+
const chat: Channel = ws.channel('chat:room_1');
|
|
1113
|
+
|
|
1114
|
+
// Type lifecycle handlers separately
|
|
1115
|
+
const handlers: SocketLifecycleHandlers = {
|
|
1116
|
+
onConnect: () => setStatus('online'),
|
|
1117
|
+
onDisconnect: () => setStatus('offline'),
|
|
1118
|
+
};
|
|
1119
|
+
useSocketLifecycle(handlers);
|
|
1120
|
+
|
|
1121
|
+
// Type your protocol config
|
|
1122
|
+
const protocol: Partial<EventProtocol> = {
|
|
1123
|
+
eventField: 'type',
|
|
1124
|
+
dataField: 'payload',
|
|
1125
|
+
channelJoin: 'subscribe',
|
|
1126
|
+
};
|
|
1127
|
+
new SharedWebSocket(url, { events: protocol });
|
|
1128
|
+
```
|
|
1129
|
+
|
|
403
1130
|
## Browser Support
|
|
404
1131
|
|
|
405
1132
|
| API | Chrome | Firefox | Safari | Edge |
|