redweb-client 0.1.0 → 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - Accept typed socket commands emitted by Redweb's server TSX controls on the page's custom route, reusing existing form feedback and DOM reconciliation. Requires Redweb 0.15.0 for socket-bound pages; ordinary Live HTML remains compatible with Redweb 0.14.0.
6
+ - Add optional `request(..., { responseType })` filtering so intermediate correlated events do not complete a request before its terminal reply. Correlated protocol errors still reject immediately; omitting the option retains existing behavior.
7
+ - Preserve explicitly bound JSON payloads, including `null`, and merge form fields over bound object payloads. Commands retain cancellation, deadlines and no-replay behavior.
8
+ - Rebuild distribution files automatically before packing/publishing, and include these release notes in the package.
9
+
10
+ Prepared for manual publication. No new runtime dependencies.
package/README.md CHANGED
@@ -1,6 +1,67 @@
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
+ ## Connection lifecycle
6
+
7
+ Connection attempts stop when a synchronous state observer calls `close()` or `dispose()`. 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.
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
+ ## Live HTML integration
12
+
13
+ ### Typed socket-page commands (0.3.0)
14
+
15
+ Version 0.3.0 accepts server-emitted command bindings on
16
+ existing Live HTML controls. It sends the actual `join`/`move` message type on
17
+ the page's custom route and reuses pending/error feedback and DOM reconciliation.
18
+ No application browser module is necessary. Socket-bound pages require Redweb
19
+ 0.15.0; the earlier client 0.2.0 does not support these command bindings.
20
+
21
+ Direct request users may pass `{ responseType: 'redweb:result' }` to wait for a
22
+ specific terminal reply. Intermediate correlated events still reach subscribers
23
+ but do not settle that request. Correlated protocol errors reject immediately.
24
+ Omitting `responseType` preserves existing request behavior. Requests retain their
25
+ deadline/cancellation and no-replay guarantees.
26
+
27
+ The optional `redweb-client/live-html` entry owns reactive DOM updates, keyed
28
+ reconciliation, delegated actions/forms, feedback and connection status for pages
29
+ rendered by Redweb. The root import stays
30
+ socket-only. Redweb serves the optional self-contained module and automatically
31
+ calls `mountLivePage()`; ordinary applications need no additional browser glue.
32
+
33
+ For explicit lifecycle ownership in a browser bundle:
34
+
35
+ ```ts
36
+ import { mountLivePage } from 'redweb-client/live-html'
37
+
38
+ const page = mountLivePage() // Same owner if already mounted on this document.
39
+ // Later, when leaving the page:
40
+ page.dispose() // Closes transport and removes owned listeners/feedback.
41
+ ```
42
+
43
+ Mount only after Redweb's `__redweb_page` configuration and page DOM exist.
44
+ Calling it again after disposal creates a fresh owner. Importing the module alone
45
+ does not access the DOM or open a socket. A connection listener that calls mount
46
+ again shares the same owner; a disposed page cannot later reset a submitted draft.
47
+
48
+ For local development with sibling repositories, run `npm run build` and
49
+ `npm link --ignore-scripts` here, then `npm link redweb-client --no-save --ignore-scripts`
50
+ in Redweb. Rebuild here after source changes; no repacking is needed. The link is
51
+ not saved in either lockfile. The optional entry was introduced in 0.2.0;
52
+ socket-bound page commands and terminal-response filtering are added in 0.3.0.
53
+
54
+ Redweb's headed-browser gates check the server-side counter, multi-user chat,
55
+ dashboard, forms and socket-bound multiplayer tutorial using real HTTP/WebSockets.
56
+ These are not cross-browser certification or a guarantee about application security.
57
+
58
+ From the linked Redweb repository, `npm run verify:client:source-coverage` runs
59
+ the complete client test inventory both plain and instrumented, then headed browser tests with
60
+ the same original-source coverage maps. Plain browser bundles must match this
61
+ client's `dist` output. It retains separate Node/browser results and currently
62
+ covers all 800 statements, 543 branches, 125 functions and 667 lines (100% each).
63
+ The same gate runs through the default `npm test` command. This metric does not
64
+ replace V8 coverage or independently count every optional-chain short circuit.
4
65
 
