@inixiative/signet 0.4.6 → 0.5.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/README.md +30 -1
- package/package.json +3 -2
- package/src/client.ts +50 -10
- package/src/contracts.ts +355 -29
- package/src/index.ts +2 -0
- package/src/installationPairing.ts +17 -4
- package/src/installationSocket.ts +381 -10
- package/src/signetSocket.ts +214 -0
- package/src/threadStreams.ts +315 -0
- package/src/threads.ts +426 -0
|
@@ -13,9 +13,14 @@ import {
|
|
|
13
13
|
saveCollectedSignet,
|
|
14
14
|
signetCredentialSchema,
|
|
15
15
|
} from './client';
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
InstallationKind,
|
|
18
|
+
InstallationSnapshot,
|
|
19
|
+
OwnerRef,
|
|
20
|
+
RegistrationRequest,
|
|
21
|
+
} from './contracts';
|
|
17
22
|
import { readPrivateJson, writePrivateJson } from './files';
|
|
18
|
-
import {
|
|
23
|
+
import { InstallationSocket } from './installationSocket';
|
|
19
24
|
|
|
20
25
|
/** Where an installation keeps what it holds for one Kingdom: its key and one file per Signet. */
|
|
21
26
|
export async function installationDirectory(root: string, url: string) {
|
|
@@ -79,7 +84,10 @@ export async function holdInstallationSignets(
|
|
|
79
84
|
url: string,
|
|
80
85
|
keyFile: string,
|
|
81
86
|
directory: string,
|
|
82
|
-
signets:
|
|
87
|
+
signets: Pick<
|
|
88
|
+
InstallationSnapshot['signets'][number],
|
|
89
|
+
'signetId' | 'integrationId' | 'owner' | 'enrollmentId'
|
|
90
|
+
>[],
|
|
83
91
|
): Promise<HeldSignet[]> {
|
|
84
92
|
return withHoldLock(directory, async () => {
|
|
85
93
|
const held: HeldSignet[] = [];
|
|
@@ -184,6 +192,9 @@ export async function pairInstallation(input: InstallationPairing) {
|
|
|
184
192
|
};
|
|
185
193
|
const follow = (snapshot: Snapshot) => {
|
|
186
194
|
refusals.length = 0;
|
|
195
|
+
const unread = snapshot.dropped.filter((drop) => drop.list === 'inquiries');
|
|
196
|
+
if (unread.length)
|
|
197
|
+
input.onError?.(Error(`Could not read ${unread.length} inquiries from Kingdom`));
|
|
187
198
|
const settled = settledInquiry(snapshot, earlier, baseline.declinedAt, claimed);
|
|
188
199
|
if (settled) return finish(settled);
|
|
189
200
|
const inquiry = newInquiry(snapshot, earlier);
|
|
@@ -268,7 +279,7 @@ const refusalsToRevoke = 3;
|
|
|
268
279
|
/** Kingdom can still approve these. */
|
|
269
280
|
const openStatuses = new Set(['sent', 'changesRequested']);
|
|
270
281
|
|
|
271
|
-
type Snapshot = Pick<InstallationSnapshot, 'pending' | 'declinedAt' | 'inquiries'>;
|
|
282
|
+
type Snapshot = Pick<InstallationSnapshot, 'pending' | 'declinedAt' | 'inquiries' | 'dropped'>;
|
|
272
283
|
|
|
273
284
|
const expired = () => Error('The review code expired before it was claimed; pair again');
|
|
274
285
|
const revoked = () => Error('Kingdom revoked this installation');
|
|
@@ -292,6 +303,8 @@ const settledInquiry = (
|
|
|
292
303
|
if (inquiry?.status === 'approved') return inquiry;
|
|
293
304
|
if (inquiry && !openStatuses.has(inquiry.status))
|
|
294
305
|
return Error(`The registration was ${inquiry.status} in Kingdom`);
|
|
306
|
+
// An inquiry this package couldn't read is not a withdrawn or expired one: keep waiting.
|
|
307
|
+
if (!inquiry && snapshot.dropped.some((drop) => drop.list === 'inquiries')) return null;
|
|
295
308
|
// A canceled inquiry is soft-deleted, so it drops out of the snapshot.
|
|
296
309
|
if (!inquiry && claimed) return withdrawn();
|
|
297
310
|
if (!inquiry && !snapshot.pending) return expired();
|
|
@@ -1,8 +1,69 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { installationInquiries, installationSignets, kingdomUrl, signetProof } from './client';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type InstallationSnapshot,
|
|
5
|
+
installationSocketAction,
|
|
6
|
+
parseInstallationSnapshot,
|
|
7
|
+
} from './contracts';
|
|
8
|
+
import { type ThreadStreamHandlers, type ThreadStreamKey, ThreadStreams } from './threadStreams';
|
|
9
|
+
import {
|
|
10
|
+
type ThreadAppendFrame,
|
|
11
|
+
type ThreadEntry,
|
|
12
|
+
type ThreadOperation,
|
|
13
|
+
type ThreadOutcome,
|
|
14
|
+
type ThreadRefusal,
|
|
15
|
+
type ThreadRequestFrame,
|
|
16
|
+
type ThreadSubscribeFrame,
|
|
17
|
+
type ThreadsLens,
|
|
18
|
+
threadAppendFrameSchema,
|
|
19
|
+
threadLeaseFrameSchema,
|
|
20
|
+
threadRefused,
|
|
21
|
+
threadRequestFrameSchema,
|
|
22
|
+
threadResponseFrameSchema,
|
|
23
|
+
threadSubscribeFrameSchema,
|
|
24
|
+
threadSubscriptionLeaseMs,
|
|
25
|
+
threadUnsubscribeFrameSchema,
|
|
26
|
+
} from './threads';
|
|
4
27
|
|
|
5
|
-
export type
|
|
28
|
+
export type ThreadRequestOf<O extends ThreadOperation> = Extract<
|
|
29
|
+
ThreadRequestFrame,
|
|
30
|
+
{ operation: O }
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
/** A member's live view this host is pushing, from the moment its snapshot was taken. */
|
|
34
|
+
export type ThreadSubscription = {
|
|
35
|
+
subscriptionId: string;
|
|
36
|
+
signetId: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
acceptedRevision: number;
|
|
39
|
+
member: ThreadSubscribeFrame['member'];
|
|
40
|
+
lens: ThreadsLens;
|
|
41
|
+
};
|
|
42
|
+
/** `overflow`: too many entries arrived while the snapshot was taken; the subscribe is refused and retried. */
|
|
43
|
+
export type ThreadSubscriptionEnd = 'unsubscribed' | 'lease' | 'disconnected' | 'overflow';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A host Foundry's side of agent conversations: one handler per operation, typed by what Kingdom
|
|
47
|
+
* relays and what it reads back. Each answers within Kingdom's 15 s wait, with `threadResult(...)`
|
|
48
|
+
* or `threadRefused(code)`. A handler that throws, or answers with something the contract doesn't
|
|
49
|
+
* allow, is answered `failed`.
|
|
50
|
+
*/
|
|
51
|
+
export type ThreadHost = {
|
|
52
|
+
[O in ThreadOperation]: (
|
|
53
|
+
frame: ThreadRequestOf<O>,
|
|
54
|
+
) => ThreadOutcome<O> | Promise<ThreadOutcome<O>>;
|
|
55
|
+
} & {
|
|
56
|
+
/**
|
|
57
|
+
* The member's view and the `journalSeq` it was taken at. The subscription is registered before
|
|
58
|
+
* this runs, and appends pushed meanwhile are held and sent after the answer, minus those at or
|
|
59
|
+
* below `journalSeq`: snapshot and subscription start at the same point (review V3).
|
|
60
|
+
*/
|
|
61
|
+
'thread.subscribe': (
|
|
62
|
+
frame: ThreadSubscribeFrame,
|
|
63
|
+
) => ThreadOutcome<'thread.subscribe'> | Promise<ThreadOutcome<'thread.subscribe'>>;
|
|
64
|
+
/** A subscription ended: Kingdom unsubscribed, its lease lapsed, or this socket dropped. */
|
|
65
|
+
ended?: (subscription: ThreadSubscription, reason: ThreadSubscriptionEnd) => void;
|
|
66
|
+
};
|
|
6
67
|
|
|
7
68
|
export type InstallationSocketOptions = {
|
|
8
69
|
url: string;
|
|
@@ -15,27 +76,60 @@ export type InstallationSocketOptions = {
|
|
|
15
76
|
viewerUrl?: string | null;
|
|
16
77
|
/** Reported on every ping so Kingdom can show how busy this installation is. */
|
|
17
78
|
sessionCount?: () => number;
|
|
79
|
+
/** Serve conversations on this installation's Foundry integrations (the host side). */
|
|
80
|
+
threads?: ThreadHost;
|
|
81
|
+
/** Hold live views of threads on other Foundries through Signets this installation holds. */
|
|
82
|
+
threadStreams?: ThreadStreamHandlers;
|
|
18
83
|
pingMs?: number;
|
|
19
84
|
pollMs?: number;
|
|
20
85
|
retryBaseMs?: number;
|
|
21
86
|
retryMaxMs?: number;
|
|
22
87
|
/** A Kingdom that never answers the proof (one without Installation sockets) is treated as down. */
|
|
23
88
|
authTimeoutMs?: number;
|
|
89
|
+
/** How long a subscription lives without Kingdom re-asserting it. */
|
|
90
|
+
leaseMs?: number;
|
|
24
91
|
WebSocket?: typeof WebSocket;
|
|
25
92
|
};
|
|
26
93
|
|
|
27
94
|
const frameSchema = z.object({ type: z.string() }).loose();
|
|
28
95
|
|
|
96
|
+
type HeldSubscription = ThreadSubscription & {
|
|
97
|
+
leaseUntil: number;
|
|
98
|
+
/** The last `journalSeq` covered (the snapshot's, then each append's); null while it is taken. */
|
|
99
|
+
sentSeq: number | null;
|
|
100
|
+
/** The last append's dense `seq` (0 = the snapshot). */
|
|
101
|
+
seq: number;
|
|
102
|
+
held: ThreadEntry[];
|
|
103
|
+
};
|
|
104
|
+
/** Entries held while one snapshot is taken; past this the subscription ends (Kingdom resyncs). */
|
|
105
|
+
const maxHeldEntries = 1_000;
|
|
106
|
+
/** WebSocket.OPEN, read from the spec rather than whichever WebSocket class was passed. */
|
|
107
|
+
const OPEN = 1;
|
|
108
|
+
|
|
109
|
+
/** An unknown selector form (or a `within`, which no v1 form fills) is refused as `unknown_selector`. */
|
|
110
|
+
const unknownSelector = (frame: Record<string, unknown>) => {
|
|
111
|
+
const threads = z
|
|
112
|
+
.object({ threads: z.object({ form: z.string(), within: z.unknown().optional() }).loose() })
|
|
113
|
+
.loose()
|
|
114
|
+
.safeParse(frame.lens);
|
|
115
|
+
return (
|
|
116
|
+
threads.success &&
|
|
117
|
+
(threads.data.threads.form !== 'ids' || threads.data.threads.within !== undefined)
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
29
121
|
/**
|
|
30
122
|
* Kingdom's live line to an Installation. It proves the installation's key, then receives a
|
|
31
123
|
* snapshot of its inquiries and Signets on connect and again whenever one changes. While the
|
|
32
|
-
* socket is down it polls the same reads, so nothing waits on the socket coming back.
|
|
124
|
+
* socket is down it polls the same reads, so nothing waits on the socket coming back. A Foundry
|
|
125
|
+
* also answers its members' calls over it (`threads`) and holds its own memberships (`threadStreams`).
|
|
33
126
|
*/
|
|
34
127
|
export class InstallationSocket {
|
|
35
128
|
private socket: WebSocket | null = null;
|
|
36
129
|
private stopped = false;
|
|
37
130
|
private attempts = 0;
|
|
38
131
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
132
|
+
private leaseTimer: ReturnType<typeof setInterval> | null = null;
|
|
39
133
|
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
134
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
135
|
private authTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -43,9 +137,16 @@ export class InstallationSocket {
|
|
|
43
137
|
private polling = false;
|
|
44
138
|
private pollGeneration = 0;
|
|
45
139
|
private viewerUrl: string | null;
|
|
140
|
+
private readonly subscriptions = new Map<string, HeldSubscription>();
|
|
141
|
+
private readonly streams: ThreadStreams | null;
|
|
46
142
|
|
|
47
143
|
constructor(private readonly options: InstallationSocketOptions) {
|
|
48
144
|
this.viewerUrl = options.viewerUrl ?? null;
|
|
145
|
+
this.streams = options.threadStreams
|
|
146
|
+
? new ThreadStreams((frame) => this.send(frame), options.threadStreams, {
|
|
147
|
+
retryBaseMs: options.retryBaseMs,
|
|
148
|
+
})
|
|
149
|
+
: null;
|
|
49
150
|
}
|
|
50
151
|
|
|
51
152
|
get connected() {
|
|
@@ -75,6 +176,63 @@ export class InstallationSocket {
|
|
|
75
176
|
this.socket?.close(1000, 'closed');
|
|
76
177
|
this.socket = null;
|
|
77
178
|
this.authenticated = false;
|
|
179
|
+
this.endSubscriptions('disconnected');
|
|
180
|
+
this.streams?.down();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The members' views this host is pushing now. */
|
|
184
|
+
threadSubscriptions(): ThreadSubscription[] {
|
|
185
|
+
return [...this.subscriptions.values()].map(
|
|
186
|
+
({ leaseUntil: _lease, sentSeq: _sent, seq: _seq, held: _held, ...subscription }) =>
|
|
187
|
+
subscription,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Push one journal entry to one subscription, already filtered to that member's view. Entries at
|
|
193
|
+
* or below what was covered are skipped; while the snapshot is being taken they are held. An
|
|
194
|
+
* entry that breaks the contract still spends its `seq`, so Kingdom sees the gap and the member
|
|
195
|
+
* resyncs rather than silently missing it. False when the subscription is gone.
|
|
196
|
+
*/
|
|
197
|
+
appendThread(subscriptionId: string, entry: ThreadEntry) {
|
|
198
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
199
|
+
if (!subscription) return false;
|
|
200
|
+
if (subscription.sentSeq === null) {
|
|
201
|
+
if (subscription.held.length >= maxHeldEntries) {
|
|
202
|
+
this.endSubscription(subscriptionId, 'overflow');
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
subscription.held.push(entry);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (entry.journalSeq <= subscription.sentSeq) return true;
|
|
209
|
+
subscription.sentSeq = entry.journalSeq;
|
|
210
|
+
subscription.seq += 1;
|
|
211
|
+
const frame = threadAppendFrameSchema.safeParse({
|
|
212
|
+
action: 'thread.append',
|
|
213
|
+
subscriptionId,
|
|
214
|
+
signetId: subscription.signetId,
|
|
215
|
+
threadId: subscription.threadId,
|
|
216
|
+
seq: subscription.seq,
|
|
217
|
+
journalSeq: entry.journalSeq,
|
|
218
|
+
entry,
|
|
219
|
+
} satisfies ThreadAppendFrame);
|
|
220
|
+
if (!frame.success) {
|
|
221
|
+
this.options.onError?.(frame.error);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
this.send(frame.data);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Open a live view of a thread through a Signet this installation holds (needs `threadStreams`). */
|
|
229
|
+
openThread(stream: ThreadStreamKey) {
|
|
230
|
+
if (!this.streams) throw Error('Pass threadStreams to hold thread views');
|
|
231
|
+
this.streams.open(stream);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
leaveThread(stream: ThreadStreamKey) {
|
|
235
|
+
this.streams?.leave(stream);
|
|
78
236
|
}
|
|
79
237
|
|
|
80
238
|
private connect() {
|
|
@@ -89,13 +247,16 @@ export class InstallationSocket {
|
|
|
89
247
|
}
|
|
90
248
|
|
|
91
249
|
private async receive(socket: WebSocket, raw: string) {
|
|
92
|
-
let
|
|
250
|
+
let json: unknown;
|
|
93
251
|
try {
|
|
94
|
-
|
|
252
|
+
json = JSON.parse(raw);
|
|
95
253
|
} catch {
|
|
96
254
|
return;
|
|
97
255
|
}
|
|
98
|
-
if (
|
|
256
|
+
if (socket !== this.socket || typeof json !== 'object' || json === null) return;
|
|
257
|
+
if (this.streams?.receive(json as Record<string, unknown>)) return;
|
|
258
|
+
const parsed = frameSchema.safeParse(json);
|
|
259
|
+
if (!parsed.success) return;
|
|
99
260
|
const frame = parsed.data;
|
|
100
261
|
try {
|
|
101
262
|
switch (frame.type) {
|
|
@@ -120,10 +281,15 @@ export class InstallationSocket {
|
|
|
120
281
|
() => this.send({ action: 'ping', sessionCount: this.options.sessionCount?.() }),
|
|
121
282
|
this.options.pingMs ?? 20_000,
|
|
122
283
|
);
|
|
284
|
+
this.leaseTimer ??= setInterval(
|
|
285
|
+
() => this.expireLeases(),
|
|
286
|
+
Math.min(this.leaseMs() / 3, 5_000),
|
|
287
|
+
);
|
|
123
288
|
if (this.viewerUrl) this.send({ action: 'advertise', viewerUrl: this.viewerUrl });
|
|
289
|
+
this.streams?.up();
|
|
124
290
|
return;
|
|
125
291
|
case 'installation.snapshot':
|
|
126
|
-
await this.options.onSnapshot(
|
|
292
|
+
await this.options.onSnapshot(parseInstallationSnapshot(frame));
|
|
127
293
|
return;
|
|
128
294
|
case 'installationRejected':
|
|
129
295
|
socket.close();
|
|
@@ -138,6 +304,13 @@ export class InstallationSocket {
|
|
|
138
304
|
case 'advertiseRejected':
|
|
139
305
|
this.options.onError?.(Error(`Kingdom refused viewer URL ${this.viewerUrl}`));
|
|
140
306
|
return;
|
|
307
|
+
case 'thread.request':
|
|
308
|
+
case 'thread.subscribe':
|
|
309
|
+
case 'thread.unsubscribe':
|
|
310
|
+
case 'thread.lease':
|
|
311
|
+
// Only an authenticated Installation socket carries conversation frames.
|
|
312
|
+
if (this.authenticated) await this.threadFrame(socket, frame);
|
|
313
|
+
return;
|
|
141
314
|
}
|
|
142
315
|
} catch (error) {
|
|
143
316
|
this.options.onError?.(error);
|
|
@@ -145,13 +318,199 @@ export class InstallationSocket {
|
|
|
145
318
|
}
|
|
146
319
|
}
|
|
147
320
|
|
|
321
|
+
private async threadFrame(socket: WebSocket, frame: Record<string, unknown>) {
|
|
322
|
+
switch (frame.type) {
|
|
323
|
+
case 'thread.request':
|
|
324
|
+
await this.threadRequest(socket, frame);
|
|
325
|
+
return;
|
|
326
|
+
case 'thread.subscribe':
|
|
327
|
+
await this.threadSubscribe(socket, frame);
|
|
328
|
+
return;
|
|
329
|
+
case 'thread.unsubscribe': {
|
|
330
|
+
const { subscriptionId } = threadUnsubscribeFrameSchema.parse(frame);
|
|
331
|
+
this.endSubscription(subscriptionId, 'unsubscribed');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
case 'thread.lease': {
|
|
335
|
+
const { subscriptionIds } = threadLeaseFrameSchema.parse(frame);
|
|
336
|
+
const leaseUntil = Date.now() + this.leaseMs();
|
|
337
|
+
for (const id of subscriptionIds) {
|
|
338
|
+
const subscription = this.subscriptions.get(id);
|
|
339
|
+
if (subscription) subscription.leaseUntil = leaseUntil;
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async threadRequest(socket: WebSocket, raw: Record<string, unknown>) {
|
|
347
|
+
const requestId = z.uuid().safeParse(raw.requestId);
|
|
348
|
+
if (!requestId.success) return this.options.onError?.(requestId.error);
|
|
349
|
+
const frame = threadRequestFrameSchema.safeParse(raw);
|
|
350
|
+
if (!frame.success) {
|
|
351
|
+
this.options.onError?.(frame.error);
|
|
352
|
+
return this.refuse(
|
|
353
|
+
socket,
|
|
354
|
+
requestId.data,
|
|
355
|
+
unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
const host = this.options.threads;
|
|
359
|
+
if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
|
|
360
|
+
try {
|
|
361
|
+
const request = frame.data;
|
|
362
|
+
switch (request.operation) {
|
|
363
|
+
case 'thread.read':
|
|
364
|
+
return this.respond(
|
|
365
|
+
socket,
|
|
366
|
+
request.requestId,
|
|
367
|
+
request.operation,
|
|
368
|
+
await host[request.operation](request),
|
|
369
|
+
);
|
|
370
|
+
case 'thread.prompt':
|
|
371
|
+
return this.respond(
|
|
372
|
+
socket,
|
|
373
|
+
request.requestId,
|
|
374
|
+
request.operation,
|
|
375
|
+
await host[request.operation](request),
|
|
376
|
+
);
|
|
377
|
+
case 'thread.floor':
|
|
378
|
+
return this.respond(
|
|
379
|
+
socket,
|
|
380
|
+
request.requestId,
|
|
381
|
+
request.operation,
|
|
382
|
+
await host[request.operation](request),
|
|
383
|
+
);
|
|
384
|
+
case 'prompt.resolve':
|
|
385
|
+
return this.respond(
|
|
386
|
+
socket,
|
|
387
|
+
request.requestId,
|
|
388
|
+
request.operation,
|
|
389
|
+
await host[request.operation](request),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
this.options.onError?.(error);
|
|
394
|
+
return this.refuse(socket, requestId.data, 'failed');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async threadSubscribe(socket: WebSocket, raw: Record<string, unknown>) {
|
|
399
|
+
const requestId = z.uuid().safeParse(raw.requestId);
|
|
400
|
+
if (!requestId.success) return this.options.onError?.(requestId.error);
|
|
401
|
+
const parsed = threadSubscribeFrameSchema.safeParse(raw);
|
|
402
|
+
if (!parsed.success) {
|
|
403
|
+
this.options.onError?.(parsed.error);
|
|
404
|
+
return this.refuse(
|
|
405
|
+
socket,
|
|
406
|
+
requestId.data,
|
|
407
|
+
unknownSelector(raw) ? 'unknown_selector' : 'invalid_request',
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const host = this.options.threads;
|
|
411
|
+
if (!host) return this.refuse(socket, requestId.data, 'failed', 'This host serves no threads');
|
|
412
|
+
const frame = parsed.data;
|
|
413
|
+
const subscription: HeldSubscription = {
|
|
414
|
+
subscriptionId: frame.requestId,
|
|
415
|
+
signetId: frame.signetId,
|
|
416
|
+
threadId: frame.threadId,
|
|
417
|
+
acceptedRevision: frame.acceptedRevision,
|
|
418
|
+
member: frame.member,
|
|
419
|
+
lens: frame.lens,
|
|
420
|
+
leaseUntil: Date.now() + this.leaseMs(),
|
|
421
|
+
sentSeq: null,
|
|
422
|
+
seq: 0,
|
|
423
|
+
held: [],
|
|
424
|
+
};
|
|
425
|
+
// Registered before the snapshot is taken, so nothing written meanwhile is lost (review V3).
|
|
426
|
+
this.subscriptions.set(subscription.subscriptionId, subscription);
|
|
427
|
+
let outcome: ThreadOutcome<'thread.subscribe'>;
|
|
428
|
+
try {
|
|
429
|
+
outcome = await host['thread.subscribe'](frame);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
this.options.onError?.(error);
|
|
432
|
+
outcome = threadRefused('failed');
|
|
433
|
+
}
|
|
434
|
+
// Ended while the view was taken (overflow, unsubscribe, lease): a view no subscription will
|
|
435
|
+
// follow would freeze the member, so refuse and let Kingdom's open fail and be retried.
|
|
436
|
+
if (this.subscriptions.get(subscription.subscriptionId) !== subscription) {
|
|
437
|
+
this.refuse(
|
|
438
|
+
socket,
|
|
439
|
+
frame.requestId,
|
|
440
|
+
'failed',
|
|
441
|
+
'The subscription ended while its view was taken',
|
|
442
|
+
);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const answered = this.respond(socket, frame.requestId, 'thread.subscribe', outcome);
|
|
446
|
+
if (!answered || !outcome.ok) {
|
|
447
|
+
this.subscriptions.delete(subscription.subscriptionId);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
subscription.sentSeq = outcome.result.journalSeq;
|
|
451
|
+
const held = subscription.held.sort((a, b) => a.journalSeq - b.journalSeq);
|
|
452
|
+
subscription.held = [];
|
|
453
|
+
for (const entry of held) this.appendThread(subscription.subscriptionId, entry);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Answers a request; false (after answering `failed`) when the handler's outcome breaks the contract. */
|
|
457
|
+
private respond<O extends ThreadOperation | 'thread.subscribe'>(
|
|
458
|
+
socket: WebSocket,
|
|
459
|
+
requestId: string,
|
|
460
|
+
operation: O,
|
|
461
|
+
outcome: ThreadOutcome<O>,
|
|
462
|
+
) {
|
|
463
|
+
const frame = threadResponseFrameSchema(operation).safeParse({
|
|
464
|
+
action: 'thread.response',
|
|
465
|
+
requestId,
|
|
466
|
+
outcome,
|
|
467
|
+
});
|
|
468
|
+
if (!frame.success) {
|
|
469
|
+
this.options.onError?.(frame.error);
|
|
470
|
+
this.refuse(socket, requestId, 'failed');
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
this.sendOn(socket, frame.data);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private refuse(socket: WebSocket, requestId: string, refusal: ThreadRefusal, message?: string) {
|
|
478
|
+
this.sendOn(socket, {
|
|
479
|
+
action: 'thread.response',
|
|
480
|
+
requestId,
|
|
481
|
+
outcome: threadRefused(refusal, message),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private leaseMs() {
|
|
486
|
+
return this.options.leaseMs ?? threadSubscriptionLeaseMs;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private expireLeases() {
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
for (const subscription of [...this.subscriptions.values()])
|
|
492
|
+
if (subscription.leaseUntil <= now)
|
|
493
|
+
this.endSubscription(subscription.subscriptionId, 'lease');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private endSubscription(subscriptionId: string, reason: ThreadSubscriptionEnd) {
|
|
497
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
498
|
+
if (!subscription) return;
|
|
499
|
+
this.subscriptions.delete(subscriptionId);
|
|
500
|
+
const { leaseUntil: _lease, sentSeq: _sent, seq: _seq, held: _held, ...ended } = subscription;
|
|
501
|
+
this.options.threads?.ended?.(ended, reason);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private endSubscriptions(reason: ThreadSubscriptionEnd) {
|
|
505
|
+
for (const id of [...this.subscriptions.keys()]) this.endSubscription(id, reason);
|
|
506
|
+
}
|
|
507
|
+
|
|
148
508
|
private send(frame: Record<string, unknown>) {
|
|
149
509
|
if (this.socket) this.sendOn(this.socket, frame);
|
|
150
510
|
}
|
|
151
511
|
|
|
152
512
|
private sendOn(socket: WebSocket, frame: Record<string, unknown>) {
|
|
153
|
-
if (socket === this.socket && socket.readyState ===
|
|
154
|
-
socket.send(JSON.stringify(frame));
|
|
513
|
+
if (socket === this.socket && socket.readyState === OPEN) socket.send(JSON.stringify(frame));
|
|
155
514
|
}
|
|
156
515
|
|
|
157
516
|
private disconnected(socket: WebSocket) {
|
|
@@ -162,6 +521,11 @@ export class InstallationSocket {
|
|
|
162
521
|
this.authTimer = null;
|
|
163
522
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
164
523
|
this.pingTimer = null;
|
|
524
|
+
if (this.leaseTimer) clearInterval(this.leaseTimer);
|
|
525
|
+
this.leaseTimer = null;
|
|
526
|
+
// Kingdom re-subscribes members after a reconnect; nothing pushes into a dead line meanwhile.
|
|
527
|
+
this.endSubscriptions('disconnected');
|
|
528
|
+
this.streams?.down();
|
|
165
529
|
if (this.stopped) return;
|
|
166
530
|
if (!this.polling) void this.poll();
|
|
167
531
|
const base = this.options.retryBaseMs ?? 1_000;
|
|
@@ -177,7 +541,12 @@ export class InstallationSocket {
|
|
|
177
541
|
installationInquiries(this.options.url, this.options.keyFile),
|
|
178
542
|
installationSignets(this.options.url, this.options.keyFile),
|
|
179
543
|
]);
|
|
180
|
-
if (!this.authenticated)
|
|
544
|
+
if (!this.authenticated)
|
|
545
|
+
await this.options.onSnapshot({
|
|
546
|
+
...inquiries,
|
|
547
|
+
...signets,
|
|
548
|
+
dropped: [...inquiries.dropped, ...signets.dropped],
|
|
549
|
+
});
|
|
181
550
|
} catch (error) {
|
|
182
551
|
this.options.onError?.(error);
|
|
183
552
|
}
|
|
@@ -197,8 +566,10 @@ export class InstallationSocket {
|
|
|
197
566
|
if (this.authTimer) clearTimeout(this.authTimer);
|
|
198
567
|
this.authTimer = null;
|
|
199
568
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
569
|
+
if (this.leaseTimer) clearInterval(this.leaseTimer);
|
|
200
570
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
201
571
|
this.pingTimer = null;
|
|
572
|
+
this.leaseTimer = null;
|
|
202
573
|
this.retryTimer = null;
|
|
203
574
|
}
|
|
204
575
|
}
|