@streamotter/client 0.1.0-rc.1

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/src/client.ts ADDED
@@ -0,0 +1,456 @@
1
+ import {
2
+ asStreamOtterError, DEFAULT_READY_TIMEOUT_MS, DEFAULT_SOCKET_PATH, GET_TOKEN_TIMEOUT_MS, parseUtcTimestamp,
3
+ RECONNECT_BASE_MS, RECONNECT_CAP_MS, streamError, StreamOtterError, TOKEN_REFRESH_LEAD_MS,
4
+ type ChannelMap, type Client, type ClientOptions, type ConnectionState, type ErrorCode, type ErrorFrame, type Hello,
5
+ type Params, type StateChange, type StreamError, type Subscription, type Unlisten, type WaitOptions
6
+ } from "@streamotter/contracts";
7
+ import { Connection } from "./connection.ts";
8
+ import { ClientSubscription, type ManagedSubscription, type SubscriptionOwner } from "./subscription.ts";
9
+
10
+ function resolveOrigin(origin: string | undefined): string {
11
+ const candidate = origin ?? (globalThis as { location?: { origin?: string } }).location?.origin;
12
+ if (typeof candidate !== "string") {
13
+ throw new StreamOtterError("INVALID_REQUEST", { message: "origin is required outside a browser page." });
14
+ }
15
+ let url: URL;
16
+ try {
17
+ url = new URL(candidate);
18
+ } catch {
19
+ throw new StreamOtterError("INVALID_REQUEST", { message: "origin must be an absolute http(s) origin." });
20
+ }
21
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== candidate.replace(/\/$/, "")) {
22
+ throw new StreamOtterError("INVALID_REQUEST", { message: "origin must be an absolute http(s) origin without a path." });
23
+ }
24
+ return url.origin;
25
+ }
26
+
27
+ function randomId(): string {
28
+ return globalThis.crypto.randomUUID();
29
+ }
30
+
31
+ /**
32
+ * Browser SDK client. Owns one Socket.IO connection at a time, reconnects with
33
+ * full-jitter backoff while subscriptions are active, suspends on authentication
34
+ * rejection until reconnect(), refreshes before token expiry, and closes prior
35
+ * subscriptions when the authenticated identity changes.
36
+ */
37
+ export class StreamClient<C extends ChannelMap> implements Client<C>, SubscriptionOwner {
38
+ readonly #origin: string;
39
+ readonly #path: string;
40
+ readonly #getToken: ClientOptions["getToken"];
41
+ readonly #stateListeners = new Set<(state: StateChange<ConnectionState>) => void>();
42
+ readonly #errorListeners = new Set<(error: StreamError) => void>();
43
+ readonly #subscriptions = new Set<ManagedSubscription>();
44
+ #state: ConnectionState = "idle";
45
+ #connection: Connection | null = null;
46
+ #identityKey: string | null = null;
47
+ #generation = 0;
48
+ #connecting = false;
49
+ #suspended = false;
50
+ #closed = false;
51
+ #everConnected = false;
52
+ #attempt = 0;
53
+ #lastConnectionError: StreamError | null = null;
54
+ #reconnectTimer: ReturnType<typeof setTimeout> | null = null;
55
+ #refreshTimer: ReturnType<typeof setTimeout> | null = null;
56
+ #tokenAbort: AbortController | null = null;
57
+ #closing: Promise<void> | null = null;
58
+
59
+ constructor(options: ClientOptions) {
60
+ if (typeof options !== "object" || options === null || typeof options.getToken !== "function") {
61
+ throw new StreamOtterError("INVALID_REQUEST", { message: "createClient requires a getToken function." });
62
+ }
63
+ this.#origin = resolveOrigin(options.origin);
64
+ this.#path = options.path ?? DEFAULT_SOCKET_PATH;
65
+ if (!this.#path.startsWith("/")) throw new StreamOtterError("INVALID_REQUEST", { message: "path must start with /." });
66
+ this.#getToken = options.getToken;
67
+ }
68
+
69
+ get state(): ConnectionState {
70
+ return this.#state;
71
+ }
72
+
73
+ // --- SubscriptionOwner ---------------------------------------------------------------
74
+
75
+ get closed(): boolean {
76
+ return this.#closed;
77
+ }
78
+
79
+ get authRequired(): boolean {
80
+ return this.#suspended;
81
+ }
82
+
83
+ get connection(): Connection | null {
84
+ return this.#connection !== null && this.#connection.connected ? this.#connection : null;
85
+ }
86
+
87
+ register(subscription: ManagedSubscription): void {
88
+ if (this.#closed) return;
89
+ this.#subscriptions.add(subscription);
90
+ if (this.#suspended) {
91
+ subscription.authRequired(this.#lastConnectionError ?? streamError("UNAUTHENTICATED"));
92
+ return;
93
+ }
94
+ const connection = this.connection;
95
+ if (connection !== null) subscription.attach(connection);
96
+ else this.#ensureConnection();
97
+ }
98
+
99
+ unregister(subscription: ManagedSubscription): void {
100
+ this.#subscriptions.delete(subscription);
101
+ }
102
+
103
+ controlTimedOut(connection: Connection): void {
104
+ if (connection !== this.#connection) return;
105
+ // After an ambiguous control request, rebuild on a new connection instead of retrying on this one.
106
+ connection.close();
107
+ }
108
+
109
+ randomId(): string {
110
+ return randomId();
111
+ }
112
+
113
+ logListenerFailure(error: unknown): void {
114
+ console.error("[streamotter] listener failed", error);
115
+ }
116
+
117
+ // --- public API ------------------------------------------------------------------------
118
+
119
+ subscribe<K extends keyof C & string>(channel: K, options: { channelVersion: C[K]["version"]; params: C[K]["params"] }): Subscription<C[K]["data"]> {
120
+ if (this.#closed) throw new StreamOtterError("CLIENT_CLOSED");
121
+ if (typeof channel !== "string" || typeof options !== "object" || options === null
122
+ || typeof options.channelVersion !== "number" || typeof options.params !== "object" || options.params === null) {
123
+ throw new StreamOtterError("INVALID_REQUEST", { message: "subscribe requires a channel name, channelVersion, and params." });
124
+ }
125
+ const unsupported = Object.keys(options).find(key => key !== "channelVersion" && key !== "params");
126
+ if (unsupported !== undefined) {
127
+ throw new StreamOtterError("UNSUPPORTED_CAPABILITY", { message: `"${unsupported}" is not supported by this SDK version.` });
128
+ }
129
+ return new ClientSubscription<C[K]["data"]>(this, channel, options.channelVersion, { ...options.params } as Params);
130
+ }
131
+
132
+ on(event: "state", listener: (state: StateChange<ConnectionState>) => void): Unlisten;
133
+ on(event: "error", listener: (error: StreamError) => void): Unlisten;
134
+ on(event: "state" | "error", listener: ((state: StateChange<ConnectionState>) => void) | ((error: StreamError) => void)): Unlisten {
135
+ const set = (event === "state" ? this.#stateListeners : event === "error" ? this.#errorListeners : undefined) as Set<typeof listener> | undefined;
136
+ if (set === undefined || typeof listener !== "function") {
137
+ throw new StreamOtterError("INVALID_REQUEST", { message: "on() accepts \"state\" or \"error\" with a function." });
138
+ }
139
+ set.add(listener);
140
+ return () => { set.delete(listener); };
141
+ }
142
+
143
+ /** Obtains a new token, replaces the connection, and recreates active subscriptions with fresh snapshots. */
144
+ reconnect(options?: WaitOptions): Promise<void> {
145
+ if (this.#closed) return Promise.reject(new StreamOtterError("CLIENT_CLOSED"));
146
+ this.#suspended = false;
147
+ this.#lastConnectionError = null;
148
+ this.#attempt = 0;
149
+ this.#clearReconnectTimer();
150
+ const previous = this.#connection;
151
+ this.#connection = null;
152
+ this.#connecting = false;
153
+ this.#generation++;
154
+ if (previous !== null) {
155
+ previous.close();
156
+ for (const subscription of this.#subscriptions) subscription.detach();
157
+ }
158
+ const wait = this.#waitForConnection(options);
159
+ void this.#connect(true);
160
+ return wait;
161
+ }
162
+
163
+ close(): Promise<void> {
164
+ if (this.#closing !== null) return this.#closing;
165
+ this.#closed = true;
166
+ this.#generation++;
167
+ this.#clearReconnectTimer();
168
+ this.#clearRefreshTimer();
169
+ this.#tokenAbort?.abort();
170
+ const error = streamError("CLIENT_CLOSED");
171
+ for (const subscription of [...this.#subscriptions]) subscription.terminateLocally("closed", error, false);
172
+ this.#subscriptions.clear();
173
+ this.#connection?.close();
174
+ this.#connection = null;
175
+ this.#setState("closed");
176
+ this.#stateListeners.clear();
177
+ this.#errorListeners.clear();
178
+ this.#closing = Promise.resolve();
179
+ return this.#closing;
180
+ }
181
+
182
+ // --- connection management -------------------------------------------------------------
183
+
184
+ #ensureConnection(): void {
185
+ if (this.#closed || this.#suspended || this.#connecting || this.#reconnectTimer !== null) return;
186
+ if (this.#connection !== null) return;
187
+ void this.#connect(false);
188
+ }
189
+
190
+ async #connect(explicit: boolean): Promise<void> {
191
+ if (this.#closed) return;
192
+ const generation = ++this.#generation;
193
+ this.#connecting = true;
194
+ this.#setState(this.#everConnected && !explicit ? "reconnecting" : "connecting");
195
+ let opened: { connection: Connection; hello: Hello };
196
+ try {
197
+ opened = await this.#open();
198
+ } catch (error) {
199
+ if (generation !== this.#generation) return;
200
+ this.#connecting = false;
201
+ this.#handleConnectFailure(error as StreamError);
202
+ return;
203
+ }
204
+ if (generation !== this.#generation) {
205
+ opened.connection.close();
206
+ return;
207
+ }
208
+ this.#connecting = false;
209
+ this.#adopt(opened.connection, opened.hello);
210
+ }
211
+
212
+ /** getToken (ten-second deadline) then a new authenticated connection. */
213
+ async #open(): Promise<{ connection: Connection; hello: Hello }> {
214
+ const controller = new AbortController();
215
+ this.#tokenAbort = controller;
216
+ let token: string;
217
+ let timer: ReturnType<typeof setTimeout> | undefined;
218
+ try {
219
+ token = await Promise.race([
220
+ Promise.resolve().then(() => this.#getToken({ signal: controller.signal })),
221
+ new Promise<never>((_resolve, reject) => {
222
+ timer = setTimeout(() => {
223
+ controller.abort();
224
+ reject(streamError("UNAUTHENTICATED", { message: "getToken did not resolve within ten seconds.", retryable: true }));
225
+ }, GET_TOKEN_TIMEOUT_MS);
226
+ })
227
+ ]);
228
+ } catch (error) {
229
+ if (typeof error === "object" && error !== null && "code" in error && (error as StreamError).code === "UNAUTHENTICATED") throw error;
230
+ throw streamError("UNAUTHENTICATED", { message: "getToken failed; call reconnect() once the application session is available.", retryable: true });
231
+ } finally {
232
+ clearTimeout(timer);
233
+ if (this.#tokenAbort === controller) this.#tokenAbort = null;
234
+ }
235
+ if (typeof token !== "string" || token.length === 0) {
236
+ throw streamError("UNAUTHENTICATED", { message: "getToken must resolve to a non-empty string.", retryable: false });
237
+ }
238
+ let connection: Connection | null = null;
239
+ const events = {
240
+ state: (frame: Parameters<ManagedSubscription["handleState"]>[1]) => this.#route(connection, frame.subscriptionId, sub => sub.handleState(connection as Connection, frame)),
241
+ data: (frame: Parameters<ManagedSubscription["handleData"]>[1]) => this.#route(connection, frame.subscriptionId, sub => sub.handleData(connection as Connection, frame)),
242
+ error: (frame: ErrorFrame) => this.#handleErrorFrame(connection, frame),
243
+ disconnect: () => this.#handleDisconnect(connection)
244
+ };
245
+ connection = new Connection({ origin: this.#origin, path: this.#path, token, events });
246
+ const hello = await connection.ready;
247
+ return { connection, hello };
248
+ }
249
+
250
+ #adopt(connection: Connection, hello: Hello): void {
251
+ if (this.#closed) {
252
+ connection.close();
253
+ return;
254
+ }
255
+ const previous = this.#connection;
256
+ this.#connection = connection;
257
+ if (previous !== null && previous !== connection) previous.close();
258
+ if (this.#identityKey !== null && hello.identityKey !== this.#identityKey) {
259
+ // Never carry a previous user's view across an account switch.
260
+ const error = streamError("UNAUTHENTICATED", { message: "The authenticated identity changed; create new subscriptions for the new identity." });
261
+ for (const subscription of [...this.#subscriptions]) subscription.terminateLocally("closed", error, true);
262
+ this.#subscriptions.clear();
263
+ this.#emitError(error);
264
+ }
265
+ this.#identityKey = hello.identityKey;
266
+ this.#everConnected = true;
267
+ this.#attempt = 0;
268
+ this.#lastConnectionError = null;
269
+ this.#setState("connected");
270
+ this.#scheduleRefresh(hello.authExpiresAt);
271
+ for (const subscription of [...this.#subscriptions]) subscription.attach(connection);
272
+ }
273
+
274
+ #handleConnectFailure(error: StreamError): void {
275
+ this.#lastConnectionError = error;
276
+ this.#emitError(error);
277
+ const suspend = error.code === "UNAUTHENTICATED" || error.code === "FORBIDDEN"
278
+ || error.code === "INVALID_REQUEST" || error.code === "UNSUPPORTED_CAPABILITY";
279
+ if (suspend) this.#suspend(error);
280
+ else this.#scheduleReconnect();
281
+ }
282
+
283
+ #handleDisconnect(connection: Connection | null): void {
284
+ if (connection === null || connection !== this.#connection) return;
285
+ this.#connection = null;
286
+ this.#clearRefreshTimer();
287
+ const error = this.#lastConnectionError;
288
+ const reason: ErrorCode | undefined = error?.code;
289
+ for (const subscription of this.#subscriptions) subscription.detach(reason);
290
+ if (this.#closed) return;
291
+ if (error !== null && error.code === "UNAUTHENTICATED" && !error.retryable) {
292
+ this.#suspend(error);
293
+ return;
294
+ }
295
+ if (error !== null && error.code === "UNAUTHENTICATED") this.#attempt = 0; // Expiry: reconnect promptly.
296
+ this.#scheduleReconnect();
297
+ }
298
+
299
+ #suspend(error: StreamError): void {
300
+ this.#suspended = true;
301
+ this.#clearReconnectTimer();
302
+ this.#setState("auth-required", error.code);
303
+ for (const subscription of [...this.#subscriptions]) subscription.authRequired(error);
304
+ }
305
+
306
+ #scheduleReconnect(): void {
307
+ if (this.#closed || this.#suspended) return;
308
+ this.#clearReconnectTimer();
309
+ if (this.#subscriptions.size === 0) {
310
+ this.#setState("idle");
311
+ return;
312
+ }
313
+ this.#setState("reconnecting", this.#lastConnectionError?.code);
314
+ const ceiling = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** this.#attempt);
315
+ this.#attempt++;
316
+ const delay = Math.random() * ceiling;
317
+ this.#reconnectTimer = setTimeout(() => {
318
+ this.#reconnectTimer = null;
319
+ if (this.#subscriptions.size === 0) {
320
+ this.#setState("idle");
321
+ return;
322
+ }
323
+ void this.#connect(false);
324
+ }, delay);
325
+ }
326
+
327
+ #scheduleRefresh(authExpiresAt: string): void {
328
+ this.#clearRefreshTimer();
329
+ const expiresAt = parseUtcTimestamp(authExpiresAt);
330
+ if (!Number.isFinite(expiresAt)) return;
331
+ const delay = expiresAt - TOKEN_REFRESH_LEAD_MS - Date.now();
332
+ if (delay <= 0 || delay > 2_147_000_000) return;
333
+ this.#refreshTimer = setTimeout(() => {
334
+ this.#refreshTimer = null;
335
+ void this.#refresh(expiresAt);
336
+ }, delay);
337
+ }
338
+
339
+ /** Replaces the connection with a freshly authenticated one before the token expires. */
340
+ async #refresh(expiresAt: number): Promise<void> {
341
+ if (this.#closed || this.#suspended || this.#connection === null) return;
342
+ const generation = this.#generation;
343
+ try {
344
+ const opened = await this.#open();
345
+ if (generation !== this.#generation || this.#closed || this.#suspended) {
346
+ opened.connection.close();
347
+ return;
348
+ }
349
+ this.#generation++;
350
+ this.#adopt(opened.connection, opened.hello);
351
+ } catch (error) {
352
+ if (generation !== this.#generation) return;
353
+ this.#emitError(error as StreamError);
354
+ if (Date.now() + 5_000 < expiresAt) {
355
+ this.#refreshTimer = setTimeout(() => {
356
+ this.#refreshTimer = null;
357
+ void this.#refresh(expiresAt);
358
+ }, 5_000);
359
+ }
360
+ }
361
+ }
362
+
363
+ #waitForConnection(options: WaitOptions | undefined): Promise<void> {
364
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
365
+ const signal = options?.signal;
366
+ if (signal?.aborted) return Promise.reject(new StreamOtterError("CANCELLED"));
367
+ return new Promise<void>((resolve, reject) => {
368
+ const cleanup = () => {
369
+ clearTimeout(timer);
370
+ signal?.removeEventListener("abort", onAbort);
371
+ unlisten();
372
+ };
373
+ const onAbort = () => { cleanup(); reject(new StreamOtterError("CANCELLED")); };
374
+ const timer = setTimeout(() => {
375
+ cleanup();
376
+ reject(new StreamOtterError("TIMEOUT", { message: `The client did not connect within ${timeoutMs} ms.` }));
377
+ }, timeoutMs);
378
+ const unlisten = this.#onInternalState(state => {
379
+ if (state === "connected") { cleanup(); resolve(); }
380
+ else if (state === "auth-required") { cleanup(); reject(asStreamOtterError(this.#lastConnectionError ?? streamError("UNAUTHENTICATED"))); }
381
+ else if (state === "closed") { cleanup(); reject(new StreamOtterError("CLIENT_CLOSED")); }
382
+ });
383
+ signal?.addEventListener("abort", onAbort, { once: true });
384
+ });
385
+ }
386
+
387
+ readonly #internalStateListeners = new Set<(state: ConnectionState) => void>();
388
+
389
+ #onInternalState(listener: (state: ConnectionState) => void): () => void {
390
+ this.#internalStateListeners.add(listener);
391
+ return () => { this.#internalStateListeners.delete(listener); };
392
+ }
393
+
394
+ #route(connection: Connection | null, subscriptionId: string, handle: (subscription: ManagedSubscription) => void): void {
395
+ if (connection === null || connection !== this.#connection) return;
396
+ for (const subscription of this.#subscriptions) {
397
+ if (subscription.id === subscriptionId) {
398
+ handle(subscription);
399
+ return;
400
+ }
401
+ }
402
+ }
403
+
404
+ #handleErrorFrame(connection: Connection | null, frame: ErrorFrame): void {
405
+ if (connection === null || connection !== this.#connection) return;
406
+ if (frame.subscriptionId !== undefined) {
407
+ this.#route(connection, frame.subscriptionId, subscription => subscription.handleError(connection, frame.error));
408
+ return;
409
+ }
410
+ this.#lastConnectionError = frame.error;
411
+ this.#emitError(frame.error);
412
+ }
413
+
414
+ #setState(state: ConnectionState, reason?: ErrorCode): void {
415
+ if (this.#state === state) return;
416
+ this.#state = state;
417
+ const change: StateChange<ConnectionState> = reason === undefined ? { state } : { state, reason };
418
+ for (const listener of [...this.#internalStateListeners]) listener(state);
419
+ for (const listener of [...this.#stateListeners]) {
420
+ try {
421
+ listener(change);
422
+ } catch (error) {
423
+ this.logListenerFailure(error);
424
+ }
425
+ }
426
+ }
427
+
428
+ #emitError(error: StreamError): void {
429
+ for (const listener of [...this.#errorListeners]) {
430
+ try {
431
+ listener(error);
432
+ } catch (cause) {
433
+ this.logListenerFailure(cause);
434
+ }
435
+ }
436
+ }
437
+
438
+ #clearReconnectTimer(): void {
439
+ if (this.#reconnectTimer !== null) {
440
+ clearTimeout(this.#reconnectTimer);
441
+ this.#reconnectTimer = null;
442
+ }
443
+ }
444
+
445
+ #clearRefreshTimer(): void {
446
+ if (this.#refreshTimer !== null) {
447
+ clearTimeout(this.#refreshTimer);
448
+ this.#refreshTimer = null;
449
+ }
450
+ }
451
+ }
452
+
453
+ /** Creates an idle client; it connects when the first subscription starts. */
454
+ export function createClient<C extends ChannelMap>(options: ClientOptions): Client<C> {
455
+ return new StreamClient<C>(options);
456
+ }
@@ -0,0 +1,138 @@
1
+ import { io, type Socket } from "socket.io-client";
2
+ import {
3
+ CONTROL_CALLBACK_TIMEOUT_MS, EVENTS, HELLO_TIMEOUT_MS, isStreamError, PROTOCOL_VERSION, streamError,
4
+ type DataFrame, type ErrorFrame, type Hello, type Receipt, type Result, type StreamError, type SubscriptionFrame
5
+ } from "@streamotter/contracts";
6
+ import { isDataFrame, isErrorFrame, isHello, isResult, isSubscriptionFrame } from "./frames.ts";
7
+
8
+ export interface ConnectionEvents {
9
+ state(frame: SubscriptionFrame): void;
10
+ data(frame: DataFrame): void;
11
+ error(frame: ErrorFrame): void;
12
+ disconnect(): void;
13
+ }
14
+
15
+ /**
16
+ * One Socket.IO connection. Socket.IO reconnection and connection-state recovery
17
+ * are disabled: the SDK owns reconnection and subscribes again only after an
18
+ * authenticated so:hello. Control requests are never buffered while disconnected.
19
+ */
20
+ export class Connection {
21
+ readonly ready: Promise<Hello>;
22
+ readonly #socket: Socket;
23
+ readonly #events: ConnectionEvents;
24
+ #connected = false;
25
+ #closed = false;
26
+ #disconnectNotified = false;
27
+ readonly #pending = new Set<(error: StreamError) => void>();
28
+
29
+ constructor(options: { origin: string; path: string; token: string; events: ConnectionEvents }) {
30
+ this.#events = options.events;
31
+ this.#socket = io(options.origin, {
32
+ path: options.path,
33
+ transports: ["websocket"],
34
+ upgrade: false,
35
+ reconnection: false,
36
+ forceNew: true,
37
+ multiplex: false,
38
+ timeout: HELLO_TIMEOUT_MS,
39
+ auth: { token: options.token, protocolVersion: PROTOCOL_VERSION },
40
+ autoConnect: false
41
+ });
42
+ this.ready = new Promise<Hello>((resolve, reject) => {
43
+ const timer = setTimeout(() => {
44
+ reject(streamError("TIMEOUT", { message: "The gateway did not complete the handshake within ten seconds." }));
45
+ this.close();
46
+ }, HELLO_TIMEOUT_MS);
47
+ this.#socket.once("connect_error", (error: Error & { data?: unknown }) => {
48
+ clearTimeout(timer);
49
+ reject(isStreamError(error.data)
50
+ ? error.data
51
+ : streamError("SOURCE_UNAVAILABLE", { message: "The gateway could not be reached; retrying.", retryable: true }));
52
+ this.close();
53
+ });
54
+ this.#socket.once(EVENTS.hello, (hello: unknown) => {
55
+ clearTimeout(timer);
56
+ if (!isHello(hello) || hello.protocolVersion !== PROTOCOL_VERSION || !hello.deliveryModes.includes("state")) {
57
+ reject(streamError("UNSUPPORTED_CAPABILITY", { message: "The gateway does not support protocol version 1 state delivery." }));
58
+ this.close();
59
+ return;
60
+ }
61
+ this.#connected = true;
62
+ resolve(hello);
63
+ });
64
+ this.#socket.once("disconnect", () => {
65
+ clearTimeout(timer);
66
+ if (!this.#connected) reject(streamError("SOURCE_UNAVAILABLE", { message: "The connection closed during the handshake.", retryable: true }));
67
+ });
68
+ });
69
+ this.ready.catch(() => undefined);
70
+
71
+ this.#socket.on(EVENTS.state, (frame: unknown) => {
72
+ if (this.#connected && isSubscriptionFrame(frame)) this.#events.state(frame);
73
+ });
74
+ this.#socket.on(EVENTS.data, (frame: unknown) => {
75
+ if (this.#connected && isDataFrame(frame)) this.#events.data(frame);
76
+ });
77
+ this.#socket.on(EVENTS.error, (frame: unknown) => {
78
+ if (isErrorFrame(frame)) this.#events.error(frame);
79
+ });
80
+ this.#socket.on("disconnect", () => this.#handleDisconnect());
81
+ this.#socket.connect();
82
+ }
83
+
84
+ get connected(): boolean {
85
+ return this.#connected && !this.#closed;
86
+ }
87
+
88
+ /** Sends a control request; rejects with TIMEOUT after five seconds or when the connection drops. */
89
+ request<T>(event: string, payload: unknown): Promise<Result<T>> {
90
+ if (!this.connected) return Promise.reject(streamError("SOURCE_UNAVAILABLE", { message: "Not connected.", retryable: true }));
91
+ return new Promise<Result<T>>((resolve, reject) => {
92
+ const fail = (error: StreamError) => {
93
+ this.#pending.delete(fail);
94
+ reject(error);
95
+ };
96
+ this.#pending.add(fail);
97
+ this.#socket.timeout(CONTROL_CALLBACK_TIMEOUT_MS).emit(event, payload, (error: Error | null, result: unknown) => {
98
+ if (!this.#pending.has(fail)) return;
99
+ this.#pending.delete(fail);
100
+ if (error !== null) {
101
+ reject(streamError("TIMEOUT", { message: "The gateway did not acknowledge a control request in time." }));
102
+ return;
103
+ }
104
+ if (!isResult(result)) {
105
+ reject(streamError("INVALID_REQUEST", { message: "The gateway sent a malformed acknowledgement." }));
106
+ return;
107
+ }
108
+ resolve(result as Result<T>);
109
+ });
110
+ });
111
+ }
112
+
113
+ receipt(receipt: Receipt): void {
114
+ if (this.connected) this.#socket.emit(EVENTS.receipt, receipt);
115
+ }
116
+
117
+ close(): void {
118
+ if (this.#closed) return;
119
+ this.#closed = true;
120
+ this.#socket.removeAllListeners(EVENTS.state);
121
+ this.#socket.removeAllListeners(EVENTS.data);
122
+ this.#socket.disconnect();
123
+ this.#handleDisconnect();
124
+ }
125
+
126
+ #handleDisconnect(): void {
127
+ this.#closed = true;
128
+ const wasConnected = this.#connected;
129
+ this.#connected = false;
130
+ for (const fail of [...this.#pending]) {
131
+ fail(streamError("SOURCE_UNAVAILABLE", { message: "The connection closed before the request was acknowledged.", retryable: true }));
132
+ }
133
+ if (wasConnected && !this.#disconnectNotified) {
134
+ this.#disconnectNotified = true;
135
+ this.#events.disconnect();
136
+ }
137
+ }
138
+ }
package/src/frames.ts ADDED
@@ -0,0 +1,59 @@
1
+ import {
2
+ isErrorCode, isPlainObject, isRevision, isStreamError,
3
+ type DataFrame, type ErrorFrame, type Hello, type Result, type StreamEvent, type SubscriptionFrame, type SubscriptionState
4
+ } from "@streamotter/contracts";
5
+
6
+ const SUBSCRIPTION_STATES: ReadonlySet<string> = new Set<SubscriptionState>([
7
+ "idle", "authorizing", "synchronizing", "live", "stale", "resync-required", "failed", "closed"
8
+ ]);
9
+
10
+ export function isHello(value: unknown): value is Hello {
11
+ return isPlainObject(value)
12
+ && value["protocolVersion"] === 1
13
+ && value["transport"] === "socket.io"
14
+ && Array.isArray(value["deliveryModes"])
15
+ && Array.isArray(value["operations"])
16
+ && typeof value["connectionId"] === "string"
17
+ && typeof value["identityKey"] === "string" && value["identityKey"].length > 0
18
+ && typeof value["authExpiresAt"] === "string";
19
+ }
20
+
21
+ export function isSubscriptionFrame(value: unknown): value is SubscriptionFrame {
22
+ return isPlainObject(value)
23
+ && typeof value["subscriptionId"] === "string"
24
+ && typeof value["epoch"] === "string"
25
+ && typeof value["state"] === "string" && SUBSCRIPTION_STATES.has(value["state"])
26
+ && (value["reason"] === undefined || isErrorCode(value["reason"]));
27
+ }
28
+
29
+ export function isStreamEvent(value: unknown): value is StreamEvent {
30
+ return isPlainObject(value)
31
+ && typeof value["id"] === "string"
32
+ && typeof value["channel"] === "string"
33
+ && typeof value["channelVersion"] === "number"
34
+ && (value["kind"] === "snapshot" || value["kind"] === "update")
35
+ && Object.hasOwn(value, "data")
36
+ && isRevision(value["revision"])
37
+ && typeof value["receivedAt"] === "string";
38
+ }
39
+
40
+ export function isDataFrame(value: unknown): value is DataFrame {
41
+ return isPlainObject(value)
42
+ && typeof value["subscriptionId"] === "string"
43
+ && typeof value["epoch"] === "string"
44
+ && typeof value["sequence"] === "number" && Number.isSafeInteger(value["sequence"]) && value["sequence"] >= 1
45
+ && isStreamEvent(value["event"]);
46
+ }
47
+
48
+ export function isErrorFrame(value: unknown): value is ErrorFrame {
49
+ return isPlainObject(value)
50
+ && isStreamError(value["error"])
51
+ && (value["subscriptionId"] === undefined || typeof value["subscriptionId"] === "string")
52
+ && (value["epoch"] === undefined || typeof value["epoch"] === "string");
53
+ }
54
+
55
+ export function isResult(value: unknown): value is Result<unknown> {
56
+ if (!isPlainObject(value) || typeof value["requestId"] !== "string") return false;
57
+ if (value["ok"] === true) return Object.hasOwn(value, "data");
58
+ return value["ok"] === false && isStreamError(value["error"]);
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createClient } from "./client.ts";
2
+ export { StreamOtterError, isStreamError } from "@streamotter/contracts";
3
+ export type {
4
+ Awaitable, ChannelContract, ChannelMap, Client, ClientOptions, ConnectionState, ErrorCode, Json, Params, Revision,
5
+ StateChange, StreamError, StreamEvent, Subscription, SubscriptionState, Unlisten, WaitOptions
6
+ } from "@streamotter/contracts";