@gwakko/shared-websocket 0.2.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +731 -4
- package/dist/SharedSocket.d.ts +5 -0
- package/dist/SharedWebSocket.d.ts +71 -13
- package/dist/adapters/react.d.ts +29 -2
- package/dist/adapters/vue.d.ts +23 -1
- package/dist/{chunk-TNEMKPGP.js → chunk-4D2ZDCA6.js} +215 -25
- package/dist/chunk-4D2ZDCA6.js.map +1 -0
- package/dist/{chunk-SMH3X34N.cjs → chunk-UEOFAFLV.cjs} +216 -26
- package/dist/chunk-UEOFAFLV.cjs.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/react.cjs +33 -3
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +31 -1
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +78 -2
- package/dist/vue.cjs +33 -11
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +31 -9
- package/dist/vue.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedSocket.ts +35 -9
- package/src/SharedWebSocket.ts +239 -25
- package/src/adapters/react.ts +63 -4
- package/src/adapters/vue.ts +57 -11
- package/src/index.ts +21 -2
- package/src/types.ts +84 -2
- package/dist/chunk-SMH3X34N.cjs.map +0 -1
- package/dist/chunk-TNEMKPGP.js.map +0 -1
package/dist/SharedSocket.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ interface SharedSocketOptions {
|
|
|
7
7
|
heartbeatInterval?: number;
|
|
8
8
|
sendBuffer?: number;
|
|
9
9
|
auth?: () => string | Promise<string>;
|
|
10
|
+
authToken?: string;
|
|
11
|
+
authParam?: string;
|
|
12
|
+
/** Heartbeat payload (default: { type: "ping" }). */
|
|
13
|
+
pingPayload?: unknown;
|
|
10
14
|
}
|
|
11
15
|
export declare class SharedSocket implements Disposable {
|
|
12
16
|
private url;
|
|
@@ -31,6 +35,7 @@ export declare class SharedSocket implements Disposable {
|
|
|
31
35
|
private startHeartbeat;
|
|
32
36
|
private stopHeartbeat;
|
|
33
37
|
private clearReconnect;
|
|
38
|
+
private buildUrl;
|
|
34
39
|
private setState;
|
|
35
40
|
[Symbol.dispose](): void;
|
|
36
41
|
}
|
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
43
|
-
private
|
|
100
|
+
private handleBecomeLeader;
|
|
101
|
+
private handleLoseLeadership;
|
|
44
102
|
[Symbol.dispose](): void;
|
|
45
103
|
}
|
package/dist/adapters/react.d.ts
CHANGED
|
@@ -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;
|
package/dist/adapters/vue.d.ts
CHANGED
|
@@ -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;
|
|
@@ -251,7 +251,10 @@ var SharedSocket = class {
|
|
|
251
251
|
reconnectMaxDelay: options.reconnectMaxDelay ?? 3e4,
|
|
252
252
|
heartbeatInterval: options.heartbeatInterval ?? 3e4,
|
|
253
253
|
sendBuffer: options.sendBuffer ?? 100,
|
|
254
|
-
auth: options.auth
|
|
254
|
+
auth: options.auth,
|
|
255
|
+
authToken: options.authToken,
|
|
256
|
+
authParam: options.authParam ?? "token",
|
|
257
|
+
pingPayload: options.pingPayload ?? { type: "ping" }
|
|
255
258
|
};
|
|
256
259
|
}
|
|
257
260
|
url;
|
|
@@ -270,12 +273,7 @@ var SharedSocket = class {
|
|
|
270
273
|
async connect() {
|
|
271
274
|
if (this.disposed) return;
|
|
272
275
|
this.setState("connecting");
|
|
273
|
-
|
|
274
|
-
if (this.opts.auth) {
|
|
275
|
-
const token = await this.opts.auth();
|
|
276
|
-
const sep = connectUrl.includes("?") ? "&" : "?";
|
|
277
|
-
connectUrl = `${connectUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
278
|
-
}
|
|
276
|
+
const connectUrl = await this.buildUrl();
|
|
279
277
|
this.ws = new WebSocket(connectUrl, this.opts.protocols);
|
|
280
278
|
this.ws.onopen = () => {
|
|
281
279
|
this.setState("connected");
|
|
@@ -356,7 +354,7 @@ var SharedSocket = class {
|
|
|
356
354
|
this.stopHeartbeat();
|
|
357
355
|
this.heartbeatTimer = setInterval(() => {
|
|
358
356
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
359
|
-
this.ws.send(JSON.stringify(
|
|
357
|
+
this.ws.send(JSON.stringify(this.opts.pingPayload));
|
|
360
358
|
}
|
|
361
359
|
}, this.opts.heartbeatInterval);
|
|
362
360
|
}
|
|
@@ -372,6 +370,19 @@ var SharedSocket = class {
|
|
|
372
370
|
this.reconnectTimer = null;
|
|
373
371
|
}
|
|
374
372
|
}
|
|
373
|
+
async buildUrl() {
|
|
374
|
+
let token;
|
|
375
|
+
if (this.opts.auth) {
|
|
376
|
+
token = await this.opts.auth();
|
|
377
|
+
} else if (this.opts.authToken) {
|
|
378
|
+
token = this.opts.authToken;
|
|
379
|
+
}
|
|
380
|
+
if (!token) return this.url;
|
|
381
|
+
const httpUrl = this.url.replace(/^ws(s?):\/\//, "http$1://");
|
|
382
|
+
const parsed = new URL(httpUrl);
|
|
383
|
+
parsed.searchParams.set(this.opts.authParam, token);
|
|
384
|
+
return parsed.toString().replace(/^http(s?):\/\//, "ws$1://");
|
|
385
|
+
}
|
|
375
386
|
setState(state) {
|
|
376
387
|
this._state = state;
|
|
377
388
|
for (const fn of this.onStateChangeFns) fn(state);
|
|
@@ -566,11 +577,32 @@ var SubscriptionManager = class {
|
|
|
566
577
|
};
|
|
567
578
|
|
|
568
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
|
+
};
|
|
569
598
|
var SharedWebSocket = class {
|
|
570
599
|
constructor(url, options = {}) {
|
|
571
600
|
this.url = url;
|
|
572
601
|
this.options = options;
|
|
602
|
+
this.proto = { ...DEFAULT_PROTOCOL, ...options.events };
|
|
603
|
+
this.log = options.debug ? options.logger ?? console : NOOP_LOGGER;
|
|
573
604
|
this.tabId = generateId();
|
|
605
|
+
this.log.debug("[SharedWS] init", { tabId: this.tabId, url });
|
|
574
606
|
this.bus = new MessageBus("shared-ws", this.tabId);
|
|
575
607
|
this.coordinator = new TabCoordinator(this.bus, this.tabId, {
|
|
576
608
|
electionTimeout: options.electionTimeout,
|
|
@@ -585,7 +617,7 @@ var SharedWebSocket = class {
|
|
|
585
617
|
this.cleanups.push(
|
|
586
618
|
this.bus.subscribe("ws:send", (msg) => {
|
|
587
619
|
if (this.coordinator.isLeader && this.socket) {
|
|
588
|
-
this.socket.send({
|
|
620
|
+
this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
|
|
589
621
|
}
|
|
590
622
|
})
|
|
591
623
|
);
|
|
@@ -595,8 +627,35 @@ var SharedWebSocket = class {
|
|
|
595
627
|
this.subs.emit(`sync:${msg.key}`, msg.value);
|
|
596
628
|
})
|
|
597
629
|
);
|
|
598
|
-
this.coordinator.onBecomeLeader(() =>
|
|
599
|
-
|
|
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
|
+
);
|
|
600
659
|
if (typeof window !== "undefined") {
|
|
601
660
|
const onBeforeUnload = () => this[Symbol.dispose]();
|
|
602
661
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
@@ -613,6 +672,10 @@ var SharedWebSocket = class {
|
|
|
613
672
|
tabId;
|
|
614
673
|
cleanups = [];
|
|
615
674
|
disposed = false;
|
|
675
|
+
proto;
|
|
676
|
+
log;
|
|
677
|
+
outgoingMiddleware = [];
|
|
678
|
+
incomingMiddleware = [];
|
|
616
679
|
get connected() {
|
|
617
680
|
return this.socket?.state === "connected" || !this.coordinator.isLeader;
|
|
618
681
|
}
|
|
@@ -623,24 +686,78 @@ var SharedWebSocket = class {
|
|
|
623
686
|
async connect() {
|
|
624
687
|
await this.coordinator.elect();
|
|
625
688
|
}
|
|
626
|
-
|
|
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
|
|
627
736
|
on(event, handler) {
|
|
628
737
|
return this.subs.on(event, handler);
|
|
629
738
|
}
|
|
739
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
630
740
|
once(event, handler) {
|
|
631
741
|
return this.subs.once(event, handler);
|
|
632
742
|
}
|
|
633
743
|
off(event, handler) {
|
|
634
744
|
this.subs.off(event, handler);
|
|
635
745
|
}
|
|
636
|
-
/** Async generator for consuming events. */
|
|
637
746
|
stream(event, signal) {
|
|
638
747
|
return this.subs.stream(event, signal);
|
|
639
748
|
}
|
|
640
|
-
/** Send message to server (auto-routed through leader). */
|
|
641
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);
|
|
642
759
|
if (this.coordinator.isLeader && this.socket) {
|
|
643
|
-
this.socket.send(
|
|
760
|
+
this.socket.send(payload);
|
|
644
761
|
} else {
|
|
645
762
|
this.bus.publish("ws:send", { event, data });
|
|
646
763
|
}
|
|
@@ -660,6 +777,50 @@ var SharedWebSocket = class {
|
|
|
660
777
|
onSync(key, fn) {
|
|
661
778
|
return this.subs.on(`sync:${key}`, fn);
|
|
662
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
|
+
}
|
|
663
824
|
disconnect() {
|
|
664
825
|
this[Symbol.dispose]();
|
|
665
826
|
}
|
|
@@ -669,7 +830,8 @@ var SharedWebSocket = class {
|
|
|
669
830
|
reconnect: this.options.reconnect,
|
|
670
831
|
reconnectMaxDelay: this.options.reconnectMaxDelay,
|
|
671
832
|
heartbeatInterval: this.options.heartbeatInterval,
|
|
672
|
-
sendBuffer: this.options.sendBuffer
|
|
833
|
+
sendBuffer: this.options.sendBuffer,
|
|
834
|
+
pingPayload: this.proto.ping
|
|
673
835
|
};
|
|
674
836
|
if (this.options.useWorker) {
|
|
675
837
|
return new WorkerSocket(this.url, {
|
|
@@ -679,23 +841,51 @@ var SharedWebSocket = class {
|
|
|
679
841
|
}
|
|
680
842
|
return new SharedSocket(this.url, {
|
|
681
843
|
...socketOptions,
|
|
682
|
-
auth: this.options.auth
|
|
844
|
+
auth: this.options.auth,
|
|
845
|
+
authToken: this.options.authToken,
|
|
846
|
+
authParam: this.options.authParam
|
|
683
847
|
});
|
|
684
848
|
}
|
|
685
|
-
|
|
849
|
+
handleBecomeLeader() {
|
|
850
|
+
this.log.info("[SharedWS] \u{1F451} became leader");
|
|
686
851
|
this.socket = this.createSocket();
|
|
687
|
-
this.socket.onMessage((
|
|
688
|
-
|
|
689
|
-
const
|
|
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);
|
|
690
865
|
this.bus.broadcast("ws:message", { event, data: payload });
|
|
691
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
|
+
});
|
|
692
881
|
this.cleanups.push(
|
|
693
882
|
this.bus.respond("ws:request", async (req) => {
|
|
694
883
|
return new Promise((resolve) => {
|
|
695
884
|
const unsub = this.socket.onMessage((response) => {
|
|
696
|
-
|
|
885
|
+
const res = response;
|
|
886
|
+
if (res?.[this.proto.eventField] === req.event || res?.requestId) {
|
|
697
887
|
unsub();
|
|
698
|
-
resolve(
|
|
888
|
+
resolve(res?.[this.proto.dataField] ?? response);
|
|
699
889
|
}
|
|
700
890
|
});
|
|
701
891
|
this.socket.send({ event: req.event, data: req.data });
|
|
@@ -704,7 +894,7 @@ var SharedWebSocket = class {
|
|
|
704
894
|
);
|
|
705
895
|
this.socket.connect();
|
|
706
896
|
}
|
|
707
|
-
|
|
897
|
+
handleLoseLeadership() {
|
|
708
898
|
if (this.socket) {
|
|
709
899
|
this.socket[Symbol.dispose]();
|
|
710
900
|
this.socket = null;
|
|
@@ -734,4 +924,4 @@ export {
|
|
|
734
924
|
SubscriptionManager,
|
|
735
925
|
SharedWebSocket
|
|
736
926
|
};
|
|
737
|
-
//# sourceMappingURL=chunk-
|
|
927
|
+
//# sourceMappingURL=chunk-4D2ZDCA6.js.map
|