@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.
@@ -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
 
@@ -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
- authToken = await this.options.auth();
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) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
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'); }
@@ -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(() => {
@@ -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
@@ -29,4 +29,7 @@ export type {
29
29
  Middleware,
30
30
  PushNotificationOptions,
31
31
  Codec,
32
+ FrameKind,
33
+ FramePayload,
34
+ ChannelAckResult,
32
35
  } from './types';
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
- export type EventHandler<T = unknown> = (data: T) => void;
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 ?? []);