redweb-client 0.1.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/LICENSE +7 -0
- package/README.md +189 -0
- package/dist/index.cjs +522 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +489 -0
- package/package.json +58 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ERROR_CODES: () => ERROR_CODES,
|
|
24
|
+
RedwebClient: () => RedwebClient,
|
|
25
|
+
RedwebProtocolError: () => RedwebProtocolError,
|
|
26
|
+
createLegacyEnvelope: () => createLegacyEnvelope,
|
|
27
|
+
createProtocolEnvelope: () => createProtocolEnvelope,
|
|
28
|
+
parseMessage: () => parseMessage,
|
|
29
|
+
resolveWebSocketUrl: () => resolveWebSocketUrl
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/protocol.ts
|
|
34
|
+
var ERROR_CODES = Object.freeze({
|
|
35
|
+
INVALID_MESSAGE: "INVALID_MESSAGE",
|
|
36
|
+
UNKNOWN_HANDLER: "UNKNOWN_HANDLER",
|
|
37
|
+
HANDLER_FAILED: "HANDLER_FAILED",
|
|
38
|
+
BINARY_UNSUPPORTED: "BINARY_UNSUPPORTED",
|
|
39
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
40
|
+
QUEUE_FULL: "QUEUE_FULL",
|
|
41
|
+
CAPACITY_REACHED: "CAPACITY_REACHED",
|
|
42
|
+
INITIALIZATION_FAILED: "INITIALIZATION_FAILED"
|
|
43
|
+
});
|
|
44
|
+
function boundedString(value, name, maxLength) {
|
|
45
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
|
|
46
|
+
throw new TypeError(`${name} must be a non-empty string of at most ${maxLength} characters.`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function validateMetadata(metadata) {
|
|
51
|
+
if (metadata.requestId !== void 0) boundedString(metadata.requestId, "requestId", 256);
|
|
52
|
+
if (metadata.sequence !== void 0 && (!Number.isSafeInteger(metadata.sequence) || metadata.sequence < 0)) {
|
|
53
|
+
throw new TypeError("sequence must be a non-negative safe integer.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function createProtocolEnvelope(version, type, payload, metadata = {}) {
|
|
57
|
+
validateMetadata(metadata);
|
|
58
|
+
return {
|
|
59
|
+
v: boundedString(version, "version", 64),
|
|
60
|
+
type: boundedString(type, "type", 256),
|
|
61
|
+
payload,
|
|
62
|
+
...metadata
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function createLegacyEnvelope(type, payload, metadata = {}) {
|
|
66
|
+
validateMetadata(metadata);
|
|
67
|
+
const checkedType = boundedString(type, "type", 256);
|
|
68
|
+
if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
|
|
69
|
+
return { ...payload, type: checkedType, ...metadata };
|
|
70
|
+
}
|
|
71
|
+
return { type: checkedType, payload, ...metadata };
|
|
72
|
+
}
|
|
73
|
+
function parseMessage(input, version) {
|
|
74
|
+
const value = typeof input === "string" ? JSON.parse(input) : input;
|
|
75
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
76
|
+
throw new TypeError("Received an invalid Redweb message.");
|
|
77
|
+
}
|
|
78
|
+
const message = value;
|
|
79
|
+
if (message.requestId !== void 0) boundedString(message.requestId, "requestId", 256);
|
|
80
|
+
if (message.sequence !== void 0 && (!Number.isSafeInteger(message.sequence) || message.sequence < 0)) {
|
|
81
|
+
throw new TypeError("sequence must be a non-negative safe integer.");
|
|
82
|
+
}
|
|
83
|
+
if (version !== void 0) {
|
|
84
|
+
boundedString(message.type, "type", 256);
|
|
85
|
+
if (message.v !== version) throw new TypeError("Received a Redweb message for a different protocol version.");
|
|
86
|
+
if (message.type === "error") {
|
|
87
|
+
if (Object.prototype.hasOwnProperty.call(message, "payload")) {
|
|
88
|
+
throw new TypeError("Received an invalid Redweb protocol error.");
|
|
89
|
+
}
|
|
90
|
+
const error = message.error;
|
|
91
|
+
if (!error || typeof error !== "object") {
|
|
92
|
+
throw new TypeError("Received an invalid Redweb protocol error.");
|
|
93
|
+
}
|
|
94
|
+
boundedString(error.code, "error.code", 256);
|
|
95
|
+
boundedString(error.message, "error.message", 1024);
|
|
96
|
+
} else {
|
|
97
|
+
if (!Object.prototype.hasOwnProperty.call(message, "payload") || message.error !== void 0) {
|
|
98
|
+
throw new TypeError("Received an invalid Redweb protocol envelope.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} else if (typeof message.type === "string") {
|
|
102
|
+
boundedString(message.type, "type", 256);
|
|
103
|
+
} else if (typeof message.error !== "string") {
|
|
104
|
+
throw new TypeError("Received an invalid Redweb message.");
|
|
105
|
+
}
|
|
106
|
+
return message;
|
|
107
|
+
}
|
|
108
|
+
function resolveWebSocketUrl(input, base) {
|
|
109
|
+
const fallbackBase = base ?? (typeof globalThis.location === "object" ? globalThis.location.href : void 0);
|
|
110
|
+
let url;
|
|
111
|
+
try {
|
|
112
|
+
url = fallbackBase === void 0 ? new URL(input) : new URL(input, fallbackBase);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new TypeError("url must be an absolute ws:/wss: URL or resolvable against baseUrl/location.");
|
|
115
|
+
}
|
|
116
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
117
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
118
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new TypeError("url must use ws: or wss:.");
|
|
119
|
+
return url.toString();
|
|
120
|
+
}
|
|
121
|
+
var RedwebProtocolError = class extends Error {
|
|
122
|
+
code;
|
|
123
|
+
envelope;
|
|
124
|
+
constructor(envelope) {
|
|
125
|
+
super(envelope.error.message);
|
|
126
|
+
this.name = "RedwebProtocolError";
|
|
127
|
+
this.code = envelope.error.code;
|
|
128
|
+
this.envelope = envelope;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// src/client.ts
|
|
133
|
+
var OPEN = 1;
|
|
134
|
+
var CONNECTING = 0;
|
|
135
|
+
var defaultReconnect = {
|
|
136
|
+
enabled: false,
|
|
137
|
+
maxAttempts: 8,
|
|
138
|
+
initialDelayMs: 250,
|
|
139
|
+
maxDelayMs: 1e4,
|
|
140
|
+
factor: 2,
|
|
141
|
+
jitter: 0.2,
|
|
142
|
+
shouldReconnect: (event) => event.code !== 1e3 && event.code !== 1008
|
|
143
|
+
};
|
|
144
|
+
function defaultFactory(url) {
|
|
145
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
146
|
+
throw new Error("WebSocket is unavailable; provide webSocketFactory outside a browser.");
|
|
147
|
+
}
|
|
148
|
+
return new globalThis.WebSocket(url);
|
|
149
|
+
}
|
|
150
|
+
function toError(value, fallback) {
|
|
151
|
+
return value instanceof Error ? value : new Error(fallback);
|
|
152
|
+
}
|
|
153
|
+
function assertNonNegativeInteger(value, name) {
|
|
154
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative integer.`);
|
|
155
|
+
}
|
|
156
|
+
var RedwebClient = class {
|
|
157
|
+
url;
|
|
158
|
+
version;
|
|
159
|
+
state = "idle";
|
|
160
|
+
socket;
|
|
161
|
+
factory;
|
|
162
|
+
reconnect;
|
|
163
|
+
maxQueueSize;
|
|
164
|
+
requestTimeoutMs;
|
|
165
|
+
createRequestId;
|
|
166
|
+
random;
|
|
167
|
+
listeners = /* @__PURE__ */ new Map();
|
|
168
|
+
anyListeners = /* @__PURE__ */ new Set();
|
|
169
|
+
binaryListeners = /* @__PURE__ */ new Set();
|
|
170
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
171
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
172
|
+
closeListeners = /* @__PURE__ */ new Set();
|
|
173
|
+
queue = [];
|
|
174
|
+
pending = /* @__PURE__ */ new Map();
|
|
175
|
+
connectPromise;
|
|
176
|
+
reconnectTimer;
|
|
177
|
+
reconnectAttempts = 0;
|
|
178
|
+
manualClose = false;
|
|
179
|
+
disposed = false;
|
|
180
|
+
requestSequence = 0;
|
|
181
|
+
constructor(url, options = {}) {
|
|
182
|
+
this.url = resolveWebSocketUrl(url, options.baseUrl);
|
|
183
|
+
const queryVersion = new URL(this.url).searchParams.get("redwebVersion") ?? void 0;
|
|
184
|
+
if (options.version !== void 0 && queryVersion !== void 0 && options.version !== queryVersion) {
|
|
185
|
+
throw new TypeError("options.version conflicts with the redwebVersion URL query.");
|
|
186
|
+
}
|
|
187
|
+
this.version = options.version ?? queryVersion;
|
|
188
|
+
if (this.version !== void 0) createProtocolEnvelope(this.version, "validation", null);
|
|
189
|
+
this.factory = options.webSocketFactory ?? defaultFactory;
|
|
190
|
+
this.reconnect = { ...defaultReconnect, ...options.reconnect };
|
|
191
|
+
this.maxQueueSize = options.maxQueueSize ?? 0;
|
|
192
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
|
|
193
|
+
this.createRequestId = options.createRequestId ?? (() => {
|
|
194
|
+
this.requestSequence += 1;
|
|
195
|
+
return `${Date.now().toString(36)}-${this.requestSequence.toString(36)}`;
|
|
196
|
+
});
|
|
197
|
+
this.random = options.random ?? Math.random;
|
|
198
|
+
assertNonNegativeInteger(this.maxQueueSize, "maxQueueSize");
|
|
199
|
+
assertNonNegativeInteger(this.requestTimeoutMs, "requestTimeoutMs");
|
|
200
|
+
assertNonNegativeInteger(this.reconnect.maxAttempts, "reconnect.maxAttempts");
|
|
201
|
+
assertNonNegativeInteger(this.reconnect.initialDelayMs, "reconnect.initialDelayMs");
|
|
202
|
+
assertNonNegativeInteger(this.reconnect.maxDelayMs, "reconnect.maxDelayMs");
|
|
203
|
+
if (!Number.isFinite(this.reconnect.factor) || this.reconnect.factor < 1) {
|
|
204
|
+
throw new TypeError("reconnect.factor must be a finite number of at least 1.");
|
|
205
|
+
}
|
|
206
|
+
if (!Number.isFinite(this.reconnect.jitter) || this.reconnect.jitter < 0 || this.reconnect.jitter > 1) {
|
|
207
|
+
throw new TypeError("reconnect.jitter must be between 0 and 1.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
connect() {
|
|
211
|
+
if (this.disposed) return Promise.reject(new Error("RedwebClient has been disposed."));
|
|
212
|
+
if (this.state === "open") return Promise.resolve();
|
|
213
|
+
if (this.connectPromise) return this.connectPromise;
|
|
214
|
+
this.manualClose = false;
|
|
215
|
+
if (this.reconnectTimer) {
|
|
216
|
+
clearTimeout(this.reconnectTimer);
|
|
217
|
+
this.reconnectTimer = void 0;
|
|
218
|
+
}
|
|
219
|
+
this.connectPromise = this.openSocket(false).finally(() => {
|
|
220
|
+
this.connectPromise = void 0;
|
|
221
|
+
});
|
|
222
|
+
return this.connectPromise;
|
|
223
|
+
}
|
|
224
|
+
openSocket(reconnecting) {
|
|
225
|
+
this.transition(reconnecting ? "reconnecting" : "connecting");
|
|
226
|
+
let socket;
|
|
227
|
+
try {
|
|
228
|
+
socket = this.factory(this.connectionUrl());
|
|
229
|
+
} catch (error) {
|
|
230
|
+
this.transition("closed");
|
|
231
|
+
if (reconnecting && !this.manualClose) this.scheduleReconnect();
|
|
232
|
+
return Promise.reject(toError(error, "Unable to create WebSocket."));
|
|
233
|
+
}
|
|
234
|
+
this.socket = socket;
|
|
235
|
+
if ("binaryType" in socket) socket.binaryType = "arraybuffer";
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
let settled = false;
|
|
238
|
+
const onOpen = () => {
|
|
239
|
+
if (this.socket !== socket) {
|
|
240
|
+
if (!settled) reject(new Error("WebSocket connection attempt was superseded."));
|
|
241
|
+
socket.close(1e3, "Superseded");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
settled = true;
|
|
245
|
+
this.reconnectAttempts = 0;
|
|
246
|
+
this.transition("open");
|
|
247
|
+
this.flushQueue();
|
|
248
|
+
resolve();
|
|
249
|
+
};
|
|
250
|
+
const onMessage = (event) => {
|
|
251
|
+
if (this.socket === socket) void this.handleIncoming(event.data);
|
|
252
|
+
};
|
|
253
|
+
const onError = (event) => {
|
|
254
|
+
if (this.socket !== socket) return;
|
|
255
|
+
const error = toError(event.error, "WebSocket connection failed.");
|
|
256
|
+
this.emitError(error);
|
|
257
|
+
};
|
|
258
|
+
const onClose = (event) => {
|
|
259
|
+
socket.removeEventListener("open", onOpen);
|
|
260
|
+
socket.removeEventListener("message", onMessage);
|
|
261
|
+
socket.removeEventListener("error", onError);
|
|
262
|
+
socket.removeEventListener("close", onClose);
|
|
263
|
+
if (this.socket !== socket) {
|
|
264
|
+
if (!settled) reject(new Error("WebSocket connection attempt was superseded."));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.socket = void 0;
|
|
268
|
+
if (!settled) {
|
|
269
|
+
settled = true;
|
|
270
|
+
reject(new Error("WebSocket closed before opening."));
|
|
271
|
+
}
|
|
272
|
+
this.rejectPending(new Error("WebSocket connection closed."));
|
|
273
|
+
this.transition("closed");
|
|
274
|
+
this.notify(this.closeListeners, event);
|
|
275
|
+
if (!this.manualClose && this.reconnect.shouldReconnect(event)) this.scheduleReconnect();
|
|
276
|
+
};
|
|
277
|
+
socket.addEventListener("open", onOpen);
|
|
278
|
+
socket.addEventListener("message", onMessage);
|
|
279
|
+
socket.addEventListener("error", onError);
|
|
280
|
+
socket.addEventListener("close", onClose);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
close(code = 1e3, reason = "Client closed") {
|
|
284
|
+
this.manualClose = true;
|
|
285
|
+
if (this.reconnectTimer) {
|
|
286
|
+
clearTimeout(this.reconnectTimer);
|
|
287
|
+
this.reconnectTimer = void 0;
|
|
288
|
+
}
|
|
289
|
+
this.rejectPending(new Error("Client closed."));
|
|
290
|
+
this.queue.length = 0;
|
|
291
|
+
const socket = this.socket;
|
|
292
|
+
if (socket && (socket.readyState === OPEN || socket.readyState === CONNECTING)) {
|
|
293
|
+
this.transition("closing");
|
|
294
|
+
socket.close(code, reason);
|
|
295
|
+
} else {
|
|
296
|
+
this.transition("closed");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
dispose() {
|
|
300
|
+
if (this.disposed) return;
|
|
301
|
+
this.disposed = true;
|
|
302
|
+
this.close(1e3, "Client disposed");
|
|
303
|
+
this.queue.length = 0;
|
|
304
|
+
this.listeners.clear();
|
|
305
|
+
this.anyListeners.clear();
|
|
306
|
+
this.binaryListeners.clear();
|
|
307
|
+
this.errorListeners.clear();
|
|
308
|
+
this.stateListeners.clear();
|
|
309
|
+
this.closeListeners.clear();
|
|
310
|
+
}
|
|
311
|
+
send(type, payload, metadata = {}) {
|
|
312
|
+
const envelope = this.version ? createProtocolEnvelope(this.version, type, payload, metadata) : createLegacyEnvelope(type, payload, metadata);
|
|
313
|
+
this.transmit(JSON.stringify(envelope));
|
|
314
|
+
}
|
|
315
|
+
sendRaw(data) {
|
|
316
|
+
this.transmit(data);
|
|
317
|
+
}
|
|
318
|
+
transmit(data, requestId) {
|
|
319
|
+
if (this.socket?.readyState === OPEN) {
|
|
320
|
+
this.socket.send(data);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (this.queue.length >= this.maxQueueSize) {
|
|
324
|
+
throw new Error(this.maxQueueSize === 0 ? "WebSocket is not open." : "Outbound message queue is full.");
|
|
325
|
+
}
|
|
326
|
+
this.queue.push(requestId === void 0 ? { data } : { data, requestId });
|
|
327
|
+
}
|
|
328
|
+
request(type, payload, options = {}) {
|
|
329
|
+
const requestId = options.requestId ?? this.createRequestId();
|
|
330
|
+
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
|
|
331
|
+
assertNonNegativeInteger(timeoutMs, "timeoutMs");
|
|
332
|
+
if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
|
|
333
|
+
if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
const timer = setTimeout(() => {
|
|
336
|
+
this.finishPending(requestId);
|
|
337
|
+
reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
|
|
338
|
+
}, timeoutMs);
|
|
339
|
+
const pending = { resolve, reject, timer };
|
|
340
|
+
if (options.signal) {
|
|
341
|
+
pending.signal = options.signal;
|
|
342
|
+
pending.abort = () => {
|
|
343
|
+
this.finishPending(requestId);
|
|
344
|
+
reject(new DOMException("The request was aborted.", "AbortError"));
|
|
345
|
+
};
|
|
346
|
+
options.signal.addEventListener("abort", pending.abort, { once: true });
|
|
347
|
+
}
|
|
348
|
+
this.pending.set(requestId, pending);
|
|
349
|
+
try {
|
|
350
|
+
const metadata = { requestId, ...options.sequence === void 0 ? {} : { sequence: options.sequence } };
|
|
351
|
+
const envelope = this.version ? createProtocolEnvelope(this.version, type, payload, metadata) : createLegacyEnvelope(type, payload, metadata);
|
|
352
|
+
this.transmit(JSON.stringify(envelope), requestId);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this.finishPending(requestId);
|
|
355
|
+
reject(toError(error, "Unable to send Redweb request."));
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
waitFor(type, options = {}) {
|
|
360
|
+
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
|
|
361
|
+
assertNonNegativeInteger(timeoutMs, "timeoutMs");
|
|
362
|
+
if (options.signal?.aborted) return Promise.reject(new DOMException("The wait was aborted.", "AbortError"));
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
const off = this.on(type, (message) => {
|
|
365
|
+
cleanup();
|
|
366
|
+
resolve(message);
|
|
367
|
+
});
|
|
368
|
+
const timer = setTimeout(() => {
|
|
369
|
+
cleanup();
|
|
370
|
+
reject(new Error(`Timed out waiting for Redweb message "${type}" after ${timeoutMs}ms.`));
|
|
371
|
+
}, timeoutMs);
|
|
372
|
+
const abort = () => {
|
|
373
|
+
cleanup();
|
|
374
|
+
reject(new DOMException("The wait was aborted.", "AbortError"));
|
|
375
|
+
};
|
|
376
|
+
const cleanup = () => {
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
off();
|
|
379
|
+
options.signal?.removeEventListener("abort", abort);
|
|
380
|
+
};
|
|
381
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
on(type, listener) {
|
|
385
|
+
let group = this.listeners.get(type);
|
|
386
|
+
if (!group) {
|
|
387
|
+
group = /* @__PURE__ */ new Set();
|
|
388
|
+
this.listeners.set(type, group);
|
|
389
|
+
}
|
|
390
|
+
group.add(listener);
|
|
391
|
+
return () => {
|
|
392
|
+
group?.delete(listener);
|
|
393
|
+
if (group?.size === 0) this.listeners.delete(type);
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
onAny(listener) {
|
|
397
|
+
return this.subscribe(this.anyListeners, listener);
|
|
398
|
+
}
|
|
399
|
+
onBinary(listener) {
|
|
400
|
+
return this.subscribe(this.binaryListeners, listener);
|
|
401
|
+
}
|
|
402
|
+
onError(listener) {
|
|
403
|
+
return this.subscribe(this.errorListeners, listener);
|
|
404
|
+
}
|
|
405
|
+
onStateChange(listener) {
|
|
406
|
+
return this.subscribe(this.stateListeners, listener);
|
|
407
|
+
}
|
|
408
|
+
onClose(listener) {
|
|
409
|
+
return this.subscribe(this.closeListeners, listener);
|
|
410
|
+
}
|
|
411
|
+
subscribe(set, listener) {
|
|
412
|
+
set.add(listener);
|
|
413
|
+
return () => {
|
|
414
|
+
set.delete(listener);
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
connectionUrl() {
|
|
418
|
+
const url = new URL(this.url);
|
|
419
|
+
if (this.version && !url.searchParams.has("redwebVersion")) url.searchParams.set("redwebVersion", this.version);
|
|
420
|
+
return url.toString();
|
|
421
|
+
}
|
|
422
|
+
transition(state) {
|
|
423
|
+
if (this.state === state) return;
|
|
424
|
+
this.state = state;
|
|
425
|
+
this.notify(this.stateListeners, state);
|
|
426
|
+
}
|
|
427
|
+
async handleIncoming(data) {
|
|
428
|
+
if (typeof data !== "string") {
|
|
429
|
+
if (data instanceof Blob || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
430
|
+
this.notify(this.binaryListeners, data);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.emitError(new TypeError("Received an unsupported WebSocket message type."));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
let message;
|
|
437
|
+
try {
|
|
438
|
+
message = parseMessage(data, this.version);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
this.emitError(toError(error, "Unable to parse Redweb message."));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const requestId = message.requestId;
|
|
444
|
+
if (typeof requestId === "string") {
|
|
445
|
+
const pending = this.pending.get(requestId);
|
|
446
|
+
if (pending) {
|
|
447
|
+
this.finishPending(requestId);
|
|
448
|
+
if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
|
|
449
|
+
else pending.resolve(message);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const messageType = "type" in message && typeof message.type === "string" ? message.type : "error";
|
|
453
|
+
const group = this.listeners.get(messageType);
|
|
454
|
+
if (group) this.notify(group, message);
|
|
455
|
+
this.notify(this.anyListeners, message);
|
|
456
|
+
}
|
|
457
|
+
finishPending(requestId) {
|
|
458
|
+
const pending = this.pending.get(requestId);
|
|
459
|
+
if (!pending) return;
|
|
460
|
+
clearTimeout(pending.timer);
|
|
461
|
+
if (pending.signal && pending.abort) pending.signal.removeEventListener("abort", pending.abort);
|
|
462
|
+
this.pending.delete(requestId);
|
|
463
|
+
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
|
|
464
|
+
if (this.queue[index]?.requestId === requestId) this.queue.splice(index, 1);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
rejectPending(error) {
|
|
468
|
+
for (const [requestId, pending] of this.pending) {
|
|
469
|
+
this.finishPending(requestId);
|
|
470
|
+
pending.reject(error);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
emitError(error) {
|
|
474
|
+
for (const listener of this.errorListeners) {
|
|
475
|
+
try {
|
|
476
|
+
listener(error);
|
|
477
|
+
} catch {
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
notify(listeners, value) {
|
|
482
|
+
for (const listener of listeners) {
|
|
483
|
+
try {
|
|
484
|
+
listener(value);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
this.emitError(toError(error, "Redweb listener failed."));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
flushQueue() {
|
|
491
|
+
while (this.socket?.readyState === OPEN && this.queue.length > 0) {
|
|
492
|
+
const entry = this.queue.shift();
|
|
493
|
+
if (entry !== void 0) this.socket.send(entry.data);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
scheduleReconnect() {
|
|
497
|
+
if (this.disposed || !this.reconnect.enabled || this.reconnectAttempts >= this.reconnect.maxAttempts) return;
|
|
498
|
+
const baseDelay = Math.min(
|
|
499
|
+
this.reconnect.maxDelayMs,
|
|
500
|
+
this.reconnect.initialDelayMs * this.reconnect.factor ** this.reconnectAttempts
|
|
501
|
+
);
|
|
502
|
+
const spread = baseDelay * this.reconnect.jitter;
|
|
503
|
+
const delay = Math.max(0, Math.round(baseDelay - spread + this.random() * spread * 2));
|
|
504
|
+
this.reconnectAttempts += 1;
|
|
505
|
+
this.transition("reconnecting");
|
|
506
|
+
this.reconnectTimer = setTimeout(() => {
|
|
507
|
+
this.reconnectTimer = void 0;
|
|
508
|
+
void this.openSocket(true).catch(() => {
|
|
509
|
+
});
|
|
510
|
+
}, delay);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
514
|
+
0 && (module.exports = {
|
|
515
|
+
ERROR_CODES,
|
|
516
|
+
RedwebClient,
|
|
517
|
+
RedwebProtocolError,
|
|
518
|
+
createLegacyEnvelope,
|
|
519
|
+
createProtocolEnvelope,
|
|
520
|
+
parseMessage,
|
|
521
|
+
resolveWebSocketUrl
|
|
522
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
}
|
|
128
|
+
|
|
129
|
+
declare const ERROR_CODES: Readonly<{
|
|
130
|
+
readonly INVALID_MESSAGE: "INVALID_MESSAGE";
|
|
131
|
+
readonly UNKNOWN_HANDLER: "UNKNOWN_HANDLER";
|
|
132
|
+
readonly HANDLER_FAILED: "HANDLER_FAILED";
|
|
133
|
+
readonly BINARY_UNSUPPORTED: "BINARY_UNSUPPORTED";
|
|
134
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
135
|
+
readonly QUEUE_FULL: "QUEUE_FULL";
|
|
136
|
+
readonly CAPACITY_REACHED: "CAPACITY_REACHED";
|
|
137
|
+
readonly INITIALIZATION_FAILED: "INITIALIZATION_FAILED";
|
|
138
|
+
}>;
|
|
139
|
+
declare function createProtocolEnvelope<T>(version: string, type: string, payload: T, metadata?: ProtocolMetadata): ProtocolEnvelope<T>;
|
|
140
|
+
declare function createLegacyEnvelope<T>(type: string, payload: T, metadata?: ProtocolMetadata): RedwebMessage<T>;
|
|
141
|
+
declare function parseMessage(input: unknown, version?: string): RedwebMessage;
|
|
142
|
+
declare function resolveWebSocketUrl(input: string | URL, base?: string | URL): string;
|
|
143
|
+
declare class RedwebProtocolError extends Error {
|
|
144
|
+
readonly code: string;
|
|
145
|
+
readonly envelope: ProtocolErrorEnvelope;
|
|
146
|
+
constructor(envelope: ProtocolErrorEnvelope);
|
|
147
|
+
}
|
|
148
|
+
|
|
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 };
|