5
66
  ## Install
6
67
 
@@ -142,7 +203,7 @@ TypeScript improves application ergonomics; it does not validate arbitrary appli
142
203
 
143
204
  - `connect()` is idempotent while connecting or open and resolves after the socket opens.
144
205
  - `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.
206
+ - `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
207
  - `send(type, payload, metadata?)` builds a legacy or protocol message.
147
208
  - `sendRaw(data)` sends text or binary data without encoding it.
148
209
  - 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 +244,35 @@ Browser WebSockets cannot set arbitrary request headers. Use secure, same-site c
183
244
  ## Development and verification
184
245
 
185
246
  ```bash
186
- npm run check
247
+ npm test
248
+ ```
249
+
250
+ `npm test` delegates to `npm run check`; there is one complete verification path.
251
+ It requires the matching sibling `../redweb` development checkout, with this
252
+ client linked into it using `npm link`. The link's canonical path is checked
253
+ **before** building, so a different installed client cannot silently be verified.
254
+ The command then builds, checks declarations through the package export map, and
255
+ delegates to Redweb's existing combined source/browser runner. It runs the complete
256
+ unit and mock-free HTTP/WebSocket test inventory plus native Chromium rendering
257
+ and transport tests. Chrome/Chromium must be installed.
258
+
259
+ After a build, `npm run test:source` runs just that full verification, with the
260
+ same checkout check. You can also run from the matching Redweb checkout:
261
+
262
+ ```bash
263
+ npm run verify:client:source-coverage
187
264
  ```
