@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.
@@ -0,0 +1,441 @@
1
+ import {
2
+ asStreamOtterError, compareRevisions, EVENTS, streamError, StreamOtterError, UNSUBSCRIBE_TIMEOUT_MS,
3
+ type DataFrame, type ErrorCode, type Json, type Params, type Revision, type StateChange, type StreamError,
4
+ type StreamEvent, type Subscription, type SubscriptionFrame, type SubscriptionState, type Unlisten, type WaitOptions
5
+ } from "@streamotter/contracts";
6
+ import type { Connection } from "./connection.ts";
7
+ import { WaiterSet } from "./waiters.ts";
8
+
9
+ /** The client's non-generic view of a subscription. */
10
+ export interface ManagedSubscription {
11
+ readonly id: string;
12
+ attach(connection: Connection): void;
13
+ detach(reason?: ErrorCode): void;
14
+ authRequired(error: StreamError): void;
15
+ terminateLocally(state: "closed" | "failed", error: StreamError, emit: boolean): void;
16
+ handleState(connection: Connection, frame: SubscriptionFrame): void;
17
+ handleData(connection: Connection, frame: DataFrame): void;
18
+ handleError(connection: Connection, error: StreamError): void;
19
+ }
20
+
21
+ /** What a subscription needs from its client. */
22
+ export interface SubscriptionOwner {
23
+ readonly closed: boolean;
24
+ readonly authRequired: boolean;
25
+ /** The current authenticated connection, if any. */
26
+ readonly connection: Connection | null;
27
+ register(subscription: ManagedSubscription): void;
28
+ unregister(subscription: ManagedSubscription): void;
29
+ /** A control request was not acknowledged; the connection must be replaced. */
30
+ controlTimedOut(connection: Connection): void;
31
+ randomId(): string;
32
+ logListenerFailure(error: unknown): void;
33
+ }
34
+
35
+ type Listener = (value: never) => unknown;
36
+
37
+ /** SDK-detected synchronization failures tolerated per incident before resync-required. */
38
+ const LOCAL_SYNC_ATTEMPTS = 3;
39
+
40
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
41
+ return (typeof value === "object" || typeof value === "function") && value !== null
42
+ && typeof (value as { then?: unknown }).then === "function";
43
+ }
44
+
45
+ /**
46
+ * Client side of one subscription. Accepts an epoch only after its ordered
47
+ * `synchronizing` frame, validates sequence and revision order, confirms SDK
48
+ * receipt after synchronous listener dispatch, and never reports `live` for a
49
+ * disconnected or superseded view.
50
+ */
51
+ export class ClientSubscription<D extends Json = Json> implements Subscription<D>, ManagedSubscription {
52
+ readonly id: string;
53
+ readonly channel: string;
54
+ readonly channelVersion: number;
55
+ readonly params: Params;
56
+ readonly #owner: SubscriptionOwner;
57
+ readonly #listeners = {
58
+ data: new Set<(event: StreamEvent<D>) => void>(),
59
+ state: new Set<(state: StateChange<SubscriptionState>) => void>(),
60
+ error: new Set<(error: StreamError) => void>()
61
+ };
62
+ readonly #waiters = new WaiterSet();
63
+ #state: SubscriptionState = "idle";
64
+ #reason: ErrorCode | undefined;
65
+ #terminal = false;
66
+ #attached: Connection | null = null;
67
+ #serverKnows = false;
68
+ #epoch: string | null = null;
69
+ /** Set after we ask the gateway to replace `#epoch`; old-epoch progress is ignored. */
70
+ #replacing: string | null = null;
71
+ #awaitingEpoch = true;
72
+ #expectedSequence = 1;
73
+ #epochRevision: Revision | null = null;
74
+ #lastRevision: Revision | null = null;
75
+ #localFailures = 0;
76
+ #lastError: StreamError | null = null;
77
+ #unsubscribing: Promise<void> | null = null;
78
+ #retryTimer: ReturnType<typeof setTimeout> | null = null;
79
+
80
+ constructor(owner: SubscriptionOwner, channel: string, channelVersion: number, params: Params) {
81
+ this.#owner = owner;
82
+ this.id = owner.randomId();
83
+ this.channel = channel;
84
+ this.channelVersion = channelVersion;
85
+ this.params = params;
86
+ // Start in the next microtask so synchronously attached listeners miss nothing.
87
+ queueMicrotask(() => {
88
+ if (!this.#terminal) this.#owner.register(this);
89
+ });
90
+ }
91
+
92
+ get state(): SubscriptionState {
93
+ return this.#state;
94
+ }
95
+
96
+ get active(): boolean {
97
+ return !this.#terminal;
98
+ }
99
+
100
+ on(event: "data", listener: (event: StreamEvent<D>) => void): Unlisten;
101
+ on(event: "state", listener: (state: StateChange<SubscriptionState>) => void): Unlisten;
102
+ on(event: "error", listener: (error: StreamError) => void): Unlisten;
103
+ on(event: "data" | "state" | "error", listener: Listener): Unlisten {
104
+ const set = this.#listeners[event] as Set<Listener> | undefined;
105
+ if (set === undefined || typeof listener !== "function") {
106
+ throw new StreamOtterError("INVALID_REQUEST", { message: "on() accepts \"data\", \"state\", or \"error\" with a function." });
107
+ }
108
+ set.add(listener);
109
+ return () => { set.delete(listener); };
110
+ }
111
+
112
+ ready(options?: WaitOptions): Promise<void> {
113
+ if (this.#state === "live") return Promise.resolve();
114
+ const blocked = this.#blockedError();
115
+ if (blocked !== null) return Promise.reject(asStreamOtterError(blocked));
116
+ return this.#waiters.wait(options);
117
+ }
118
+
119
+ resync(options?: WaitOptions): Promise<void> {
120
+ const blocked = this.#blockedError(true);
121
+ if (blocked !== null) return Promise.reject(asStreamOtterError(blocked));
122
+ const wait = this.#waiters.wait(options);
123
+ this.#localFailures = 0;
124
+ this.#requestFreshSynchronization();
125
+ return wait;
126
+ }
127
+
128
+ unsubscribe(): Promise<void> {
129
+ if (this.#unsubscribing !== null) return this.#unsubscribing;
130
+ const connection = this.#attached;
131
+ const serverKnows = this.#serverKnows;
132
+ this.#terminate("closed", undefined, new StreamOtterError("CANCELLED", { message: "The subscription was unsubscribed." }));
133
+ this.#unsubscribing = serverKnows && connection !== null && connection.connected
134
+ ? this.#sendUnsubscribe(connection)
135
+ : Promise.resolve();
136
+ return this.#unsubscribing;
137
+ }
138
+
139
+ // --- called by the client ----------------------------------------------------------
140
+
141
+ /** Subscribes on a freshly authenticated connection. */
142
+ attach(connection: Connection): void {
143
+ if (this.#terminal) return;
144
+ this.#clearRetry();
145
+ this.#attached = connection;
146
+ this.#serverKnows = false;
147
+ this.#epoch = null;
148
+ this.#replacing = null;
149
+ this.#awaitingEpoch = true;
150
+ this.#setState("authorizing");
151
+ void this.#subscribe(connection);
152
+ }
153
+
154
+ /** The transport was lost; the view is stale until a new synchronization completes. */
155
+ detach(reason?: ErrorCode): void {
156
+ this.#clearRetry();
157
+ this.#attached = null;
158
+ this.#serverKnows = false;
159
+ this.#awaitingEpoch = true;
160
+ this.#replacing = null;
161
+ if (this.#terminal || this.#state === "resync-required") return;
162
+ this.#setState("stale", reason);
163
+ }
164
+
165
+ /** Authentication was rejected; waiting for an explicit reconnect(). */
166
+ authRequired(error: StreamError): void {
167
+ this.detach("UNAUTHENTICATED");
168
+ if (!this.#terminal) this.#waiters.rejectAll(error);
169
+ }
170
+
171
+ /** Terminates without contacting the server (client close or identity change). */
172
+ terminateLocally(state: "closed" | "failed", error: StreamError, emit: boolean): void {
173
+ if (this.#terminal) return;
174
+ if (emit) this.#emitError(error);
175
+ this.#terminate(state, error.code, error);
176
+ }
177
+
178
+ handleState(connection: Connection, frame: SubscriptionFrame): void {
179
+ if (this.#terminal || connection !== this.#attached) return;
180
+ // A frame proves the gateway holds this subscription, even if its acknowledgement
181
+ // continuation has not run yet (both can arrive in one socket read).
182
+ this.#serverKnows = true;
183
+ if (frame.state === "synchronizing") {
184
+ if (frame.epoch === this.#epoch && !this.#awaitingEpoch) return;
185
+ this.#epoch = frame.epoch;
186
+ this.#replacing = null;
187
+ this.#awaitingEpoch = false;
188
+ this.#expectedSequence = 1;
189
+ this.#epochRevision = null;
190
+ this.#setState("synchronizing");
191
+ return;
192
+ }
193
+ if (frame.epoch !== (this.#epoch ?? "")) return;
194
+ if (this.#replacing !== null && frame.epoch === this.#replacing
195
+ && (frame.state === "live" || frame.state === "resync-required")) {
196
+ return; // Progress of the epoch we asked the gateway to replace.
197
+ }
198
+ if (frame.state === "live" && this.#awaitingEpoch) return;
199
+ switch (frame.state) {
200
+ case "failed":
201
+ this.#serverKnows = false;
202
+ this.#terminate("failed", frame.reason, this.#lastError ?? streamError(frame.reason ?? "INTERNAL"));
203
+ return;
204
+ case "closed":
205
+ this.#serverKnows = false;
206
+ this.#terminate("closed", frame.reason, this.#lastError ?? streamError(frame.reason ?? "CANCELLED"));
207
+ return;
208
+ case "resync-required":
209
+ this.#setState("resync-required", frame.reason ?? "RESYNC_REQUIRED");
210
+ return;
211
+ case "live":
212
+ this.#localFailures = 0;
213
+ this.#setState("live");
214
+ return;
215
+ case "stale":
216
+ case "authorizing":
217
+ this.#setState(frame.state, frame.reason);
218
+ return;
219
+ default:
220
+ return;
221
+ }
222
+ }
223
+
224
+ handleData(connection: Connection, frame: DataFrame): void {
225
+ if (this.#terminal || connection !== this.#attached) return;
226
+ this.#serverKnows = true;
227
+ if (this.#awaitingEpoch || frame.epoch !== this.#epoch) return;
228
+ if (frame.sequence !== this.#expectedSequence) {
229
+ this.#localSyncFailure("INVALID_REQUEST", `Expected sequence ${this.#expectedSequence} but received ${frame.sequence}.`);
230
+ return;
231
+ }
232
+ const event = frame.event;
233
+ if (event.channel !== this.channel || event.channelVersion !== this.channelVersion) {
234
+ this.#localSyncFailure("INVALID_PAYLOAD", "A frame named a different channel contract.");
235
+ return;
236
+ }
237
+ if (frame.sequence === 1) {
238
+ if (event.kind !== "snapshot") {
239
+ this.#localSyncFailure("INVALID_REQUEST", "The first frame of an epoch must be a snapshot.");
240
+ return;
241
+ }
242
+ if (this.#lastRevision !== null && compareRevisions(event.revision, this.#lastRevision) < 0) {
243
+ this.#localSyncFailure("INVALID_PAYLOAD", "The snapshot is older than state this subscription already delivered.");
244
+ return;
245
+ }
246
+ } else if (event.kind !== "update" || (this.#epochRevision !== null && compareRevisions(event.revision, this.#epochRevision) <= 0)) {
247
+ this.#localSyncFailure("INVALID_REQUEST", "Updates must follow the snapshot in increasing revision order.");
248
+ return;
249
+ }
250
+ this.#expectedSequence++;
251
+ this.#epochRevision = event.revision;
252
+ if (this.#lastRevision === null || compareRevisions(event.revision, this.#lastRevision) > 0) this.#lastRevision = event.revision;
253
+ for (const listener of [...this.#listeners.data]) {
254
+ if (!this.#invoke(listener, event as StreamEvent<D>)) return;
255
+ }
256
+ connection.receipt({ subscriptionId: this.id, epoch: frame.epoch, sequence: frame.sequence });
257
+ }
258
+
259
+ handleError(connection: Connection, error: StreamError): void {
260
+ if (this.#terminal || connection !== this.#attached) return;
261
+ this.#lastError = error;
262
+ this.#emitError(error);
263
+ }
264
+
265
+ // --- internals -----------------------------------------------------------------------
266
+
267
+ #blockedError(forResync = false): StreamError | null {
268
+ if (this.#owner.closed && this.#state === "closed") return streamError("CLIENT_CLOSED");
269
+ if (this.#terminal) {
270
+ if (this.#state === "failed") return this.#lastError ?? streamError("INTERNAL");
271
+ return this.#owner.closed ? streamError("CLIENT_CLOSED") : streamError("CANCELLED", { message: "The subscription was unsubscribed." });
272
+ }
273
+ if (this.#owner.authRequired) return streamError("UNAUTHENTICATED", { message: "Authentication is required; call reconnect()." });
274
+ if (!forResync && this.#state === "resync-required") return streamError("RESYNC_REQUIRED");
275
+ return null;
276
+ }
277
+
278
+ async #subscribe(connection: Connection): Promise<void> {
279
+ let result;
280
+ try {
281
+ result = await connection.request<{ subscriptionId: string }>(EVENTS.subscribe, {
282
+ requestId: this.#owner.randomId(),
283
+ subscriptionId: this.id,
284
+ channel: this.channel,
285
+ channelVersion: this.channelVersion,
286
+ params: this.params
287
+ });
288
+ } catch (error) {
289
+ if (connection !== this.#attached || this.#terminal) return;
290
+ if ((error as StreamError).code === "TIMEOUT") this.#owner.controlTimedOut(connection);
291
+ return;
292
+ }
293
+ if (connection !== this.#attached || this.#terminal) return;
294
+ if (result.ok) {
295
+ this.#serverKnows = true;
296
+ return;
297
+ }
298
+ this.#lastError = result.error;
299
+ this.#emitError(result.error);
300
+ if (result.error.code === "OVERLOADED") {
301
+ // Rate or subscription limit: bounded retry (one- then two-second backoff), then resync-required.
302
+ this.#localFailures++;
303
+ if (this.#localFailures >= LOCAL_SYNC_ATTEMPTS) {
304
+ this.#setState("resync-required", "OVERLOADED");
305
+ return;
306
+ }
307
+ this.#setState("stale", "OVERLOADED");
308
+ this.#retryTimer = setTimeout(() => {
309
+ this.#retryTimer = null;
310
+ if (this.#attached === connection && connection.connected && !this.#terminal) this.attach(connection);
311
+ }, 1_000 * 2 ** (this.#localFailures - 1));
312
+ return;
313
+ }
314
+ this.#terminate("failed", result.error.code, result.error);
315
+ }
316
+
317
+ #clearRetry(): void {
318
+ if (this.#retryTimer !== null) {
319
+ clearTimeout(this.#retryTimer);
320
+ this.#retryTimer = null;
321
+ }
322
+ }
323
+
324
+ #requestFreshSynchronization(): void {
325
+ const connection = this.#attached;
326
+ if (connection === null || !connection.connected) return; // Reconnection resynchronizes.
327
+ if (!this.#serverKnows) {
328
+ if (this.#state === "authorizing") return; // A subscribe is already in flight.
329
+ this.attach(connection);
330
+ return;
331
+ }
332
+ if (this.#state === "authorizing" || this.#state === "synchronizing") return; // Joins the running synchronization.
333
+ this.#replacing = this.#epoch;
334
+ this.#awaitingEpoch = true;
335
+ this.#setState("authorizing");
336
+ connection.request(EVENTS.resync, { requestId: this.#owner.randomId(), subscriptionId: this.id }).then(result => {
337
+ if (connection !== this.#attached || this.#terminal || result.ok) return;
338
+ this.#lastError = result.error;
339
+ this.#emitError(result.error);
340
+ if (result.error.code === "INVALID_REQUEST") this.attach(connection); // The gateway no longer knows it.
341
+ }, (error: StreamError) => {
342
+ if (connection === this.#attached && !this.#terminal && error.code === "TIMEOUT") this.#owner.controlTimedOut(connection);
343
+ });
344
+ }
345
+
346
+ /** The SDK rejected a frame. Resynchronize, bounded per incident. */
347
+ #localSyncFailure(code: ErrorCode, message: string): void {
348
+ const error = streamError(code, { message, requestId: this.#owner.randomId() });
349
+ this.#lastError = error;
350
+ this.#emitError(error);
351
+ if (this.#terminal) return;
352
+ this.#localFailures++;
353
+ if (this.#localFailures >= LOCAL_SYNC_ATTEMPTS) {
354
+ const connection = this.#attached;
355
+ if (this.#serverKnows && connection !== null && connection.connected) void this.#sendUnsubscribe(connection);
356
+ this.#serverKnows = false;
357
+ this.#awaitingEpoch = true;
358
+ this.#setState("resync-required", "RESYNC_REQUIRED");
359
+ return;
360
+ }
361
+ this.#setState("stale", code);
362
+ this.#requestFreshSynchronization();
363
+ }
364
+
365
+ #sendUnsubscribe(connection: Connection): Promise<void> {
366
+ return new Promise<void>(resolve => {
367
+ const timer = setTimeout(resolve, UNSUBSCRIBE_TIMEOUT_MS);
368
+ connection.request(EVENTS.unsubscribe, { requestId: this.#owner.randomId(), subscriptionId: this.id })
369
+ .then(() => undefined, () => undefined)
370
+ .finally(() => { clearTimeout(timer); resolve(); });
371
+ });
372
+ }
373
+
374
+ #terminate(state: "closed" | "failed", reason: ErrorCode | undefined, error: StreamError): void {
375
+ if (this.#terminal) return;
376
+ this.#terminal = true;
377
+ this.#clearRetry();
378
+ this.#attached = null;
379
+ this.#owner.unregister(this);
380
+ this.#lastError = error;
381
+ this.#setState(state, reason, true);
382
+ this.#waiters.rejectAll(error);
383
+ this.#listeners.data.clear();
384
+ this.#listeners.state.clear();
385
+ this.#listeners.error.clear();
386
+ }
387
+
388
+ #setState(state: SubscriptionState, reason?: ErrorCode, force = false): void {
389
+ if (this.#terminal && !force) return;
390
+ if (this.#state === state && this.#reason === reason) return;
391
+ this.#state = state;
392
+ this.#reason = reason;
393
+ const change: StateChange<SubscriptionState> = reason === undefined ? { state } : { state, reason };
394
+ for (const listener of [...this.#listeners.state]) {
395
+ if (!this.#invoke(listener, change)) return;
396
+ }
397
+ if (state === "live") this.#waiters.resolveAll();
398
+ else if (state === "resync-required") this.#waiters.rejectAll(streamError("RESYNC_REQUIRED"));
399
+ }
400
+
401
+ /** Runs a data/state listener. Failures become HANDLER_FAILED and fail the subscription. */
402
+ #invoke<T>(listener: (value: T) => unknown, value: T): boolean {
403
+ let returned: unknown;
404
+ try {
405
+ returned = listener(value);
406
+ } catch (error) {
407
+ this.#listenerFailed(error);
408
+ return false;
409
+ }
410
+ if (isThenable(returned)) {
411
+ Promise.resolve(returned).catch((error: unknown) => this.#listenerFailed(error));
412
+ }
413
+ return true;
414
+ }
415
+
416
+ #listenerFailed(cause: unknown): void {
417
+ if (this.#terminal) return;
418
+ this.#owner.logListenerFailure(cause);
419
+ const error = streamError("HANDLER_FAILED", {
420
+ message: "A subscription listener threw; the subscription failed.",
421
+ requestId: this.#owner.randomId()
422
+ });
423
+ const connection = this.#attached;
424
+ const serverKnows = this.#serverKnows;
425
+ this.#emitError(error);
426
+ this.#terminate("failed", "HANDLER_FAILED", error);
427
+ if (serverKnows && connection !== null && connection.connected) void this.#sendUnsubscribe(connection);
428
+ }
429
+
430
+ /** Error-listener failures are logged, never re-emitted. */
431
+ #emitError(error: StreamError): void {
432
+ for (const listener of [...this.#listeners.error]) {
433
+ try {
434
+ const returned = listener(error);
435
+ if (isThenable(returned)) Promise.resolve(returned).catch((cause: unknown) => this.#owner.logListenerFailure(cause));
436
+ } catch (cause) {
437
+ this.#owner.logListenerFailure(cause);
438
+ }
439
+ }
440
+ }
441
+ }
package/src/waiters.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { asStreamOtterError, DEFAULT_READY_TIMEOUT_MS, StreamOtterError, type StreamError, type WaitOptions } from "@streamotter/contracts";
2
+
3
+ /**
4
+ * A set of independent waiters. Each waiter has its own timeout and signal;
5
+ * cancelling one never affects the others or the operation they wait for.
6
+ */
7
+ export class WaiterSet {
8
+ readonly #waiters = new Set<{ resolve: () => void; reject: (error: StreamError) => void }>();
9
+
10
+ get size(): number {
11
+ return this.#waiters.size;
12
+ }
13
+
14
+ wait(options: WaitOptions | undefined): Promise<void> {
15
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
16
+ const signal = options?.signal;
17
+ if (signal?.aborted) return Promise.reject(new StreamOtterError("CANCELLED"));
18
+ return new Promise<void>((resolve, reject) => {
19
+ const cleanup = () => {
20
+ clearTimeout(timer);
21
+ signal?.removeEventListener("abort", onAbort);
22
+ this.#waiters.delete(waiter);
23
+ };
24
+ const waiter = {
25
+ resolve: () => { cleanup(); resolve(); },
26
+ reject: (error: StreamError) => { cleanup(); reject(asStreamOtterError(error)); }
27
+ };
28
+ const onAbort = () => waiter.reject(new StreamOtterError("CANCELLED"));
29
+ const timer = setTimeout(() => waiter.reject(new StreamOtterError("TIMEOUT", {
30
+ message: `The subscription did not become live within ${timeoutMs} ms.`
31
+ })), timeoutMs);
32
+ signal?.addEventListener("abort", onAbort, { once: true });
33
+ this.#waiters.add(waiter);
34
+ });
35
+ }
36
+
37
+ resolveAll(): void {
38
+ for (const waiter of [...this.#waiters]) waiter.resolve();
39
+ }
40
+
41
+ rejectAll(error: StreamError): void {
42
+ for (const waiter of [...this.#waiters]) waiter.reject(error);
43
+ }
44
+ }