@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 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
- useWorker: true, // optional: run WebSocket in Web Worker (offloads main thread)
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` | — | JWT token provider |
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 |