@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.
@@ -9,6 +9,8 @@ interface SharedSocketOptions {
9
9
  auth?: () => string | Promise<string>;
10
10
  authToken?: string;
11
11
  authParam?: string;
12
+ /** Heartbeat payload (default: { type: "ping" }). */
13
+ pingPayload?: unknown;
12
14
  }
13
15
  export declare class SharedSocket implements Disposable {
14
16
  private url;
@@ -1,13 +1,20 @@
1
1
  import './utils/disposable';
2
- import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler } from './types';
2
+ import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventMap, Middleware } from './types';
3
3
  /**
4
4
  * SharedWebSocket — shares ONE WebSocket connection across browser tabs.
5
5
  *
6
- * One tab becomes the "leader" and holds the WebSocket.
7
- * Other tabs are "followers" receiving data via BroadcastChannel.
8
- * If the leader closes, a new leader is elected automatically.
6
+ * @typeParam TEvents - Event map for type-safe subscriptions.
7
+ *
8
+ * @example
9
+ * // Typed events
10
+ * type Events = {
11
+ * 'chat.message': { text: string; userId: string };
12
+ * 'order.created': { id: string; total: number };
13
+ * };
14
+ * const ws = new SharedWebSocket<Events>(url);
15
+ * ws.on('chat.message', (msg) => msg.text); // ← msg: { text, userId }
9
16
  */
