@rxferrix/client 0.0.0-stage → 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.
Files changed (97) hide show
  1. package/dist/api.d.ts +60 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/api.js +22 -0
  4. package/dist/api.js.map +1 -0
  5. package/dist/auth.d.ts +251 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +531 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/bytes.d.ts +21 -0
  10. package/dist/bytes.d.ts.map +1 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/bytes.js.map +1 -0
  13. package/dist/canonical.d.ts +13 -0
  14. package/dist/canonical.d.ts.map +1 -0
  15. package/dist/canonical.js +66 -0
  16. package/dist/canonical.js.map +1 -0
  17. package/dist/client.d.ts +170 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +638 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/diff.d.ts +20 -0
  22. package/dist/diff.d.ts.map +1 -0
  23. package/dist/diff.js +100 -0
  24. package/dist/diff.js.map +1 -0
  25. package/dist/errors.d.ts +24 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +34 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/index.d.ts +14 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/local_query_set.d.ts +56 -0
  34. package/dist/local_query_set.d.ts.map +1 -0
  35. package/dist/local_query_set.js +108 -0
  36. package/dist/local_query_set.js.map +1 -0
  37. package/dist/logging.d.ts +15 -0
  38. package/dist/logging.d.ts.map +1 -0
  39. package/dist/logging.js +17 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/optimistic_updates.d.ts +48 -0
  42. package/dist/optimistic_updates.d.ts.map +1 -0
  43. package/dist/optimistic_updates.js +113 -0
  44. package/dist/optimistic_updates.js.map +1 -0
  45. package/dist/paginated_query.d.ts +118 -0
  46. package/dist/paginated_query.d.ts.map +1 -0
  47. package/dist/paginated_query.js +199 -0
  48. package/dist/paginated_query.js.map +1 -0
  49. package/dist/protocol.d.ts +136 -0
  50. package/dist/protocol.d.ts.map +1 -0
  51. package/dist/protocol.js +11 -0
  52. package/dist/protocol.js.map +1 -0
  53. package/dist/query_state.d.ts +16 -0
  54. package/dist/query_state.d.ts.map +1 -0
  55. package/dist/query_state.js +2 -0
  56. package/dist/query_state.js.map +1 -0
  57. package/dist/remote_query_set.d.ts +37 -0
  58. package/dist/remote_query_set.d.ts.map +1 -0
  59. package/dist/remote_query_set.js +87 -0
  60. package/dist/remote_query_set.js.map +1 -0
  61. package/dist/request_manager.d.ts +57 -0
  62. package/dist/request_manager.d.ts.map +1 -0
  63. package/dist/request_manager.js +163 -0
  64. package/dist/request_manager.js.map +1 -0
  65. package/dist/storage.d.ts +45 -0
  66. package/dist/storage.d.ts.map +1 -0
  67. package/dist/storage.js +120 -0
  68. package/dist/storage.js.map +1 -0
  69. package/dist/testing.d.ts +68 -0
  70. package/dist/testing.d.ts.map +1 -0
  71. package/dist/testing.js +97 -0
  72. package/dist/testing.js.map +1 -0
  73. package/dist/web_socket_manager.d.ts +88 -0
  74. package/dist/web_socket_manager.d.ts.map +1 -0
  75. package/dist/web_socket_manager.js +291 -0
  76. package/dist/web_socket_manager.js.map +1 -0
  77. package/package.json +50 -4
  78. package/src/api.ts +86 -0
  79. package/src/auth.ts +636 -0
  80. package/src/bytes.ts +57 -0
  81. package/src/canonical.ts +61 -0
  82. package/src/client.ts +740 -0
  83. package/src/diff.ts +87 -0
  84. package/src/errors.ts +36 -0
  85. package/src/index.ts +60 -0
  86. package/src/local_query_set.ts +130 -0
  87. package/src/logging.ts +26 -0
  88. package/src/optimistic_updates.ts +137 -0
  89. package/src/paginated_query.ts +299 -0
  90. package/src/protocol.ts +152 -0
  91. package/src/query_state.ts +12 -0
  92. package/src/remote_query_set.ts +105 -0
  93. package/src/request_manager.ts +199 -0
  94. package/src/storage.ts +171 -0
  95. package/src/testing.ts +120 -0
  96. package/src/web_socket_manager.ts +322 -0
  97. package/README.md +0 -3
