@zerotal/client 1.0.0

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.
@@ -0,0 +1,170 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+ import { ApiClientError } from "./ApiClient.ts";
3
+
4
+ // ── Types ─────────────────────────────────────────────────────────────────────
5
+
6
+ export type CircuitState = "closed" | "open" | "half-open";
7
+
8
+ export interface CircuitBreakerOptions {
9
+ /**
10
+ * Number of consecutive failures required to open the circuit.
11
+ * Default: `5`.
12
+ */
13
+ threshold?: number;
14
+ /**
15
+ * Milliseconds to wait in the open state before allowing a single probe
16
+ * request through (transition to half-open). Default: `30_000` (30 s).
17
+ */
18
+ resetTimeout?: number;
19
+ /**
20
+ * Predicate that decides whether a thrown error counts as a failure.
21
+ * By default, network errors and `ApiClientError` responses with status ≥ 500
22
+ * are failures; 4xx errors are not (they indicate client mistakes, not
23
+ * upstream outages).
24
+ */
25
+ isFailure?: (error: unknown) => boolean;
26
+ }
27
+
28
+ // ── Error thrown when the circuit is open ─────────────────────────────────────
29
+
30
+ export class CircuitBreakerOpenError extends ZerotalError {
31
+ constructor() {
32
+ super(
33
+ "Circuit breaker is open — upstream service is currently unavailable",
34
+ "E_CIRCUIT_OPEN",
35
+ 503,
36
+ );
37
+ }
38
+ }
39
+
40
+ // ── CircuitBreaker ────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * A three-state circuit breaker that wraps async operations.
44
+ *
45
+ * | State | Behaviour |
46
+ * |------------|-------------------------------------------------------------------|
47
+ * | `closed` | Requests pass through; consecutive failures are counted. |
48
+ * | `open` | All calls immediately throw `CircuitBreakerOpenError`. |
49
+ * | `half-open`| One probe request is allowed through to test recovery. |
50
+ *
51
+ * State transitions:
52
+ * - **closed → open** when failure count reaches `threshold`
53
+ * - **open → half-open** after `resetTimeout` ms have elapsed
54
+ * - **half-open → closed** on a successful probe
55
+ * - **half-open → open** on a failed probe (resets the timeout)
56
+ *
57
+ * @example
58
+ * const breaker = new CircuitBreaker({ threshold: 3, resetTimeout: 10_000 });
59
+ *
60
+ * const client = createApiClient({
61
+ * baseUrl: 'https://api.example.com',
62
+ * circuitBreaker: breaker,
63
+ * });
64
+ */
65
+ export class CircuitBreaker {
66
+ private _state: CircuitState = "closed";
67
+ private _failures: number = 0;
68
+ private _openedAt: number | undefined;
69
+ private _probing: boolean = false; // guards a single half-open probe
70
+
71
+ private readonly _threshold: number;
72
+ private readonly _resetTimeout: number;
73
+ private readonly _isFailure: (error: unknown) => boolean;
74
+
75
+ constructor(options: CircuitBreakerOptions = {}) {
76
+ this._threshold = options.threshold ?? 5;
77
+ this._resetTimeout = options.resetTimeout ?? 30_000;
78
+ this._isFailure = options.isFailure ?? _defaultIsFailure;
79
+ }
80
+
81
+ get state(): CircuitState {
82
+ return this._state;
83
+ }
84
+ get failures(): number {
85
+ return this._failures;
86
+ }
87
+
88
+ /**
89
+ * Wrap `fn` in the circuit breaker. Throws `CircuitBreakerOpenError` when
90
+ * the circuit is open and the reset timeout has not yet elapsed.
91
+ */
92
+ async call<T>(fn: () => Promise<T>): Promise<T> {
93
+ if (this._state === "open") {
94
+ if (Date.now() - this._openedAt! < this._resetTimeout) {
95
+ throw new CircuitBreakerOpenError();
96
+ }
97
+ // Timeout elapsed — allow one probe
98
+ this._state = "half-open";
99
+ }
100
+
101
+ if (this._state === "half-open") {
102
+ // Reject concurrent calls while a probe is already in flight
103
+ if (this._probing) throw new CircuitBreakerOpenError();
104
+ this._probing = true;
105
+ }
106
+
107
+ try {
108
+ const result = await fn();
109
+ this._onSuccess();
110
+ return result;
111
+ } catch (err) {
112
+ if (this._isFailure(err)) {
113
+ this._onFailure();
114
+ }
115
+ throw err;
116
+ } finally {
117
+ // Unconditional. The guard used to be `if (this._state !== "half-open")`, which released
118
+ // the flag only when there was nothing to release: a probe that failed with a
119
+ // non-counted error (_isFailure is `status >= 500`, so every 4xx) skipped both
120
+ // _onSuccess and _onFailure, left the state at "half-open" with _probing stuck true, and
121
+ // the `state === "open"` timeout branch was never reachable again. A healthy upstream
122
+ // then 503'd forever until someone called reset().
123
+ this._probing = false;
124
+ }
125
+ }
126
+
127
+ /** Manually reset to the closed state (useful in tests or admin endpoints). */
128
+ reset(): void {
129
+ this._state = "closed";
130
+ this._failures = 0;
131
+ this._openedAt = undefined;
132
+ this._probing = false;
133
+ }
134
+
135
+ // ── Private ─────────────────────────────────────────────────────────────────
136
+
137
+ private _onSuccess(): void {
138
+ this._state = "closed";
139
+ this._failures = 0;
140
+ this._openedAt = undefined;
141
+ this._probing = false;
142
+ }
143
+
144
+ private _onFailure(): void {
145
+ this._probing = false;
146
+
147
+ if (this._state === "half-open") {
148
+ // Failed probe — back to open, reset the timeout
149
+ this._state = "open";
150
+ this._openedAt = Date.now();
151
+ return;
152
+ }
153
+
154
+ this._failures++;
155
+ if (this._failures >= this._threshold) {
156
+ this._state = "open";
157
+ this._openedAt = Date.now();
158
+ }
159
+ }
160
+ }
161
+
162
+ // ── Default failure predicate ─────────────────────────────────────────────────
163
+
164
+ function _defaultIsFailure(error: unknown): boolean {
165
+ if (error instanceof ApiClientError) {
166
+ return error.status >= 500;
167
+ }
168
+ // Network errors, timeouts, DNS failures, etc.
169
+ return true;
170
+ }
package/src/Socket.ts ADDED
@@ -0,0 +1,531 @@
1
+ // ── Socket: first-party realtime client for @zerotal/broadcasting ────────────
2
+ //
3
+ // A small, dependency-free WebSocket client that speaks the *native* Zerotal
4
+ // broadcasting protocol (the `ws` / `redis` drivers) and exposes a familiar
5
+ // realtime-client API — the same `channel()/private()/join()/leave()` +
6
+ // `listen()/here()/joining()/leaving()` surface — so it works with `@zerotal/flow`'s
7
+ // `@on('echo:…')` listeners with zero external dependencies.
8
+ //
9
+ // import { Socket } from "@zerotal/client";
10
+ //
11
+ // const socket = new Socket(); // ws(s)://<host>/app/ws
12
+ // socket.channel("posts").listen("PostPublished", (e) => render(e));
13
+ // socket.private(`orders.${id}`).listen("OrderUpdated", (e) => update(e.order));
14
+ //
15
+ // const room = socket.presence(`chat.${id}`)
16
+ // .here((members) => setOnline(members))
17
+ // .joining((m) => addOnline(m))
18
+ // .leaving((m) => removeOnline(m))
19
+ // .listen("Message", (e) => append(e));
20
+ //
21
+ // Private/presence channels are authorized with a per-subscription HMAC signature, like
22
+ // Pusher: before subscribing, the client POSTs `{ socket_id, channel_name }` to
23
+ // `authEndpoint` (default `/broadcasting/auth`), and the server signs it against the app's
24
+ // APP_KEY after running the `routes/channels.ts` rules. The signature is socket-bound, so it
25
+ // is re-fetched on every (re)connection. Set `authEndpoint: false` to skip the fetch and rely
26
+ // on connection-level authorization (the server's `authorizeWith` callback) instead.
27
+
28
+ /** A single member of a presence channel (matches the server's `PresenceMember`). */
29
+ export interface PresenceMember {
30
+ id: string | number;
31
+ info: Record<string, unknown>;
32
+ }
33
+
34
+ /** Connection-state events you can observe via `socket.on(state, cb)`. */
35
+ export type SocketState = "connecting" | "connected" | "disconnected" | "reconnecting" | "error";
36
+
37
+ /** Minimal WebSocket surface — lets tests inject a fake. Matches the browser `WebSocket`. */
38
+ export interface SocketLike {
39
+ send(data: string): void;
40
+ close(code?: number, reason?: string): void;
41
+ readyState: number;
42
+ onopen: ((ev: unknown) => void) | null;
43
+ onclose: ((ev: { code?: number; reason?: string }) => void) | null;
44
+ onerror: ((ev: unknown) => void) | null;
45
+ onmessage: ((ev: { data: unknown }) => void) | null;
46
+ }
47
+
48
+ export interface SocketOptions {
49
+ /**
50
+ * Full WebSocket URL. When omitted it's derived from the page location (browser) as
51
+ * `ws(s)://<host>:<port><path>`. Required in non-browser environments.
52
+ */
53
+ url?: string;
54
+ /** WebSocket upgrade path. Defaults to `/app/ws` (matches the broadcasting config default). */
55
+ path?: string;
56
+ /** Host override (defaults to `location.hostname`). */
57
+ host?: string;
58
+ /** Port override (defaults to `location.port`). */
59
+ port?: string | number;
60
+ /** Force a scheme. Defaults to `wss` on https pages, `ws` otherwise. */
61
+ scheme?: "ws" | "wss";
62
+ /**
63
+ * Authentication for the connection and for private/presence channels:
64
+ * - `params` — query params appended to the WS URL (browsers can't set WS headers), read
65
+ * by the server on upgrade.
66
+ * - `headers` — sent on the `POST /broadcasting/auth` request (e.g. a CSRF token).
67
+ */
68
+ auth?: { params?: Record<string, string>; headers?: Record<string, string> };
69
+ /**
70
+ * Endpoint that signs a private/presence subscription (Pusher-style). Defaults to
71
+ * `/broadcasting/auth`. Set to `false` to skip the signature fetch and rely on
72
+ * connection-level authorization instead.
73
+ */
74
+ authEndpoint?: string | false;
75
+ /** Inject a `fetch` implementation for the auth request (tests / non-DOM runtimes). */
76
+ fetch?: typeof fetch;
77
+ /** Connect immediately on construction. Default: true. */
78
+ autoConnect?: boolean;
79
+ /** Reconnect automatically after an unexpected close. Default: true. */
80
+ reconnect?: boolean;
81
+ /** Initial reconnect delay in ms (doubles each attempt up to `maxReconnectDelay`). Default: 1000. */
82
+ reconnectDelay?: number;
83
+ /** Cap on the reconnect backoff in ms. Default: 30000. */
84
+ maxReconnectDelay?: number;
85
+ /** Heartbeat ping interval in ms (0 disables). Default: 30000. */
86
+ pingInterval?: number;
87
+ /** Inject a WebSocket constructor (tests / non-DOM runtimes). Defaults to the global. */
88
+ WebSocket?: new (url: string) => SocketLike;
89
+ }
90
+
91
+ type Listener = (payload: unknown) => void;
92
+
93
+ // Server → client framing (native BroadcastManager protocol).
94
+ interface ServerFrame {
95
+ event: string;
96
+ channel?: string;
97
+ data?: unknown;
98
+ message?: string;
99
+ }
100
+
101
+ const WS_OPEN = 1;
102
+
103
+ /** A subscribed channel. Bind broadcast events with {@link Channel.listen}. */
104
+ export class Channel {
105
+ /** @internal event name → set of listeners */
106
+ protected _listeners = new Map<string, Set<Listener>>();
107
+ /** @internal fired on `subscription_error` */
108
+ protected _errorCbs = new Set<Listener>();
109
+ /** @internal fired on `subscription_succeeded` */
110
+ protected _subscribedCbs = new Set<Listener>();
111
+
112
+ constructor(
113
+ /** The wire channel name (already prefixed, e.g. `private-orders.42`). */
114
+ readonly name: string,
115
+ protected readonly _socket: Socket,
116
+ ) {}
117
+
118
+ /** Bind a callback to a broadcast event on this channel. Chainable. */
119
+ listen(event: string, cb: Listener): this {
120
+ let set = this._listeners.get(event);
121
+ if (!set) this._listeners.set(event, (set = new Set()));
122
+ set.add(cb);
123
+ return this;
124
+ }
125
+
126
+ /** Remove a specific listener, or all listeners for an event when `cb` is omitted. Chainable. */
127
+ stopListening(event: string, cb?: Listener): this {
128
+ if (cb) this._listeners.get(event)?.delete(cb);
129
+ else this._listeners.delete(event);
130
+ return this;
131
+ }
132
+
133
+ /** Called when the server rejects the subscription (e.g. unauthorized). Chainable. */
134
+ error(cb: Listener): this {
135
+ this._errorCbs.add(cb);
136
+ return this;
137
+ }
138
+
139
+ /** Called once the subscription is confirmed. Chainable. */
140
+ subscribed(cb: Listener): this {
141
+ this._subscribedCbs.add(cb);
142
+ return this;
143
+ }
144
+
145
+ /** Leave (unsubscribe from) this channel. */
146
+ unsubscribe(): void {
147
+ this._socket.leaveChannel(this.name);
148
+ }
149
+
150
+ /** @internal Dispatch an incoming frame to the bound listeners. */
151
+ _dispatch(frame: ServerFrame): void {
152
+ switch (frame.event) {
153
+ case "subscription_succeeded":
154
+ this._emit(this._subscribedCbs, frame.data);
155
+ break;
156
+ case "subscription_error":
157
+ this._emit(this._errorCbs, frame.message ?? frame.data ?? "Unauthorized");
158
+ break;
159
+ default: {
160
+ const set = this._listeners.get(frame.event);
161
+ if (set) this._emit(set, frame.data);
162
+ }
163
+ }
164
+ }
165
+
166
+ protected _emit(cbs: Set<Listener>, payload: unknown): void {
167
+ for (const cb of cbs) {
168
+ try {
169
+ cb(payload);
170
+ } catch (err) {
171
+ console.error(`[Socket] listener for "${this.name}" threw:`, err);
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ /** A presence channel — tracks who's currently subscribed. */
178
+ export class PresenceChannel extends Channel {
179
+ private _hereCbs = new Set<Listener>();
180
+ private _joiningCbs = new Set<Listener>();
181
+ private _leavingCbs = new Set<Listener>();
182
+
183
+ /** Called once with the full member list when you join. Chainable. */
184
+ here(cb: (members: PresenceMember[]) => void): this {
185
+ this._hereCbs.add(cb as Listener);
186
+ return this;
187
+ }
188
+
189
+ /** Called when another member joins. Chainable. */
190
+ joining(cb: (member: PresenceMember) => void): this {
191
+ this._joiningCbs.add(cb as Listener);
192
+ return this;
193
+ }
194
+
195
+ /** Called when a member leaves. Chainable. */
196
+ leaving(cb: (member: PresenceMember) => void): this {
197
+ this._leavingCbs.add(cb as Listener);
198
+ return this;
199
+ }
200
+
201
+ override _dispatch(frame: ServerFrame): void {
202
+ const data = frame.data as { members?: PresenceMember[]; member?: PresenceMember } | undefined;
203
+ switch (frame.event) {
204
+ case "subscription_succeeded":
205
+ this._emit(this._subscribedCbs, frame.data);
206
+ this._emit(this._hereCbs, data?.members ?? []);
207
+ break;
208
+ case "presence:member_added":
209
+ if (data?.member) this._emit(this._joiningCbs, data.member);
210
+ break;
211
+ case "presence:member_removed":
212
+ if (data?.member) this._emit(this._leavingCbs, data.member);
213
+ break;
214
+ default:
215
+ super._dispatch(frame);
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Realtime client for Zerotal broadcasting (native `ws` / `redis` drivers). Exposes a
222
+ * familiar realtime-client API over the native protocol — see the module header for usage.
223
+ */
224
+ export class Socket {
225
+ private _ws: SocketLike | null = null;
226
+ private _channels = new Map<string, Channel>();
227
+ private _socketId: string | null = null;
228
+ private _state: SocketState = "disconnected";
229
+ private _stateCbs = new Map<SocketState, Set<(detail?: unknown) => void>>();
230
+ private _reconnectAttempts = 0;
231
+ private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
232
+ private _pingTimer: ReturnType<typeof setInterval> | null = null;
233
+ private _closedByUser = false;
234
+ private readonly _opts: Required<
235
+ Pick<
236
+ SocketOptions,
237
+ | "path"
238
+ | "autoConnect"
239
+ | "reconnect"
240
+ | "reconnectDelay"
241
+ | "maxReconnectDelay"
242
+ | "pingInterval"
243
+ | "authEndpoint"
244
+ >
245
+ > &
246
+ SocketOptions;
247
+
248
+ constructor(options: SocketOptions = {}) {
249
+ // The caller's options go FIRST, then the defaults fill the gaps.
250
+ //
251
+ // This used to be the other way round — defaults, then `...options` — but object spread
252
+ // copies own properties even when their value is `undefined`. So
253
+ // `new Socket({ url, pingInterval: cfg.pingInterval })` with an unset config wrote
254
+ // `undefined` straight back over the 30000 default, and `setInterval(fn, undefined)`
255
+ // coerces the delay to 0: measured at ~830 pings/second per client. The `pingInterval <= 0`
256
+ // guard did not catch it because `undefined <= 0` is false. The same spread silently
257
+ // disabled `reconnect`. Passing options first keeps every explicitly-supplied field
258
+ // (`url`, `host`, `port`, `scheme`, …) while letting `??` reject undefined.
259
+ this._opts = {
260
+ ...options,
261
+ path: options.path ?? "/app/ws",
262
+ autoConnect: options.autoConnect ?? true,
263
+ reconnect: options.reconnect ?? true,
264
+ reconnectDelay: options.reconnectDelay ?? 1000,
265
+ maxReconnectDelay: options.maxReconnectDelay ?? 30000,
266
+ pingInterval: options.pingInterval ?? 30000,
267
+ authEndpoint: options.authEndpoint ?? "/broadcasting/auth",
268
+ };
269
+ if (this._opts.autoConnect) this.connect();
270
+ }
271
+
272
+ // ── Connection ────────────────────────────────────────────────────────────
273
+
274
+ /** The socket id assigned by the server — pass it as the `X-Socket-ID` header so server
275
+ * `toOthers()` broadcasts skip this client. `null` until connected. */
276
+ socketId(): string | null {
277
+ return this._socketId;
278
+ }
279
+
280
+ /** Current connection state. */
281
+ get state(): SocketState {
282
+ return this._state;
283
+ }
284
+
285
+ /** Open the connection (no-op if already open/connecting). */
286
+ connect(): void {
287
+ if (this._ws && (this._ws.readyState === WS_OPEN || this._ws.readyState === 0)) return;
288
+ this._closedByUser = false;
289
+ this._setState(this._reconnectAttempts > 0 ? "reconnecting" : "connecting");
290
+
291
+ const Ctor = this._opts.WebSocket ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
292
+ if (!Ctor) throw new Error("[Socket] No WebSocket implementation available.");
293
+
294
+ const ws = new (Ctor as unknown as new (url: string) => SocketLike)(this._buildUrl());
295
+ this._ws = ws;
296
+ ws.onopen = () => this._onOpen();
297
+ ws.onmessage = (ev) => this._onMessage(ev.data);
298
+ ws.onclose = (ev) => this._onClose(ev);
299
+ ws.onerror = (ev) => this._setState("error", ev);
300
+ }
301
+
302
+ /** Close the connection and stop reconnecting. */
303
+ disconnect(): void {
304
+ this._closedByUser = true;
305
+ this._clearTimers();
306
+ this._ws?.close();
307
+ this._ws = null;
308
+ this._setState("disconnected");
309
+ }
310
+
311
+ /** Register a connection-state callback. Returns an unsubscribe function. */
312
+ on(state: SocketState, cb: (detail?: unknown) => void): () => void {
313
+ let set = this._stateCbs.get(state);
314
+ if (!set) this._stateCbs.set(state, (set = new Set()));
315
+ set.add(cb);
316
+ return () => set!.delete(cb);
317
+ }
318
+
319
+ // ── Channels ──────────────────────────────────────────────────────────────
320
+
321
+ /** Subscribe to a public channel. */
322
+ channel(name: string): Channel {
323
+ return this._subscribe(name, () => new Channel(name, this));
324
+ }
325
+
326
+ /** Subscribe to a private channel (the `private-` prefix is added for you). */
327
+ private(name: string): Channel {
328
+ const wire = `private-${name}`;
329
+ return this._subscribe(wire, () => new Channel(wire, this));
330
+ }
331
+
332
+ /** Subscribe to a presence channel (the `presence-` prefix is added for you). */
333
+ presence(name: string): PresenceChannel {
334
+ const wire = `presence-${name}`;
335
+ return this._subscribe(wire, () => new PresenceChannel(wire, this)) as PresenceChannel;
336
+ }
337
+
338
+ /** Echo alias for {@link presence} (`window.Echo.join(...)`). */
339
+ join(name: string): PresenceChannel {
340
+ return this.presence(name);
341
+ }
342
+
343
+ /**
344
+ * Leave a channel by its base name — unsubscribes the public, `private-`, and `presence-`
345
+ * variants (Echo's `leave()` semantics).
346
+ */
347
+ leave(name: string): void {
348
+ this.leaveChannel(name);
349
+ this.leaveChannel(`private-${name}`);
350
+ this.leaveChannel(`presence-${name}`);
351
+ }
352
+
353
+ /** Leave one exact (already-prefixed) channel. */
354
+ leaveChannel(wireName: string): void {
355
+ if (!this._channels.delete(wireName)) return;
356
+ this._sendRaw({ event: "unsubscribe", channel: wireName });
357
+ }
358
+
359
+ /** All currently-subscribed channels (by wire name). */
360
+ channels(): Channel[] {
361
+ return [...this._channels.values()];
362
+ }
363
+
364
+ // ── Internals ─────────────────────────────────────────────────────────────
365
+
366
+ private _subscribe(wireName: string, make: () => Channel): Channel {
367
+ const existing = this._channels.get(wireName);
368
+ if (existing) return existing;
369
+ const ch = make();
370
+ this._channels.set(wireName, ch);
371
+ if (this._state === "connected") this._subscribeChannel(wireName);
372
+ return ch;
373
+ }
374
+
375
+ /**
376
+ * Send a `subscribe` for one channel. Public channels subscribe directly; private/presence
377
+ * channels first fetch a Pusher-style signature from `authEndpoint` (covering the current
378
+ * socket id) and include it. Re-runs per (re)connection, since the signature is socket-bound.
379
+ */
380
+ private _subscribeChannel(wireName: string): void {
381
+ if (this._state !== "connected") return;
382
+ const needsAuth = wireName.startsWith("private-") || wireName.startsWith("presence-");
383
+
384
+ if (!needsAuth || this._opts.authEndpoint === false) {
385
+ this._sendRaw({ event: "subscribe", channel: wireName });
386
+ return;
387
+ }
388
+
389
+ void this._authorize(wireName).then((res) => {
390
+ // The channel may have been left, or the connection dropped, while auth was in flight.
391
+ if (!this._channels.has(wireName) || this._state !== "connected") return;
392
+ if (!res) {
393
+ this._channels.get(wireName)?._dispatch({
394
+ event: "subscription_error",
395
+ channel: wireName,
396
+ message: "Authorization failed",
397
+ });
398
+ return;
399
+ }
400
+ this._sendRaw({
401
+ event: "subscribe",
402
+ channel: wireName,
403
+ auth: res.auth,
404
+ ...(res.channel_data !== undefined ? { channelData: res.channel_data } : {}),
405
+ });
406
+ });
407
+ }
408
+
409
+ /** Fetch a per-subscription signature from the auth endpoint for `channel`. */
410
+ private async _authorize(
411
+ channel: string,
412
+ ): Promise<{ auth: string; channel_data?: string } | null> {
413
+ const endpoint = this._opts.authEndpoint;
414
+ if (endpoint === false || !this._socketId) return null;
415
+ const doFetch = this._opts.fetch ?? (globalThis as { fetch?: typeof fetch }).fetch;
416
+ if (!doFetch) return null;
417
+ try {
418
+ const res = await doFetch(endpoint, {
419
+ method: "POST",
420
+ credentials: "include",
421
+ headers: { "Content-Type": "application/json", ...(this._opts.auth?.headers ?? {}) },
422
+ body: JSON.stringify({ socket_id: this._socketId, channel_name: channel }),
423
+ });
424
+ if (!res.ok) return null;
425
+ return (await res.json()) as { auth: string; channel_data?: string };
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
431
+ private _onOpen(): void {
432
+ this._reconnectAttempts = 0;
433
+ // Don't flip to "connected" until the server's `connected` frame gives us a socketId.
434
+ this._startPing();
435
+ }
436
+
437
+ private _onMessage(raw: unknown): void {
438
+ let frame: ServerFrame;
439
+ try {
440
+ frame = JSON.parse(typeof raw === "string" ? raw : String(raw)) as ServerFrame;
441
+ } catch {
442
+ return;
443
+ }
444
+
445
+ switch (frame.event) {
446
+ case "connected": {
447
+ this._socketId = (frame.data as { socketId?: string } | undefined)?.socketId ?? null;
448
+ this._setState("connected");
449
+ // (Re)subscribe every channel now the connection is live (private/presence re-auth
450
+ // against the new socket id).
451
+ for (const name of this._channels.keys()) this._subscribeChannel(name);
452
+ return;
453
+ }
454
+ case "pong":
455
+ return;
456
+ case "error":
457
+ this._setState("error", frame.message);
458
+ return;
459
+ }
460
+
461
+ // Channel-scoped frame → dispatch to that channel.
462
+ if (frame.channel) {
463
+ this._channels.get(frame.channel)?._dispatch(frame);
464
+ }
465
+ }
466
+
467
+ private _onClose(ev: { code?: number; reason?: string }): void {
468
+ this._clearTimers();
469
+ this._ws = null;
470
+ this._socketId = null;
471
+ if (this._closedByUser) {
472
+ this._setState("disconnected");
473
+ return;
474
+ }
475
+ this._setState("disconnected", ev);
476
+ if (this._opts.reconnect) this._scheduleReconnect();
477
+ }
478
+
479
+ private _scheduleReconnect(): void {
480
+ const delay = Math.min(
481
+ this._opts.reconnectDelay * 2 ** this._reconnectAttempts,
482
+ this._opts.maxReconnectDelay,
483
+ );
484
+ this._reconnectAttempts++;
485
+ this._reconnectTimer = setTimeout(() => this.connect(), delay);
486
+ }
487
+
488
+ private _startPing(): void {
489
+ if (this._opts.pingInterval <= 0) return;
490
+ this._pingTimer = setInterval(() => this._sendRaw({ event: "ping" }), this._opts.pingInterval);
491
+ }
492
+
493
+ private _clearTimers(): void {
494
+ if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
495
+ if (this._pingTimer) clearInterval(this._pingTimer);
496
+ this._reconnectTimer = null;
497
+ this._pingTimer = null;
498
+ }
499
+
500
+ private _sendRaw(msg: Record<string, unknown>): void {
501
+ if (this._ws && this._ws.readyState === WS_OPEN) this._ws.send(JSON.stringify(msg));
502
+ }
503
+
504
+ private _setState(state: SocketState, detail?: unknown): void {
505
+ this._state = state;
506
+ const set = this._stateCbs.get(state);
507
+ if (set) for (const cb of set) cb(detail);
508
+ }
509
+
510
+ private _buildUrl(): string {
511
+ if (this._opts.url) return this._withAuth(this._opts.url);
512
+
513
+ const loc = (globalThis as { location?: { protocol: string; hostname: string; port: string } })
514
+ .location;
515
+ const scheme = this._opts.scheme ?? (loc?.protocol === "https:" ? "wss" : "ws");
516
+ const host = this._opts.host ?? loc?.hostname;
517
+ if (!host) {
518
+ throw new Error("[Socket] No URL and no `location` to derive one — pass `url` or `host`.");
519
+ }
520
+ const port = this._opts.port ?? loc?.port ?? "";
521
+ const authority = port ? `${host}:${port}` : host;
522
+ return this._withAuth(`${scheme}://${authority}${this._opts.path}`);
523
+ }
524
+
525
+ private _withAuth(url: string): string {
526
+ const params = this._opts.auth?.params;
527
+ if (!params || Object.keys(params).length === 0) return url;
528
+ const qs = new URLSearchParams(params).toString();
529
+ return url + (url.includes("?") ? "&" : "?") + qs;
530
+ }
531
+ }