188
265
 
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.
266
+ The combined gate shares the same original-source instrumentation across the complete
267
+ Node test inventory and native Chromium rendering/transport tests. Plain and
268
+ instrumented behavior must agree, and all tracked statements, branches, functions
269
+ and lines must reach 100%. This is not exhaustive accounting of compiler-generated
270
+ or optional-chain branches. Packed-pair verification and publication are separate
271
+ release steps; this checkout-aware command is development tooling, not a runtime
272
+ dependency on the Redweb repository.
273
+
274
+ The separate `npm run test:v8` command retains the original Node-only V8 coverage gate
275
+ and its unchanged thresholds. It currently fails because browser modules are not
276
+ executed in Node. Renaming that diagnostic does not fix or waive its failure:
277
+ the default command uses the explicitly documented combined original-source
278
+ denominator, including actual browser execution, rather than the Node-only report.
@@ -0,0 +1,134 @@
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
+ /** Ignore intermediate correlated events; protocol errors still reject immediately. */
64
+ responseType?: string;
65
+ timeoutMs?: number;
66
+ signal?: AbortSignal;
67
+ }
68
+ interface WaitOptions {
69
+ timeoutMs?: number;
70
+ signal?: AbortSignal;
71
+ }
72
+ type MessageListener<T = unknown> = (message: RedwebMessage<T>) => void;
73
+ type BinaryListener = (data: ArrayBuffer | ArrayBufferView | Blob) => void;
74
+ type ErrorListener = (error: Error) => void;
75
+ type StateListener = (state: ClientState) => void;
76
+ type CloseListener = (event: SocketEventLike) => void;
77
+
78
+ declare class RedwebClient<IncomingEvents extends object = Record<string, unknown>, OutgoingEvents extends object = Record<string, unknown>> {
79
+ readonly url: string;
80
+ readonly version: string | undefined;
81
+ state: ClientState;
82
+ socket: WebSocketLike | undefined;
83
+ private readonly factory;
84
+ private readonly reconnect;
85
+ private readonly maxQueueSize;
86
+ private readonly requestTimeoutMs;
87
+ private readonly createRequestId;
88
+ private readonly random;
89
+ private readonly listeners;
90
+ private readonly anyListeners;
91
+ private readonly binaryListeners;
92
+ private readonly errorListeners;
93
+ private readonly stateListeners;
94
+ private readonly closeListeners;
95
+ private readonly queue;
96
+ private readonly pending;
97
+ private connectPromise;
98
+ private reconnectTimer;
99
+ private reconnectAttempts;
100
+ private manualClose;
101
+ private disposed;
102
+ private requestSequence;
103
+ private generation;
104
+ constructor(url: string, options?: RedwebClientOptions);
105
+ connect(): Promise<void>;
106
+ private current;
107
+ private retire;
108
+ private openSocket;
109
+ close(code?: number, reason?: string): void;
110
+ dispose(): void;
111
+ send<K extends keyof OutgoingEvents & string>(type: K, payload: OutgoingEvents[K], metadata?: ProtocolMetadata): void;
112
+ sendRaw(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
113
+ private transmit;
114
+ request<TResponse = unknown, TPayload = unknown>(type: string, payload: TPayload, options?: RequestOptions): Promise<RedwebMessage<TResponse>>;
115
+ waitFor<K extends keyof IncomingEvents & string>(type: K, options?: WaitOptions): Promise<RedwebMessage<IncomingEvents[K]>>;
116
+ on<K extends keyof IncomingEvents & string>(type: K, listener: MessageListener<IncomingEvents[K]>): () => void;
117
+ onAny(listener: MessageListener): () => void;
118
+ onBinary(listener: BinaryListener): () => void;
119
+ onError(listener: ErrorListener): () => void;
120
+ onStateChange(listener: StateListener): () => void;
121
+ onClose(listener: CloseListener): () => void;
122
+ private subscribe;
123
+ private connectionUrl;
124
+ private transition;
125
+ private handleIncoming;
126
+ private finishPending;
127
+ private rejectPending;
128
+ private emitError;
129
+ private notify;
130
+ private flushQueue;
131
+ private scheduleReconnect;
132
+ }
133
+
134
+ 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,134 @@
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
+ /** Ignore intermediate correlated events; protocol errors still reject immediately. */
64
+ responseType?: string;
65
+ timeoutMs?: number;
66
+ signal?: AbortSignal;
67
+ }
68
+ interface WaitOptions {
69
+ timeoutMs?: number;
70
+ signal?: AbortSignal;
71
+ }
72
+ type MessageListener<T = unknown> = (message: RedwebMessage<T>) => void;
73
+ type BinaryListener = (data: ArrayBuffer | ArrayBufferView | Blob) => void;
74
+ type ErrorListener = (error: Error) => void;
75
+ type StateListener = (state: ClientState) => void;
76
+ type CloseListener = (event: SocketEventLike) => void;
77
+
78
+ declare class RedwebClient<IncomingEvents extends object = Record<string, unknown>, OutgoingEvents extends object = Record<string, unknown>> {
79
+ readonly url: string;
80
+ readonly version: string | undefined;
81
+ state: ClientState;
82
+ socket: WebSocketLike | undefined;
83
+ private readonly factory;
84
+ private readonly reconnect;
85
+ private readonly maxQueueSize;
86
+ private readonly requestTimeoutMs;
87
+ private readonly createRequestId;
88
+ private readonly random;
89
+ private readonly listeners;
90
+ private readonly anyListeners;
91
+ private readonly binaryListeners;
92
+ private readonly errorListeners;
93
+ private readonly stateListeners;
94
+ private readonly closeListeners;
95
+ private readonly queue;
96
+ private readonly pending;
97
+ private connectPromise;
98
+ private reconnectTimer;
99
+ private reconnectAttempts;
100
+ private manualClose;
101
+ private disposed;
102
+ private requestSequence;
103
+ private generation;
104
+ constructor(url: string, options?: RedwebClientOptions);
105
+ connect(): Promise<void>;
106
+ private current;
107
+ private retire;
108
+ private openSocket;
109
+ close(code?: number, reason?: string): void;
110
+ dispose(): void;
111
+ send<K extends keyof OutgoingEvents & string>(type: K, payload: OutgoingEvents[K], metadata?: ProtocolMetadata): void;
112
+ sendRaw(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
113
+ private transmit;
114
+ request<TResponse = unknown, TPayload = unknown>(type: string, payload: TPayload, options?: RequestOptions): Promise<RedwebMessage<TResponse>>;
115
+ waitFor<K extends keyof IncomingEvents & string>(type: K, options?: WaitOptions): Promise<RedwebMessage<IncomingEvents[K]>>;
116
+ on<K extends keyof IncomingEvents & string>(type: K, listener: MessageListener<IncomingEvents[K]>): () => void;
117
+ onAny(listener: MessageListener): () => void;
118
+ onBinary(listener: BinaryListener): () => void;
119
+ onError(listener: ErrorListener): () => void;
120
+ onStateChange(listener: StateListener): () => void;
121
+ onClose(listener: CloseListener): () => void;
122
+ private subscribe;
123
+ private connectionUrl;
124
+ private transition;
125
+ private handleIncoming;
126
+ private finishPending;
127
+ private rejectPending;
128
+ private emitError;
129
+ private notify;
130
+ private flushQueue;
131
+ private scheduleReconnect;
132
+ }
133
+
134
+ 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;
@@ -329,6 +368,9 @@ var RedwebClient = class {
329
368
  const requestId = options.requestId ?? this.createRequestId();
330
369
  const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
331
370
  assertNonNegativeInteger(timeoutMs, "timeoutMs");
371
+ if (options.responseType !== void 0 && (typeof options.responseType !== "string" || !options.responseType || options.responseType.length > 256)) {
372
+ return Promise.reject(new TypeError("responseType must be a non-empty string of at most 256 characters."));
373
+ }
332
374
  if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
333
375
  if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
334
376
  return new Promise((resolve, reject) => {
@@ -336,7 +378,7 @@ var RedwebClient = class {
336
378
  this.finishPending(requestId);
337
379
  reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
338
380
  }, timeoutMs);
339
- const pending = { resolve, reject, timer };
381
+ const pending = { resolve, reject, timer, responseType: options.responseType };
340
382
  if (options.signal) {
341
383
  pending.signal = options.signal;
342
384
  pending.abort = () => {
@@ -443,7 +485,7 @@ var RedwebClient = class {
443
485
  const requestId = message.requestId;
444
486
  if (typeof requestId === "string") {
445
487
  const pending = this.pending.get(requestId);
446
- if (pending) {
488
+ if (pending && (pending.responseType === void 0 || message.type === pending.responseType || message.type === "error" && "error" in message)) {
447
489
  this.finishPending(requestId);
448
490
  if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
449
491
  else pending.resolve(message);
@@ -490,21 +532,25 @@ var RedwebClient = class {
490
532
  flushQueue() {
491
533
  while (this.socket?.readyState === OPEN && this.queue.length > 0) {
492
534
  const entry = this.queue.shift();
493
- if (entry !== void 0) this.socket.send(entry.data);
535
+ this.socket.send(entry.data);
494
536
  }
495
537
  }
496
538
  scheduleReconnect() {
497
- if (this.disposed || !this.reconnect.enabled || this.reconnectAttempts >= this.reconnect.maxAttempts) return;
539
+ if (this.disposed || this.manualClose || !this.reconnect.enabled || this.reconnectAttempts >= this.reconnect.maxAttempts) return;
540
+ const generation = this.generation;
498
541
  const baseDelay = Math.min(
499
542
  this.reconnect.maxDelayMs,
500
543
  this.reconnect.initialDelayMs * this.reconnect.factor ** this.reconnectAttempts
501
544
  );
502
545
  const spread = baseDelay * this.reconnect.jitter;
503
546
  const delay = Math.max(0, Math.round(baseDelay - spread + this.random() * spread * 2));
547
+ if (!this.current(generation)) return;
504
548
  this.reconnectAttempts += 1;
505
549
  this.transition("reconnecting");
550
+ if (!this.current(generation)) return;
506
551
  this.reconnectTimer = setTimeout(() => {
507
552
  this.reconnectTimer = void 0;
553
+ if (!this.current(generation)) return;
508
554
  void this.openSocket(true).catch(() => {
509
555
  });
510
556
  }, delay);