@@ -0,0 +1,322 @@
1
+ import { CLIENT_VERSION, type ClientMessage, type ServerMessage } from "./protocol.js";
2
+ import type { Logger } from "./logging.js";
3
+
4
+ /** Silence threshold: the server pings every 15s, so 30s without any frame means the socket is dead. */
5
+ export const SERVER_INACTIVITY_TIMEOUT_MS = 30_000;
6
+ const INITIAL_BACKOFF_MS = 250;
7
+ const MAX_BACKOFF_MS = 16_000;
8
+ /** The close code a server sends when it restarts or hands over to a new process (RFC 6455 "Service Restart"). */
9
+ const SERVICE_RESTART = 1012;
10
+ /** Spread of the immediate reconnects after a restart, so every client doesn't arrive in the same millisecond. */
11
+ const RESTART_JITTER_MS = 250;
12
+
13
+ export type SocketState = "disconnected" | "connecting" | "ready" | "stopped";
14
+
15
+ export interface WebSocketManagerCallbacks {
16
+ /**
17
+ * The socket is open, `Connect` has been sent, and the manager is not paused. The client must now (re)build the
18
+ * server state for this socket: Authenticate, ModifyQuerySet, pending mutations.
19
+ */
20
+ onOpen(): void;
21
+ /** `resume()` was called while the socket was already initialized: the client may flush anything it held back. */
22
+ onResume(): void;
23
+ /** A parsed server message (Ping is answered internally and also forwarded). */
24
+ onMessage(message: ServerMessage): void;
25
+ /** The current socket is gone (closed by either side). Called once per socket that reached `connecting`. */
26
+ onClose(reason: string, wasOpen: boolean): void;
27
+ /** Any observable state change (for connection-state subscribers). */
28
+ onStateChange(): void;
29
+ }
30
+
31
+ export interface WebSocketManagerOptions {
32
+ url: string;
33
+ sessionId: string;
34
+ webSocketConstructor: typeof WebSocket;
35
+ logger: Logger;
36
+ }
37
+
38
+ /**
39
+ * Owns the WebSocket: connecting, the `Connect` handshake, the server-silence watchdog, Pong replies, and
40
+ * reconnecting with exponential backoff and jitter.
41
+ *
42
+ * Outgoing traffic is only possible while the socket is `ready` and not paused. While a reconnect is underway (the
43
+ * socket is `connecting` or `disconnected`) nothing is sent: the client keeps its state locally and rebuilds the
44
+ * server's view of it in `onOpen` on the next socket. `pause()` additionally holds back `onOpen` (or lets the client
45
+ * hold back its own sends) while the client fetches an auth token, so that queries are not run with stale identity.
46
+ */
47
+ export class WebSocketManager {
48
+ private readonly url: string;
49
+ private readonly sessionId: string;
50
+ private readonly WS: typeof WebSocket;
51
+ private readonly logger: Logger;
52
+ private readonly callbacks: WebSocketManagerCallbacks;
53
+
54
+ private ws: WebSocket | null = null;
55
+ private _state: SocketState = "disconnected";
56
+ /** True once `Connect` was sent and `onOpen` delivered for the current socket. */
57
+ private initialized = false;
58
+ private paused = false;
59
+ private _connectionCount = -1;
60
+ private _hasEverConnected = false;
61
+ private lastCloseReason: string | null = null;
62
+ private retries = 0;
63
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
64
+ private watchdogTimer: ReturnType<typeof setTimeout> | null = null;
65
+ private readonly removeEnvListeners: () => void;
66
+
67
+ constructor(options: WebSocketManagerOptions, callbacks: WebSocketManagerCallbacks) {
68
+ this.url = options.url;
69
+ this.sessionId = options.sessionId;
70
+ this.WS = options.webSocketConstructor;
71
+ this.logger = options.logger;
72
+ this.callbacks = callbacks;
73
+ this.removeEnvListeners = this.installEnvListeners();
74
+ }
75
+
76
+ /** Opens the first socket. */
77
+ start(): void {
78
+ if (this.ws === null && this.reconnectTimer === null) this.connect();
79
+ }
80
+
81
+ get state(): SocketState {
82
+ return this._state;
83
+ }
84
+ get connectionCount(): number {
85
+ return Math.max(this._connectionCount, 0);
86
+ }
87
+ get hasEverConnected(): boolean {
88
+ return this._hasEverConnected;
89
+ }
90
+ get connectionRetries(): number {
91
+ return this.retries;
92
+ }
93
+ get isPaused(): boolean {
94
+ return this.paused;
95
+ }
96
+
97
+ /** True when a message handed to `send` would go out right now. */
98
+ canSend(): boolean {
99
+ return this._state === "ready" && this.initialized && !this.paused;
100
+ }
101
+
102
+ /** Sends if possible; returns whether the message was sent. */
103
+ send(message: ClientMessage): boolean {
104
+ if (!this.canSend()) {
105
+ this.logger.debug("not sent (socket not ready):", message.type);
106
+ return false;
107
+ }
108
+ return this.rawSend(message);
109
+ }
110
+
111
+ /** Called by the client once the server has proven healthy (a Transition was applied): resets the backoff. */
112
+ markHealthy(): void {
113
+ this.retries = 0;
114
+ }
115
+
116
+ /** Holds back `onOpen` and client sends until `resume()`. Pings are still answered. */
117
+ pause(): void {
118
+ if (this._state === "stopped") return;
119
+ this.paused = true;
120
+ this.callbacks.onStateChange();
121
+ }
122
+
123
+ resume(): void {
124
+ if (!this.paused) return;
125
+ this.paused = false;
126
+ if (this._state === "ready") {
127
+ if (!this.initialized) {
128
+ this.initialized = true;
129
+ this.callbacks.onOpen();
130
+ } else {
131
+ this.callbacks.onResume();
132
+ }
133
+ }
134
+ this.callbacks.onStateChange();
135
+ }
136
+
137
+ /** Drops the current socket and reconnects after a backoff delay. */
138
+ reconnect(reason: string): void {
139
+ if (this._state === "stopped") return;
140
+ this.logger.debug(`reconnecting: ${reason}`);
141
+ this.closeCurrent(reason);
142
+ this.scheduleReconnect(reason);
143
+ }
144
+
145
+ /** Permanently closes the socket. */
146
+ stop(): void {
147
+ if (this._state === "stopped") return;
148
+ this.closeCurrent("ClientClosed");
149
+ this.clearReconnectTimer();
150
+ this._state = "stopped";
151
+ this.removeEnvListeners();
152
+ this.callbacks.onStateChange();
153
+ }
154
+
155
+ // -------------------------------------------------------------------------------------------------------------
156
+
157
+ private rawSend(message: ClientMessage): boolean {
158
+ const ws = this.ws;
159
+ if (!ws) return false;
160
+ try {
161
+ this.logger.debug("->", message);
162
+ ws.send(JSON.stringify(message));
163
+ return true;
164
+ } catch (e) {
165
+ this.logger.warn("WebSocket send failed", e);
166
+ this.reconnect("SendFailed");
167
+ return false;
168
+ }
169
+ }
170
+
171
+ private connect(): void {
172
+ if (this._state === "stopped") return;
173
+ this.clearReconnectTimer();
174
+ let ws: WebSocket;
175
+ try {
176
+ ws = new this.WS(this.url);
177
+ } catch (e) {
178
+ this.logger.warn("failed to construct WebSocket", e);
179
+ this._state = "disconnected";
180
+ this.scheduleReconnect("ConstructorFailed");
181
+ return;
182
+ }
183
+ this.ws = ws;
184
+ this._state = "connecting";
185
+ this.initialized = false;
186
+ this._connectionCount += 1;
187
+ this.callbacks.onStateChange();
188
+
189
+ ws.onopen = () => {
190
+ if (this.ws !== ws) return;
191
+ this._state = "ready";
192
+ this._hasEverConnected = true;
193
+ this.resetWatchdog();
194
+ this.rawSend({
195
+ type: "Connect",
196
+ sessionId: this.sessionId,
197
+ connectionCount: this._connectionCount,
198
+ lastCloseReason: this.lastCloseReason,
199
+ clientVersion: CLIENT_VERSION,
200
+ features: ["diffs"],
201
+ });
202
+ if (!this.paused) {
203
+ this.initialized = true;
204
+ this.callbacks.onOpen();
205
+ }
206
+ this.callbacks.onStateChange();
207
+ };
208
+ ws.onmessage = (event: MessageEvent) => {
209
+ if (this.ws !== ws) return;
210
+ this.resetWatchdog();
211
+ let message: ServerMessage;
212
+ try {
213
+ message = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)) as ServerMessage;
214
+ } catch (e) {
215
+ this.logger.warn("received malformed frame", e);
216
+ this.reconnect("MalformedMessage");
217
+ return;
218
+ }
219
+ this.logger.debug("<-", message);
220
+ if (message.type === "Ping") this.rawSend({ type: "Pong" });
221
+ this.callbacks.onMessage(message);
222
+ };
223
+ ws.onerror = () => {
224
+ // An error is always followed by a close event; reconnection is handled there.
225
+ if (this.ws === ws) this.logger.debug("WebSocket error");
226
+ };
227
+ ws.onclose = (event: CloseEvent) => {
228
+ if (this.ws !== ws) return;
229
+ const reason = event?.reason ? `${event.code}: ${event.reason}` : `closed (${event?.code ?? "?"})`;
230
+ this.closeCurrent(reason);
231
+ // 1012 "service restart": the server is restarting or handing over to a new process, and the new one is
232
+ // already accepting (or about to): reconnect right away (with a little jitter) instead of backing off.
233
+ if (event?.code === SERVICE_RESTART) this.retries = 0;
234
+ this.scheduleReconnect(reason, event?.code === SERVICE_RESTART ? Math.random() * RESTART_JITTER_MS : undefined);
235
+ };
236
+ }
237
+
238
+ /** Detaches and closes the current socket (if any), reporting `onClose` once. */
239
+ private closeCurrent(reason: string): void {
240
+ this.clearWatchdog();
241
+ const ws = this.ws;
242
+ if (!ws) return;
243
+ const wasOpen = this._state === "ready";
244
+ this.ws = null;
245
+ this.initialized = false;
246
+ this._state = "disconnected";
247
+ this.lastCloseReason = reason;
248
+ ws.onopen = null;
249
+ ws.onmessage = null;
250
+ ws.onerror = null;
251
+ ws.onclose = null;
252
+ try {
253
+ ws.close();
254
+ } catch {
255
+ // ignore
256
+ }
257
+ this.callbacks.onClose(reason, wasOpen);
258
+ this.callbacks.onStateChange();
259
+ }
260
+
261
+ private scheduleReconnect(reason: string, delayMs?: number): void {
262
+ if (this._state === "stopped" || this.reconnectTimer !== null) return;
263
+ const delay = delayMs ?? this.nextBackoff();
264
+ this.retries += 1;
265
+ this.logger.info(`connection lost (${reason}); reconnecting in ${Math.round(delay)}ms`);
266
+ this.reconnectTimer = setTimeout(() => {
267
+ this.reconnectTimer = null;
268
+ this.connect();
269
+ }, delay);
270
+ this.callbacks.onStateChange();
271
+ }
272
+
273
+ /** Exponential backoff with "equal jitter": half fixed, half random. */
274
+ private nextBackoff(): number {
275
+ const cap = Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** Math.min(this.retries, 16));
276
+ return cap / 2 + Math.random() * (cap / 2);
277
+ }
278
+
279
+ private clearReconnectTimer(): void {
280
+ if (this.reconnectTimer !== null) {
281
+ clearTimeout(this.reconnectTimer);
282
+ this.reconnectTimer = null;
283
+ }
284
+ }
285
+
286
+ private resetWatchdog(): void {
287
+ this.clearWatchdog();
288
+ this.watchdogTimer = setTimeout(() => {
289
+ this.watchdogTimer = null;
290
+ this.reconnect("ServerInactivity");
291
+ }, SERVER_INACTIVITY_TIMEOUT_MS);
292
+ }
293
+
294
+ private clearWatchdog(): void {
295
+ if (this.watchdogTimer !== null) {
296
+ clearTimeout(this.watchdogTimer);
297
+ this.watchdogTimer = null;
298
+ }
299
+ }
300
+
301
+ /** Skip the backoff wait when the browser comes back online or the tab becomes visible. */
302
+ private installEnvListeners(): () => void {
303
+ const g = globalThis as {
304
+ addEventListener?: (t: string, l: () => void) => void;
305
+ removeEventListener?: (t: string, l: () => void) => void;
306
+ document?: { visibilityState?: string };
307
+ };
308
+ if (typeof g.addEventListener !== "function" || typeof g.removeEventListener !== "function") return () => {};
309
+ const kick = () => {
310
+ if (g.document?.visibilityState === "hidden") return;
311
+ if (this._state === "disconnected" && this.reconnectTimer !== null) {
312
+ this.connect();
313
+ }
314
+ };
315
+ g.addEventListener("online", kick);
316
+ g.addEventListener("visibilitychange", kick);
317
+ return () => {
318
+ g.removeEventListener?.("online", kick);
319
+ g.removeEventListener?.("visibilitychange", kick);
320
+ };
321
+ }
322
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.