10
- export declare class SharedWebSocket implements Disposable {
17
+ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implements Disposable {
11
18
  private readonly url;
12
19
  private readonly options;
13
20
  private bus;
@@ -18,18 +25,53 @@ export declare class SharedWebSocket implements Disposable {
18
25
  private tabId;
19
26
  private cleanups;
20
27
  private disposed;
21
- constructor(url: string, options?: SharedWebSocketOptions);
28
+ private readonly proto;
29
+ private readonly log;
30
+ private outgoingMiddleware;
31
+ private incomingMiddleware;
32
+ constructor(url: string, options?: SharedWebSocketOptions<TEvents>);
22
33
  get connected(): boolean;
23
34
  get tabRole(): TabRole;
24
35
  /** Start leader election and connect. */
25
36
  connect(): Promise<void>;
26
- /** Subscribe to server events (works in ALL tabs). */
27
- on(event: string, handler: EventHandler): Unsubscribe;
28
- once(event: string, handler: EventHandler): Unsubscribe;
37
+ /** Called when WebSocket connection opens (broadcast to all tabs). */
38
+ onConnect(fn: () => void): Unsubscribe;
39
+ /** Called when WebSocket connection closes (broadcast to all tabs). */
40
+ onDisconnect(fn: () => void): Unsubscribe;
41
+ /** Called when WebSocket starts reconnecting (broadcast to all tabs). */
42
+ onReconnecting(fn: () => void): Unsubscribe;
43
+ /** Called when this tab becomes leader or loses leadership. */
44
+ onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe;
45
+ /** Called on WebSocket or network error (broadcast to all tabs). */
46
+ onError(fn: (error: unknown) => void): Unsubscribe;
47
+ /**
48
+ * Add middleware to transform messages before send or after receive.
49
+ * Return null from middleware to drop the message.
50
+ *
51
+ * @example
52
+ * // Add timestamp to every outgoing message
53
+ * ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
54
+ *
55
+ * @example
56
+ * // Decrypt incoming messages
57
+ * ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));
58
+ *
59
+ * @example
60
+ * // Drop messages from blocked users
61
+ * ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);
62
+ */
63
+ use(direction: 'outgoing' | 'incoming', fn: Middleware): this;
64
+ /** Subscribe to server events (works in ALL tabs). Type-safe with EventMap. */
65
+ on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
66
+ on(event: string, handler: EventHandler<unknown>): Unsubscribe;
67
+ once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
68
+ once(event: string, handler: EventHandler<unknown>): Unsubscribe;
29
69
  off(event: string, handler?: EventHandler): void;
30
- /** Async generator for consuming events. */
70
+ /** Async generator for consuming events. Type-safe with EventMap. */
71
+ stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;
31
72
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
32
- /** Send message to server (auto-routed through leader). */
73
+ /** Send message to server (auto-routed through leader). Type-safe with EventMap. */
74
+ send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
33
75
  send(event: string, data: unknown): void;
34
76
  /** Request/response through server via leader. */
35
77
  request<T>(event: string, data: unknown, timeout?: number): Promise<T>;
@@ -37,9 +79,25 @@ export declare class SharedWebSocket implements Disposable {
37
79
  sync<T>(key: string, value: T): void;
38
80
  getSync<T>(key: string): T | undefined;
39
81
  onSync<T>(key: string, fn: (value: T) => void): Unsubscribe;
82
+ /**
83
+ * Subscribe to a private/scoped channel. Returns a channel handle with
84
+ * scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe.
85
+ *
86
+ * @example
87
+ * const chat = ws.channel('chat:room_123');
88
+ * chat.on('message', (msg) => render(msg));
89
+ * chat.send('message', { text: 'Hello' });
90
+ * chat.leave(); // sends leave + unsubscribes
91
+ *
92
+ * @example
93
+ * // Private notifications for tenant
94
+ * const notifications = ws.channel(`tenant:${tenantId}:notifications`);
95
+ * notifications.on('alert', (alert) => showToast(alert));
96
+ */
97
+ channel(name: string): Channel;
40
98
  disconnect(): void;
41
99
  private createSocket;
42
- private onBecomeLeader;
43
- private onLoseLeadership;
100
+ private handleBecomeLeader;
101
+ private handleLoseLeadership;
44
102
  [Symbol.dispose](): void;
45
103
  }
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { SharedWebSocket } from '../SharedWebSocket';
3
- import type { SharedWebSocketOptions, TabRole } from '../types';
3
+ import type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';
4
4
  /**
5
5
  * Provider props — pass URL and options as props for flexibility.
6
6
  *
@@ -32,7 +32,7 @@ export interface SharedWebSocketProviderProps {
32
32
  * );
33
33
  * }
34
34
  */
35
- export declare function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SharedWebSocket | null>>;
35
+ export declare function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SharedWebSocket<import("..").EventMap> | null>>;
36
36
  /**
37
37
  * Access the SharedWebSocket instance from context.
38
38
  *
@@ -137,3 +137,30 @@ export declare function useSocketStatus(): {
137
137
  connected: boolean;
138
138
  tabRole: TabRole;
139
139
  };
140
+ /**
141
+ * Lifecycle hooks — react to connection state changes.
142
+ *
143
+ * @example
144
+ * useSocketLifecycle({
145
+ * onConnect: () => console.log('Connected!'),
146
+ * onDisconnect: () => console.log('Disconnected'),
147
+ * onReconnecting: () => showSpinner(),
148
+ * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),
149
+ * onError: (err) => reportError(err),
150
+ * });
151
+ */
152
+ export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): void;
153
+ /**
154
+ * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
155
+ *
156
+ * @example
157
+ * const chat = useChannel('chat:room_123');
158
+ * const message = useSocketEvent('chat:room_123:message');
159
+ * chat.send('message', { text: 'Hello' });
160
+ *
161
+ * @example
162
+ * // Tenant notifications
163
+ * const notifications = useChannel(`tenant:${tenantId}:notifications`);
164
+ * useSocketCallback(`tenant:${tenantId}:notifications:alert`, showToast);
165
+ */
166
+ export declare function useChannel(name: string): import("..").Channel;
@@ -1,6 +1,6 @@
1
1
  import { type Ref, type InjectionKey, type App } from 'vue';
2
2
  import { SharedWebSocket } from '../SharedWebSocket';
3
- import type { SharedWebSocketOptions, TabRole } from '../types';
3
+ import type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';
4
4
  export declare const SharedWebSocketKey: InjectionKey<SharedWebSocket>;
5
5
  /**
6
6
  * Vue 3 plugin for SharedWebSocket.
@@ -101,3 +101,25 @@ export declare function useSocketStatus(): {
101
101
  connected: Ref<boolean>;
102
102
  tabRole: Ref<TabRole>;
103
103
  };
104
+ /**
105
+ * Lifecycle hooks — react to connection state changes.
106
+ *
107
+ * @example
108
+ * useSocketLifecycle({
109
+ * onConnect: () => console.log('Connected!'),
110
+ * onDisconnect: () => showOfflineBanner(),
111
+ * onReconnecting: () => showSpinner(),
112
+ * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),
113
+ * onError: (err) => reportError(err),
114
+ * });
115
+ */
116
+ export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): void;
117
+ /**
118
+ * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
119
+ *
120
+ * @example
121
+ * const chat = useChannel('chat:room_123');
122
+ * // Listen via useSocketEvent('chat:room_123:message')
123
+ * // Send via chat.send('message', { text: 'Hello' })
124
+ */
125
+ export declare function useChannel(name: string): import("..").Channel;
@@ -253,7 +253,8 @@ var SharedSocket = class {
253
253
  sendBuffer: options.sendBuffer ?? 100,
254
254
  auth: options.auth,
255
255
  authToken: options.authToken,
256
- authParam: options.authParam ?? "token"
256
+ authParam: options.authParam ?? "token",
257
+ pingPayload: options.pingPayload ?? { type: "ping" }
257
258
  };
258
259
  }
