@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/vue.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const
|
|
1
|
+
{"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n value.value = typed;\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n items.value = [...items.value, typed];\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: unknown) => {\n callback(data as T);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n\n/**\n * Lifecycle hooks — react to connection state changes.\n *\n * @example\n * useSocketLifecycle({\n * onConnect: () => console.log('Connected!'),\n * onDisconnect: () => showOfflineBanner(),\n * onReconnecting: () => showSpinner(),\n * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),\n * onError: (err) => reportError(err),\n * });\n */\nexport function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {\n const socket = useSharedWebSocket();\n const unsubs: (() => void)[] = [];\n\n if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));\n if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));\n if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));\n if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));\n if (handlers.onError) unsubs.push(socket.onError(handlers.onError));\n\n onUnmounted(() => unsubs.forEach((u) => u()));\n}\n\n/**\n * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.\n *\n * @example\n * const chat = useChannel('chat:room_123');\n * // Listen via useSocketEvent('chat:room_123:message')\n * // Send via chat.send('message', { text: 'Hello' })\n */\nexport function useChannel(name: string) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name);\n\n onUnmounted(() => channel.leave());\n\n return channel;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
package/src/SharedSocket.ts
CHANGED
|
@@ -9,6 +9,10 @@ interface SharedSocketOptions {
|
|
|
9
9
|
heartbeatInterval?: number;
|
|
10
10
|
sendBuffer?: number;
|
|
11
11
|
auth?: () => string | Promise<string>;
|
|
12
|
+
authToken?: string;
|
|
13
|
+
authParam?: string;
|
|
14
|
+
/** Heartbeat payload (default: { type: "ping" }). */
|
|
15
|
+
pingPayload?: unknown;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export class SharedSocket implements Disposable {
|
|
@@ -22,7 +26,12 @@ export class SharedSocket implements Disposable {
|
|
|
22
26
|
private onMessageFns = new Set<EventHandler>();
|
|
23
27
|
private onStateChangeFns = new Set<(state: SocketState) => void>();
|
|
24
28
|
|
|
25
|
-
private readonly opts: Required<Omit<SharedSocketOptions, 'auth'
|
|
29
|
+
private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam' | 'pingPayload'>> & {
|
|
30
|
+
auth?: () => string | Promise<string>;
|
|
31
|
+
authToken?: string;
|
|
32
|
+
authParam: string;
|
|
33
|
+
pingPayload: unknown;
|
|
34
|
+
};
|
|
26
35
|
|
|
27
36
|
constructor(
|
|
28
37
|
private url: string,
|
|
@@ -35,6 +44,9 @@ export class SharedSocket implements Disposable {
|
|
|
35
44
|
heartbeatInterval: options.heartbeatInterval ?? 30_000,
|
|
36
45
|
sendBuffer: options.sendBuffer ?? 100,
|
|
37
46
|
auth: options.auth,
|
|
47
|
+
authToken: options.authToken,
|
|
48
|
+
authParam: options.authParam ?? 'token',
|
|
49
|
+
pingPayload: options.pingPayload ?? { type: 'ping' },
|
|
38
50
|
};
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -47,13 +59,7 @@ export class SharedSocket implements Disposable {
|
|
|
47
59
|
|
|
48
60
|
this.setState('connecting');
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
if (this.opts.auth) {
|
|
52
|
-
const token = await this.opts.auth();
|
|
53
|
-
const sep = connectUrl.includes('?') ? '&' : '?';
|
|
54
|
-
connectUrl = `${connectUrl}${sep}token=${encodeURIComponent(token)}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
62
|
+
const connectUrl = await this.buildUrl();
|
|
57
63
|
this.ws = new WebSocket(connectUrl, this.opts.protocols);
|
|
58
64
|
|
|
59
65
|
this.ws.onopen = () => {
|
|
@@ -150,7 +156,7 @@ export class SharedSocket implements Disposable {
|
|
|
150
156
|
this.stopHeartbeat();
|
|
151
157
|
this.heartbeatTimer = setInterval(() => {
|
|
152
158
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
153
|
-
this.ws.send(JSON.stringify(
|
|
159
|
+
this.ws.send(JSON.stringify(this.opts.pingPayload));
|
|
154
160
|
}
|
|
155
161
|
}, this.opts.heartbeatInterval);
|
|
156
162
|
}
|
|
@@ -169,6 +175,26 @@ export class SharedSocket implements Disposable {
|
|
|
169
175
|
}
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
private async buildUrl(): Promise<string> {
|
|
179
|
+
// Resolve token: callback > static > none
|
|
180
|
+
let token: string | undefined;
|
|
181
|
+
if (this.opts.auth) {
|
|
182
|
+
token = await this.opts.auth();
|
|
183
|
+
} else if (this.opts.authToken) {
|
|
184
|
+
token = this.opts.authToken;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!token) return this.url;
|
|
188
|
+
|
|
189
|
+
// WebSocket URLs (ws://, wss://) are not fully supported by URL API.
|
|
190
|
+
// Convert to http(s) for parsing, then back to ws(s).
|
|
191
|
+
const httpUrl = this.url.replace(/^ws(s?):\/\//, 'http$1://');
|
|
192
|
+
const parsed = new URL(httpUrl);
|
|
193
|
+
parsed.searchParams.set(this.opts.authParam, token);
|
|
194
|
+
|
|
195
|
+
return parsed.toString().replace(/^http(s?):\/\//, 'ws$1://');
|
|
196
|
+
}
|
|
197
|
+
|
|
172
198
|
private setState(state: SocketState): void {
|
|
173
199
|
this._state = state;
|
|
174
200
|
for (const fn of this.onStateChangeFns) fn(state);
|
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) {
|
|
@@ -167,27 +353,55 @@ export class SharedWebSocket implements Disposable {
|
|
|
167
353
|
return new SharedSocket(this.url, {
|
|
168
354
|
...socketOptions,
|
|
169
355
|
auth: this.options.auth,
|
|
356
|
+
authToken: this.options.authToken,
|
|
357
|
+
authParam: this.options.authParam,
|
|
170
358
|
});
|
|
171
359
|
}
|
|
172
360
|
|
|
173
|
-
private
|
|
361
|
+
private handleBecomeLeader(): void {
|
|
362
|
+
this.log.info('[SharedWS] 👑 became leader');
|
|
174
363
|
this.socket = this.createSocket();
|
|
175
364
|
|
|
176
|
-
this.socket.onMessage((
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
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);
|
|
180
379
|
this.bus.broadcast('ws:message', { event, data: payload });
|
|
181
380
|
});
|
|
182
381
|
|
|
183
|
-
|
|
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
|
+
|
|
184
397
|
this.cleanups.push(
|
|
185
398
|
this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {
|
|
186
399
|
return new Promise((resolve) => {
|
|
187
|
-
const unsub = this.socket!.onMessage((response:
|
|
188
|
-
|
|
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) {
|
|
189
403
|
unsub();
|
|
190
|
-
resolve(
|
|
404
|
+
resolve(res?.[this.proto.dataField] ?? response);
|
|
191
405
|
}
|
|
192
406
|
});
|
|
193
407
|
this.socket!.send({ event: req.event, data: req.data });
|
|
@@ -198,7 +412,7 @@ export class SharedWebSocket implements Disposable {
|
|
|
198
412
|
this.socket.connect();
|
|
199
413
|
}
|
|
200
414
|
|
|
201
|
-
private
|
|
415
|
+
private handleLoseLeadership(): void {
|
|
202
416
|
if (this.socket) {
|
|
203
417
|
this.socket[Symbol.dispose]();
|
|
204
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
|
+
}
|