@gwakko/shared-websocket 0.3.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +722 -0
- package/dist/SharedSocket.d.ts +2 -0
- package/dist/SharedWebSocket.d.ts +71 -13
- package/dist/adapters/react.d.ts +29 -2
- package/dist/adapters/vue.d.ts +23 -1
- package/dist/{chunk-JJTAPRPG.js → chunk-4D2ZDCA6.js} +196 -18
- package/dist/chunk-4D2ZDCA6.js.map +1 -0
- package/dist/{chunk-Q4OKSJX7.cjs → chunk-UEOFAFLV.cjs} +197 -19
- package/dist/chunk-UEOFAFLV.cjs.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/react.cjs +33 -3
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +31 -1
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +73 -2
- package/dist/vue.cjs +33 -11
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +31 -9
- package/dist/vue.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedSocket.ts +6 -2
- package/src/SharedWebSocket.ts +237 -25
- package/src/adapters/react.ts +63 -4
- package/src/adapters/vue.ts +57 -11
- package/src/index.ts +21 -2
- package/src/types.ts +79 -2
- package/dist/chunk-JJTAPRPG.js.map +0 -1
- package/dist/chunk-Q4OKSJX7.cjs.map +0 -1
package/src/SharedSocket.ts
CHANGED
|
@@ -11,6 +11,8 @@ interface SharedSocketOptions {
|
|
|
11
11
|
auth?: () => string | Promise<string>;
|
|
12
12
|
authToken?: string;
|
|
13
13
|
authParam?: string;
|
|
14
|
+
/** Heartbeat payload (default: { type: "ping" }). */
|
|
15
|
+
pingPayload?: unknown;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export class SharedSocket implements Disposable {
|
|
@@ -24,10 +26,11 @@ export class SharedSocket implements Disposable {
|
|
|
24
26
|
private onMessageFns = new Set<EventHandler>();
|
|
25
27
|
private onStateChangeFns = new Set<(state: SocketState) => void>();
|
|
26
28
|
|
|
27
|
-
private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam'>> & {
|
|
29
|
+
private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam' | 'pingPayload'>> & {
|
|
28
30
|
auth?: () => string | Promise<string>;
|
|
29
31
|
authToken?: string;
|
|
30
32
|
authParam: string;
|
|
33
|
+
pingPayload: unknown;
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
constructor(
|
|
@@ -43,6 +46,7 @@ export class SharedSocket implements Disposable {
|
|
|
43
46
|
auth: options.auth,
|
|
44
47
|
authToken: options.authToken,
|
|
45
48
|
authParam: options.authParam ?? 'token',
|
|
49
|
+
pingPayload: options.pingPayload ?? { type: 'ping' },
|
|
46
50
|
};
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -152,7 +156,7 @@ export class SharedSocket implements Disposable {
|
|
|
152
156
|
this.stopHeartbeat();
|
|
153
157
|
this.heartbeatTimer = setInterval(() => {
|
|
154
158
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
155
|
-
this.ws.send(JSON.stringify(
|
|
159
|
+
this.ws.send(JSON.stringify(this.opts.pingPayload));
|
|
156
160
|
}
|
|
157
161
|
}, this.opts.heartbeatInterval);
|
|
158
162
|
}
|
package/src/SharedWebSocket.ts
CHANGED
|
@@ -5,7 +5,23 @@ import { TabCoordinator } from './TabCoordinator';
|
|
|
5
5
|
import { SharedSocket } from './SharedSocket';
|
|
6
6
|
import { WorkerSocket } from './WorkerSocket';
|
|
7
7
|
import { SubscriptionManager } from './SubscriptionManager';
|
|
8
|
-
import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler } from './types';
|
|
8
|
+
import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventProtocol, EventMap, Logger, Middleware } from './types';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PROTOCOL: EventProtocol = {
|
|
11
|
+
eventField: 'event',
|
|
12
|
+
dataField: 'data',
|
|
13
|
+
channelJoin: '$channel:join',
|
|
14
|
+
channelLeave: '$channel:leave',
|
|
15
|
+
ping: { type: 'ping' },
|
|
16
|
+
defaultEvent: 'message',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const NOOP_LOGGER: Logger = {
|
|
20
|
+
debug() {},
|
|
21
|
+
info() {},
|
|
22
|
+
warn() {},
|
|
23
|
+
error() {},
|
|
24
|
+
};
|
|
9
25
|
|
|
10
26
|
/** Common interface for both SharedSocket and WorkerSocket. */
|
|
11
27
|
interface SocketAdapter {
|
|
@@ -21,11 +37,18 @@ interface SocketAdapter {
|
|
|
21
37
|
/**
|
|
22
38
|
* SharedWebSocket — shares ONE WebSocket connection across browser tabs.
|
|
23
39
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
40
|
+
* @typeParam TEvents - Event map for type-safe subscriptions.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* // Typed events
|
|
44
|
+
* type Events = {
|
|
45
|
+
* 'chat.message': { text: string; userId: string };
|
|
46
|
+
* 'order.created': { id: string; total: number };
|
|
47
|
+
* };
|
|
48
|
+
* const ws = new SharedWebSocket<Events>(url);
|
|
49
|
+
* ws.on('chat.message', (msg) => msg.text); // ← msg: { text, userId }
|
|
27
50
|
*/
|
|
28
|
-
export class SharedWebSocket implements Disposable {
|
|
51
|
+
export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Disposable {
|
|
29
52
|
private bus: MessageBus;
|
|
30
53
|
private coordinator: TabCoordinator;
|
|
31
54
|
private socket: SocketAdapter | null = null;
|
|
@@ -34,12 +57,19 @@ export class SharedWebSocket implements Disposable {
|
|
|
34
57
|
private tabId: string;
|
|
35
58
|
private cleanups: Unsubscribe[] = [];
|
|
36
59
|
private disposed = false;
|
|
60
|
+
private readonly proto: EventProtocol;
|
|
61
|
+
private readonly log: Logger;
|
|
62
|
+
private outgoingMiddleware: Middleware[] = [];
|
|
63
|
+
private incomingMiddleware: Middleware[] = [];
|
|
37
64
|
|
|
38
65
|
constructor(
|
|
39
66
|
private readonly url: string,
|
|
40
|
-
private readonly options: SharedWebSocketOptions = {}
|
|
67
|
+
private readonly options: SharedWebSocketOptions<TEvents> = {} as SharedWebSocketOptions<TEvents>,
|
|
41
68
|
) {
|
|
69
|
+
this.proto = { ...DEFAULT_PROTOCOL, ...options.events };
|
|
70
|
+
this.log = options.debug ? (options.logger ?? console) : NOOP_LOGGER;
|
|
42
71
|
this.tabId = generateId();
|
|
72
|
+
this.log.debug('[SharedWS] init', { tabId: this.tabId, url });
|
|
43
73
|
this.bus = new MessageBus('shared-ws', this.tabId);
|
|
44
74
|
this.coordinator = new TabCoordinator(this.bus, this.tabId, {
|
|
45
75
|
electionTimeout: options.electionTimeout,
|
|
@@ -58,7 +88,7 @@ export class SharedWebSocket implements Disposable {
|
|
|
58
88
|
this.cleanups.push(
|
|
59
89
|
this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {
|
|
60
90
|
if (this.coordinator.isLeader && this.socket) {
|
|
61
|
-
this.socket.send({
|
|
91
|
+
this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
|
|
62
92
|
}
|
|
63
93
|
}),
|
|
64
94
|
);
|
|
@@ -72,8 +102,37 @@ export class SharedWebSocket implements Disposable {
|
|
|
72
102
|
);
|
|
73
103
|
|
|
74
104
|
// Leader lifecycle
|
|
75
|
-
this.coordinator.onBecomeLeader(() =>
|
|
76
|
-
|
|
105
|
+
this.coordinator.onBecomeLeader(() => {
|
|
106
|
+
this.handleBecomeLeader();
|
|
107
|
+
this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: true });
|
|
108
|
+
});
|
|
109
|
+
this.coordinator.onLoseLeadership(() => {
|
|
110
|
+
this.handleLoseLeadership();
|
|
111
|
+
this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: false });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Lifecycle events from bus (all tabs receive)
|
|
115
|
+
this.cleanups.push(
|
|
116
|
+
this.bus.subscribe<{ type: string; isLeader?: boolean; error?: unknown }>('ws:lifecycle', (msg) => {
|
|
117
|
+
switch (msg.type) {
|
|
118
|
+
case 'connect':
|
|
119
|
+
this.subs.emit('$lifecycle:connect', undefined);
|
|
120
|
+
break;
|
|
121
|
+
case 'disconnect':
|
|
122
|
+
this.subs.emit('$lifecycle:disconnect', undefined);
|
|
123
|
+
break;
|
|
124
|
+
case 'reconnecting':
|
|
125
|
+
this.subs.emit('$lifecycle:reconnecting', undefined);
|
|
126
|
+
break;
|
|
127
|
+
case 'leader':
|
|
128
|
+
this.subs.emit('$lifecycle:leader', msg.isLeader);
|
|
129
|
+
break;
|
|
130
|
+
case 'error':
|
|
131
|
+
this.subs.emit('$lifecycle:error', msg.error);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
77
136
|
|
|
78
137
|
// Cleanup on tab close
|
|
79
138
|
if (typeof window !== 'undefined') {
|
|
@@ -96,12 +155,74 @@ export class SharedWebSocket implements Disposable {
|
|
|
96
155
|
await this.coordinator.elect();
|
|
97
156
|
}
|
|
98
157
|
|
|
99
|
-
|
|
100
|
-
|
|
158
|
+
// ─── Lifecycle Hooks ─────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/** Called when WebSocket connection opens (broadcast to all tabs). */
|
|
161
|
+
onConnect(fn: () => void): Unsubscribe {
|
|
162
|
+
return this.subs.on('$lifecycle:connect', fn);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Called when WebSocket connection closes (broadcast to all tabs). */
|
|
166
|
+
onDisconnect(fn: () => void): Unsubscribe {
|
|
167
|
+
return this.subs.on('$lifecycle:disconnect', fn);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Called when WebSocket starts reconnecting (broadcast to all tabs). */
|
|
171
|
+
onReconnecting(fn: () => void): Unsubscribe {
|
|
172
|
+
return this.subs.on('$lifecycle:reconnecting', fn);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Called when this tab becomes leader or loses leadership. */
|
|
176
|
+
onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {
|
|
177
|
+
return this.subs.on('$lifecycle:leader', fn as EventHandler);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Called on WebSocket or network error (broadcast to all tabs). */
|
|
181
|
+
onError(fn: (error: unknown) => void): Unsubscribe {
|
|
182
|
+
return this.subs.on('$lifecycle:error', fn as EventHandler);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Middleware ───────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Add middleware to transform messages before send or after receive.
|
|
189
|
+
* Return null from middleware to drop the message.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* // Add timestamp to every outgoing message
|
|
193
|
+
* ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* // Decrypt incoming messages
|
|
197
|
+
* ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* // Drop messages from blocked users
|
|
201
|
+
* ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);
|
|
202
|
+
*/
|
|
203
|
+
use(direction: 'outgoing' | 'incoming', fn: Middleware): this {
|
|
204
|
+
if (direction === 'outgoing') {
|
|
205
|
+
this.outgoingMiddleware.push(fn);
|
|
206
|
+
} else {
|
|
207
|
+
this.incomingMiddleware.push(fn);
|
|
208
|
+
}
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Event Subscription ──────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** Subscribe to server events (works in ALL tabs). Type-safe with EventMap. */
|
|
215
|
+
on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
|
|
216
|
+
on(event: string, handler: EventHandler<unknown>): Unsubscribe;
|
|
217
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
218
|
+
on(event: string, handler: (data: any) => void): Unsubscribe {
|
|
101
219
|
return this.subs.on(event, handler);
|
|
102
220
|
}
|
|
103
221
|
|
|
104
|
-
once(event:
|
|
222
|
+
once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
|
|
223
|
+
once(event: string, handler: EventHandler<unknown>): Unsubscribe;
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
225
|
+
once(event: string, handler: (data: any) => void): Unsubscribe {
|
|
105
226
|
return this.subs.once(event, handler);
|
|
106
227
|
}
|
|
107
228
|
|
|
@@ -109,15 +230,31 @@ export class SharedWebSocket implements Disposable {
|
|
|
109
230
|
this.subs.off(event, handler);
|
|
110
231
|
}
|
|
111
232
|
|
|
112
|
-
/** Async generator for consuming events. */
|
|
233
|
+
/** Async generator for consuming events. Type-safe with EventMap. */
|
|
234
|
+
stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;
|
|
235
|
+
stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
|
|
113
236
|
stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
|
|
114
237
|
return this.subs.stream(event, signal);
|
|
115
238
|
}
|
|
116
239
|
|
|
117
|
-
/** Send message to server (auto-routed through leader). */
|
|
240
|
+
/** Send message to server (auto-routed through leader). Type-safe with EventMap. */
|
|
241
|
+
send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
|
|
242
|
+
send(event: string, data: unknown): void;
|
|
118
243
|
send(event: string, data: unknown): void {
|
|
244
|
+
let payload: unknown = { [this.proto.eventField]: event, [this.proto.dataField]: data };
|
|
245
|
+
|
|
246
|
+
for (const mw of this.outgoingMiddleware) {
|
|
247
|
+
payload = mw(payload);
|
|
248
|
+
if (payload === null) {
|
|
249
|
+
this.log.debug('[SharedWS] ✗ outgoing dropped by middleware', event);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.log.debug('[SharedWS] → send', event, data);
|
|
255
|
+
|
|
119
256
|
if (this.coordinator.isLeader && this.socket) {
|
|
120
|
-
this.socket.send(
|
|
257
|
+
this.socket.send(payload);
|
|
121
258
|
} else {
|
|
122
259
|
this.bus.publish('ws:send', { event, data });
|
|
123
260
|
}
|
|
@@ -142,6 +279,54 @@ export class SharedWebSocket implements Disposable {
|
|
|
142
279
|
return this.subs.on(`sync:${key}`, fn as EventHandler);
|
|
143
280
|
}
|
|
144
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Subscribe to a private/scoped channel. Returns a channel handle with
|
|
284
|
+
* scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* const chat = ws.channel('chat:room_123');
|
|
288
|
+
* chat.on('message', (msg) => render(msg));
|
|
289
|
+
* chat.send('message', { text: 'Hello' });
|
|
290
|
+
* chat.leave(); // sends leave + unsubscribes
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* // Private notifications for tenant
|
|
294
|
+
* const notifications = ws.channel(`tenant:${tenantId}:notifications`);
|
|
295
|
+
* notifications.on('alert', (alert) => showToast(alert));
|
|
296
|
+
*/
|
|
297
|
+
channel(name: string): Channel {
|
|
298
|
+
// Notify server about channel subscription
|
|
299
|
+
this.send(this.proto.channelJoin, { channel: name });
|
|
300
|
+
|
|
301
|
+
const self = this;
|
|
302
|
+
const unsubs: Unsubscribe[] = [];
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
name,
|
|
306
|
+
on(event: string, handler: EventHandler): Unsubscribe {
|
|
307
|
+
const unsub = self.subs.on(`${name}:${event}`, handler);
|
|
308
|
+
unsubs.push(unsub);
|
|
309
|
+
return unsub;
|
|
310
|
+
},
|
|
311
|
+
once(event: string, handler: EventHandler): Unsubscribe {
|
|
312
|
+
const unsub = self.subs.once(`${name}:${event}`, handler);
|
|
313
|
+
unsubs.push(unsub);
|
|
314
|
+
return unsub;
|
|
315
|
+
},
|
|
316
|
+
send(event: string, data: unknown): void {
|
|
317
|
+
self.send(`${name}:${event}`, data);
|
|
318
|
+
},
|
|
319
|
+
stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
|
|
320
|
+
return self.subs.stream(`${name}:${event}`, signal);
|
|
321
|
+
},
|
|
322
|
+
leave(): void {
|
|
323
|
+
self.send(self.proto.channelLeave, { channel: name });
|
|
324
|
+
for (const unsub of unsubs) unsub();
|
|
325
|
+
unsubs.length = 0;
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
145
330
|
disconnect(): void {
|
|
146
331
|
this[Symbol.dispose]();
|
|
147
332
|
}
|
|
@@ -153,6 +338,7 @@ export class SharedWebSocket implements Disposable {
|
|
|
153
338
|
reconnectMaxDelay: this.options.reconnectMaxDelay,
|
|
154
339
|
heartbeatInterval: this.options.heartbeatInterval,
|
|
155
340
|
sendBuffer: this.options.sendBuffer,
|
|
341
|
+
pingPayload: this.proto.ping,
|
|
156
342
|
};
|
|
157
343
|
|
|
158
344
|
if (this.options.useWorker) {
|
|
@@ -172,24 +358,50 @@ export class SharedWebSocket implements Disposable {
|
|
|
172
358
|
});
|
|
173
359
|
}
|
|
174
360
|
|
|
175
|
-
private
|
|
361
|
+
private handleBecomeLeader(): void {
|
|
362
|
+
this.log.info('[SharedWS] 👑 became leader');
|
|
176
363
|
this.socket = this.createSocket();
|
|
177
364
|
|
|
178
|
-
this.socket.onMessage((
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
365
|
+
this.socket.onMessage((raw: unknown) => {
|
|
366
|
+
let data: unknown = raw;
|
|
367
|
+
for (const mw of this.incomingMiddleware) {
|
|
368
|
+
data = mw(data);
|
|
369
|
+
if (data === null) {
|
|
370
|
+
this.log.debug('[SharedWS] ✗ incoming dropped by middleware');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const msg = data as Record<string, unknown> | null | undefined;
|
|
376
|
+
const event = (msg?.[this.proto.eventField] as string) ?? this.proto.defaultEvent;
|
|
377
|
+
const payload = msg?.[this.proto.dataField] ?? data;
|
|
378
|
+
this.log.debug('[SharedWS] ← recv', event, payload);
|
|
182
379
|
this.bus.broadcast('ws:message', { event, data: payload });
|
|
183
380
|
});
|
|
184
381
|
|
|
185
|
-
|
|
382
|
+
this.socket.onStateChange((state: string) => {
|
|
383
|
+
this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : `state: ${state}`);
|
|
384
|
+
switch (state) {
|
|
385
|
+
case 'connected':
|
|
386
|
+
this.bus.broadcast('ws:lifecycle', { type: 'connect' });
|
|
387
|
+
break;
|
|
388
|
+
case 'closed':
|
|
389
|
+
this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
|
|
390
|
+
break;
|
|
391
|
+
case 'reconnecting':
|
|
392
|
+
this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
186
397
|
this.cleanups.push(
|
|
187
398
|
this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {
|
|
188
399
|
return new Promise((resolve) => {
|
|
189
|
-
const unsub = this.socket!.onMessage((response:
|
|
190
|
-
|
|
400
|
+
const unsub = this.socket!.onMessage((response: unknown) => {
|
|
401
|
+
const res = response as Record<string, unknown> | undefined;
|
|
402
|
+
if (res?.[this.proto.eventField] === req.event || res?.requestId) {
|
|
191
403
|
unsub();
|
|
192
|
-
resolve(
|
|
404
|
+
resolve(res?.[this.proto.dataField] ?? response);
|
|
193
405
|
}
|
|
194
406
|
});
|
|
195
407
|
this.socket!.send({ event: req.event, data: req.data });
|
|
@@ -200,7 +412,7 @@ export class SharedWebSocket implements Disposable {
|
|
|
200
412
|
this.socket.connect();
|
|
201
413
|
}
|
|
202
414
|
|
|
203
|
-
private
|
|
415
|
+
private handleLoseLeadership(): void {
|
|
204
416
|
if (this.socket) {
|
|
205
417
|
this.socket[Symbol.dispose]();
|
|
206
418
|
this.socket = null;
|
package/src/adapters/react.ts
CHANGED
|
@@ -2,13 +2,14 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useContext,
|
|
4
4
|
useEffect,
|
|
5
|
+
useRef,
|
|
5
6
|
useState,
|
|
6
7
|
useEffectEvent,
|
|
7
8
|
type ReactNode,
|
|
8
9
|
createElement,
|
|
9
10
|
} from 'react';
|
|
10
11
|
import { SharedWebSocket } from '../SharedWebSocket';
|
|
11
|
-
import type { SharedWebSocketOptions, TabRole } from '../types';
|
|
12
|
+
import type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers, EventHandler } from '../types';
|
|
12
13
|
|
|
13
14
|
// ─── Context ─────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -115,7 +116,7 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
|
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
useEffect(() => {
|
|
118
|
-
const unsub = socket.on(event, onEvent);
|
|
119
|
+
const unsub = socket.on(event, onEvent as EventHandler);
|
|
119
120
|
return unsub;
|
|
120
121
|
}, [socket, event]);
|
|
121
122
|
|
|
@@ -159,7 +160,7 @@ export function useSocketStream<T>(event: string, callback?: (data: T) => void):
|
|
|
159
160
|
|
|
160
161
|
useEffect(() => {
|
|
161
162
|
if (!callback) setItems([]);
|
|
162
|
-
const unsub = socket.on(event, onEvent);
|
|
163
|
+
const unsub = socket.on(event, onEvent as EventHandler);
|
|
163
164
|
return unsub;
|
|
164
165
|
}, [socket, event]);
|
|
165
166
|
|
|
@@ -237,7 +238,7 @@ export function useSocketCallback<T>(event: string, callback: (data: T) => void)
|
|
|
237
238
|
});
|
|
238
239
|
|
|
239
240
|
useEffect(() => {
|
|
240
|
-
const unsub = socket.on(event, handler);
|
|
241
|
+
const unsub = socket.on(event, handler as EventHandler);
|
|
241
242
|
return unsub;
|
|
242
243
|
}, [socket, event]);
|
|
243
244
|
}
|
|
@@ -269,3 +270,61 @@ export function useSocketStatus(): {
|
|
|
269
270
|
|
|
270
271
|
return { connected, tabRole };
|
|
271
272
|
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Lifecycle hooks — react to connection state changes.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* useSocketLifecycle({
|
|
279
|
+
* onConnect: () => console.log('Connected!'),
|
|
280
|
+
* onDisconnect: () => console.log('Disconnected'),
|
|
281
|
+
* onReconnecting: () => showSpinner(),
|
|
282
|
+
* onLeaderChange: (isLeader) => console.log('Leader:', isLeader),
|
|
283
|
+
* onError: (err) => reportError(err),
|
|
284
|
+
* });
|
|
285
|
+
*/
|
|
286
|
+
export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
287
|
+
const socket = useSharedWebSocket();
|
|
288
|
+
|
|
289
|
+
const onConnect = useEffectEvent(() => handlers.onConnect?.());
|
|
290
|
+
const onDisconnect = useEffectEvent(() => handlers.onDisconnect?.());
|
|
291
|
+
const onReconnecting = useEffectEvent(() => handlers.onReconnecting?.());
|
|
292
|
+
const onLeaderChange = useEffectEvent((isLeader: boolean) => handlers.onLeaderChange?.(isLeader));
|
|
293
|
+
const onError = useEffectEvent((error: unknown) => handlers.onError?.(error));
|
|
294
|
+
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const unsubs = [
|
|
297
|
+
socket.onConnect(onConnect),
|
|
298
|
+
socket.onDisconnect(onDisconnect),
|
|
299
|
+
socket.onReconnecting(onReconnecting),
|
|
300
|
+
socket.onLeaderChange(onLeaderChange),
|
|
301
|
+
socket.onError(onError),
|
|
302
|
+
];
|
|
303
|
+
return () => unsubs.forEach((u) => u());
|
|
304
|
+
}, [socket]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* const chat = useChannel('chat:room_123');
|
|
312
|
+
* const message = useSocketEvent('chat:room_123:message');
|
|
313
|
+
* chat.send('message', { text: 'Hello' });
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* // Tenant notifications
|
|
317
|
+
* const notifications = useChannel(`tenant:${tenantId}:notifications`);
|
|
318
|
+
* useSocketCallback(`tenant:${tenantId}:notifications:alert`, showToast);
|
|
319
|
+
*/
|
|
320
|
+
export function useChannel(name: string) {
|
|
321
|
+
const socket = useSharedWebSocket();
|
|
322
|
+
const channelRef = useRef(socket.channel(name));
|
|
323
|
+
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
channelRef.current = socket.channel(name);
|
|
326
|
+
return () => channelRef.current.leave();
|
|
327
|
+
}, [socket, name]);
|
|
328
|
+
|
|
329
|
+
return channelRef.current;
|
|
330
|
+
}
|
package/src/adapters/vue.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type App,
|
|
10
10
|
} from 'vue';
|
|
11
11
|
import { SharedWebSocket } from '../SharedWebSocket';
|
|
12
|
-
import type { SharedWebSocketOptions, TabRole } from '../types';
|
|
12
|
+
import type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';
|
|
13
13
|
|
|
14
14
|
// ─── Plugin ──────────────────────────────────────────────
|
|
15
15
|
|
|
@@ -78,13 +78,15 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
|
|
|
78
78
|
const socket = useSharedWebSocket();
|
|
79
79
|
const value = ref<T | undefined>(undefined) as Ref<T | undefined>;
|
|
80
80
|
|
|
81
|
-
const
|
|
81
|
+
const handler = (data: unknown) => {
|
|
82
|
+
const typed = data as T;
|
|
82
83
|
if (callback) {
|
|
83
|
-
callback(
|
|
84
|
+
callback(typed);
|
|
84
85
|
} else {
|
|
85
|
-
value.value =
|
|
86
|
+
value.value = typed;
|
|
86
87
|
}
|
|
87
|
-
}
|
|
88
|
+
};
|
|
89
|
+
const unsub = socket.on(event, handler);
|
|
88
90
|
|
|
89
91
|
onUnmounted(unsub);
|
|
90
92
|
return readonly(value) as Ref<T | undefined>;
|
|
@@ -117,13 +119,15 @@ export function useSocketStream<T>(event: string, callback?: (data: T) => void):
|
|
|
117
119
|
const socket = useSharedWebSocket();
|
|
118
120
|
const items = ref<T[]>([]) as Ref<T[]>;
|
|
119
121
|
|
|
120
|
-
const
|
|
122
|
+
const handler = (data: unknown) => {
|
|
123
|
+
const typed = data as T;
|
|
121
124
|
if (callback) {
|
|
122
|
-
callback(
|
|
125
|
+
callback(typed);
|
|
123
126
|
} else {
|
|
124
|
-
items.value = [...items.value,
|
|
127
|
+
items.value = [...items.value, typed];
|
|
125
128
|
}
|
|
126
|
-
}
|
|
129
|
+
};
|
|
130
|
+
const unsub = socket.on(event, handler);
|
|
127
131
|
|
|
128
132
|
onUnmounted(unsub);
|
|
129
133
|
return readonly(items) as Ref<T[]>;
|
|
@@ -178,8 +182,8 @@ export function useSocketSync<T>(key: string, initialValue: T, callback?: (value
|
|
|
178
182
|
export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
|
|
179
183
|
const socket = useSharedWebSocket();
|
|
180
184
|
|
|
181
|
-
const unsub = socket.on(event, (data:
|
|
182
|
-
callback(data);
|
|
185
|
+
const unsub = socket.on(event, (data: unknown) => {
|
|
186
|
+
callback(data as T);
|
|
183
187
|
});
|
|
184
188
|
|
|
185
189
|
onUnmounted(unsub);
|
|
@@ -211,3 +215,45 @@ export function useSocketStatus(): {
|
|
|
211
215
|
tabRole: readonly(tabRole) as Ref<TabRole>,
|
|
212
216
|
};
|
|
213
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Lifecycle hooks — react to connection state changes.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* useSocketLifecycle({
|
|
224
|
+
* onConnect: () => console.log('Connected!'),
|
|
225
|
+
* onDisconnect: () => showOfflineBanner(),
|
|
226
|
+
* onReconnecting: () => showSpinner(),
|
|
227
|
+
* onLeaderChange: (isLeader) => console.log('Leader:', isLeader),
|
|
228
|
+
* onError: (err) => reportError(err),
|
|
229
|
+
* });
|
|
230
|
+
*/
|
|
231
|
+
export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
232
|
+
const socket = useSharedWebSocket();
|
|
233
|
+
const unsubs: (() => void)[] = [];
|
|
234
|
+
|
|
235
|
+
if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));
|
|
236
|
+
if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));
|
|
237
|
+
if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
|
|
238
|
+
if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
|
|
239
|
+
if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
|
|
240
|
+
|
|
241
|
+
onUnmounted(() => unsubs.forEach((u) => u()));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* const chat = useChannel('chat:room_123');
|
|
249
|
+
* // Listen via useSocketEvent('chat:room_123:message')
|
|
250
|
+
* // Send via chat.send('message', { text: 'Hello' })
|
|
251
|
+
*/
|
|
252
|
+
export function useChannel(name: string) {
|
|
253
|
+
const socket = useSharedWebSocket();
|
|
254
|
+
const channel = socket.channel(name);
|
|
255
|
+
|
|
256
|
+
onUnmounted(() => channel.leave());
|
|
257
|
+
|
|
258
|
+
return channel;
|
|
259
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
+
// Core
|
|
1
2
|
export { SharedWebSocket } from './SharedWebSocket';
|
|
2
|
-
export { withSocket
|
|
3
|
+
export { withSocket } from './withSocket';
|
|
4
|
+
|
|
5
|
+
// Internal components (for advanced usage)
|
|
3
6
|
export { MessageBus } from './MessageBus';
|
|
4
7
|
export { TabCoordinator } from './TabCoordinator';
|
|
5
8
|
export { SharedSocket } from './SharedSocket';
|
|
6
9
|
export { WorkerSocket } from './WorkerSocket';
|
|
7
10
|
export { SubscriptionManager } from './SubscriptionManager';
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
// Types
|
|
13
|
+
export type { WithSocketCallback, WithSocketOptions, SocketScope } from './withSocket';
|
|
14
|
+
export type {
|
|
15
|
+
SharedWebSocketOptions,
|
|
16
|
+
SocketState,
|
|
17
|
+
TabRole,
|
|
18
|
+
Unsubscribe,
|
|
19
|
+
EventHandler,
|
|
20
|
+
Channel,
|
|
21
|
+
EventProtocol,
|
|
22
|
+
BusMessage,
|
|
23
|
+
SocketLifecycleHandlers,
|
|
24
|
+
EventMap,
|
|
25
|
+
Logger,
|
|
26
|
+
Middleware,
|
|
27
|
+
} from './types';
|