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