@inixiative/signet 0.4.3 → 0.5.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,214 @@
1
+ import { z } from 'zod';
2
+ import type { SignetClient } from './client';
3
+ import { kingdomUrl } from './client';
4
+ import { signetPresentedFrameSchema } from './contracts';
5
+ import { type ThreadStreamHandlers, ThreadStreams } from './threadStreams';
6
+
7
+ export type SignetSocketOptions = ThreadStreamHandlers & {
8
+ /** The Signet this socket presents; it renews the access token the presentation rides on. */
9
+ client: SignetClient;
10
+ /** Kingdom accepted (or renewed) the presentation; it holds until `expiresAt`. */
11
+ onPresented?: (presentation: z.infer<typeof signetPresentedFrameSchema>) => void;
12
+ pingMs?: number;
13
+ retryBaseMs?: number;
14
+ retryMaxMs?: number;
15
+ /** A Kingdom that never answers the presentation is treated as down. */
16
+ authTimeoutMs?: number;
17
+ /** Renew this long before the access token expires (it lives five minutes). */
18
+ renewBeforeMs?: number;
19
+ WebSocket?: typeof WebSocket;
20
+ };
21
+
22
+ const frameSchema = z.object({ type: z.string() }).loose();
23
+ /** WebSocket.OPEN, read from the spec rather than whichever WebSocket class was passed. */
24
+ const OPEN = 1;
25
+
26
+ /**
27
+ * Kingdom's live line to a Signet presenter: an outside agent holding a member's Signet on a host
28
+ * Foundry's threads (design §5.6.1). It authenticates as that Signet's presentation
29
+ * (`authenticateSignet`: the access token and a single-use proof bound to the Signet socket), renews
30
+ * the presentation before the token expires (`renewPresentation`), and holds the member's
31
+ * `accessThread` streams: entering a thread is opening its stream, and Kingdom closes it on leave,
32
+ * revocation, any revision, expiry or the host going offline. Posts are not sent here; each is its
33
+ * own `access/execute thread.prompt` through the `SignetClient`.
34
+ */
35
+ export class SignetSocket {
36
+ private socket: WebSocket | null = null;
37
+ private stopped = false;
38
+ private attempts = 0;
39
+ private authenticated = false;
40
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
41
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
42
+ private authTimer: ReturnType<typeof setTimeout> | null = null;
43
+ private renewTimer: ReturnType<typeof setTimeout> | null = null;
44
+ private readonly streams: ThreadStreams;
45
+
46
+ constructor(private readonly options: SignetSocketOptions) {
47
+ this.streams = new ThreadStreams((frame) => this.send(frame), options, {
48
+ retryBaseMs: options.retryBaseMs,
49
+ // Kingdom closed streams because the presentation lapsed: re-present on a new connection.
50
+ onPresentationExpired: () => this.socket?.close(),
51
+ });
52
+ }
53
+
54
+ get connected() {
55
+ return this.authenticated;
56
+ }
57
+
58
+ start() {
59
+ if (this.socket || this.retryTimer) return;
60
+ this.stopped = false;
61
+ this.connect();
62
+ }
63
+
64
+ /** Enter a thread this Signet's lens selects: a snapshot of the member's view, then appends. */
65
+ open(threadId: string) {
66
+ this.streams.open({ signetId: this.options.client.signetId, threadId });
67
+ }
68
+
69
+ /** Leave a thread: Kingdom closes the stream. */
70
+ leave(threadId: string) {
71
+ this.streams.leave({ signetId: this.options.client.signetId, threadId });
72
+ }
73
+
74
+ /** The threads this socket holds open, or is opening. */
75
+ threads() {
76
+ return this.streams.held().map((stream) => stream.threadId);
77
+ }
78
+
79
+ close() {
80
+ this.stopped = true;
81
+ this.clearTimers();
82
+ this.socket?.close(1000, 'closed');
83
+ this.socket = null;
84
+ this.authenticated = false;
85
+ this.streams.down();
86
+ }
87
+
88
+ private connect() {
89
+ this.retryTimer = null;
90
+ if (this.stopped) return;
91
+ const Socket = this.options.WebSocket ?? WebSocket;
92
+ const socket = new Socket(kingdomUrl(this.options.client.url).replace(/^http/, 'ws'));
93
+ this.socket = socket;
94
+ socket.onmessage = (event) => void this.receive(socket, String(event.data));
95
+ socket.onclose = () => this.disconnected(socket);
96
+ socket.onerror = () => socket.close();
97
+ }
98
+
99
+ private async receive(socket: WebSocket, raw: string) {
100
+ let json: unknown;
101
+ try {
102
+ json = JSON.parse(raw);
103
+ } catch {
104
+ return;
105
+ }
106
+ if (socket !== this.socket || typeof json !== 'object' || json === null) return;
107
+ if (this.streams.receive(json as Record<string, unknown>)) return;
108
+ const parsed = frameSchema.safeParse(json);
109
+ if (!parsed.success) return;
110
+ const frame = parsed.data;
111
+ try {
112
+ switch (frame.type) {
113
+ case 'connected': {
114
+ this.authTimer = setTimeout(() => socket.close(), this.options.authTimeoutMs ?? 10_000);
115
+ const { accessToken, proof } = await this.options.client.socketPresentation();
116
+ this.sendOn(socket, { action: 'authenticateSignet', accessToken, proof });
117
+ return;
118
+ }
119
+ case 'signet': {
120
+ const presented = signetPresentedFrameSchema.parse(frame);
121
+ if (presented.signetId !== this.options.client.signetId) {
122
+ this.options.onError?.(Error('Kingdom presented another Signet on this socket'));
123
+ socket.close();
124
+ return;
125
+ }
126
+ if (this.authTimer) clearTimeout(this.authTimer);
127
+ this.authTimer = null;
128
+ this.scheduleRenewal(socket, presented.expiresAt);
129
+ this.options.onPresented?.(presented);
130
+ if (this.authenticated) return;
131
+ this.authenticated = true;
132
+ this.attempts = 0;
133
+ this.pingTimer ??= setInterval(
134
+ () => this.send({ action: 'ping' }),
135
+ this.options.pingMs ?? 20_000,
136
+ );
137
+ this.streams.up();
138
+ return;
139
+ }
140
+ case 'signetRejected':
141
+ this.options.onError?.(Error('Kingdom refused this Signet presentation'));
142
+ socket.close();
143
+ return;
144
+ case 'reconnect':
145
+ socket.close();
146
+ return;
147
+ }
148
+ } catch (error) {
149
+ this.options.onError?.(error);
150
+ if (frame.type === 'connected') socket.close();
151
+ }
152
+ }
153
+
154
+ // The presentation is bounded by the access token; Kingdom closes every stream if none follows.
155
+ private scheduleRenewal(socket: WebSocket, expiresAt: string) {
156
+ if (this.renewTimer) clearTimeout(this.renewTimer);
157
+ const delay = Math.max(
158
+ 0,
159
+ Date.parse(expiresAt) - Date.now() - (this.options.renewBeforeMs ?? 60_000),
160
+ );
161
+ this.renewTimer = setTimeout(() => void this.renew(socket, expiresAt), delay);
162
+ }
163
+
164
+ private async renew(socket: WebSocket, expiresAt: string) {
165
+ this.renewTimer = null;
166
+ if (socket !== this.socket) return;
167
+ try {
168
+ const { accessToken, proof } = await this.options.client.socketPresentation({ renew: true });
169
+ this.sendOn(socket, { action: 'renewPresentation', accessToken, proof });
170
+ // Kingdom answers with `signet` (which reschedules); silence means the presentation is lost.
171
+ this.authTimer = setTimeout(() => socket.close(), this.options.authTimeoutMs ?? 10_000);
172
+ } catch (error) {
173
+ this.options.onError?.(error);
174
+ if (socket !== this.socket) return;
175
+ // Try again while the old presentation still holds; past it, reconnect and present afresh.
176
+ if (Date.parse(expiresAt) > Date.now())
177
+ this.renewTimer = setTimeout(
178
+ () => void this.renew(socket, expiresAt),
179
+ this.options.retryBaseMs ?? 1_000,
180
+ );
181
+ else socket.close();
182
+ }
183
+ }
184
+
185
+ private send(frame: Record<string, unknown>) {
186
+ if (this.socket) this.sendOn(this.socket, frame);
187
+ }
188
+
189
+ private sendOn(socket: WebSocket, frame: Record<string, unknown>) {
190
+ if (socket === this.socket && socket.readyState === OPEN) socket.send(JSON.stringify(frame));
191
+ }
192
+
193
+ private disconnected(socket: WebSocket) {
194
+ if (socket !== this.socket) return;
195
+ this.socket = null;
196
+ this.authenticated = false;
197
+ this.clearTimers();
198
+ this.streams.down();
199
+ if (this.stopped) return;
200
+ const base = this.options.retryBaseMs ?? 1_000;
201
+ const delay = Math.min(base * 2 ** this.attempts++, this.options.retryMaxMs ?? 30_000);
202
+ this.retryTimer = setTimeout(() => this.connect(), delay);
203
+ }
204
+
205
+ private clearTimers() {
206
+ for (const timer of [this.authTimer, this.renewTimer, this.retryTimer])
207
+ if (timer) clearTimeout(timer);
208
+ if (this.pingTimer) clearInterval(this.pingTimer);
209
+ this.authTimer = null;
210
+ this.renewTimer = null;
211
+ this.retryTimer = null;
212
+ this.pingTimer = null;
213
+ }
214
+ }
@@ -0,0 +1,315 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ type AccessThreadCloseReason,
4
+ accessThreadAppendType,
5
+ accessThreadCloseReasonSchema,
6
+ accessThreadStreamName,
7
+ parseAccessThreadStreamName,
8
+ parseThreadView,
9
+ type ThreadEntry,
10
+ type ThreadViewResult,
11
+ threadEntrySchema,
12
+ } from './threads';
13
+
14
+ export type ThreadStreamKey = { signetId: string; threadId: string };
15
+
16
+ /** What a member does with its live views. `reason` is null when Kingdom gave none. */
17
+ export type ThreadStreamHandlers = {
18
+ /** A fresh view: on open, and again after every resync. Replace what was held for the thread. */
19
+ onSnapshot: (stream: ThreadStreamKey, view: ThreadViewResult) => void | Promise<void>;
20
+ onEntry: (stream: ThreadStreamKey, entry: ThreadEntry) => void | Promise<void>;
21
+ /** Kingdom ended the stream for good, or refused to open it (`rejected`); it is not reopened. */
22
+ onClosed?: (stream: ThreadStreamKey, reason: AccessThreadCloseReason | 'rejected' | null) => void;
23
+ /** A frame that didn't parse, or entries dropped from one (the rest are kept). */
24
+ onError?: (error: unknown) => void;
25
+ };
26
+
27
+ export type ThreadStreamOptions = {
28
+ retryBaseMs?: number;
29
+ /** An open Kingdom never answers is sent again after this long (a relay waits up to 15 s). */
30
+ openTimeoutMs?: number;
31
+ /** Kingdom closed a stream because the socket's presentation lapsed: re-present, then reopen. */
32
+ onPresentationExpired?: () => void;
33
+ };
34
+
35
+ const reasonSchema = accessThreadCloseReasonSchema.optional().catch(undefined);
36
+ const controlFrameSchema = z.discriminatedUnion('type', [
37
+ z.object({ type: z.literal('opened'), stream: z.string() }),
38
+ z.object({ type: z.literal('openRejected'), stream: z.string(), reason: reasonSchema }),
39
+ z.object({ type: z.literal('closed'), stream: z.string(), reason: reasonSchema }),
40
+ z.object({ type: z.literal('resync'), stream: z.string() }),
41
+ z.object({
42
+ type: z.literal('error'),
43
+ action: z.string(),
44
+ stream: z.string(),
45
+ retryable: z.boolean(),
46
+ }),
47
+ ]);
48
+ const dataFrameSchema = z.discriminatedUnion('action', [
49
+ z.object({
50
+ category: z.literal('data'),
51
+ action: z.literal('snapshot'),
52
+ stream: z.string(),
53
+ seq: z.number().int(),
54
+ payload: z.unknown(),
55
+ }),
56
+ z.object({
57
+ category: z.literal('data'),
58
+ action: z.literal('append'),
59
+ stream: z.string(),
60
+ seq: z.number().int(),
61
+ type: z.string(),
62
+ payload: z.unknown(),
63
+ }),
64
+ ]);
65
+
66
+ type StreamState = {
67
+ key: ThreadStreamKey;
68
+ lastSeq: number | null;
69
+ attempts: number;
70
+ /** An open is on the wire; a resync or retry meanwhile waits for its answer, not a second open. */
71
+ pending: boolean;
72
+ /** Something asked for a fresh snapshot while an open was pending: open again once it answers. */
73
+ again: boolean;
74
+ };
75
+
76
+ /** Kingdom closed the stream, but the member may come back: under a new lens, or once re-presented. */
77
+ const reopenAfter = new Set<AccessThreadCloseReason>(['revised', 'presentationExpired']);
78
+ /** Kingdom closed the stream until the host returns: reopen with backoff. */
79
+ const retryAfter = new Set<AccessThreadCloseReason>(['hostOffline']);
80
+
81
+ /**
82
+ * A member's `accessThread` streams on one Kingdom socket, as Kingdom's data-stream primitive and
83
+ * its web client run them (Kingdom `docs/claude/WEBSOCKETS.md`, `packages/ui/src/lib/ws/
84
+ * createApiWebsocket.ts`, which isn't published): one open in flight per stream, a snapshot
85
+ * carrying `seq`, then appends; an append at or below the last seq is already in the snapshot, and a
86
+ * gap or `resync` re-opens for a fresh snapshot. No replay. Frames are handled one at a time, so an
87
+ * append never overtakes the snapshot before it. The socket that owns it sends and re-authenticates.
88
+ */
89
+ export class ThreadStreams {
90
+ private readonly streams = new Map<string, StreamState>();
91
+ /** Closes this member sent and Kingdom hasn't acknowledged yet, per stream (answered in order). */
92
+ private readonly leaving = new Map<string, number>();
93
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
94
+ private queue: Promise<void> = Promise.resolve();
95
+ private ready = false;
96
+
97
+ constructor(
98
+ private readonly send: (frame: Record<string, unknown>) => void,
99
+ private readonly handlers: ThreadStreamHandlers,
100
+ private readonly options: ThreadStreamOptions = {},
101
+ ) {}
102
+
103
+ /** The streams this member holds open, or is opening. */
104
+ held(): ThreadStreamKey[] {
105
+ return [...this.streams.values()].map((state) => ({ ...state.key }));
106
+ }
107
+
108
+ /** Enter a thread: open its stream now, or once the socket is authenticated. */
109
+ open(key: ThreadStreamKey) {
110
+ const stream = accessThreadStreamName(key);
111
+ if (this.streams.has(stream)) return;
112
+ this.streams.set(stream, {
113
+ key: { ...key },
114
+ lastSeq: null,
115
+ attempts: 0,
116
+ pending: false,
117
+ again: false,
118
+ });
119
+ this.sendOpen(stream);
120
+ }
121
+
122
+ /** Leave a thread: Kingdom closes the stream (and the host's subscription). */
123
+ leave(key: ThreadStreamKey) {
124
+ const stream = accessThreadStreamName(key);
125
+ if (!this.streams.delete(stream)) return;
126
+ this.clearTimer(stream);
127
+ if (!this.ready) return;
128
+ this.leaving.set(stream, (this.leaving.get(stream) ?? 0) + 1);
129
+ this.send({ action: 'close', stream });
130
+ }
131
+
132
+ /** The socket (re)authenticated: open every held stream for a fresh snapshot. */
133
+ up() {
134
+ this.ready = true;
135
+ for (const stream of this.streams.keys()) this.sendOpen(stream);
136
+ }
137
+
138
+ /** The socket dropped: everything held is stale until the next snapshot. */
139
+ down() {
140
+ this.ready = false;
141
+ this.leaving.clear();
142
+ for (const stream of [...this.timers.keys()]) this.clearTimer(stream);
143
+ for (const state of this.streams.values()) {
144
+ state.lastSeq = null;
145
+ state.pending = false;
146
+ state.again = false;
147
+ }
148
+ }
149
+
150
+ /** Handle a frame if it belongs to an `accessThread` stream; true when it did. */
151
+ receive(frame: Record<string, unknown>): boolean {
152
+ if (typeof frame.stream !== 'string' || !parseAccessThreadStreamName(frame.stream))
153
+ return false;
154
+ this.queue = this.queue
155
+ .then(() => this.handle(frame))
156
+ .catch(this.handlers.onError ?? (() => {}));
157
+ return true;
158
+ }
159
+
160
+ private async handle(frame: Record<string, unknown>) {
161
+ if (frame.category === 'data') {
162
+ const data = dataFrameSchema.safeParse(frame);
163
+ if (!data.success) return this.handlers.onError?.(data.error);
164
+ return this.data(data.data);
165
+ }
166
+ const control = controlFrameSchema.safeParse(frame);
167
+ if (!control.success) return this.handlers.onError?.(control.error);
168
+ this.control(control.data);
169
+ }
170
+
171
+ private control(frame: z.infer<typeof controlFrameSchema>) {
172
+ const state = this.streams.get(frame.stream);
173
+ switch (frame.type) {
174
+ case 'opened':
175
+ if (state) state.attempts = 0;
176
+ return;
177
+ case 'resync':
178
+ return this.reopen(frame.stream);
179
+ case 'error':
180
+ if (!state || frame.action !== 'open') return;
181
+ this.answered(state);
182
+ if (frame.retryable) return this.retry(frame.stream, state);
183
+ return this.end(frame.stream, 'rejected');
184
+ case 'openRejected':
185
+ if (state) this.answered(state);
186
+ return this.closedBy(frame.stream, frame.reason ?? 'rejected');
187
+ case 'closed':
188
+ // Our own close is only acknowledged (no reason, or `left`); a close with any other reason,
189
+ // or one we didn't ask for, is Kingdom ending the stream.
190
+ if ((frame.reason ?? 'left') === 'left' && this.acknowledge(frame.stream)) return;
191
+ return this.closedBy(frame.stream, frame.reason ?? null);
192
+ }
193
+ }
194
+
195
+ private closedBy(stream: string, reason: AccessThreadCloseReason | 'rejected' | null) {
196
+ const state = this.streams.get(stream);
197
+ if (!state) return;
198
+ if (reason === 'presentationExpired') {
199
+ // The socket re-presents; `up()` then reopens everything held.
200
+ state.lastSeq = null;
201
+ this.options.onPresentationExpired?.();
202
+ return;
203
+ }
204
+ if (reason && reasonIn(reopenAfter, reason)) return this.reopen(stream);
205
+ if (reason && reasonIn(retryAfter, reason)) return this.retry(stream, state);
206
+ this.end(stream, reason);
207
+ }
208
+
209
+ private async data(frame: z.infer<typeof dataFrameSchema>) {
210
+ const state = this.streams.get(frame.stream);
211
+ if (!state) return;
212
+ if (frame.action === 'snapshot') {
213
+ this.answered(state);
214
+ const view = parseThreadView(
215
+ z.object({ data: z.unknown() }).safeParse(frame.payload).data?.data,
216
+ );
217
+ if (!view) return this.abandon(state, Error(`${frame.stream}: unreadable snapshot`));
218
+ const { dropped, ...rest } = view;
219
+ if (dropped) this.handlers.onError?.(Error(`${frame.stream}: dropped ${dropped} entries`));
220
+ state.lastSeq = frame.seq;
221
+ await this.handlers.onSnapshot(state.key, rest);
222
+ if (state.again) {
223
+ state.again = false;
224
+ this.reopen(frame.stream);
225
+ }
226
+ return;
227
+ }
228
+ if (state.lastSeq === null || frame.seq <= state.lastSeq) return;
229
+ if (frame.seq > state.lastSeq + 1) return this.reopen(frame.stream);
230
+ state.lastSeq = frame.seq;
231
+ const entry = threadEntrySchema.safeParse(frame.payload);
232
+ if (frame.type !== accessThreadAppendType || !entry.success)
233
+ return this.handlers.onError?.(Error(`${frame.stream}: unreadable ${frame.type} append`));
234
+ await this.handlers.onEntry(state.key, entry.data);
235
+ }
236
+
237
+ private sendOpen(stream: string) {
238
+ const state = this.streams.get(stream);
239
+ if (!state || !this.ready) return;
240
+ if (state.pending) {
241
+ state.again = true;
242
+ return;
243
+ }
244
+ state.pending = true;
245
+ state.lastSeq = null;
246
+ this.send({ action: 'open', stream });
247
+ this.setTimer(stream, this.options.openTimeoutMs ?? 30_000, () => {
248
+ state.pending = false;
249
+ this.reopen(stream);
250
+ });
251
+ }
252
+
253
+ private answered(state: StreamState) {
254
+ state.pending = false;
255
+ this.clearTimer(accessThreadStreamName(state.key));
256
+ }
257
+
258
+ private acknowledge(stream: string) {
259
+ const pending = this.leaving.get(stream) ?? 0;
260
+ if (pending === 0) return false;
261
+ if (pending === 1) this.leaving.delete(stream);
262
+ else this.leaving.set(stream, pending - 1);
263
+ return true;
264
+ }
265
+
266
+ // Until the next snapshot, appends on the wire are already in it.
267
+ private reopen(stream: string) {
268
+ const state = this.streams.get(stream);
269
+ if (!state) return;
270
+ state.lastSeq = null;
271
+ this.sendOpen(stream);
272
+ }
273
+
274
+ private retry(stream: string, state: StreamState) {
275
+ const delay = Math.min((this.options.retryBaseMs ?? 1_000) * 2 ** state.attempts++, 30_000);
276
+ this.setTimer(stream, delay, () => this.reopen(stream));
277
+ }
278
+
279
+ private setTimer(stream: string, delay: number, fire: () => void) {
280
+ this.clearTimer(stream);
281
+ this.timers.set(
282
+ stream,
283
+ setTimeout(() => {
284
+ this.timers.delete(stream);
285
+ fire();
286
+ }, delay),
287
+ );
288
+ }
289
+
290
+ private clearTimer(stream: string) {
291
+ const timer = this.timers.get(stream);
292
+ if (timer) clearTimeout(timer);
293
+ this.timers.delete(stream);
294
+ }
295
+
296
+ // Re-opening an unreadable snapshot would read (and meter) the same thing again: leave instead.
297
+ private abandon(state: StreamState, error: unknown) {
298
+ this.handlers.onError?.(error);
299
+ this.leave(state.key);
300
+ this.handlers.onClosed?.(state.key, null);
301
+ }
302
+
303
+ private end(stream: string, reason: AccessThreadCloseReason | 'rejected' | null) {
304
+ const state = this.streams.get(stream);
305
+ if (!state) return;
306
+ this.streams.delete(stream);
307
+ this.clearTimer(stream);
308
+ this.handlers.onClosed?.(state.key, reason);
309
+ }
310
+ }
311
+
312
+ const reasonIn = (
313
+ set: Set<AccessThreadCloseReason>,
314
+ reason: AccessThreadCloseReason | 'rejected',
315
+ ) => reason !== 'rejected' && set.has(reason);