universal-api-layer 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,529 @@
1
+ import {
2
+ abortable,
3
+ validateResponse
4
+ } from "../chunk-DXYWZQW5.js";
5
+ import {
6
+ ApiError
7
+ } from "../chunk-YTM3FV65.js";
8
+
9
+ // src/websocket/index.ts
10
+ var retryCodes = /* @__PURE__ */ new Set([1001, 1005, 1006, 1011, 1012, 1013, 1014]);
11
+ var encoder = new TextEncoder();
12
+ function configuration(message) {
13
+ throw new ApiError(message, 0, {}, void 0, "configuration");
14
+ }
15
+ function limit(value, name, minimum = 1) {
16
+ if (!Number.isSafeInteger(value) || value < minimum || value > 2147483647)
17
+ configuration(`${name} is outside its supported integer range`);
18
+ return value;
19
+ }
20
+ function socketUrl(value) {
21
+ let url;
22
+ try {
23
+ url = new URL(value);
24
+ } catch {
25
+ return configuration("An absolute ws(s) URL is required");
26
+ }
27
+ if (!["ws:", "wss:"].includes(url.protocol) || url.username || url.password || value.includes("#") || value.includes("\\"))
28
+ configuration("Invalid WebSocket URL");
29
+ return url;
30
+ }
31
+ function byteSize(value) {
32
+ if (typeof value === "string") return encoder.encode(value).byteLength;
33
+ if (value instanceof Blob) return value.size;
34
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
35
+ return value.byteLength;
36
+ return configuration("Unsupported WebSocket message data");
37
+ }
38
+ function validateClose(code, reason) {
39
+ if (code !== 1e3 && (!Number.isInteger(code) || code < 3e3 || code > 4999))
40
+ configuration("Close code must be 1000 or between 3000 and 4999");
41
+ if (typeof reason !== "string" || encoder.encode(reason).length > 123)
42
+ configuration("Close reason must be at most 123 UTF-8 bytes");
43
+ }
44
+ function createSocketClient(config) {
45
+ const options = { ...config };
46
+ const format = options.format ?? "json";
47
+ if (format !== "json" && format !== "raw")
48
+ configuration("Socket format must be json or raw");
49
+ const connectTimeout = limit(
50
+ options.connectTimeoutMs ?? 1e4,
51
+ "connectTimeoutMs"
52
+ );
53
+ const messageLimit = limit(
54
+ options.maxMessageSize ?? 1048576,
55
+ "maxMessageSize"
56
+ );
57
+ const pendingLimit = limit(
58
+ options.maxPendingMessages ?? 64,
59
+ "maxPendingMessages"
60
+ );
61
+ const bufferedLimit = limit(
62
+ options.maxBufferedAmount ?? 1048576,
63
+ "maxBufferedAmount"
64
+ );
65
+ const reconnect = options.reconnect === false ? void 0 : {
66
+ maxAttempts: 5,
67
+ baseDelayMs: 500,
68
+ maxDelayMs: 3e4,
69
+ jitter: true,
70
+ ...options.reconnect
71
+ };
72
+ if (reconnect) {
73
+ limit(reconnect.maxAttempts, "maxAttempts", 0);
74
+ limit(reconnect.baseDelayMs, "baseDelayMs");
75
+ limit(reconnect.maxDelayMs, "maxDelayMs");
76
+ if (reconnect.baseDelayMs > reconnect.maxDelayMs || typeof reconnect.jitter !== "boolean")
77
+ configuration("Invalid reconnect settings");
78
+ }
79
+ const requiresAuth = options.requiresAuth ?? Boolean(options.sessionClient);
80
+ if (requiresAuth && !options.sessionClient)
81
+ configuration("requiresAuth requires a sessionClient");
82
+ if (typeof options.url !== "string" && typeof options.url !== "function")
83
+ configuration("Socket url must be a string or provider");
84
+ const origins = new Set(
85
+ (options.allowedOrigins ?? []).map((value) => {
86
+ const url = socketUrl(value);
87
+ if (url.pathname !== "/" || url.search)
88
+ configuration("allowedOrigins must contain exact ws(s) origins");
89
+ return url.origin;
90
+ })
91
+ );
92
+ if (typeof options.url === "string") {
93
+ const origin = socketUrl(options.url).origin;
94
+ if (options.allowedOrigins === void 0) origins.add(origin);
95
+ if (!origins.has(origin)) configuration("WebSocket origin is not allowed");
96
+ } else if (!origins.size)
97
+ configuration("URL providers require allowedOrigins");
98
+ const protocols = [...options.protocols ?? []];
99
+ if (new Set(protocols).size !== protocols.length || protocols.some(
100
+ (value) => typeof value !== "string" || !/^[!#$%&'*+\-.^_`|~\da-z]+$/i.test(value)
101
+ ))
102
+ configuration("Invalid WebSocket subprotocols");
103
+ let snapshot = Object.freeze({
104
+ status: "idle",
105
+ reconnectAttempt: 0,
106
+ error: null,
107
+ close: null
108
+ });
109
+ const stateListeners = /* @__PURE__ */ new Set();
110
+ const messageListeners = /* @__PURE__ */ new Set();
111
+ const errorListeners = /* @__PURE__ */ new Set();
112
+ let wanted = false, disposed = false, generation = 0, attempts = 0, pending = 0;
113
+ let socket;
114
+ let controller;
115
+ let timeout, retry;
116
+ let detach, unsubscribeSession;
117
+ let revision;
118
+ let chain = Promise.resolve();
119
+ function listenerError(cause) {
120
+ const error = new ApiError(
121
+ "WebSocket listener failed",
122
+ 0,
123
+ {},
124
+ void 0,
125
+ "callback",
126
+ cause
127
+ );
128
+ for (const listener of [...errorListeners]) {
129
+ try {
130
+ listener(error);
131
+ } catch {
132
+ }
133
+ }
134
+ }
135
+ function update(value) {
136
+ snapshot = Object.freeze({
137
+ ...snapshot,
138
+ ...value,
139
+ reconnectAttempt: attempts
140
+ });
141
+ for (const listener of [...stateListeners]) {
142
+ try {
143
+ listener();
144
+ } catch (cause) {
145
+ listenerError(cause);
146
+ }
147
+ }
148
+ }
149
+ function report(error) {
150
+ update({ error });
151
+ for (const listener of [...errorListeners]) {
152
+ try {
153
+ listener(error);
154
+ } catch {
155
+ }
156
+ }
157
+ }
158
+ function sessionValid() {
159
+ const state = options.sessionClient?.getSessionSnapshot();
160
+ return (!state || state.revision === revision) && (!requiresAuth || Boolean(state?.userId));
161
+ }
162
+ const current = (id) => wanted && !disposed && id === generation && sessionValid();
163
+ function release(code = 1e3, reason = "") {
164
+ generation++;
165
+ clearTimeout(timeout);
166
+ clearTimeout(retry);
167
+ timeout = retry = void 0;
168
+ detach?.();
169
+ detach = void 0;
170
+ controller?.abort(
171
+ new ApiError(
172
+ "WebSocket attempt cancelled",
173
+ 0,
174
+ {},
175
+ void 0,
176
+ "cancelled"
177
+ )
178
+ );
179
+ controller = void 0;
180
+ const previous = socket;
181
+ socket = void 0;
182
+ pending = 0;
183
+ chain = Promise.resolve();
184
+ if (previous && previous.readyState < 2) {
185
+ try {
186
+ previous.close(code, reason);
187
+ } catch {
188
+ }
189
+ }
190
+ }
191
+ function stop(code = 1e3, reason = "", state = {}) {
192
+ wanted = false;
193
+ release(code, reason);
194
+ if (!state.keepSession) {
195
+ unsubscribeSession?.();
196
+ unsubscribeSession = void 0;
197
+ }
198
+ update({
199
+ status: state.status ?? "closed",
200
+ ...state.close === void 0 ? {} : { close: state.close },
201
+ ...state.clearError ? { error: null } : {}
202
+ });
203
+ }
204
+ function failed(error, canRetry, close = null) {
205
+ const id = generation;
206
+ report(error);
207
+ if (!current(id)) return;
208
+ release();
209
+ if (canRetry && reconnect && attempts < reconnect.maxAttempts) {
210
+ const delay = Math.min(
211
+ reconnect.maxDelayMs,
212
+ reconnect.baseDelayMs * 2 ** Math.min(attempts, 30)
213
+ );
214
+ attempts++;
215
+ const nextGeneration = generation;
216
+ update({ status: "reconnecting", close });
217
+ if (!current(nextGeneration)) return;
218
+ retry = setTimeout(
219
+ () => {
220
+ retry = void 0;
221
+ if (current(nextGeneration)) void attempt();
222
+ },
223
+ reconnect.jitter ? Math.floor(delay * (0.5 + Math.random() * 0.5)) : delay
224
+ );
225
+ } else {
226
+ wanted = false;
227
+ update({ status: "closed", close });
228
+ }
229
+ }
230
+ async function attempt() {
231
+ release();
232
+ const id = generation;
233
+ if (!current(id)) return;
234
+ controller = new AbortController();
235
+ const signal = controller.signal;
236
+ timeout = setTimeout(() => {
237
+ if (current(id))
238
+ failed(
239
+ new ApiError(
240
+ "WebSocket connection timed out",
241
+ 0,
242
+ {},
243
+ void 0,
244
+ "timeout"
245
+ ),
246
+ true
247
+ );
248
+ }, connectTimeout);
249
+ update({ status: attempts ? "reconnecting" : "connecting", close: null });
250
+ try {
251
+ const value = typeof options.url === "string" ? options.url : await abortable(
252
+ () => options.url(signal),
253
+ signal
254
+ );
255
+ if (!current(id)) return;
256
+ const url = socketUrl(value);
257
+ if (!origins.has(url.origin))
258
+ configuration("WebSocket origin is not allowed");
259
+ const factory = options.webSocketFactory ?? ((target, protocols2) => {
260
+ if (typeof globalThis.WebSocket !== "function")
261
+ configuration("WebSocket is unavailable; provide webSocketFactory");
262
+ return new globalThis.WebSocket(target, protocols2);
263
+ });
264
+ const transport = factory(url.href, [...protocols]);
265
+ if (!current(id)) {
266
+ transport.close(1e3);
267
+ return;
268
+ }
269
+ socket = transport;
270
+ transport.binaryType = "arraybuffer";
271
+ const onOpen = () => {
272
+ if (!current(id)) return;
273
+ clearTimeout(timeout);
274
+ timeout = void 0;
275
+ update({ status: "open", error: null, close: null });
276
+ };
277
+ const onError = () => {
278
+ if (current(id))
279
+ report(
280
+ new ApiError(
281
+ "WebSocket transport error",
282
+ 0,
283
+ {},
284
+ void 0,
285
+ "network"
286
+ )
287
+ );
288
+ };
289
+ const onClose = (event) => {
290
+ if (!current(id)) return;
291
+ const close = Object.freeze({
292
+ code: event.code,
293
+ reason: event.reason,
294
+ wasClean: event.wasClean
295
+ });
296
+ if (event.code === 1e3) {
297
+ stop(1e3, "", { close, keepSession: true });
298
+ } else
299
+ failed(
300
+ new ApiError(
301
+ "WebSocket connection closed",
302
+ 0,
303
+ {},
304
+ void 0,
305
+ "network"
306
+ ),
307
+ retryCodes.has(event.code),
308
+ close
309
+ );
310
+ };
311
+ const onMessage = (event) => {
312
+ if (!current(id) || snapshot.status !== "open") return;
313
+ const data = event.data;
314
+ if (!(typeof data === "string" || data instanceof ArrayBuffer || data instanceof Blob)) {
315
+ report(
316
+ new ApiError(
317
+ "Unsupported incoming WebSocket data",
318
+ 0,
319
+ {},
320
+ void 0,
321
+ "parse"
322
+ )
323
+ );
324
+ return;
325
+ }
326
+ if (byteSize(data) > messageLimit || pending >= pendingLimit) {
327
+ report(
328
+ new ApiError(
329
+ "WebSocket incoming message limit exceeded",
330
+ 0,
331
+ {},
332
+ void 0,
333
+ "backpressure"
334
+ )
335
+ );
336
+ if (current(id)) stop(1e3, "Message limit exceeded");
337
+ return;
338
+ }
339
+ pending++;
340
+ chain = chain.then(async () => {
341
+ if (!current(id)) return;
342
+ let parsed = data;
343
+ if (format === "json") {
344
+ if (typeof data !== "string")
345
+ throw new ApiError(
346
+ "Expected a JSON text WebSocket message",
347
+ 0,
348
+ {},
349
+ void 0,
350
+ "parse"
351
+ );
352
+ try {
353
+ parsed = JSON.parse(data);
354
+ } catch {
355
+ throw new ApiError(
356
+ "Invalid JSON WebSocket message",
357
+ 0,
358
+ {},
359
+ void 0,
360
+ "parse"
361
+ );
362
+ }
363
+ }
364
+ const result = options.schema ? await abortable(
365
+ () => validateResponse(options.schema, parsed, 0),
366
+ signal
367
+ ) : parsed;
368
+ for (const listener of [...messageListeners]) {
369
+ if (!current(id)) break;
370
+ try {
371
+ listener(result);
372
+ } catch (cause) {
373
+ listenerError(cause);
374
+ }
375
+ }
376
+ }).catch((cause) => {
377
+ if (current(id))
378
+ report(
379
+ cause instanceof ApiError ? cause : new ApiError(
380
+ "WebSocket message processing failed",
381
+ 0,
382
+ {},
383
+ void 0,
384
+ "parse",
385
+ cause
386
+ )
387
+ );
388
+ }).finally(() => {
389
+ if (current(id)) pending--;
390
+ });
391
+ };
392
+ transport.addEventListener("open", onOpen);
393
+ transport.addEventListener("error", onError);
394
+ transport.addEventListener("message", onMessage);
395
+ transport.addEventListener("close", onClose);
396
+ detach = () => {
397
+ transport.removeEventListener("open", onOpen);
398
+ transport.removeEventListener("error", onError);
399
+ transport.removeEventListener("message", onMessage);
400
+ transport.removeEventListener("close", onClose);
401
+ };
402
+ } catch (cause) {
403
+ if (current(id))
404
+ failed(
405
+ cause instanceof ApiError ? cause : new ApiError(
406
+ "WebSocket connection setup failed",
407
+ 0,
408
+ {},
409
+ void 0,
410
+ "network"
411
+ ),
412
+ !(cause instanceof ApiError) || cause.code === "network"
413
+ );
414
+ }
415
+ }
416
+ function send(data) {
417
+ if (!wanted || !sessionValid() || !socket || socket.readyState !== 1 || snapshot.status !== "open")
418
+ throw new ApiError(
419
+ "WebSocket is not open; message was not queued",
420
+ 0,
421
+ {},
422
+ void 0,
423
+ "network"
424
+ );
425
+ if (socket.bufferedAmount + byteSize(data) > bufferedLimit)
426
+ throw new ApiError(
427
+ "WebSocket outgoing buffer limit exceeded",
428
+ 0,
429
+ {},
430
+ void 0,
431
+ "backpressure"
432
+ );
433
+ try {
434
+ socket.send(data);
435
+ } catch (cause) {
436
+ throw new ApiError(
437
+ "WebSocket send failed; delivery is unknown",
438
+ 0,
439
+ {},
440
+ void 0,
441
+ "network",
442
+ cause
443
+ );
444
+ }
445
+ }
446
+ return Object.freeze({
447
+ connect() {
448
+ if (disposed) configuration("Socket client is disposed");
449
+ if (wanted) return;
450
+ if (requiresAuth && !options.sessionClient?.getSessionSnapshot().userId)
451
+ throw new ApiError(
452
+ "An authenticated session is required",
453
+ 401,
454
+ {},
455
+ void 0,
456
+ "auth"
457
+ );
458
+ revision = options.sessionClient?.getSessionSnapshot().revision;
459
+ wanted = true;
460
+ attempts = 0;
461
+ unsubscribeSession?.();
462
+ unsubscribeSession = options.sessionClient?.subscribeSession(() => {
463
+ if (!sessionValid())
464
+ stop(1e3, "Session changed", { close: null, clearError: true });
465
+ });
466
+ update({ error: null, close: null });
467
+ if (wanted) void attempt();
468
+ },
469
+ disconnect(code = 1e3, reason = "") {
470
+ validateClose(code, reason);
471
+ if (!disposed) stop(code, reason);
472
+ },
473
+ dispose() {
474
+ if (disposed) return;
475
+ disposed = true;
476
+ stop(1e3, "Disposed", {
477
+ status: "disposed",
478
+ close: null,
479
+ clearError: true
480
+ });
481
+ stateListeners.clear();
482
+ messageListeners.clear();
483
+ errorListeners.clear();
484
+ },
485
+ send,
486
+ sendJson(value) {
487
+ let data;
488
+ try {
489
+ data = JSON.stringify(value);
490
+ } catch (cause) {
491
+ throw new ApiError(
492
+ "Message is not JSON serializable",
493
+ 0,
494
+ {},
495
+ void 0,
496
+ "configuration",
497
+ cause
498
+ );
499
+ }
500
+ if (data === void 0) configuration("Message is not JSON serializable");
501
+ send(data);
502
+ },
503
+ getSnapshot: () => snapshot,
504
+ subscribe(listener) {
505
+ if (disposed) configuration("Socket client is disposed");
506
+ stateListeners.add(listener);
507
+ return () => {
508
+ stateListeners.delete(listener);
509
+ };
510
+ },
511
+ onMessage(listener) {
512
+ if (disposed) configuration("Socket client is disposed");
513
+ messageListeners.add(listener);
514
+ return () => {
515
+ messageListeners.delete(listener);
516
+ };
517
+ },
518
+ onError(listener) {
519
+ if (disposed) configuration("Socket client is disposed");
520
+ errorListeners.add(listener);
521
+ return () => {
522
+ errorListeners.delete(listener);
523
+ };
524
+ }
525
+ });
526
+ }
527
+ export {
528
+ createSocketClient
529
+ };
@@ -0,0 +1,8 @@
1
+ import { SocketClient, SocketSnapshot } from './index.js';
2
+ import '../client-ByBlZaRg.js';
3
+
4
+ /** Subscribes only; the application owns connect/disconnect and may share a client. */
5
+ declare function useSocketState<T>(client: SocketClient<T>): SocketSnapshot;
6
+ declare function useSocketMessage<T>(client: SocketClient<T>, listener: (message: T) => void): void;
7
+
8
+ export { useSocketMessage, useSocketState };
@@ -0,0 +1,23 @@
1
+ // src/websocket/react.ts
2
+ import { useEffect, useRef, useSyncExternalStore } from "react";
3
+ function useSocketState(client) {
4
+ return useSyncExternalStore(
5
+ client.subscribe,
6
+ client.getSnapshot,
7
+ client.getSnapshot
8
+ );
9
+ }
10
+ function useSocketMessage(client, listener) {
11
+ const latest = useRef(listener);
12
+ useEffect(() => {
13
+ latest.current = listener;
14
+ });
15
+ useEffect(
16
+ () => client.onMessage((message) => latest.current(message)),
17
+ [client]
18
+ );
19
+ }
20
+ export {
21
+ useSocketMessage,
22
+ useSocketState
23
+ };
@@ -0,0 +1,9 @@
1
+ import { Ref } from 'vue';
2
+ import { SocketClient, SocketSnapshot } from './index.js';
3
+ import '../client-ByBlZaRg.js';
4
+
5
+ /** Subscribes only; the application owns connect/disconnect and may share a client. */
6
+ declare function useSocketState<T>(client: SocketClient<T>): Readonly<Ref<SocketSnapshot>>;
7
+ declare function useSocketMessage<T>(client: SocketClient<T>, listener: (message: T) => void): void;
8
+
9
+ export { useSocketMessage, useSocketState };
@@ -0,0 +1,39 @@
1
+ import {
2
+ ApiError
3
+ } from "../chunk-YTM3FV65.js";
4
+
5
+ // src/websocket/vue.ts
6
+ import {
7
+ getCurrentScope,
8
+ onScopeDispose,
9
+ shallowReadonly,
10
+ shallowRef
11
+ } from "vue";
12
+ function assertScope() {
13
+ if (!getCurrentScope())
14
+ throw new ApiError(
15
+ "Socket composables require an active Vue effect scope",
16
+ 0,
17
+ {},
18
+ void 0,
19
+ "configuration"
20
+ );
21
+ }
22
+ function useSocketState(client) {
23
+ assertScope();
24
+ const state = shallowRef(client.getSnapshot());
25
+ onScopeDispose(
26
+ client.subscribe(() => {
27
+ state.value = client.getSnapshot();
28
+ })
29
+ );
30
+ return shallowReadonly(state);
31
+ }
32
+ function useSocketMessage(client, listener) {
33
+ assertScope();
34
+ onScopeDispose(client.onMessage(listener));
35
+ }
36
+ export {
37
+ useSocketMessage,
38
+ useSocketState
39
+ };