259
260
  url;
@@ -353,7 +354,7 @@ var SharedSocket = class {
353
354
  this.stopHeartbeat();
354
355
  this.heartbeatTimer = setInterval(() => {
355
356
  if (this.ws?.readyState === WebSocket.OPEN) {
356
- this.ws.send(JSON.stringify({ type: "ping" }));
357
+ this.ws.send(JSON.stringify(this.opts.pingPayload));
357
358
  }
358
359
  }, this.opts.heartbeatInterval);
359
360
  }
@@ -576,11 +577,32 @@ var SubscriptionManager = class {
576
577
  };
577
578
 
578
579
  // src/SharedWebSocket.ts
580
+ var DEFAULT_PROTOCOL = {
581
+ eventField: "event",
582
+ dataField: "data",
583
+ channelJoin: "$channel:join",
584
+ channelLeave: "$channel:leave",
585
+ ping: { type: "ping" },
586
+ defaultEvent: "message"
587
+ };
588
+ var NOOP_LOGGER = {
589
+ debug() {
590
+ },
591
+ info() {
592
+ },
593
+ warn() {
594
+ },
595
+ error() {
596
+ }
597
+ };
579
598
  var SharedWebSocket = class {
580
599
  constructor(url, options = {}) {
581
600
  this.url = url;
582
601
  this.options = options;
602
+ this.proto = { ...DEFAULT_PROTOCOL, ...options.events };
603
+ this.log = options.debug ? options.logger ?? console : NOOP_LOGGER;
583
604
  this.tabId = generateId();
605
+ this.log.debug("[SharedWS] init", { tabId: this.tabId, url });
584
606
  this.bus = new MessageBus("shared-ws", this.tabId);
585
607
  this.coordinator = new TabCoordinator(this.bus, this.tabId, {
586
608
  electionTimeout: options.electionTimeout,
@@ -595,7 +617,7 @@ var SharedWebSocket = class {
595
617
  this.cleanups.push(
596
618
  this.bus.subscribe("ws:send", (msg) => {
597
619
  if (this.coordinator.isLeader && this.socket) {
598
- this.socket.send({ event: msg.event, data: msg.data });
620
+ this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
599
621
  }
600
622
  })
601
623
  );
@@ -605,8 +627,35 @@ var SharedWebSocket = class {
605
627
  this.subs.emit(`sync:${msg.key}`, msg.value);
606
628
  })
607
629
  );
608
- this.coordinator.onBecomeLeader(() => this.onBecomeLeader());
609
- this.coordinator.onLoseLeadership(() => this.onLoseLeadership());
630
+ this.coordinator.onBecomeLeader(() => {
631
+ this.handleBecomeLeader();
632
+ this.bus.broadcast("ws:lifecycle", { type: "leader", isLeader: true });
633
+ });
634
+ this.coordinator.onLoseLeadership(() => {
635
+ this.handleLoseLeadership();
636
+ this.bus.broadcast("ws:lifecycle", { type: "leader", isLeader: false });
637
+ });
638
+ this.cleanups.push(
639
+ this.bus.subscribe("ws:lifecycle", (msg) => {
640
+ switch (msg.type) {
641
+ case "connect":
642
+ this.subs.emit("$lifecycle:connect", void 0);
643
+ break;
644
+ case "disconnect":
645
+ this.subs.emit("$lifecycle:disconnect", void 0);
646
+ break;
647
+ case "reconnecting":
648
+ this.subs.emit("$lifecycle:reconnecting", void 0);
649
+ break;
650
+ case "leader":
651
+ this.subs.emit("$lifecycle:leader", msg.isLeader);
652
+ break;
653
+ case "error":
654
+ this.subs.emit("$lifecycle:error", msg.error);
655
+ break;
656
+ }
657
+ })
658
+ );
610
659
  if (typeof window !== "undefined") {
611
660
  const onBeforeUnload = () => this[Symbol.dispose]();
612
661
  window.addEventListener("beforeunload", onBeforeUnload);
@@ -623,6 +672,10 @@ var SharedWebSocket = class {
623
672
  tabId;
624
673
  cleanups = [];
625
674
  disposed = false;
675
+ proto;
676
+ log;
677
+ outgoingMiddleware = [];
678
+ incomingMiddleware = [];
626
679
  get connected() {
627
680
  return this.socket?.state === "connected" || !this.coordinator.isLeader;
628
681
  }
@@ -633,24 +686,78 @@ var SharedWebSocket = class {
633
686
  async connect() {
634
687
  await this.coordinator.elect();
635
688
  }
636
- /** Subscribe to server events (works in ALL tabs). */
689
+ // ─── Lifecycle Hooks ─────────────────────────────────
690
+ /** Called when WebSocket connection opens (broadcast to all tabs). */
691
+ onConnect(fn) {
692
+ return this.subs.on("$lifecycle:connect", fn);
693
+ }
694
+ /** Called when WebSocket connection closes (broadcast to all tabs). */
695
+ onDisconnect(fn) {
696
+ return this.subs.on("$lifecycle:disconnect", fn);
697
+ }
698
+ /** Called when WebSocket starts reconnecting (broadcast to all tabs). */
699
+ onReconnecting(fn) {
700
+ return this.subs.on("$lifecycle:reconnecting", fn);
701
+ }
702
+ /** Called when this tab becomes leader or loses leadership. */
703
+ onLeaderChange(fn) {
704
+ return this.subs.on("$lifecycle:leader", fn);
705
+ }
706
+ /** Called on WebSocket or network error (broadcast to all tabs). */
707
+ onError(fn) {
708
+ return this.subs.on("$lifecycle:error", fn);
709
+ }
710
+ // ─── Middleware ───────────────────────────────────────
711
+ /**
712
+ * Add middleware to transform messages before send or after receive.
713
+ * Return null from middleware to drop the message.
714
+ *
715
+ * @example
716
+ * // Add timestamp to every outgoing message
717
+ * ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
718
+ *
719
+ * @example
720
+ * // Decrypt incoming messages
721
+ * ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));
722
+ *
723
+ * @example
724
+ * // Drop messages from blocked users
725
+ * ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);
726
+ */
727
+ use(direction, fn) {
728
+ if (direction === "outgoing") {
729
+ this.outgoingMiddleware.push(fn);
730
+ } else {
731
+ this.incomingMiddleware.push(fn);
732
+ }
733
+ return this;
734
+ }
735
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
637
736
  on(event, handler) {
638
737
  return this.subs.on(event, handler);
639
738
  }
739
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
640
740
  once(event, handler) {
641
741
  return this.subs.once(event, handler);
642
742
  }
643
743
  off(event, handler) {
644
744
  this.subs.off(event, handler);
645
745
  }
646
- /** Async generator for consuming events. */
647
746
  stream(event, signal) {
648
747
  return this.subs.stream(event, signal);
649
748
  }
650
- /** Send message to server (auto-routed through leader). */
651
749
  send(event, data) {
750
+ let payload = { [this.proto.eventField]: event, [this.proto.dataField]: data };
751
+ for (const mw of this.outgoingMiddleware) {
752
+ payload = mw(payload);
753
+ if (payload === null) {
754
+ this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", event);
755
+ return;
756
+ }
757
+ }
758
+ this.log.debug("[SharedWS] \u2192 send", event, data);
652
759
  if (this.coordinator.isLeader && this.socket) {
653
- this.socket.send({ event, data });
760
+ this.socket.send(payload);
654
761
  } else {
655
762
  this.bus.publish("ws:send", { event, data });
656
763
  }
@@ -670,6 +777,50 @@ var SharedWebSocket = class {
670
777
  onSync(key, fn) {
671
778
  return this.subs.on(`sync:${key}`, fn);
672
779
  }
780
+ /**
781
+ * Subscribe to a private/scoped channel. Returns a channel handle with
782
+ * scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe.
783
+ *
784
+ * @example
785
+ * const chat = ws.channel('chat:room_123');
786
+ * chat.on('message', (msg) => render(msg));
787
+ * chat.send('message', { text: 'Hello' });
788
+ * chat.leave(); // sends leave + unsubscribes
789
+ *
790
+ * @example
791
+ * // Private notifications for tenant
792
+ * const notifications = ws.channel(`tenant:${tenantId}:notifications`);
793
+ * notifications.on('alert', (alert) => showToast(alert));
794
+ */
795
+ channel(name) {
796
+ this.send(this.proto.channelJoin, { channel: name });
797
+ const self = this;
798
+ const unsubs = [];
799
+ return {
800
+ name,
801
+ on(event, handler) {
802
+ const unsub = self.subs.on(`${name}:${event}`, handler);
803
+ unsubs.push(unsub);
804
+ return unsub;
805
+ },
806
+ once(event, handler) {
807
+ const unsub = self.subs.once(`${name}:${event}`, handler);
808
+ unsubs.push(unsub);
809
+ return unsub;
810
+ },
811
+ send(event, data) {
812
+ self.send(`${name}:${event}`, data);
813
+ },
814
+ stream(event, signal) {
815
+ return self.subs.stream(`${name}:${event}`, signal);
816
+ },
817
+ leave() {
818
+ self.send(self.proto.channelLeave, { channel: name });
819
+ for (const unsub of unsubs) unsub();
820
+ unsubs.length = 0;
821
+ }
822
+ };
823
+ }
673
824
  disconnect() {
674
825
  this[Symbol.dispose]();
675
826
  }
@@ -679,7 +830,8 @@ var SharedWebSocket = class {
679
830
  reconnect: this.options.reconnect,
680
831
  reconnectMaxDelay: this.options.reconnectMaxDelay,
681
832
  heartbeatInterval: this.options.heartbeatInterval,
682
- sendBuffer: this.options.sendBuffer
833
+ sendBuffer: this.options.sendBuffer,
834
+ pingPayload: this.proto.ping
683
835
  };
684
836
  if (this.options.useWorker) {
685
837
  return new WorkerSocket(this.url, {
@@ -694,20 +846,46 @@ var SharedWebSocket = class {
694
846
  authParam: this.options.authParam
695
847
  });
696
848
  }
697
- onBecomeLeader() {
849
+ handleBecomeLeader() {
850
+ this.log.info("[SharedWS] \u{1F451} became leader");
698
851
  this.socket = this.createSocket();
699
- this.socket.onMessage((data) => {
700
- const event = data?.event ?? "message";
701
- const payload = data?.data ?? data;
852
+ this.socket.onMessage((raw) => {
853
+ let data = raw;
854
+ for (const mw of this.incomingMiddleware) {
855
+ data = mw(data);
856
+ if (data === null) {
857
+ this.log.debug("[SharedWS] \u2717 incoming dropped by middleware");
858
+ return;
859
+ }
860
+ }
861
+ const msg = data;
862
+ const event = msg?.[this.proto.eventField] ?? this.proto.defaultEvent;
863
+ const payload = msg?.[this.proto.dataField] ?? data;
864
+ this.log.debug("[SharedWS] \u2190 recv", event, payload);
702
865
  this.bus.broadcast("ws:message", { event, data: payload });
703
866
  });
867
+ this.socket.onStateChange((state) => {
868
+ this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : `state: ${state}`);
869
+ switch (state) {
870
+ case "connected":
871
+ this.bus.broadcast("ws:lifecycle", { type: "connect" });
872
+ break;
873
+ case "closed":
874
+ this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
875
+ break;
876
+ case "reconnecting":
877
+ this.bus.broadcast("ws:lifecycle", { type: "reconnecting" });
878
+ break;
879
+ }
880
+ });
704
881
  this.cleanups.push(
705
882
  this.bus.respond("ws:request", async (req) => {
706
883
  return new Promise((resolve) => {
707
884
  const unsub = this.socket.onMessage((response) => {
708
- if (response?.event === req.event || response?.requestId) {
885
+ const res = response;
886
+ if (res?.[this.proto.eventField] === req.event || res?.requestId) {
709
887
  unsub();
710
- resolve(response?.data ?? response);
888
+ resolve(res?.[this.proto.dataField] ?? response);
711
889
  }
712
890
  });
713
891
  this.socket.send({ event: req.event, data: req.data });
@@ -716,7 +894,7 @@ var SharedWebSocket = class {
716
894
  );
717
895
  this.socket.connect();
718
896
  }
719
- onLoseLeadership() {
897
+ handleLoseLeadership() {
720
898
  if (this.socket) {
721
899
  this.socket[Symbol.dispose]();
722
900
  this.socket = null;
@@ -746,4 +924,4 @@ export {
746
924
  SubscriptionManager,
747
925
  SharedWebSocket
748
926
  };
749
- //# sourceMappingURL=chunk-JJTAPRPG.js.map
927
+ //# sourceMappingURL=chunk-4D2ZDCA6.js.map