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 +10 -0
- package/README.md +93 -4
- package/dist/client-D35g1hrL.d.cts +134 -0
- package/dist/client-D35g1hrL.d.ts +134 -0
- package/dist/index.cjs +59 -13
- package/dist/index.d.cts +3 -128
- package/dist/index.d.ts +3 -128
- package/dist/index.js +59 -13
- package/dist/live-html.cjs +1059 -0
- package/dist/live-html.d.cts +12 -0
- package/dist/live-html.d.ts +12 -0
- package/dist/live-html.js +1031 -0
- package/package.json +13 -4
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
220
|
+
let resolve;
|
|
221
|
+
let reject;
|
|
222
|
+
const promise = new Promise((yes, no) => {
|
|
223
|
+
resolve = yes;
|
|
224
|
+
reject = no;
|
|
221
225
|
});
|
|
222
|
-
|
|
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.
|
|
231
|
-
|
|
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
|
-
|
|
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);
|