@dungle-scrubs/tether-client 0.1.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,333 @@
1
+ import { Effect, Either } from "effect";
2
+ import WebSocket from "ws";
3
+ import { resolveServiceAuthToken } from "./auth-token.js";
4
+ import { sleepUnrefEffect } from "./effect-timing.js";
5
+ import { ModuleObservability, readModuleObservabilityOptions } from "./observability.js";
6
+ import { parseWebSocketServerEnvelope, webSocketOperation } from "./protocol.js";
7
+ const defaultReconnectBaseDelayMs = 100;
8
+ const defaultReconnectMaxDelayMs = 2_000;
9
+ /**
10
+ * Observer-only stream client for replaying and following durable session
11
+ * events. Use this for passive readers such as bridges, approval observers, and
12
+ * dashboards; use `ParticipantRuntimeClient` when the caller must publish
13
+ * participant events, claim tasks, refresh claims, or complete task work.
14
+ */
15
+ export class SessionEventStreamClient {
16
+ config;
17
+ deliveryQueue = [];
18
+ eventHandlers = new Set();
19
+ errorBacklog = [];
20
+ errorHandlers = new Set();
21
+ observability = new ModuleObservability(readModuleObservabilityOptions("SessionEventStreamClient"));
22
+ closePromise = Promise.resolve();
23
+ connectCount = 0;
24
+ draining = false;
25
+ eventCount = 0;
26
+ /** Highest sequence whose handlers have all resolved; the durable resume cursor. */
27
+ lastObservedSeq;
28
+ reconnectFailureCount = 0;
29
+ reconnectSuccessCount = 0;
30
+ replayComplete = Promise.resolve();
31
+ replayCompleteCount = 0;
32
+ replayCompleteSettled = true;
33
+ rejectReplayComplete = null;
34
+ resolveReplayComplete = null;
35
+ socket = null;
36
+ stopped = false;
37
+ webSocketFactory;
38
+ /** Creates an observer around the supplied stream configuration. */
39
+ constructor(config) {
40
+ this.config = config;
41
+ this.lastObservedSeq = config.afterSeq;
42
+ this.webSocketFactory = config.webSocketFactory ?? ((url) => new WebSocket(url));
43
+ }
44
+ /** Opens a passive observer stream without participant task authority. */
45
+ static async connect(config) {
46
+ const client = new SessionEventStreamClient(config);
47
+ await client.open(config.afterSeq);
48
+ return client;
49
+ }
50
+ /** Requests a graceful WebSocket close and stops reconnect attempts. */
51
+ close() {
52
+ this.stopped = true;
53
+ this.socket?.close();
54
+ }
55
+ /** Returns stream cursor, connection, handler, and replay diagnostics. */
56
+ debugInfo() {
57
+ return {
58
+ connectCount: this.connectCount,
59
+ eventCount: this.eventCount,
60
+ eventHandlerCount: this.eventHandlers.size,
61
+ lastObservedSeq: this.lastObservedSeq,
62
+ reconnectFailureCount: this.reconnectFailureCount,
63
+ reconnectSuccessCount: this.reconnectSuccessCount,
64
+ replayCompleteCount: this.replayCompleteCount,
65
+ socketReadyState: this.socket?.readyState ?? WebSocket.CLOSED,
66
+ stopped: this.stopped,
67
+ };
68
+ }
69
+ /** Registers an error callback and returns an unsubscribe function. */
70
+ onError(handler) {
71
+ this.errorHandlers.add(handler);
72
+ for (const error of this.errorBacklog.splice(0)) {
73
+ handler(error);
74
+ }
75
+ return () => {
76
+ this.errorHandlers.delete(handler);
77
+ };
78
+ }
79
+ /** Registers an event callback and resumes serial delivery of any backlog. */
80
+ onEvent(handler) {
81
+ this.eventHandlers.add(handler);
82
+ void this.drainDeliveryQueue();
83
+ return () => {
84
+ this.eventHandlers.delete(handler);
85
+ };
86
+ }
87
+ /** Reopens the stream from the highest observed event sequence. */
88
+ async reconnect() {
89
+ this.socket?.close();
90
+ await this.waitForClose().catch(() => undefined);
91
+ await this.reconnectWithBackoff(0);
92
+ return this;
93
+ }
94
+ /** Resolves when the current socket closes. */
95
+ async waitForClose() {
96
+ await this.closePromise;
97
+ }
98
+ /** Resolves once the current stream replay has completed. */
99
+ async waitForReplayComplete() {
100
+ await this.replayComplete;
101
+ }
102
+ /** Opens a WebSocket stream from the requested event cursor. */
103
+ async open(afterSeq) {
104
+ await this.observability.traceBoundary("open", { afterSeq, sessionId: this.config.sessionId }, async () => {
105
+ // Each connect resumes from lastObservedSeq, so the server replays every
106
+ // event after it. Discard any un-acknowledged items queued from a prior
107
+ // socket to avoid delivering them twice once replay re-sends them.
108
+ this.deliveryQueue.length = 0;
109
+ this.replayCompleteSettled = false;
110
+ const replayComplete = new Promise((resolve, reject) => {
111
+ this.rejectReplayComplete = reject;
112
+ this.resolveReplayComplete = resolve;
113
+ });
114
+ replayComplete.catch(() => undefined);
115
+ this.replayComplete = replayComplete;
116
+ const socket = this.webSocketFactory(buildSessionEventStreamUrl({
117
+ ...this.config,
118
+ afterSeq,
119
+ }));
120
+ this.socket = socket;
121
+ this.connectCount += 1;
122
+ this.closePromise = new Promise((resolve) => {
123
+ socket.once("close", () => {
124
+ if (this.socket === socket) {
125
+ this.socket = null;
126
+ }
127
+ this.settleReplayCompleteError(new Error("WebSocket closed before replay completed"));
128
+ resolve();
129
+ });
130
+ });
131
+ socket.on("error", (error) => {
132
+ this.settleReplayCompleteError(error);
133
+ this.emitError(error);
134
+ });
135
+ socket.on("message", (data) => {
136
+ this.handleMessage(data);
137
+ });
138
+ await new Promise((resolve, reject) => {
139
+ socket.once("open", resolve);
140
+ socket.once("error", reject);
141
+ });
142
+ });
143
+ }
144
+ /** Reopens the stream with bounded retry backoff. */
145
+ async reconnectWithBackoff(initialDelayMs = null) {
146
+ await Effect.runPromise(this.buildReconnectWithBackoff(initialDelayMs));
147
+ }
148
+ /** Builds the bounded reconnect loop as an Effect program. */
149
+ buildReconnectWithBackoff(initialDelayMs = null) {
150
+ return Effect.gen(this, function* () {
151
+ let attempt = 0;
152
+ let delayMs = initialDelayMs ?? observerReconnectDelayMs(attempt, this.config);
153
+ while (!this.stopped) {
154
+ if (delayMs > 0) {
155
+ yield* sleepUnrefEffect(delayMs);
156
+ }
157
+ const opened = yield* Effect.either(Effect.tryPromise({
158
+ catch: (error) => error,
159
+ try: () => this.open(this.lastObservedSeq),
160
+ }));
161
+ if (Either.isRight(opened)) {
162
+ this.reconnectSuccessCount += 1;
163
+ return;
164
+ }
165
+ const error = opened.left;
166
+ this.reconnectFailureCount += 1;
167
+ this.emitError(error instanceof Error ? error : new Error("Observer reconnect failed"));
168
+ attempt += 1;
169
+ delayMs = observerReconnectDelayMs(attempt, this.config);
170
+ }
171
+ });
172
+ }
173
+ /** Parses one server envelope and enqueues it for serial, in-order delivery. */
174
+ handleMessage(data) {
175
+ try {
176
+ const envelope = parseWebSocketServerEnvelope(JSON.parse(String(data)));
177
+ if (!envelope) {
178
+ this.emitError(new Error("Unsupported WebSocket envelope"));
179
+ return;
180
+ }
181
+ if (envelope.op === webSocketOperation.event) {
182
+ this.eventCount += 1;
183
+ this.deliveryQueue.push({ event: envelope.event, kind: "event" });
184
+ void this.drainDeliveryQueue();
185
+ return;
186
+ }
187
+ if (envelope.op === webSocketOperation.replayComplete) {
188
+ this.replayCompleteCount += 1;
189
+ this.deliveryQueue.push({ kind: "replay-complete" });
190
+ void this.drainDeliveryQueue();
191
+ return;
192
+ }
193
+ if (envelope.op === webSocketOperation.error) {
194
+ const error = new Error(envelope.error);
195
+ this.settleReplayCompleteError(error);
196
+ this.emitError(error);
197
+ }
198
+ }
199
+ catch (error) {
200
+ this.emitError(error instanceof Error ? error : new Error("Failed to handle WebSocket message"));
201
+ }
202
+ }
203
+ /**
204
+ * Drains queued events through registered handlers strictly one at a time and
205
+ * in sequence order. A single drain runs at a time, so handlers never overlap
206
+ * and later events wait behind in-flight delivery. The resume cursor advances
207
+ * only after an event's handlers all resolve, and a rejection halts delivery
208
+ * without acknowledging the failed event so the stream reconnects and replays
209
+ * from the last successfully handled position instead of skipping past it.
210
+ */
211
+ async drainDeliveryQueue() {
212
+ if (this.draining) {
213
+ return;
214
+ }
215
+ this.draining = true;
216
+ try {
217
+ while (!this.stopped && this.deliveryQueue.length > 0) {
218
+ const next = this.deliveryQueue[0];
219
+ if (!next) {
220
+ break;
221
+ }
222
+ if (next.kind === "replay-complete") {
223
+ this.deliveryQueue.shift();
224
+ this.settleReplayComplete();
225
+ continue;
226
+ }
227
+ if (this.eventHandlers.size === 0) {
228
+ // Preserve the backlog until a handler registers and resumes delivery.
229
+ break;
230
+ }
231
+ // Take ownership before awaiting so a concurrent reconnect that clears
232
+ // the queue cannot re-deliver the event now in flight.
233
+ this.deliveryQueue.shift();
234
+ const delivered = await this.deliverEventToHandlers(next.event);
235
+ if (!delivered) {
236
+ this.recoverFromDeliveryFailure();
237
+ return;
238
+ }
239
+ this.lastObservedSeq = Math.max(this.lastObservedSeq, next.event.seq);
240
+ }
241
+ }
242
+ finally {
243
+ this.draining = false;
244
+ }
245
+ }
246
+ /** Awaits every registered handler in turn; returns false on the first rejection. */
247
+ async deliverEventToHandlers(event) {
248
+ for (const handler of [...this.eventHandlers]) {
249
+ try {
250
+ await handler(event);
251
+ }
252
+ catch (error) {
253
+ this.emitError(error instanceof Error ? error : new Error("Session event handler failed"));
254
+ return false;
255
+ }
256
+ }
257
+ return true;
258
+ }
259
+ /**
260
+ * Recovers after a handler rejection by discarding un-acknowledged events and
261
+ * reconnecting from the last successfully handled sequence, so the server
262
+ * replays the failed event rather than the stream advancing past it.
263
+ */
264
+ recoverFromDeliveryFailure() {
265
+ if (this.stopped) {
266
+ return;
267
+ }
268
+ this.deliveryQueue.length = 0;
269
+ const socket = this.socket;
270
+ this.socket = null;
271
+ socket?.close();
272
+ void this.reconnectWithBackoff();
273
+ }
274
+ /** Routes operational failures to registered error handlers. */
275
+ emitError(error) {
276
+ if (this.errorHandlers.size === 0) {
277
+ this.errorBacklog.push(error);
278
+ return;
279
+ }
280
+ for (const handler of this.errorHandlers) {
281
+ handler(error);
282
+ }
283
+ }
284
+ /** Resolves the current replay wait once. */
285
+ settleReplayComplete() {
286
+ if (this.replayCompleteSettled) {
287
+ return;
288
+ }
289
+ this.replayCompleteSettled = true;
290
+ this.resolveReplayComplete?.();
291
+ this.resolveReplayComplete = null;
292
+ this.rejectReplayComplete = null;
293
+ }
294
+ /** Rejects the current replay wait once when the stream fails before replay completion. */
295
+ settleReplayCompleteError(error) {
296
+ if (this.replayCompleteSettled) {
297
+ return;
298
+ }
299
+ this.replayCompleteSettled = true;
300
+ this.rejectReplayComplete?.(error);
301
+ this.resolveReplayComplete = null;
302
+ this.rejectReplayComplete = null;
303
+ }
304
+ }
305
+ /**
306
+ * Runtime-kind value that selects the server's passive full-event observer
307
+ * mode: the connection receives the durable event stream (replay + live) but
308
+ * does not register a durable participant or acquire a control lease. Keep this
309
+ * in sync with `classifyHostPresenceStream` in the Tether gateway.
310
+ */
311
+ export const sessionEventObserverRuntimeKind = "observer";
312
+ /** Builds the observer WebSocket URL without participant or task authority. */
313
+ export function buildSessionEventStreamUrl(config) {
314
+ const url = new URL(`/sessions/${encodeURIComponent(config.sessionId)}/stream`, config.serviceUrl);
315
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
316
+ url.searchParams.set("after", String(config.afterSeq));
317
+ // Opt into the passive full-event observer mode so the server delivers the
318
+ // durable event stream without treating this connection as a control
319
+ // participant. A passive reader never acquires a control lease, so its
320
+ // reconnects cannot collide with the runtime's own control channel.
321
+ url.searchParams.set("runtimeKind", sessionEventObserverRuntimeKind);
322
+ const authToken = resolveServiceAuthToken(config.authToken);
323
+ if (authToken) {
324
+ url.searchParams.set("access_token", authToken);
325
+ }
326
+ return url.toString();
327
+ }
328
+ /** Computes bounded exponential reconnect backoff from observer configuration. */
329
+ function observerReconnectDelayMs(attempt, config) {
330
+ const baseDelayMs = config.reconnect?.baseDelayMs ?? defaultReconnectBaseDelayMs;
331
+ const maxDelayMs = config.reconnect?.maxDelayMs ?? defaultReconnectMaxDelayMs;
332
+ return Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
333
+ }
@@ -0,0 +1,48 @@
1
+ import type { SessionEvent } from "./types.js";
2
+ /** Constructor settings for task cancellation memory. */
3
+ export interface TaskCancellationRegistryOptions {
4
+ /** Maximum inactive cancelled task ids to remember. */
5
+ readonly maxCancelledTaskIds?: number;
6
+ }
7
+ /** Inspectable cancellation registry state for runtime diagnostics. */
8
+ export interface TaskCancellationRegistryDebugInfo {
9
+ /** Number of active task abort controllers. */
10
+ readonly activeTaskCount: number;
11
+ /** Number of active and inactive cancelled task ids currently remembered. */
12
+ readonly cancelledTaskIdCount: number;
13
+ /** Number of inactive cancelled task ids currently remembered. */
14
+ readonly inactiveCancelledTaskIdCount: number;
15
+ /** Maximum inactive cancelled task ids retained by this registry. */
16
+ readonly maxCancelledTaskIds: number;
17
+ }
18
+ /** Tracks cancellation events and exposes abort signals for active task work. */
19
+ export declare class TaskCancellationRegistry {
20
+ private readonly maxCancelledTaskIds;
21
+ private readonly cancelledTaskIds;
22
+ private readonly controllersByTaskId;
23
+ /** Creates a cancellation registry with bounded inactive cancellation memory. */
24
+ constructor(options?: TaskCancellationRegistryOptions);
25
+ /** Aborts in-flight work for a task without marking it cancelled. */
26
+ abortActive(taskId: string): void;
27
+ /** Starts an abortable active-work scope for a task. */
28
+ begin(taskId: string): AbortSignal;
29
+ /** Marks a task as cancelled and aborts in-flight work for it. */
30
+ cancel(taskId: string): void;
31
+ /**
32
+ * Ends active-work tracking for a task and drops its cancellation memory.
33
+ *
34
+ * Task ids are unique and terminal tasks never become claimable again, so it
35
+ * is safe to forget a cancelled id once no executor is tracking it. This
36
+ * keeps long-lived participant runtimes from accumulating cancelled ids
37
+ * indefinitely.
38
+ */
39
+ end(taskId: string): void;
40
+ /** Checks whether a task has been cancelled in the observed event stream. */
41
+ isCancelled(taskId: string): boolean;
42
+ /** Observes one session event and records task cancellation when present. */
43
+ observe(event: SessionEvent): string | null;
44
+ /** Returns bounded cancellation-memory counters for diagnostics. */
45
+ debugInfo(): TaskCancellationRegistryDebugInfo;
46
+ private rememberCancellation;
47
+ private inactiveCancelledTaskIdCount;
48
+ }
@@ -0,0 +1,95 @@
1
+ import { taskIdFromCancelledEvent } from "./protocol.js";
2
+ const defaultMaxCancelledTaskIds = 1024;
3
+ /** Tracks cancellation events and exposes abort signals for active task work. */
4
+ export class TaskCancellationRegistry {
5
+ maxCancelledTaskIds;
6
+ cancelledTaskIds = new Set();
7
+ controllersByTaskId = new Map();
8
+ /** Creates a cancellation registry with bounded inactive cancellation memory. */
9
+ constructor(options = {}) {
10
+ this.maxCancelledTaskIds = Math.max(0, options.maxCancelledTaskIds ?? defaultMaxCancelledTaskIds);
11
+ }
12
+ /** Aborts in-flight work for a task without marking it cancelled. */
13
+ abortActive(taskId) {
14
+ this.controllersByTaskId.get(taskId)?.abort();
15
+ }
16
+ /** Starts an abortable active-work scope for a task. */
17
+ begin(taskId) {
18
+ const controller = new AbortController();
19
+ this.controllersByTaskId.set(taskId, controller);
20
+ if (this.cancelledTaskIds.has(taskId)) {
21
+ controller.abort();
22
+ }
23
+ return controller.signal;
24
+ }
25
+ /** Marks a task as cancelled and aborts in-flight work for it. */
26
+ cancel(taskId) {
27
+ const controller = this.controllersByTaskId.get(taskId);
28
+ controller?.abort();
29
+ this.rememberCancellation(taskId);
30
+ }
31
+ /**
32
+ * Ends active-work tracking for a task and drops its cancellation memory.
33
+ *
34
+ * Task ids are unique and terminal tasks never become claimable again, so it
35
+ * is safe to forget a cancelled id once no executor is tracking it. This
36
+ * keeps long-lived participant runtimes from accumulating cancelled ids
37
+ * indefinitely.
38
+ */
39
+ end(taskId) {
40
+ this.controllersByTaskId.delete(taskId);
41
+ this.cancelledTaskIds.delete(taskId);
42
+ }
43
+ /** Checks whether a task has been cancelled in the observed event stream. */
44
+ isCancelled(taskId) {
45
+ return this.cancelledTaskIds.has(taskId);
46
+ }
47
+ /** Observes one session event and records task cancellation when present. */
48
+ observe(event) {
49
+ const taskId = taskIdFromCancelledEvent(event);
50
+ if (taskId) {
51
+ this.cancel(taskId);
52
+ }
53
+ return taskId;
54
+ }
55
+ /** Returns bounded cancellation-memory counters for diagnostics. */
56
+ debugInfo() {
57
+ return {
58
+ activeTaskCount: this.controllersByTaskId.size,
59
+ cancelledTaskIdCount: this.cancelledTaskIds.size,
60
+ inactiveCancelledTaskIdCount: this.inactiveCancelledTaskIdCount(),
61
+ maxCancelledTaskIds: this.maxCancelledTaskIds,
62
+ };
63
+ }
64
+ rememberCancellation(taskId) {
65
+ if (this.maxCancelledTaskIds === 0) {
66
+ if (this.controllersByTaskId.has(taskId)) {
67
+ this.cancelledTaskIds.add(taskId);
68
+ }
69
+ return;
70
+ }
71
+ this.cancelledTaskIds.delete(taskId);
72
+ this.cancelledTaskIds.add(taskId);
73
+ while (this.inactiveCancelledTaskIdCount() > this.maxCancelledTaskIds) {
74
+ const oldestTaskId = this.cancelledTaskIds.values().next().value;
75
+ if (typeof oldestTaskId !== "string") {
76
+ return;
77
+ }
78
+ if (this.controllersByTaskId.has(oldestTaskId)) {
79
+ this.cancelledTaskIds.delete(oldestTaskId);
80
+ this.cancelledTaskIds.add(oldestTaskId);
81
+ continue;
82
+ }
83
+ this.cancelledTaskIds.delete(oldestTaskId);
84
+ }
85
+ }
86
+ inactiveCancelledTaskIdCount() {
87
+ let count = 0;
88
+ for (const taskId of this.cancelledTaskIds) {
89
+ if (!this.controllersByTaskId.has(taskId)) {
90
+ count += 1;
91
+ }
92
+ }
93
+ return count;
94
+ }
95
+ }
@@ -0,0 +1,8 @@
1
+ export type { ClientSessionBindingRecord, ControlChannel, ControlLeaseSnapshot, ControlLeaseStatus, ParticipantRecord, ParticipantRuntimeKind, ParticipantRuntimeSnapshot, ParticipantRuntimeSnapshotStatus, SessionDebugControlLeaseSummary, SessionDebugParticipantSummary, SessionDebugSummary, SessionDebugTaskSummary, SessionEvent, SessionEventType, SessionRecord, TaskListStatus, TaskRecord, TaskSnapshot, TaskSnapshotStatus, } from "@dungle-scrubs/tether-protocol";
2
+ export type Result<TValue, TError extends Error = Error> = {
3
+ readonly ok: true;
4
+ readonly value: TValue;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly error: TError;
8
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@dungle-scrubs/tether-client",
3
+ "version": "0.1.1",
4
+ "description": "Participant runtime client for Tether REST and WebSocket integration.",
5
+ "license": "MIT",
6
+ "author": "Kevin Frilot",
7
+ "homepage": "https://github.com/dungle-scrubs/tether/tree/main/packages/client#readme",
8
+ "bugs": "https://github.com/dungle-scrubs/tether/issues",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/dungle-scrubs/tether.git",
12
+ "directory": "packages/client"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./dist/index.js",
18
+ "types": "./dist/index.d.ts"
19
+ },
20
+ "./observability": {
21
+ "import": "./dist/observability.js",
22
+ "types": "./dist/observability.d.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@opentelemetry/api": "^1.9.1",
33
+ "effect": "^3.21.2",
34
+ "ws": "^8.20.1",
35
+ "zod": "^4.4.3",
36
+ "@dungle-scrubs/tether-protocol": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.9.1",
40
+ "@types/ws": "^8.18.1",
41
+ "esbuild": "0.25.12",
42
+ "vitest": "^4.1.7"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "format": "biome format --config-path=../../tooling/biome.json --write .",
47
+ "format:check": "biome format --config-path=../../tooling/biome.json .",
48
+ "lint": "biome lint --config-path=../../tooling/biome.json .",
49
+ "pack:tether": "pnpm --filter @dungle-scrubs/tether-protocol build && pnpm --filter @dungle-scrubs/tether-client build && node scripts/pack-tether-client.mjs",
50
+ "test": "vitest run --passWithNoTests",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json"
52
+ }
53
+ }