@gwakko/shared-websocket 0.13.0 → 0.14.5
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 +37 -0
- package/dist/SharedSocket.d.ts +2 -0
- package/dist/SharedWebSocket.d.ts +141 -5
- package/dist/SubscriptionManager.d.ts +1 -1
- package/dist/WorkerSocket.d.ts +2 -0
- package/dist/adapters/react.d.ts +3 -3
- package/dist/adapters/vue.d.ts +3 -3
- package/dist/{chunk-RKVYLJTQ.cjs → chunk-HIKH74NQ.cjs} +505 -69
- package/dist/chunk-HIKH74NQ.cjs.map +1 -0
- package/dist/{chunk-IK4HLA3K.js → chunk-N63ZMMWV.js} +496 -60
- package/dist/chunk-N63ZMMWV.js.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.cjs +8 -8
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +7 -7
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +152 -1
- package/dist/vue.cjs +8 -8
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +7 -7
- package/dist/vue.js.map +1 -1
- package/dist/worker/socket.worker.d.ts +2 -0
- package/package.json +1 -1
- package/src/MessageBus.ts +8 -1
- package/src/SharedSocket.ts +28 -3
- package/src/SharedWebSocket.ts +577 -63
- package/src/SubscriptionManager.ts +4 -4
- package/src/WorkerSocket.ts +27 -3
- package/src/adapters/react.ts +9 -9
- package/src/adapters/vue.ts +9 -9
- package/src/index.ts +3 -0
- package/src/types.ts +162 -1
- package/src/worker/socket.worker.ts +7 -0
- package/dist/chunk-IK4HLA3K.js.map +0 -1
- package/dist/chunk-RKVYLJTQ.cjs.map +0 -1
|
@@ -16,9 +16,9 @@ export class SubscriptionManager implements Disposable {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
once(event: string, handler: EventHandler): Unsubscribe {
|
|
19
|
-
const wrapper: EventHandler = (data) => {
|
|
19
|
+
const wrapper: EventHandler = (data, raw) => {
|
|
20
20
|
unsub();
|
|
21
|
-
handler(data);
|
|
21
|
+
handler(data, raw);
|
|
22
22
|
};
|
|
23
23
|
const unsub = this.on(event, wrapper);
|
|
24
24
|
return unsub;
|
|
@@ -32,11 +32,11 @@ export class SubscriptionManager implements Disposable {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
emit(event: string, data: unknown): void {
|
|
35
|
+
emit(event: string, data: unknown, raw?: unknown): void {
|
|
36
36
|
this.lastMessages.set(event, data);
|
|
37
37
|
const set = this.handlers.get(event);
|
|
38
38
|
if (set) {
|
|
39
|
-
for (const fn of set) fn(data);
|
|
39
|
+
for (const fn of set) fn(data, raw);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
package/src/WorkerSocket.ts
CHANGED
|
@@ -31,6 +31,7 @@ export class WorkerSocket implements Disposable {
|
|
|
31
31
|
reconnect?: boolean;
|
|
32
32
|
reconnectMaxDelay?: number;
|
|
33
33
|
reconnectMaxRetries?: number;
|
|
34
|
+
authFailureCloseCodes?: number[];
|
|
34
35
|
heartbeatInterval?: number;
|
|
35
36
|
sendBuffer?: number;
|
|
36
37
|
workerUrl?: string | URL;
|
|
@@ -45,11 +46,27 @@ export class WorkerSocket implements Disposable {
|
|
|
45
46
|
return this._state;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
private setState(s: SocketState): void {
|
|
50
|
+
this._state = s;
|
|
51
|
+
for (const fn of this.onStateChangeFns) fn(s);
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
async connect(): Promise<void> {
|
|
49
55
|
// Resolve auth token before sending to worker (functions can't cross worker boundary)
|
|
50
56
|
let authToken: string | undefined;
|
|
51
57
|
if (this.options.auth) {
|
|
52
|
-
|
|
58
|
+
try {
|
|
59
|
+
authToken = await this.options.auth();
|
|
60
|
+
} catch {
|
|
61
|
+
// auth() threw — pause reconnect until user provides fresh creds.
|
|
62
|
+
this.setState('failed');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!authToken) {
|
|
66
|
+
// Configured auth callback returned nothing — same fail-closed behavior.
|
|
67
|
+
this.setState('failed');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
53
70
|
} else if (this.options.authToken) {
|
|
54
71
|
authToken = this.options.authToken;
|
|
55
72
|
}
|
|
@@ -95,6 +112,7 @@ export class WorkerSocket implements Disposable {
|
|
|
95
112
|
reconnect: this.options.reconnect ?? true,
|
|
96
113
|
reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,
|
|
97
114
|
reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
|
|
115
|
+
authFailureCloseCodes: this.options.authFailureCloseCodes ?? [1008],
|
|
98
116
|
heartbeatInterval: this.options.heartbeatInterval ?? 30_000,
|
|
99
117
|
bufferSize: this.options.sendBuffer ?? 100,
|
|
100
118
|
pingPayload: this.options.pingPayload,
|
|
@@ -145,6 +163,7 @@ export class WorkerSocket implements Disposable {
|
|
|
145
163
|
let heartbeatTimer = null, reconnectTimer = null;
|
|
146
164
|
let url = '', protocols = [], shouldReconnect = true;
|
|
147
165
|
let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
|
|
166
|
+
let authFailCodes = new Set([1008]);
|
|
148
167
|
let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
|
|
149
168
|
|
|
150
169
|
function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
|
|
@@ -154,7 +173,12 @@ export class WorkerSocket implements Disposable {
|
|
|
154
173
|
ws = new WebSocket(url, protocols);
|
|
155
174
|
ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
|
|
156
175
|
ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
|
|
157
|
-
ws.onclose = (e) => {
|
|
176
|
+
ws.onclose = (e) => {
|
|
177
|
+
stopHB();
|
|
178
|
+
self.postMessage({ type: 'close', code: e.code, reason: e.reason });
|
|
179
|
+
if (authFailCodes.has(e.code)) { setState('failed'); return; }
|
|
180
|
+
if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed');
|
|
181
|
+
};
|
|
158
182
|
ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
|
|
159
183
|
}
|
|
160
184
|
function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
|
|
@@ -178,7 +202,7 @@ export class WorkerSocket implements Disposable {
|
|
|
178
202
|
}
|
|
179
203
|
self.onmessage = (e) => {
|
|
180
204
|
const c = e.data;
|
|
181
|
-
if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
|
|
205
|
+
if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; if (c.authFailureCloseCodes) authFailCodes = new Set(c.authFailureCloseCodes); hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
|
|
182
206
|
if (c.type === 'send') send(c.data);
|
|
183
207
|
if (c.type === 'reconnect') manualReconnect();
|
|
184
208
|
if (c.type === 'disconnect') { disposed = true; stopHB(); if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) { ws.onclose = null; if (ws.readyState < 2) ws.close(1000); ws = null; } buffer = []; setState('closed'); }
|
package/src/adapters/react.ts
CHANGED
|
@@ -149,13 +149,13 @@ export function useSocketAuth(): {
|
|
|
149
149
|
* setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50
|
|
150
150
|
* });
|
|
151
151
|
*/
|
|
152
|
-
export function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined {
|
|
152
|
+
export function useSocketEvent<T>(event: string, callback?: (data: T, raw?: unknown) => void): T | undefined {
|
|
153
153
|
const socket = useSharedWebSocket();
|
|
154
154
|
const [value, setValue] = useState<T | undefined>(undefined);
|
|
155
155
|
|
|
156
|
-
const onEvent = useEffectEvent((data: T) => {
|
|
156
|
+
const onEvent = useEffectEvent((data: T, raw?: unknown) => {
|
|
157
157
|
if (callback) {
|
|
158
|
-
callback(data);
|
|
158
|
+
callback(data, raw);
|
|
159
159
|
} else {
|
|
160
160
|
setValue(data);
|
|
161
161
|
}
|
|
@@ -192,13 +192,13 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
|
|
|
192
192
|
* if (entry.level === 'error') setErrors(prev => [...prev, entry]);
|
|
193
193
|
* });
|
|
194
194
|
*/
|
|
195
|
-
export function useSocketStream<T>(event: string, callback?: (data: T) => void): T[] {
|
|
195
|
+
export function useSocketStream<T>(event: string, callback?: (data: T, raw?: unknown) => void): T[] {
|
|
196
196
|
const socket = useSharedWebSocket();
|
|
197
197
|
const [items, setItems] = useState<T[]>([]);
|
|
198
198
|
|
|
199
|
-
const onEvent = useEffectEvent((data: T) => {
|
|
199
|
+
const onEvent = useEffectEvent((data: T, raw?: unknown) => {
|
|
200
200
|
if (callback) {
|
|
201
|
-
callback(data);
|
|
201
|
+
callback(data, raw);
|
|
202
202
|
} else {
|
|
203
203
|
setItems((prev) => [...prev, data]);
|
|
204
204
|
}
|
|
@@ -276,11 +276,11 @@ export function useSocketSync<T>(
|
|
|
276
276
|
* }
|
|
277
277
|
* });
|
|
278
278
|
*/
|
|
279
|
-
export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
|
|
279
|
+
export function useSocketCallback<T>(event: string, callback: (data: T, raw?: unknown) => void): void {
|
|
280
280
|
const socket = useSharedWebSocket();
|
|
281
281
|
|
|
282
|
-
const handler = useEffectEvent((data: T) => {
|
|
283
|
-
callback(data);
|
|
282
|
+
const handler = useEffectEvent((data: T, raw?: unknown) => {
|
|
283
|
+
callback(data, raw);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
286
|
useEffect(() => {
|
package/src/adapters/vue.ts
CHANGED
|
@@ -110,14 +110,14 @@ export function useSocketAuth(): {
|
|
|
110
110
|
* analytics.track('order_received', order);
|
|
111
111
|
* });
|
|
112
112
|
*/
|
|
113
|
-
export function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {
|
|
113
|
+
export function useSocketEvent<T>(event: string, callback?: (data: T, raw?: unknown) => void): Ref<T | undefined> {
|
|
114
114
|
const socket = useSharedWebSocket();
|
|
115
115
|
const value = ref<T | undefined>(undefined) as Ref<T | undefined>;
|
|
116
116
|
|
|
117
|
-
const handler = (data: unknown) => {
|
|
117
|
+
const handler = (data: unknown, raw?: unknown) => {
|
|
118
118
|
const typed = data as T;
|
|
119
119
|
if (callback) {
|
|
120
|
-
callback(typed);
|
|
120
|
+
callback(typed, raw);
|
|
121
121
|
} else {
|
|
122
122
|
value.value = typed;
|
|
123
123
|
}
|
|
@@ -151,14 +151,14 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
|
|
|
151
151
|
* if (entry.level === 'error') errors.value = [...errors.value, entry];
|
|
152
152
|
* });
|
|
153
153
|
*/
|
|
154
|
-
export function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {
|
|
154
|
+
export function useSocketStream<T>(event: string, callback?: (data: T, raw?: unknown) => void): Ref<T[]> {
|
|
155
155
|
const socket = useSharedWebSocket();
|
|
156
156
|
const items = ref<T[]>([]) as Ref<T[]>;
|
|
157
157
|
|
|
158
|
-
const handler = (data: unknown) => {
|
|
158
|
+
const handler = (data: unknown, raw?: unknown) => {
|
|
159
159
|
const typed = data as T;
|
|
160
160
|
if (callback) {
|
|
161
|
-
callback(typed);
|
|
161
|
+
callback(typed, raw);
|
|
162
162
|
} else {
|
|
163
163
|
items.value = [...items.value, typed];
|
|
164
164
|
}
|
|
@@ -215,11 +215,11 @@ export function useSocketSync<T>(key: string, initialValue: T, callback?: (value
|
|
|
215
215
|
* showToast(n.title);
|
|
216
216
|
* });
|
|
217
217
|
*/
|
|
218
|
-
export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
|
|
218
|
+
export function useSocketCallback<T>(event: string, callback: (data: T, raw?: unknown) => void): void {
|
|
219
219
|
const socket = useSharedWebSocket();
|
|
220
220
|
|
|
221
|
-
const unsub = socket.on(event, (data: unknown) => {
|
|
222
|
-
callback(data as T);
|
|
221
|
+
const unsub = socket.on(event, (data: unknown, raw?: unknown) => {
|
|
222
|
+
callback(data as T, raw);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
onUnmounted(unsub);
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'failed';
|
|
2
2
|
export type TabRole = 'leader' | 'follower';
|
|
3
3
|
export type Unsubscribe = () => void;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Event handler. Receives the extracted `data` (per `dataField`) plus the
|
|
6
|
+
* full raw envelope as a second argument. Use `raw` to access top-level
|
|
7
|
+
* fields outside `dataField` (e.g. `id`, `kind`, `channel`, `type`).
|
|
8
|
+
*/
|
|
9
|
+
export type EventHandler<T = unknown> = (data: T, raw?: unknown) => void;
|
|
5
10
|
|
|
6
11
|
/** Type-safe event map. Keys are event names, values are payload types. */
|
|
7
12
|
export type EventMap = Record<string, unknown>;
|
|
@@ -48,12 +53,61 @@ export interface Logger {
|
|
|
48
53
|
/** Middleware function — transform or inspect messages. Return null to drop. */
|
|
49
54
|
export type Middleware<T = unknown> = (message: T) => T | null;
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Kinds of frames the library emits. Lets `EventProtocol.frameBuilder`
|
|
58
|
+
* take full control over the wire shape per kind — e.g. produce flat
|
|
59
|
+
* `{ type, channel, event, data }` envelopes for Pusher/Reverb/custom
|
|
60
|
+
* servers instead of the default `{ event, data }` two-key wrapper.
|
|
61
|
+
*/
|
|
62
|
+
export type FrameKind =
|
|
63
|
+
| 'event' // user payload via ws.send / Channel.send
|
|
64
|
+
| 'subscribe' // channel join
|
|
65
|
+
| 'unsubscribe' // channel leave
|
|
66
|
+
| 'topic-subscribe' // topic subscribe
|
|
67
|
+
| 'topic-unsubscribe' // topic unsubscribe
|
|
68
|
+
| 'auth-login' // authenticate(token)
|
|
69
|
+
| 'auth-logout'; // deauthenticate()
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Structured payload passed to `frameBuilder`. Fields are populated
|
|
73
|
+
* based on `kind`:
|
|
74
|
+
*
|
|
75
|
+
* - `event` → `{ event, data, channel?, extras? }`
|
|
76
|
+
* `channel` is set when sent via `Channel.send`.
|
|
77
|
+
* - `subscribe`/`unsubscribe` → `{ channel, extras? }`
|
|
78
|
+
* - `topic-subscribe`/`topic-unsubscribe` → `{ topic, extras? }`
|
|
79
|
+
* - `auth-login` → `{ data: token, extras? }` (`data` is the raw token)
|
|
80
|
+
* - `auth-logout` → `{ extras? }`
|
|
81
|
+
*/
|
|
82
|
+
export interface FramePayload {
|
|
83
|
+
/** Channel name. Set for subscribe/unsubscribe and channel-scoped events. */
|
|
84
|
+
channel?: string;
|
|
85
|
+
/** Topic name. Set for topic-subscribe/topic-unsubscribe. */
|
|
86
|
+
topic?: string;
|
|
87
|
+
/** Bare event name (without channel prefix). Set for `kind: 'event'`. */
|
|
88
|
+
event?: string;
|
|
89
|
+
/** Payload — user data, auth token, etc. */
|
|
90
|
+
data?: unknown;
|
|
91
|
+
/** Extra top-level fields to merge into the wire envelope. */
|
|
92
|
+
extras?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
51
95
|
export interface SharedWebSocketOptions<TEvents extends EventMap = EventMap> {
|
|
52
96
|
protocols?: string[];
|
|
53
97
|
reconnect?: boolean;
|
|
54
98
|
reconnectMaxDelay?: number;
|
|
55
99
|
/** Max reconnect attempts before giving up (default: Infinity — retry forever). */
|
|
56
100
|
reconnectMaxRetries?: number;
|
|
101
|
+
/**
|
|
102
|
+
* WebSocket close codes that indicate "auth failed — don't retry."
|
|
103
|
+
* On these codes the library sets state to 'failed' and stops auto-reconnect
|
|
104
|
+
* instead of looping with the same expired credentials. Default: `[1008]`
|
|
105
|
+
* (PolicyViolation). Add 4xxx app-specific codes if your server uses them.
|
|
106
|
+
*
|
|
107
|
+
* To recover, call `ws.authenticate(newToken)` (auto-reconnects when
|
|
108
|
+
* the local tab is the leader) or `ws.reconnect()` directly.
|
|
109
|
+
*/
|
|
110
|
+
authFailureCloseCodes?: number[];
|
|
57
111
|
heartbeatInterval?: number;
|
|
58
112
|
electionTimeout?: number;
|
|
59
113
|
leaderHeartbeat?: number;
|
|
@@ -65,6 +119,42 @@ export interface SharedWebSocketOptions<TEvents extends EventMap = EventMap> {
|
|
|
65
119
|
authToken?: string;
|
|
66
120
|
/** Query parameter name for the token (default: "token"). */
|
|
67
121
|
authParam?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Optional. Periodic token refresh — runs on the leader tab only via
|
|
124
|
+
* `setInterval(refresh, refreshTokenInterval)`. When the timer fires
|
|
125
|
+
* and the connection is currently authenticated, the returned token
|
|
126
|
+
* is passed to `authenticate()` so the server sees the new credentials
|
|
127
|
+
* before the old one expires. Falls back to `auth` if unset.
|
|
128
|
+
*
|
|
129
|
+
* Use this for long-running tabs where the server would otherwise
|
|
130
|
+
* close with an auth-failure code mid-session. Pair with a sensible
|
|
131
|
+
* interval — typically ~80% of your token TTL (e.g. 48 minutes for a
|
|
132
|
+
* 60-minute token).
|
|
133
|
+
*
|
|
134
|
+
* If the callback throws, the failure is logged at `warn` and the
|
|
135
|
+
* timer keeps running for the next interval; the server will still
|
|
136
|
+
* close on its own when the token expires, at which point
|
|
137
|
+
* `authFailureCloseCodes` and `ws.authenticate(...)` handle recovery.
|
|
138
|
+
*/
|
|
139
|
+
refresh?: () => string | Promise<string>;
|
|
140
|
+
/**
|
|
141
|
+
* Refresh interval in milliseconds. Disabled when unset or `<= 0`.
|
|
142
|
+
* No default — opt-in.
|
|
143
|
+
*/
|
|
144
|
+
refreshTokenInterval?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Max number of follower-routed dispatches each tab buffers locally for
|
|
147
|
+
* replay across leader handover. When the leader dies between receiving
|
|
148
|
+
* a follower's dispatch and writing it to the socket, the new leader
|
|
149
|
+
* gathers pending entries from all tabs and replays them. Cap protects
|
|
150
|
+
* memory; oldest entries are dropped on overflow. Set to `0` to disable
|
|
151
|
+
* the buffer entirely. Default: 100.
|
|
152
|
+
*
|
|
153
|
+
* Note: the replay is at-least-once — a leader that dies AFTER socket
|
|
154
|
+
* write but BEFORE broadcasting "flushed" will cause a duplicate. Make
|
|
155
|
+
* server-side handlers idempotent if duplicates would matter.
|
|
156
|
+
*/
|
|
157
|
+
outboundBufferSize?: number;
|
|
68
158
|
/** Run WebSocket inside a Web Worker (offloads JSON parsing, heartbeat from main thread). */
|
|
69
159
|
useWorker?: boolean;
|
|
70
160
|
/** Custom worker URL (if useWorker is true and you want to provide your own worker file). */
|
|
@@ -117,6 +207,56 @@ export interface EventProtocol {
|
|
|
117
207
|
authLogout: string;
|
|
118
208
|
/** Event name server sends to revoke auth (default: "$auth:revoked"). */
|
|
119
209
|
authRevoked: string;
|
|
210
|
+
/**
|
|
211
|
+
* Optional. Takes full control of outgoing frame shape per `FrameKind`.
|
|
212
|
+
* If unset, the default builder reproduces the legacy two-key envelope:
|
|
213
|
+
* `{ ...extras, [eventField]: <event-name>, [dataField]: <data> }`
|
|
214
|
+
* using `channelJoin` / `channelLeave` / `topicSubscribe` / etc. for
|
|
215
|
+
* control-frame event names.
|
|
216
|
+
*
|
|
217
|
+
* Return-value contract:
|
|
218
|
+
* - any concrete value → use as the wire frame
|
|
219
|
+
* - `null` → drop the frame (intentional filter / no-op)
|
|
220
|
+
* - `undefined` → fall back to the library default for this kind
|
|
221
|
+
*
|
|
222
|
+
* @example Flat envelope (Pusher / Reverb / custom Go server)
|
|
223
|
+
* frameBuilder: (kind, p) => {
|
|
224
|
+
* switch (kind) {
|
|
225
|
+
* case 'subscribe': return { type: 'subscribe', channel: p.channel };
|
|
226
|
+
* case 'unsubscribe': return { type: 'unsubscribe', channel: p.channel };
|
|
227
|
+
* case 'auth-login': return { type: 'auth', token: p.data };
|
|
228
|
+
* case 'auth-logout': return { type: 'logout' };
|
|
229
|
+
* case 'event':
|
|
230
|
+
* return p.channel
|
|
231
|
+
* ? { type: 'event', channel: p.channel, event: p.event, data: p.data, ...p.extras }
|
|
232
|
+
* : { type: 'event', event: p.event, data: p.data, ...p.extras };
|
|
233
|
+
* default: return undefined; // unknown kind → library default
|
|
234
|
+
* }
|
|
235
|
+
* }
|
|
236
|
+
*/
|
|
237
|
+
frameBuilder?: (kind: FrameKind, payload: FramePayload) => unknown;
|
|
238
|
+
/**
|
|
239
|
+
* Optional. If provided, `channel(name).ready` waits for an incoming
|
|
240
|
+
* frame this matcher classifies as `'ok'` before resolving. Returns:
|
|
241
|
+
* - `'ok'` → resolve.
|
|
242
|
+
* - `'reject'` → reject with a "subscribe rejected" error.
|
|
243
|
+
* - `'pending'` → keep watching subsequent frames.
|
|
244
|
+
*
|
|
245
|
+
* Without a matcher, `Channel.ready` resolves immediately after the
|
|
246
|
+
* subscribe frame is dispatched — appropriate for fire-and-forget
|
|
247
|
+
* servers that don't send subscribe acks.
|
|
248
|
+
*
|
|
249
|
+
* @example Phoenix `phx_reply`
|
|
250
|
+
* channelAckMatcher: (frame, channel) => {
|
|
251
|
+
* const f = frame as { topic: string; event: string; payload: { status: 'ok' | 'error' } };
|
|
252
|
+
* if (f.topic !== channel) return 'pending';
|
|
253
|
+
* if (f.event !== 'phx_reply') return 'pending';
|
|
254
|
+
* return f.payload.status === 'ok' ? 'ok' : 'reject';
|
|
255
|
+
* }
|
|
256
|
+
*/
|
|
257
|
+
channelAckMatcher?: (frame: unknown, channel: string) => ChannelAckResult;
|
|
258
|
+
/** Timeout in ms for `Channel.ready` when `channelAckMatcher` is set. Default: 5000. */
|
|
259
|
+
channelAckTimeout?: number;
|
|
120
260
|
}
|
|
121
261
|
|
|
122
262
|
/** Push notification options. */
|
|
@@ -150,9 +290,30 @@ export interface SocketLifecycleHandlers {
|
|
|
150
290
|
onAuthChange?: (authenticated: boolean) => void;
|
|
151
291
|
}
|
|
152
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Result returned by `EventProtocol.channelAckMatcher` for each
|
|
295
|
+
* incoming frame while a `Channel` is awaiting its subscribe ack.
|
|
296
|
+
*
|
|
297
|
+
* - `'ok'` → resolve `Channel.ready`, stop watching.
|
|
298
|
+
* - `'reject'` → reject `Channel.ready` with a "subscribe rejected"
|
|
299
|
+
* error, stop watching.
|
|
300
|
+
* - `'pending'` → keep watching for subsequent frames.
|
|
301
|
+
*/
|
|
302
|
+
export type ChannelAckResult = 'ok' | 'reject' | 'pending';
|
|
303
|
+
|
|
153
304
|
/** Scoped channel handle for private/topic-based subscriptions. */
|
|
154
305
|
export interface Channel {
|
|
155
306
|
readonly name: string;
|
|
307
|
+
/**
|
|
308
|
+
* Resolves once the server has accepted the subscription. By default
|
|
309
|
+
* (no `channelAckMatcher` configured) this resolves immediately after
|
|
310
|
+
* the subscribe frame is dispatched — fire-and-forget servers don't
|
|
311
|
+
* send acks. Configure `EventProtocol.channelAckMatcher` to wait for
|
|
312
|
+
* a real ack frame; the promise then rejects on a matched
|
|
313
|
+
* "rejected" frame, on `channelAckTimeout`, or if `.leave()` is
|
|
314
|
+
* called before the ack arrives.
|
|
315
|
+
*/
|
|
316
|
+
readonly ready: Promise<void>;
|
|
156
317
|
on(event: string, handler: EventHandler): Unsubscribe;
|
|
157
318
|
once(event: string, handler: EventHandler): Unsubscribe;
|
|
158
319
|
send(event: string, data: unknown): void;
|
|
@@ -26,6 +26,7 @@ interface WorkerCommand {
|
|
|
26
26
|
reconnect?: boolean;
|
|
27
27
|
reconnectMaxDelay?: number;
|
|
28
28
|
reconnectMaxRetries?: number;
|
|
29
|
+
authFailureCloseCodes?: number[];
|
|
29
30
|
heartbeatInterval?: number;
|
|
30
31
|
bufferSize?: number;
|
|
31
32
|
}
|
|
@@ -42,6 +43,7 @@ let currentProtocols: string[] = [];
|
|
|
42
43
|
let shouldReconnect = true;
|
|
43
44
|
let maxDelay = 30_000;
|
|
44
45
|
let maxRetries = Infinity;
|
|
46
|
+
let authFailureCloseCodes: Set<number> = new Set([1008]);
|
|
45
47
|
let heartbeatInterval = 30_000;
|
|
46
48
|
let maxBuffer = 100;
|
|
47
49
|
|
|
@@ -100,6 +102,10 @@ function doConnect() {
|
|
|
100
102
|
stopHeartbeat();
|
|
101
103
|
self.postMessage({ type: 'close', code: ev.code, reason: ev.reason });
|
|
102
104
|
|
|
105
|
+
if (authFailureCloseCodes.has(ev.code)) {
|
|
106
|
+
setState('failed');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
103
109
|
if (!disposed && shouldReconnect && ev.code !== 1000) {
|
|
104
110
|
scheduleReconnect();
|
|
105
111
|
} else {
|
|
@@ -220,6 +226,7 @@ self.onmessage = (ev: MessageEvent<WorkerCommand>) => {
|
|
|
220
226
|
if (cmd.reconnect !== undefined) shouldReconnect = cmd.reconnect;
|
|
221
227
|
if (cmd.reconnectMaxDelay) maxDelay = cmd.reconnectMaxDelay;
|
|
222
228
|
if (cmd.reconnectMaxRetries !== undefined) maxRetries = cmd.reconnectMaxRetries;
|
|
229
|
+
if (cmd.authFailureCloseCodes) authFailureCloseCodes = new Set(cmd.authFailureCloseCodes);
|
|
223
230
|
if (cmd.heartbeatInterval) heartbeatInterval = cmd.heartbeatInterval;
|
|
224
231
|
if (cmd.bufferSize) maxBuffer = cmd.bufferSize;
|
|
225
232
|
connect(cmd.url!, cmd.protocols ?? []);
|