@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 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 |