redweb-client 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,6 +1,57 @@
1
1
  # redweb-client
2
2
 
3
- Browser-first WebSocket client for [Redweb](https://redweb.magnisolution.com/). It adds connection lifecycle, typed message subscriptions, request correlation, bounded queuing, opt-in reconnection, and exact Redweb 0.9 protocol envelopes without adding runtime dependencies.
3
+ Browser-first WebSocket client for [Redweb](https://redweb.magnisolution.com/). It adds connection lifecycle, typed message subscriptions, request correlation, bounded queuing, opt-in reconnection, and versioned Redweb protocol envelopes without adding runtime dependencies.
4
+
5
+ ## Unreleased lifecycle hardening
6
+
7
+ The development branch fixes connection attempts continuing after a synchronous state observer calls `close()` or `dispose()`. Connection attempts are identified across callback boundaries; a reentrant `connect()` shares an existing attempt, and an explicit replacement is not cleared by an older attempt finishing. Cancelled transport-factory results are closed after their lifecycle listeners are attached. These changes are not yet published in npm version 0.1.0.
8
+
9
+ Integration regressions use real WebSocket connections and timers for observer-driven cancellation, upgrade-refusal retries, queued delivery, cancellation without replay, and rejected sends after disposal. Unit tests use isolated transport fixtures and explicitly simulated stale scheduling; integration tests do not mock transports. The complete verification command and its coverage scope are described below.
10
+
11
+ ## Unreleased Live HTML integration
12
+
13
+ The optional `redweb-client/live-html` entry owns reactive DOM updates, keyed
14
+ reconciliation, delegated actions/forms, feedback and connection status for pages
15
+ rendered by the matching Redweb development branch. The root import stays
16
+ socket-only. Redweb serves the optional self-contained module and automatically
17
+ calls `mountLivePage()`; ordinary applications need no additional browser glue.
18
+
19
+ For explicit lifecycle ownership in a browser bundle:
20
+
21
+ ```ts
22
+ import { mountLivePage } from 'redweb-client/live-html'
23
+
24
+ const page = mountLivePage() // Same owner if already mounted on this document.
25
+ // Later, when leaving the page:
26
+ page.dispose() // Closes transport and removes owned listeners/feedback.
27
+ ```
28
+
29
+ Mount only after Redweb's `__redweb_page` configuration and page DOM exist.
30
+ Calling it again after disposal creates a fresh owner. Importing the module alone
31
+ does not access the DOM or open a socket. A connection listener that calls mount
32
+ again shares the same owner; a disposed page cannot later reset a submitted draft.
33
+
34
+ For local development with sibling repositories, run `npm run build` and
35
+ `npm link --ignore-scripts` here, then `npm link redweb-client --no-save --ignore-scripts`
36
+ in Redweb. Rebuild here after source changes; no repacking is needed. The link is
37
+ not saved in either lockfile. Published 0.1.0 does not include this entry; client
38
+ publication and Redweb dependency/lockfile updates remain release prerequisites.
39
+
40
+ Redweb's native-browser gate checks the server-side counter, multi-user chat,
41
+ dashboard and forms using real HTTP/WebSockets. Its rendering coverage verifier
42
+ measures all bundled Live HTML modules separately from transport and currently
43
+ covers 426 statements, 262 branches, 64 functions and 351 lines (100% each).
44
+ This is emitted-frontend coverage, not complete original-source/client-package or
45
+ cross-browser certification. Separate V8 measurements are not substituted for
46
+ the complete original-source gate below; the full release gate is not approved.
47
+
48
+ From the linked Redweb repository, `npm run verify:client:source-coverage` runs
49
+ all 77 client tests both plain and instrumented, then native browser tests with
50
+ the same original-source coverage maps. Plain browser bundles must match this
51
+ client's `dist` output. It retains separate Node/browser results and currently
52
+ covers all 791 statements, 521 branches, 125 functions and 659 lines (100% each).
53
+ The same gate runs through the default `npm test` command. This metric does not
54
+ replace V8 coverage or independently count every optional-chain short circuit.
4
55
 
5
56
  ## Install
6
57
 
@@ -142,7 +193,7 @@ TypeScript improves application ergonomics; it does not validate arbitrary appli
142
193
 
143
194
  - `connect()` is idempotent while connecting or open and resolves after the socket opens.
144
195
  - `close(code?, reason?)` suppresses reconnection, rejects pending requests, and discards unsent queued data.
145
- - `dispose()` closes once, clears subscriptions and queued messages, and permanently prevents reuse.
196
+ - `dispose()` closes once, clears subscriptions and queued messages, and prevents reconnection and new sends/requests. Sends throw and requests reject after disposal, even when outbound queue capacity is available.
146
197
  - `send(type, payload, metadata?)` builds a legacy or protocol message.
147
198
  - `sendRaw(data)` sends text or binary data without encoding it.
148
199
  - Sends while disconnected throw by default. Set a finite `maxQueueSize` to queue a bounded number of messages until the next open. A queued request is removed if it times out, is aborted, or is rejected by `close()`; a command cannot execute later after its caller has been told it was cancelled.
@@ -183,7 +234,35 @@ Browser WebSockets cannot set arbitrary request headers. Use secure, same-site c
183
234
  ## Development and verification
184
235
 
185
236
  ```bash
186
- npm run check
237
+ npm test
238
+ ```
239
+
240
+ `npm test` delegates to `npm run check`; there is one complete verification path.
241
+ It requires the matching sibling `../redweb` development checkout, with this
242
+ client linked into it using `npm link`. The link's canonical path is checked
243
+ **before** building, so a different installed client cannot silently be verified.
244
+ The command then builds, checks declarations through the package export map, and
245
+ delegates to Redweb's existing combined source/browser runner. It runs the complete
246
+ unit and mock-free HTTP/WebSocket test inventory plus native Chromium rendering
247
+ and transport tests. Chrome/Chromium must be installed.
248
+
249
+ After a build, `npm run test:source` runs just that full verification, with the
250
+ same checkout check. You can also run from the matching Redweb checkout:
251
+
252
+ ```bash
253
+ npm run verify:client:source-coverage
187
254
  ```
188
255
 
189
- The clean-worktree gate builds first, resolves the public declarations through the package export map, then runs unit tests plus mock-free integration tests against a real local Redweb HTTP/WebSocket server. Coverage is enforced at 100% for statements, branches, functions, and lines.
256
+ The combined gate shares the same original-source instrumentation across the complete
257
+ Node test inventory and native Chromium rendering/transport tests. Plain and
258
+ instrumented behavior must agree, and all tracked statements, branches, functions
259
+ and lines must reach 100%. This is not exhaustive accounting of compiler-generated
260
+ or optional-chain branches. Packed-pair verification and publication are separate
261
+ release steps; this checkout-aware command is development tooling, not a runtime
262
+ dependency on the Redweb repository.
263
+
264
+ The separate `npm run test:v8` command retains the original Node-only V8 coverage gate
265
+ and its unchanged thresholds. It currently fails because browser modules are not
266
+ executed in Node. Renaming that diagnostic does not fix or waive its failure:
267
+ the default command uses the explicitly documented combined original-source
268
+ denominator, including actual browser execution, rather than the Node-only report.
@@ -0,0 +1,132 @@
1
+ type ClientState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closing' | 'closed';
2
+ interface ProtocolMetadata {
3
+ requestId?: string;
4
+ sequence?: number;
5
+ }
6
+ interface ProtocolEnvelope<T = unknown> extends ProtocolMetadata {
7
+ v: string;
8
+ type: string;
9
+ payload: T;
10
+ }
11
+ interface ProtocolErrorEnvelope extends ProtocolMetadata {
12
+ v: string;
13
+ type: 'error';
14
+ error: {
15
+ code: string;
16
+ message: string;
17
+ };
18
+ }
19
+ interface LegacyErrorMessage extends ProtocolMetadata {
20
+ error: string;
21
+ type?: never;
22
+ }
23
+ type RedwebMessage<T = unknown> = ProtocolEnvelope<T> | ProtocolErrorEnvelope | LegacyErrorMessage | ({
24
+ type: string;
25
+ requestId?: string;
26
+ sequence?: number;
27
+ } & Record<string, unknown>);
28
+ interface SocketEventLike {
29
+ data?: unknown;
30
+ code?: number;
31
+ reason?: string;
32
+ wasClean?: boolean;
33
+ error?: unknown;
34
+ }
35
+ interface WebSocketLike {
36
+ readonly readyState: number;
37
+ binaryType?: string;
38
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
39
+ close(code?: number, reason?: string): void;
40
+ addEventListener(type: 'open' | 'message' | 'error' | 'close', listener: (event: SocketEventLike) => void): void;
41
+ removeEventListener(type: 'open' | 'message' | 'error' | 'close', listener: (event: SocketEventLike) => void): void;
42
+ }
43
+ interface ReconnectOptions {
44
+ enabled?: boolean;
45
+ maxAttempts?: number;
46
+ initialDelayMs?: number;
47
+ maxDelayMs?: number;
48
+ factor?: number;
49
+ jitter?: number;
50
+ shouldReconnect?: (event: SocketEventLike) => boolean;
51
+ }
52
+ interface RedwebClientOptions {
53
+ version?: string;
54
+ reconnect?: ReconnectOptions;
55
+ maxQueueSize?: number;
56
+ requestTimeoutMs?: number;
57
+ webSocketFactory?: (url: string) => WebSocketLike;
58
+ createRequestId?: () => string;
59
+ baseUrl?: string | URL;
60
+ random?: () => number;
61
+ }
62
+ interface RequestOptions extends ProtocolMetadata {
63
+ timeoutMs?: number;
64
+ signal?: AbortSignal;
65
+ }
66
+ interface WaitOptions {
67
+ timeoutMs?: number;
68
+ signal?: AbortSignal;
69
+ }
70
+ type MessageListener<T = unknown> = (message: RedwebMessage<T>) => void;
71
+ type BinaryListener = (data: ArrayBuffer | ArrayBufferView | Blob) => void;
72
+ type ErrorListener = (error: Error) => void;
73
+ type StateListener = (state: ClientState) => void;
74
+ type CloseListener = (event: SocketEventLike) => void;
75
+
76
+ declare class RedwebClient<IncomingEvents extends object = Record<string, unknown>, OutgoingEvents extends object = Record<string, unknown>> {
77
+ readonly url: string;
78
+ readonly version: string | undefined;
79
+ state: ClientState;
80
+ socket: WebSocketLike | undefined;
81
+ private readonly factory;
82
+ private readonly reconnect;
83
+ private readonly maxQueueSize;
84
+ private readonly requestTimeoutMs;
85
+ private readonly createRequestId;
86
+ private readonly random;
87
+ private readonly listeners;
88
+ private readonly anyListeners;
89
+ private readonly binaryListeners;
90
+ private readonly errorListeners;
91
+ private readonly stateListeners;
92
+ private readonly closeListeners;
93
+ private readonly queue;
94
+ private readonly pending;
95
+ private connectPromise;
96
+ private reconnectTimer;
97
+ private reconnectAttempts;
98
+ private manualClose;
99
+ private disposed;
100
+ private requestSequence;
101
+ private generation;
102
+ constructor(url: string, options?: RedwebClientOptions);
103
+ connect(): Promise<void>;
104
+ private current;
105
+ private retire;
106
+ private openSocket;
107
+ close(code?: number, reason?: string): void;
108
+ dispose(): void;
109
+ send<K extends keyof OutgoingEvents & string>(type: K, payload: OutgoingEvents[K], metadata?: ProtocolMetadata): void;
110
+ sendRaw(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
111
+ private transmit;
112
+ request<TResponse = unknown, TPayload = unknown>(type: string, payload: TPayload, options?: RequestOptions): Promise<RedwebMessage<TResponse>>;
113
+ waitFor<K extends keyof IncomingEvents & string>(type: K, options?: WaitOptions): Promise<RedwebMessage<IncomingEvents[K]>>;
114
+ on<K extends keyof IncomingEvents & string>(type: K, listener: MessageListener<IncomingEvents[K]>): () => void;
115
+ onAny(listener: MessageListener): () => void;
116
+ onBinary(listener: BinaryListener): () => void;
117
+ onError(listener: ErrorListener): () => void;
118
+ onStateChange(listener: StateListener): () => void;
119
+ onClose(listener: CloseListener): () => void;
120
+ private subscribe;
121
+ private connectionUrl;
122
+ private transition;
123
+ private handleIncoming;
124
+ private finishPending;
125
+ private rejectPending;
126
+ private emitError;
127
+ private notify;
128
+ private flushQueue;
129
+ private scheduleReconnect;
130
+ }
131
+
132
+ export { type BinaryListener as B, type ClientState as C, type ErrorListener as E, type LegacyErrorMessage as L, type MessageListener as M, type ProtocolErrorEnvelope as P, type RedwebMessage as R, type SocketEventLike as S, type WaitOptions as W, type ProtocolMetadata as a, type ProtocolEnvelope as b, type CloseListener as c, type ReconnectOptions as d, RedwebClient as e, type RedwebClientOptions as f, type RequestOptions as g, type StateListener as h, type WebSocketLike as i };
@@ -0,0 +1,132 @@
1
+ type ClientState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closing' | 'closed';
2
+ interface ProtocolMetadata {
3
+ requestId?: string;
4
+ sequence?: number;
5
+ }
6
+ interface ProtocolEnvelope<T = unknown> extends ProtocolMetadata {
7
+ v: string;
8
+ type: string;
9
+ payload: T;
10
+ }
11
+ interface ProtocolErrorEnvelope extends ProtocolMetadata {
12
+ v: string;
13
+ type: 'error';
14
+ error: {
15
+ code: string;
16
+ message: string;
17
+ };
18
+ }
19
+ interface LegacyErrorMessage extends ProtocolMetadata {
20
+ error: string;
21
+ type?: never;
22
+ }
23
+ type RedwebMessage<T = unknown> = ProtocolEnvelope<T> | ProtocolErrorEnvelope | LegacyErrorMessage | ({
24
+ type: string;
25
+ requestId?: string;
26
+ sequence?: number;
27
+ } & Record<string, unknown>);
28
+ interface SocketEventLike {
29
+ data?: unknown;
30
+ code?: number;
31
+ reason?: string;
32
+ wasClean?: boolean;
33
+ error?: unknown;
34
+ }
35
+ interface WebSocketLike {
36
+ readonly readyState: number;
37
+ binaryType?: string;
38
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
39
+ close(code?: number, reason?: string): void;
40
+ addEventListener(type: 'open' | 'message' | 'error' | 'close', listener: (event: SocketEventLike) => void): void;
41
+ removeEventListener(type: 'open' | 'message' | 'error' | 'close', listener: (event: SocketEventLike) => void): void;
42
+ }
43
+ interface ReconnectOptions {
44
+ enabled?: boolean;
45
+ maxAttempts?: number;
46
+ initialDelayMs?: number;
47
+ maxDelayMs?: number;
48
+ factor?: number;
49
+ jitter?: number;
50
+ shouldReconnect?: (event: SocketEventLike) => boolean;
51
+ }
52
+ interface RedwebClientOptions {
53
+ version?: string;
54
+ reconnect?: ReconnectOptions;
55
+ maxQueueSize?: number;
56
+ requestTimeoutMs?: number;
57
+ webSocketFactory?: (url: string) => WebSocketLike;
58
+ createRequestId?: () => string;
59
+ baseUrl?: string | URL;
60
+ random?: () => number;
61
+ }
62
+ interface RequestOptions extends ProtocolMetadata {
63
+ timeoutMs?: number;
64
+ signal?: AbortSignal;
65
+ }
66
+ interface WaitOptions {
67
+ timeoutMs?: number;
68
+ signal?: AbortSignal;
69
+ }
70
+ type MessageListener<T = unknown> = (message: RedwebMessage<T>) => void;
71
+ type BinaryListener = (data: ArrayBuffer | ArrayBufferView | Blob) => void;
72
+ type ErrorListener = (error: Error) => void;
73
+ type StateListener = (state: ClientState) => void;
74
+ type CloseListener = (event: SocketEventLike) => void;
75
+
76
+ declare class RedwebClient<IncomingEvents extends object = Record<string, unknown>, OutgoingEvents extends object = Record<string, unknown>> {
77
+ readonly url: string;
78
+ readonly version: string | undefined;
79
+ state: ClientState;
80
+ socket: WebSocketLike | undefined;
81
+ private readonly factory;
82
+ private readonly reconnect;
83
+ private readonly maxQueueSize;
84
+ private readonly requestTimeoutMs;
85
+ private readonly createRequestId;
86
+ private readonly random;
87
+ private readonly listeners;
88
+ private readonly anyListeners;
89
+ private readonly binaryListeners;
90
+ private readonly errorListeners;
91
+ private readonly stateListeners;
92
+ private readonly closeListeners;
93
+ private readonly queue;
94
+ private readonly pending;
95
+ private connectPromise;
96
+ private reconnectTimer;
97
+ private reconnectAttempts;
98
+ private manualClose;
99
+ private disposed;
100
+ private requestSequence;
101
+ private generation;
102
+ constructor(url: string, options?: RedwebClientOptions);
103
+ connect(): Promise<void>;
104
+ private current;
105
+ private retire;
106
+ private openSocket;
107
+ close(code?: number, reason?: string): void;
108
+ dispose(): void;
109
+ send<K extends keyof OutgoingEvents & string>(type: K, payload: OutgoingEvents[K], metadata?: ProtocolMetadata): void;
110
+ sendRaw(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
111
+ private transmit;
112
+ request<TResponse = unknown, TPayload = unknown>(type: string, payload: TPayload, options?: RequestOptions): Promise<RedwebMessage<TResponse>>;
113
+ waitFor<K extends keyof IncomingEvents & string>(type: K, options?: WaitOptions): Promise<RedwebMessage<IncomingEvents[K]>>;
114
+ on<K extends keyof IncomingEvents & string>(type: K, listener: MessageListener<IncomingEvents[K]>): () => void;
115
+ onAny(listener: MessageListener): () => void;
116
+ onBinary(listener: BinaryListener): () => void;
117
+ onError(listener: ErrorListener): () => void;
118
+ onStateChange(listener: StateListener): () => void;
119
+ onClose(listener: CloseListener): () => void;
120
+ private subscribe;
121
+ private connectionUrl;
122
+ private transition;
123
+ private handleIncoming;
124
+ private finishPending;
125
+ private rejectPending;
126
+ private emitError;
127
+ private notify;
128
+ private flushQueue;
129
+ private scheduleReconnect;
130
+ }
131
+
132
+ export { type BinaryListener as B, type ClientState as C, type ErrorListener as E, type LegacyErrorMessage as L, type MessageListener as M, type ProtocolErrorEnvelope as P, type RedwebMessage as R, type SocketEventLike as S, type WaitOptions as W, type ProtocolMetadata as a, type ProtocolEnvelope as b, type CloseListener as c, type ReconnectOptions as d, RedwebClient as e, type RedwebClientOptions as f, type RequestOptions as g, type StateListener as h, type WebSocketLike as i };
package/dist/index.cjs CHANGED
@@ -178,6 +178,7 @@ var RedwebClient = class {
178
178
  manualClose = false;
179
179
  disposed = false;
180
180
  requestSequence = 0;
181
+ generation = 0;
181
182
  constructor(url, options = {}) {
182
183
  this.url = resolveWebSocketUrl(url, options.baseUrl);
183
184
  const queryVersion = new URL(this.url).searchParams.get("redwebVersion") ?? void 0;
@@ -216,27 +217,51 @@ var RedwebClient = class {
216
217
  clearTimeout(this.reconnectTimer);
217
218
  this.reconnectTimer = void 0;
218
219
  }
219
- this.connectPromise = this.openSocket(false).finally(() => {
220
- this.connectPromise = void 0;
220
+ let resolve;
221
+ let reject;
222
+ const promise = new Promise((yes, no) => {
223
+ resolve = yes;
224
+ reject = no;
221
225
  });
222
- return this.connectPromise;
226
+ this.connectPromise = promise;
227
+ const finish = () => this.retire(promise);
228
+ void this.openSocket(false).then(() => {
229
+ finish();
230
+ resolve();
231
+ }, (error) => {
232
+ finish();
233
+ reject(error);
234
+ });
235
+ return promise;
236
+ }
237
+ current(generation) {
238
+ return this.generation === generation && !this.manualClose && !this.disposed;
239
+ }
240
+ retire(promise) {
241
+ if (this.connectPromise === promise) this.connectPromise = void 0;
223
242
  }
224
243
  openSocket(reconnecting) {
244
+ const opening = this.connectPromise;
245
+ const generation = ++this.generation;
225
246
  this.transition(reconnecting ? "reconnecting" : "connecting");
247
+ if (!this.current(generation)) return Promise.reject(new Error("WebSocket connection attempt was cancelled."));
226
248
  let socket;
227
249
  try {
228
250
  socket = this.factory(this.connectionUrl());
229
251
  } catch (error) {
230
- this.transition("closed");
231
- if (reconnecting && !this.manualClose) this.scheduleReconnect();
252
+ if (this.current(generation)) {
253
+ this.retire(opening);
254
+ this.transition("closed");
255
+ if (reconnecting && this.current(generation)) this.scheduleReconnect();
256
+ }
232
257
  return Promise.reject(toError(error, "Unable to create WebSocket."));
233
258
  }
234
- this.socket = socket;
259
+ if (this.current(generation)) this.socket = socket;
235
260
  if ("binaryType" in socket) socket.binaryType = "arraybuffer";
236
261
  return new Promise((resolve, reject) => {
237
262
  let settled = false;
238
263
  const onOpen = () => {
239
- if (this.socket !== socket) {
264
+ if (this.socket !== socket || !this.current(generation)) {
240
265
  if (!settled) reject(new Error("WebSocket connection attempt was superseded."));
241
266
  socket.close(1e3, "Superseded");
242
267
  return;
@@ -244,11 +269,15 @@ var RedwebClient = class {
244
269
  settled = true;
245
270
  this.reconnectAttempts = 0;
246
271
  this.transition("open");
272
+ if (!this.current(generation)) {
273
+ reject(new Error("WebSocket connection attempt was cancelled."));
274
+ return;
275
+ }
247
276
  this.flushQueue();
248
277
  resolve();
249
278
  };
250
279
  const onMessage = (event) => {
251
- if (this.socket === socket) void this.handleIncoming(event.data);
280
+ if (this.socket === socket && this.current(generation)) void this.handleIncoming(event.data);
252
281
  };
253
282
  const onError = (event) => {
254
283
  if (this.socket !== socket) return;
@@ -270,18 +299,27 @@ var RedwebClient = class {
270
299
  reject(new Error("WebSocket closed before opening."));
271
300
  }
272
301
  this.rejectPending(new Error("WebSocket connection closed."));
302
+ const closedGeneration = this.generation;
303
+ this.retire(opening);
273
304
  this.transition("closed");
274
305
  this.notify(this.closeListeners, event);
275
- if (!this.manualClose && this.reconnect.shouldReconnect(event)) this.scheduleReconnect();
306
+ if (!this.manualClose && this.reconnect.shouldReconnect(event) && this.generation === closedGeneration) this.scheduleReconnect();
276
307
  };
277
308
  socket.addEventListener("open", onOpen);
278
309
  socket.addEventListener("message", onMessage);
279
310
  socket.addEventListener("error", onError);
280
311
  socket.addEventListener("close", onClose);
312
+ if (!this.current(generation)) {
313
+ settled = true;
314
+ reject(new Error("WebSocket connection attempt was cancelled."));
315
+ socket.close(1e3, "Cancelled");
316
+ }
281
317
  });
282
318
  }
283
319
  close(code = 1e3, reason = "Client closed") {
284
320
  this.manualClose = true;
321
+ this.generation++;
322
+ this.connectPromise = void 0;
285
323
  if (this.reconnectTimer) {
286
324
  clearTimeout(this.reconnectTimer);
287
325
  this.reconnectTimer = void 0;
@@ -316,6 +354,7 @@ var RedwebClient = class {
316
354
  this.transmit(data);
317
355
  }
318
356
  transmit(data, requestId) {
357
+ if (this.disposed) throw new Error("RedwebClient has been disposed.");
319
358
  if (this.socket?.readyState === OPEN) {
320
359
  this.socket.send(data);
321
360
  return;
@@ -490,21 +529,25 @@ var RedwebClient = class {
490
529
  flushQueue() {
491
530
  while (this.socket?.readyState === OPEN && this.queue.length > 0) {
492
531
  const entry = this.queue.shift();
493
- if (entry !== void 0) this.socket.send(entry.data);
532
+ this.socket.send(entry.data);
494
533
  }
495
534
  }
496
535
  scheduleReconnect() {
497
- if (this.disposed || !this.reconnect.enabled || this.reconnectAttempts >= this.reconnect.maxAttempts) return;
536
+ if (this.disposed || this.manualClose || !this.reconnect.enabled || this.reconnectAttempts >= this.reconnect.maxAttempts) return;
537
+ const generation = this.generation;
498
538
  const baseDelay = Math.min(
499
539
  this.reconnect.maxDelayMs,
500
540
  this.reconnect.initialDelayMs * this.reconnect.factor ** this.reconnectAttempts
501
541
  );
502
542
  const spread = baseDelay * this.reconnect.jitter;
503
543
  const delay = Math.max(0, Math.round(baseDelay - spread + this.random() * spread * 2));
544
+ if (!this.current(generation)) return;
504
545
  this.reconnectAttempts += 1;
505
546
  this.transition("reconnecting");
547
+ if (!this.current(generation)) return;
506
548
  this.reconnectTimer = setTimeout(() => {
507
549
  this.reconnectTimer = void 0;
550
+ if (!this.current(generation)) return;
508
551
  void this.openSocket(true).catch(() => {
509
552
  });
510
553
  }, delay);
package/dist/index.d.cts CHANGED
@@ -1,130 +1,5 @@
1
- type ClientState = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closing' | 'closed';
2
- interface ProtocolMetadata {
3
- requestId?: string;
4
- sequence?: number;
5
- }
6
- interface ProtocolEnvelope<T = unknown> extends ProtocolMetadata {
7
- v: string;
8
- type: string;
9
- payload: T;
10
- }
11
- interface ProtocolErrorEnvelope extends ProtocolMetadata {
12
- v: string;
13
- type: 'error';
14
- error: {
15
- code: string;
16
- message: string;
17
- };
18
- }
19
- interface LegacyErrorMessage extends ProtocolMetadata {
20
- error: string;
21
- type?: never;
22
- }
23
- type RedwebMessage<T = unknown> = ProtocolEnvelope<T> | ProtocolErrorEnvelope | LegacyErrorMessage | ({
24
- type: string;
25
- requestId?: string;
26
- sequence?: number;
27
- } & Record<string, unknown>);
28
- interface SocketEventLike {
29
- data?: unknown;
30
- code?: number;
31
- reason?: string;
32
- wasClean?: boolean;
33
- error?: unknown;
34
- }
35
- interface WebSocketLike {
36
- readonly readyState: number;
37
- binaryType?: string;
38
- send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
39
- close(code?: number, reason?: string): void;
40
- addEventListener(type: 'open' | 'message' | 'error' | 'close', listener: (event: SocketEventLike) => void): void;
41
- removeEventListener(type: 'open' | 'message' | 'error' | 'close', listener: (event: SocketEventLike) => void): void;
42
- }
43
- interface ReconnectOptions {
44
- enabled?: boolean;
45
- maxAttempts?: number;
46
- initialDelayMs?: number;
47
- maxDelayMs?: number;
48
- factor?: number;
49
- jitter?: number;
50
- shouldReconnect?: (event: SocketEventLike) => boolean;
51
- }
52
- interface RedwebClientOptions {
53
- version?: string;
54
- reconnect?: ReconnectOptions;
55
- maxQueueSize?: number;
56
- requestTimeoutMs?: number;
57
- webSocketFactory?: (url: string) => WebSocketLike;
58
- createRequestId?: () => string;
59
- baseUrl?: string | URL;
60
- random?: () => number;
61
- }
62
- interface RequestOptions extends ProtocolMetadata {
63
- timeoutMs?: number;
64
- signal?: AbortSignal;
65
- }
66
- interface WaitOptions {
67
- timeoutMs?: number;
68
- signal?: AbortSignal;
69
- }
70
- type MessageListener<T = unknown> = (message: RedwebMessage<T>) => void;
71
- type BinaryListener = (data: ArrayBuffer | ArrayBufferView | Blob) => void;
72
- type ErrorListener = (error: Error) => void;
73
- type StateListener = (state: ClientState) => void;
74
- type CloseListener = (event: SocketEventLike) => void;
75
-
76
- declare class RedwebClient<IncomingEvents extends object = Record<string, unknown>, OutgoingEvents extends object = Record<string, unknown>> {
77
- readonly url: string;
78
- readonly version: string | undefined;
79
- state: ClientState;
80
- socket: WebSocketLike | undefined;
81
- private readonly factory;
82
- private readonly reconnect;
83
- private readonly maxQueueSize;
84
- private readonly requestTimeoutMs;
85
- private readonly createRequestId;
86
- private readonly random;
87
- private readonly listeners;
88
- private readonly anyListeners;
89
- private readonly binaryListeners;
90
- private readonly errorListeners;
91
- private readonly stateListeners;
92
- private readonly closeListeners;
93
- private readonly queue;
94
- private readonly pending;
95
- private connectPromise;
96
- private reconnectTimer;
97
- private reconnectAttempts;
98
- private manualClose;
99
- private disposed;
100
- private requestSequence;
101
- constructor(url: string, options?: RedwebClientOptions);
102
- connect(): Promise<void>;
103
- private openSocket;
104
- close(code?: number, reason?: string): void;
105
- dispose(): void;
106
- send<K extends keyof OutgoingEvents & string>(type: K, payload: OutgoingEvents[K], metadata?: ProtocolMetadata): void;
107
- sendRaw(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
108
- private transmit;
109
- request<TResponse = unknown, TPayload = unknown>(type: string, payload: TPayload, options?: RequestOptions): Promise<RedwebMessage<TResponse>>;
110
- waitFor<K extends keyof IncomingEvents & string>(type: K, options?: WaitOptions): Promise<RedwebMessage<IncomingEvents[K]>>;
111
- on<K extends keyof IncomingEvents & string>(type: K, listener: MessageListener<IncomingEvents[K]>): () => void;
112
- onAny(listener: MessageListener): () => void;
113
- onBinary(listener: BinaryListener): () => void;
114
- onError(listener: ErrorListener): () => void;
115
- onStateChange(listener: StateListener): () => void;
116
- onClose(listener: CloseListener): () => void;
117
- private subscribe;
118
- private connectionUrl;
119
- private transition;
120
- private handleIncoming;
121
- private finishPending;
122
- private rejectPending;
123
- private emitError;
124
- private notify;
125
- private flushQueue;
126
- private scheduleReconnect;
127
- }
1
+ import { P as ProtocolErrorEnvelope, a as ProtocolMetadata, R as RedwebMessage, b as ProtocolEnvelope } from './client-Dq8oKJwh.cjs';
2
+ export { B as BinaryListener, C as ClientState, c as CloseListener, E as ErrorListener, L as LegacyErrorMessage, M as MessageListener, d as ReconnectOptions, e as RedwebClient, f as RedwebClientOptions, g as RequestOptions, S as SocketEventLike, h as StateListener, W as WaitOptions, i as WebSocketLike } from './client-Dq8oKJwh.cjs';
128
3
 
129
4
  declare const ERROR_CODES: Readonly<{
130
5
  readonly INVALID_MESSAGE: "INVALID_MESSAGE";
@@ -146,4 +21,4 @@ declare class RedwebProtocolError extends Error {
146
21
  constructor(envelope: ProtocolErrorEnvelope);
147
22
  }
148
23
 
149
- export { type BinaryListener, type ClientState, type CloseListener, ERROR_CODES, type ErrorListener, type LegacyErrorMessage, type MessageListener, type ProtocolEnvelope, type ProtocolErrorEnvelope, type ProtocolMetadata, type ReconnectOptions, RedwebClient, type RedwebClientOptions, type RedwebMessage, RedwebProtocolError, type RequestOptions, type SocketEventLike, type StateListener, type WaitOptions, type WebSocketLike, createLegacyEnvelope, createProtocolEnvelope, parseMessage, resolveWebSocketUrl };
24
+ export { ERROR_CODES, ProtocolEnvelope, ProtocolErrorEnvelope, ProtocolMetadata, RedwebMessage, RedwebProtocolError, createLegacyEnvelope, createProtocolEnvelope, parseMessage, resolveWebSocketUrl };