@cortexkit/subc-client 0.3.4 → 0.4.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.
- package/README.md +27 -13
- package/dist/client.d.ts +49 -54
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +303 -262
- package/dist/client.js.map +1 -1
- package/dist/envelope.d.ts +24 -17
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +91 -72
- package/dist/envelope.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +35 -8
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +221 -71
- package/dist/provider.js.map +1 -1
- package/dist/route-handle.d.ts +25 -0
- package/dist/route-handle.d.ts.map +1 -0
- package/dist/route-handle.js +52 -0
- package/dist/route-handle.js.map +1 -0
- package/dist/socket.d.ts +7 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +23 -0
- package/dist/socket.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +374 -277
- package/src/envelope.ts +136 -74
- package/src/index.ts +7 -0
- package/src/provider.ts +361 -94
- package/src/route-handle.ts +60 -0
- package/src/socket.ts +37 -0
package/src/provider.ts
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
|
|
3
3
|
import { AuthError, authenticateClient } from "./auth.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_RECONNECT_BACKOFF,
|
|
6
|
+
type BindIdentity,
|
|
7
|
+
type ReconnectBackoff,
|
|
8
|
+
type RequestOptions,
|
|
9
|
+
type RouteTarget,
|
|
10
|
+
} from "./client.js";
|
|
5
11
|
import { ConnectionFileError, readConnectionFile, type ConnectionInfo } from "./connection-file.js";
|
|
6
12
|
import {
|
|
13
|
+
AdmissionClass,
|
|
7
14
|
buildFlags,
|
|
8
15
|
buildFrame,
|
|
9
16
|
buildFrameWithVersion,
|
|
10
|
-
decodeHeader,
|
|
11
17
|
encodeFrame,
|
|
12
18
|
FrameType,
|
|
13
|
-
HEADER_LEN,
|
|
14
19
|
Priority,
|
|
15
20
|
PROTOCOL_VERSION,
|
|
16
21
|
type Frame,
|
|
17
22
|
} from "./envelope.js";
|
|
23
|
+
import {
|
|
24
|
+
belongsToConnection,
|
|
25
|
+
createRouteHandle,
|
|
26
|
+
newConnectionToken,
|
|
27
|
+
RouteHandle,
|
|
28
|
+
StaleRouteHandleError,
|
|
29
|
+
} from "./route-handle.js";
|
|
18
30
|
import {
|
|
19
31
|
SocketClosedError,
|
|
20
32
|
SocketTimeoutError,
|
|
@@ -181,12 +193,14 @@ export interface ManagementSurfaceManifestOptions {
|
|
|
181
193
|
* events and `signal` to learn when the consumer cancelled or the route went away.
|
|
182
194
|
*/
|
|
183
195
|
export interface ProviderRequestContext {
|
|
196
|
+
/** The immutable route generation that received this request. */
|
|
197
|
+
readonly handle: RouteHandle;
|
|
184
198
|
/**
|
|
185
199
|
* Emit an interim event as a StreamData frame on this request's (channel, corr).
|
|
186
200
|
* The consumer receives it via its subscription `onEvent`. A no-op once the
|
|
187
201
|
* request has been aborted (cancelled or route-gone).
|
|
188
202
|
*/
|
|
189
|
-
emit(body: Uint8Array): Promise<void>;
|
|
203
|
+
emit(body: Uint8Array, opts?: ProviderEmitOptions): Promise<void>;
|
|
190
204
|
/** Aborts when the consumer sends Cancel for this request, or the route is torn down. */
|
|
191
205
|
signal: AbortSignal;
|
|
192
206
|
/**
|
|
@@ -207,7 +221,7 @@ export interface ProviderRequestContext {
|
|
|
207
221
|
* events via `ctx.emit`). Throwing produces an Error terminal.
|
|
208
222
|
*/
|
|
209
223
|
export type ProviderHandler = (
|
|
210
|
-
|
|
224
|
+
handle: RouteHandle,
|
|
211
225
|
body: Uint8Array,
|
|
212
226
|
ctx: ProviderRequestContext,
|
|
213
227
|
) => Promise<Uint8Array | void> | Uint8Array | void;
|
|
@@ -220,10 +234,17 @@ export type Principal =
|
|
|
220
234
|
| { kind: "unverified" };
|
|
221
235
|
|
|
222
236
|
export interface RouteBindRequest {
|
|
223
|
-
|
|
237
|
+
handle: RouteHandle;
|
|
224
238
|
target: RouteTarget;
|
|
225
239
|
identity: BindIdentity;
|
|
226
240
|
principal?: Principal;
|
|
241
|
+
/** Consumer-declared reverse-request capabilities for this bind. This is a declaration, not a verified privilege; providers must treat an omitted field as no reverse-request capability. Known MCP method-family values today are "elicitation", "sampling", and "roots". */
|
|
242
|
+
consumer_capabilities?: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface ProviderEmitOptions {
|
|
246
|
+
priority?: Priority;
|
|
247
|
+
admissionClass?: AdmissionClass;
|
|
227
248
|
}
|
|
228
249
|
|
|
229
250
|
export type BindDecision =
|
|
@@ -248,7 +269,9 @@ export interface SubcProviderConnectOptions {
|
|
|
248
269
|
handshakeTimeoutMs?: number;
|
|
249
270
|
controlOps?: string[] | null;
|
|
250
271
|
onBind?: (request: RouteBindRequest) => Promise<BindDecision> | BindDecision;
|
|
251
|
-
|
|
272
|
+
/** Runs only after an accepted bind ack is queued and the handle is installed. */
|
|
273
|
+
onBound?: (handle: RouteHandle) => void | Promise<void>;
|
|
274
|
+
onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
|
|
252
275
|
/** Backoff for provider reconnect after an unexpected socket drop. */
|
|
253
276
|
reconnectBackoff?: ReconnectBackoff;
|
|
254
277
|
/** Injectable sleep for timer-free reconnect and debounce tests. */
|
|
@@ -273,7 +296,9 @@ interface NormalizedSubcProviderConnectOptions {
|
|
|
273
296
|
handshakeTimeoutMs?: number;
|
|
274
297
|
controlOps?: string[] | null;
|
|
275
298
|
onBind?: (request: RouteBindRequest) => Promise<BindDecision> | BindDecision;
|
|
276
|
-
|
|
299
|
+
/** Runs only after an accepted bind ack is queued and the handle is installed. */
|
|
300
|
+
onBound?: (handle: RouteHandle) => void | Promise<void>;
|
|
301
|
+
onRouteGone?: (handle: RouteHandle) => void | Promise<void>;
|
|
277
302
|
reconnectBackoff: ReconnectBackoff;
|
|
278
303
|
sleep: (ms: number) => Promise<void>;
|
|
279
304
|
restoredDebounceMs: number;
|
|
@@ -386,11 +411,11 @@ export function managementSurfaceManifest(opts: ManagementSurfaceManifestOptions
|
|
|
386
411
|
}
|
|
387
412
|
|
|
388
413
|
export function jsonProviderHandler<Request = unknown, Response = unknown>(
|
|
389
|
-
handler: (
|
|
414
|
+
handler: (handle: RouteHandle, request: Request) => Promise<Response> | Response,
|
|
390
415
|
): ProviderHandler {
|
|
391
|
-
return async (
|
|
416
|
+
return async (handle, body) => {
|
|
392
417
|
const request = JSON.parse(Buffer.from(body).toString("utf8")) as Request;
|
|
393
|
-
const response = await handler(
|
|
418
|
+
const response = await handler(handle, request);
|
|
394
419
|
return encodeJson(response);
|
|
395
420
|
};
|
|
396
421
|
}
|
|
@@ -404,6 +429,11 @@ export class SubcProvider {
|
|
|
404
429
|
// A socket drop only makes the reply path stale; handlers may still finish their
|
|
405
430
|
// durable work, and their late sends are ignored by the generation guard.
|
|
406
431
|
private readonly inflight = new Map<string, AbortController>();
|
|
432
|
+
private readonly pending = new Map<string, { resolve: (frame: Frame) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
433
|
+
private readonly liveRoutes = new Map<number, RouteHandle>();
|
|
434
|
+
private connectionToken = newConnectionToken();
|
|
435
|
+
private nextCorr = 1n;
|
|
436
|
+
private ingressEpochDropCount = 0;
|
|
407
437
|
private readonly requestGate = new AsyncPermitPool(DEFAULT_PROVIDER_HANDLER_CAPACITY);
|
|
408
438
|
private reconnecting: Promise<void> | null = null;
|
|
409
439
|
private generation = 1;
|
|
@@ -433,6 +463,11 @@ export class SubcProvider {
|
|
|
433
463
|
this.enqueueConnectionState({ state: "connected", epoch: this.connectionEpoch });
|
|
434
464
|
}
|
|
435
465
|
|
|
466
|
+
/** Number of nonzero-channel ingress frames rejected by endpoint validation. */
|
|
467
|
+
get droppedIngressFrames(): number {
|
|
468
|
+
return this.ingressEpochDropCount;
|
|
469
|
+
}
|
|
470
|
+
|
|
436
471
|
get conn(): ConnectionInfo {
|
|
437
472
|
return this.currentConn;
|
|
438
473
|
}
|
|
@@ -441,6 +476,86 @@ export class SubcProvider {
|
|
|
441
476
|
return this.connectionEpoch;
|
|
442
477
|
}
|
|
443
478
|
|
|
479
|
+
/** Send a reverse request on exactly one installed route generation. */
|
|
480
|
+
async request(handle: RouteHandle, body: Uint8Array, opts: RequestOptions = {}): Promise<Uint8Array> {
|
|
481
|
+
this.assertLiveHandle(handle);
|
|
482
|
+
const corr = this.allocateCorr();
|
|
483
|
+
const key = routeKey(handle, corr);
|
|
484
|
+
const timeoutMs = opts.timeoutMs ?? WRITE_TIMEOUT_MS;
|
|
485
|
+
const frame = buildFrame(
|
|
486
|
+
FrameType.Request,
|
|
487
|
+
buildFlags(
|
|
488
|
+
false,
|
|
489
|
+
opts.priority ?? Priority.Interactive,
|
|
490
|
+
false,
|
|
491
|
+
opts.admissionClass ?? AdmissionClass.Normal,
|
|
492
|
+
),
|
|
493
|
+
handle.channel,
|
|
494
|
+
handle.epoch,
|
|
495
|
+
corr,
|
|
496
|
+
body,
|
|
497
|
+
);
|
|
498
|
+
return await new Promise<Uint8Array>((resolve, reject) => {
|
|
499
|
+
const timer = setTimeout(() => {
|
|
500
|
+
if (this.pending.delete(key)) reject(new SubcProviderError("reverse request timed out", "request_timeout"));
|
|
501
|
+
}, timeoutMs);
|
|
502
|
+
this.pending.set(key, {
|
|
503
|
+
resolve: (response) => resolve(response.body),
|
|
504
|
+
reject,
|
|
505
|
+
timer,
|
|
506
|
+
});
|
|
507
|
+
this.sendOn(this.sock, this.generation, frame).catch((error) => {
|
|
508
|
+
const pending = this.pending.get(key);
|
|
509
|
+
if (!pending) return;
|
|
510
|
+
this.pending.delete(key);
|
|
511
|
+
clearTimeout(pending.timer);
|
|
512
|
+
reject(error instanceof Error ? error : new SubcProviderError(String(error)));
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Emit an unsolicited Push after onBound has published the route. */
|
|
518
|
+
async push(handle: RouteHandle, body: Uint8Array, opts: ProviderEmitOptions = {}): Promise<void> {
|
|
519
|
+
this.assertLiveHandle(handle);
|
|
520
|
+
await this.sendOn(
|
|
521
|
+
this.sock,
|
|
522
|
+
this.generation,
|
|
523
|
+
buildFrame(
|
|
524
|
+
FrameType.Push,
|
|
525
|
+
buildFlags(
|
|
526
|
+
false,
|
|
527
|
+
opts.priority ?? Priority.Interactive,
|
|
528
|
+
false,
|
|
529
|
+
opts.admissionClass ?? AdmissionClass.Normal,
|
|
530
|
+
),
|
|
531
|
+
handle.channel,
|
|
532
|
+
handle.epoch,
|
|
533
|
+
0n,
|
|
534
|
+
body,
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
cancel(handle: RouteHandle, corr: bigint): void {
|
|
540
|
+
this.assertLiveHandle(handle);
|
|
541
|
+
void this.sendOn(
|
|
542
|
+
this.sock,
|
|
543
|
+
this.generation,
|
|
544
|
+
buildFrame(FrameType.Cancel, controlFlags(), handle.channel, handle.epoch, corr, new Uint8Array(0)),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
closeRoute(handle: RouteHandle): void {
|
|
549
|
+
this.assertLiveHandle(handle);
|
|
550
|
+
this.liveRoutes.delete(handle.channel);
|
|
551
|
+
this.abortHandle(handle);
|
|
552
|
+
void this.sendOn(
|
|
553
|
+
this.sock,
|
|
554
|
+
this.generation,
|
|
555
|
+
buildFrame(FrameType.Goodbye, controlFlags(), handle.channel, handle.epoch, 0n, new Uint8Array(0)),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
444
559
|
/** Read the connection file, authenticate as a client, register the manifest with HELLO, and serve frames. */
|
|
445
560
|
static async connect(opts: SubcProviderConnectOptions): Promise<SubcProvider> {
|
|
446
561
|
if (opts.manifest.protocol_ver !== PROTOCOL_VERSION) {
|
|
@@ -461,7 +576,7 @@ export class SubcProvider {
|
|
|
461
576
|
this.cancelRestoredDebounce();
|
|
462
577
|
const sock = this.sock;
|
|
463
578
|
try {
|
|
464
|
-
await sendFrame(sock, buildFrame(FrameType.Goodbye, controlFlags(), 0, 0n, new Uint8Array(0)));
|
|
579
|
+
await sendFrame(sock, buildFrame(FrameType.Goodbye, controlFlags(), 0, 0, 0n, new Uint8Array(0)));
|
|
465
580
|
} catch {
|
|
466
581
|
// The daemon may already have closed the connection; close() remains best-effort.
|
|
467
582
|
} finally {
|
|
@@ -491,22 +606,16 @@ export class SubcProvider {
|
|
|
491
606
|
private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
|
|
492
607
|
try {
|
|
493
608
|
for (;;) {
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
const header = decodeHeader(headerBytes);
|
|
497
|
-
const body =
|
|
498
|
-
header.len === 0
|
|
499
|
-
? new Uint8Array(0)
|
|
500
|
-
: await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
|
|
501
|
-
const keepGoing = await this.dispatch({ header, body }, sock, generation);
|
|
609
|
+
const frame = await sock.readFrame(Number.POSITIVE_INFINITY, Date.now() + BODY_READ_TIMEOUT_MS);
|
|
610
|
+
const keepGoing = await this.dispatch(frame, sock, generation);
|
|
502
611
|
if (!keepGoing) {
|
|
503
612
|
if (this.sock === sock && this.generation === generation) this.closeStarted = true;
|
|
504
613
|
break;
|
|
505
614
|
}
|
|
506
615
|
}
|
|
507
|
-
} catch (
|
|
616
|
+
} catch (error) {
|
|
508
617
|
if (this.sock === sock && this.generation === generation && !this.closeStarted) {
|
|
509
|
-
this.handleUnexpectedDrop(sock, generation,
|
|
618
|
+
this.handleUnexpectedDrop(sock, generation, error instanceof Error ? error : new SubcProviderError(String(error)));
|
|
510
619
|
return;
|
|
511
620
|
}
|
|
512
621
|
} finally {
|
|
@@ -518,6 +627,35 @@ export class SubcProvider {
|
|
|
518
627
|
}
|
|
519
628
|
|
|
520
629
|
private async dispatch(frame: Frame, sock: SubcSocket, generation: number): Promise<boolean> {
|
|
630
|
+
let handle: RouteHandle | null = null;
|
|
631
|
+
if (frame.header.channel !== 0) {
|
|
632
|
+
handle = this.liveRoutes.get(frame.header.channel) ?? null;
|
|
633
|
+
if (!handle || handle.epoch !== frame.header.epoch) {
|
|
634
|
+
this.ingressEpochDropCount += 1;
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (handle) {
|
|
640
|
+
const pendingKey = routeKey(handle, frame.header.corr);
|
|
641
|
+
const pending = this.pending.get(pendingKey);
|
|
642
|
+
if (pending) {
|
|
643
|
+
if (frame.header.ty === FrameType.Push || frame.header.ty === FrameType.StreamData) return true;
|
|
644
|
+
if (frame.header.ty === FrameType.Response || frame.header.ty === FrameType.StreamEnd) {
|
|
645
|
+
this.pending.delete(pendingKey);
|
|
646
|
+
clearTimeout(pending.timer);
|
|
647
|
+
pending.resolve(frame);
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
if (frame.header.ty === FrameType.Error) {
|
|
651
|
+
this.pending.delete(pendingKey);
|
|
652
|
+
clearTimeout(pending.timer);
|
|
653
|
+
pending.reject(providerErrorFromFrame(frame));
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
521
659
|
switch (frame.header.ty) {
|
|
522
660
|
case FrameType.Ping:
|
|
523
661
|
if (frame.header.channel === 0) {
|
|
@@ -529,6 +667,7 @@ export class SubcProvider {
|
|
|
529
667
|
FrameType.Pong,
|
|
530
668
|
frame.header.flags,
|
|
531
669
|
0,
|
|
670
|
+
0,
|
|
532
671
|
frame.header.corr,
|
|
533
672
|
new Uint8Array(0),
|
|
534
673
|
),
|
|
@@ -536,24 +675,21 @@ export class SubcProvider {
|
|
|
536
675
|
}
|
|
537
676
|
return true;
|
|
538
677
|
case FrameType.Goodbye:
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
this.
|
|
543
|
-
await this.opts.onRouteGone?.(frame.header.channel);
|
|
678
|
+
if (!handle) return false;
|
|
679
|
+
this.liveRoutes.delete(handle.channel);
|
|
680
|
+
this.abortHandle(handle);
|
|
681
|
+
await this.opts.onRouteGone?.(handle);
|
|
544
682
|
return true;
|
|
545
683
|
case FrameType.Cancel:
|
|
546
|
-
|
|
547
|
-
// streaming handler observes ctx.signal and ends with a StreamEnd terminal.
|
|
548
|
-
this.inflight.get(routeKey(generation, frame.header.channel, frame.header.corr))?.abort();
|
|
684
|
+
if (handle) this.inflight.get(routeKey(handle, frame.header.corr))?.abort();
|
|
549
685
|
return true;
|
|
550
686
|
case FrameType.Request:
|
|
551
687
|
if (frame.header.channel === 0) {
|
|
552
688
|
await this.handleControlRequest(frame, sock, generation);
|
|
553
|
-
} else {
|
|
554
|
-
void this.handleDataRequest(frame, sock, generation).catch((
|
|
689
|
+
} else if (handle) {
|
|
690
|
+
void this.handleDataRequest(frame, handle, sock, generation).catch((error) => {
|
|
555
691
|
if (!this.closeStarted && this.sock === sock && this.generation === generation) {
|
|
556
|
-
console.warn("SubcProvider handler failed after its request was dispatched",
|
|
692
|
+
console.warn("SubcProvider handler failed after its request was dispatched", error);
|
|
557
693
|
}
|
|
558
694
|
});
|
|
559
695
|
}
|
|
@@ -563,19 +699,27 @@ export class SubcProvider {
|
|
|
563
699
|
}
|
|
564
700
|
}
|
|
565
701
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const prefix = `${generation}:${channel}:`;
|
|
702
|
+
private abortHandle(handle: RouteHandle): void {
|
|
703
|
+
const prefix = `${handle.channel}:${handle.epoch}:`;
|
|
569
704
|
for (const [key, controller] of this.inflight) {
|
|
570
705
|
if (key.startsWith(prefix)) controller.abort();
|
|
571
706
|
}
|
|
707
|
+
for (const [key, pending] of this.pending) {
|
|
708
|
+
if (!key.startsWith(prefix)) continue;
|
|
709
|
+
this.pending.delete(key);
|
|
710
|
+
clearTimeout(pending.timer);
|
|
711
|
+
pending.reject(new StaleRouteHandleError(handle));
|
|
712
|
+
}
|
|
572
713
|
}
|
|
573
714
|
|
|
574
|
-
private abortGeneration(
|
|
575
|
-
|
|
576
|
-
for (const [key,
|
|
577
|
-
|
|
715
|
+
private abortGeneration(_generation: number): void {
|
|
716
|
+
this.abortAllInflight();
|
|
717
|
+
for (const [key, pending] of this.pending) {
|
|
718
|
+
this.pending.delete(key);
|
|
719
|
+
clearTimeout(pending.timer);
|
|
720
|
+
pending.reject(new SubcProviderError("provider connection dropped", "connection_dropped"));
|
|
578
721
|
}
|
|
722
|
+
this.liveRoutes.clear();
|
|
579
723
|
}
|
|
580
724
|
|
|
581
725
|
private abortAllInflight(): void {
|
|
@@ -583,11 +727,19 @@ export class SubcProvider {
|
|
|
583
727
|
}
|
|
584
728
|
|
|
585
729
|
private async handleControlRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
|
|
586
|
-
const request = parseJson(frame.body) as
|
|
730
|
+
const request = parseJson(frame.body) as {
|
|
731
|
+
op?: string;
|
|
732
|
+
route_channel?: unknown;
|
|
733
|
+
epoch?: unknown;
|
|
734
|
+
target?: unknown;
|
|
735
|
+
identity?: unknown;
|
|
736
|
+
principal?: unknown;
|
|
737
|
+
consumer_capabilities?: unknown;
|
|
738
|
+
};
|
|
587
739
|
if (request.op === HEALTH_CHECK_OP) {
|
|
588
|
-
void this.handleHealthRequest(frame, sock, generation).catch((
|
|
740
|
+
void this.handleHealthRequest(frame, sock, generation).catch((error) => {
|
|
589
741
|
if (!this.closeStarted && this.sock === sock && this.generation === generation) {
|
|
590
|
-
console.warn("SubcProvider health handler failed after its request was dispatched",
|
|
742
|
+
console.warn("SubcProvider health handler failed after its request was dispatched", error);
|
|
591
743
|
}
|
|
592
744
|
});
|
|
593
745
|
return;
|
|
@@ -596,81 +748,154 @@ export class SubcProvider {
|
|
|
596
748
|
throw new SubcProviderError(`unsupported module control request ${request.op ?? "<missing op>"}`);
|
|
597
749
|
}
|
|
598
750
|
|
|
751
|
+
const tentative = createRouteHandle(
|
|
752
|
+
numberField(request.route_channel, "route_channel"),
|
|
753
|
+
numberField(request.epoch, "epoch"),
|
|
754
|
+
this.connectionToken,
|
|
755
|
+
);
|
|
599
756
|
const bindRequest: RouteBindRequest = {
|
|
600
|
-
|
|
757
|
+
handle: tentative,
|
|
601
758
|
target: request.target as RouteTarget,
|
|
602
759
|
identity: request.identity as BindIdentity,
|
|
603
760
|
principal: request.principal as Principal | undefined,
|
|
761
|
+
consumer_capabilities: request.consumer_capabilities as string[] | undefined,
|
|
604
762
|
};
|
|
605
763
|
|
|
606
|
-
|
|
764
|
+
let decision: BindDecision | undefined;
|
|
765
|
+
try {
|
|
766
|
+
decision = await this.opts.onBind?.(bindRequest);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
try {
|
|
769
|
+
await this.sendError(
|
|
770
|
+
frame,
|
|
771
|
+
"route_rejected",
|
|
772
|
+
error instanceof Error ? error.message : String(error),
|
|
773
|
+
controlFlags(),
|
|
774
|
+
sock,
|
|
775
|
+
generation,
|
|
776
|
+
);
|
|
777
|
+
} finally {
|
|
778
|
+
await this.opts.onRouteGone?.(tentative);
|
|
779
|
+
}
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
607
782
|
const rejection = bindRejection(decision);
|
|
608
783
|
if (rejection) {
|
|
609
|
-
|
|
784
|
+
try {
|
|
785
|
+
await this.sendError(frame, rejection.code, rejection.message, controlFlags(), sock, generation);
|
|
786
|
+
} finally {
|
|
787
|
+
await this.opts.onRouteGone?.(tentative);
|
|
788
|
+
}
|
|
610
789
|
return;
|
|
611
790
|
}
|
|
612
791
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
792
|
+
try {
|
|
793
|
+
await this.sendOn(
|
|
794
|
+
sock,
|
|
795
|
+
generation,
|
|
796
|
+
buildFrameWithVersion(
|
|
797
|
+
frame.header.ver,
|
|
798
|
+
FrameType.Response,
|
|
799
|
+
controlFlags(),
|
|
800
|
+
0,
|
|
801
|
+
0,
|
|
802
|
+
frame.header.corr,
|
|
803
|
+
encodeJson({ op: "route.bind" }),
|
|
804
|
+
),
|
|
805
|
+
);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
await this.opts.onRouteGone?.(tentative);
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (this.sock !== sock || this.generation !== generation || this.closeStarted || this.closedErr) {
|
|
812
|
+
await this.opts.onRouteGone?.(tentative);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
this.liveRoutes.set(tentative.channel, tentative);
|
|
816
|
+
await this.opts.onBound?.(tentative);
|
|
625
817
|
}
|
|
626
818
|
|
|
627
|
-
private async handleDataRequest(
|
|
628
|
-
|
|
629
|
-
|
|
819
|
+
private async handleDataRequest(
|
|
820
|
+
frame: Frame,
|
|
821
|
+
handle: RouteHandle,
|
|
822
|
+
sock: SubcSocket,
|
|
823
|
+
generation: number,
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
const { corr, ver } = frame.header;
|
|
826
|
+
const key = routeKey(handle, corr);
|
|
630
827
|
const controller = new AbortController();
|
|
631
828
|
this.inflight.set(key, controller);
|
|
632
|
-
const
|
|
633
|
-
|
|
829
|
+
const context: ProviderRequestContext = {
|
|
830
|
+
handle,
|
|
634
831
|
signal: controller.signal,
|
|
635
832
|
currentEpoch: () => this.connectionEpoch,
|
|
636
|
-
emit: async (eventBody) => {
|
|
637
|
-
|
|
833
|
+
emit: async (eventBody, options = {}) => {
|
|
834
|
+
this.assertLiveHandle(handle);
|
|
638
835
|
if (controller.signal.aborted) return;
|
|
639
836
|
await this.sendOn(
|
|
640
837
|
sock,
|
|
641
838
|
generation,
|
|
642
|
-
buildFrameWithVersion(
|
|
839
|
+
buildFrameWithVersion(
|
|
840
|
+
ver,
|
|
841
|
+
FrameType.StreamData,
|
|
842
|
+
buildFlags(
|
|
843
|
+
false,
|
|
844
|
+
options.priority ?? Priority.Interactive,
|
|
845
|
+
false,
|
|
846
|
+
options.admissionClass ?? AdmissionClass.Normal,
|
|
847
|
+
),
|
|
848
|
+
handle.channel,
|
|
849
|
+
handle.epoch,
|
|
850
|
+
corr,
|
|
851
|
+
eventBody,
|
|
852
|
+
),
|
|
643
853
|
);
|
|
644
854
|
},
|
|
645
855
|
};
|
|
646
856
|
const releasePermit = await (this.requestGate ?? new AsyncPermitPool(DEFAULT_PROVIDER_HANDLER_CAPACITY)).acquire();
|
|
857
|
+
const dataFlags = buildFlags(false, Priority.Interactive, false);
|
|
647
858
|
try {
|
|
648
|
-
const body = await this.opts.handler(
|
|
859
|
+
const body = await this.opts.handler(handle, frame.body, context);
|
|
860
|
+
if (controller.signal.aborted) return;
|
|
861
|
+
this.assertLiveHandle(handle);
|
|
649
862
|
if (body === undefined) {
|
|
650
|
-
// A streaming handler that ended: close the held-open request with a
|
|
651
|
-
// StreamEnd terminal (the consumer's subscription resolves).
|
|
652
863
|
await this.sendOn(
|
|
653
864
|
sock,
|
|
654
865
|
generation,
|
|
655
|
-
buildFrameWithVersion(
|
|
866
|
+
buildFrameWithVersion(
|
|
867
|
+
ver,
|
|
868
|
+
FrameType.StreamEnd,
|
|
869
|
+
dataFlags,
|
|
870
|
+
handle.channel,
|
|
871
|
+
handle.epoch,
|
|
872
|
+
corr,
|
|
873
|
+
new Uint8Array(0),
|
|
874
|
+
),
|
|
656
875
|
);
|
|
657
876
|
} else if (body instanceof Uint8Array) {
|
|
658
877
|
await this.sendOn(
|
|
659
878
|
sock,
|
|
660
879
|
generation,
|
|
661
|
-
buildFrameWithVersion(
|
|
880
|
+
buildFrameWithVersion(
|
|
881
|
+
ver,
|
|
882
|
+
FrameType.Response,
|
|
883
|
+
dataFlags,
|
|
884
|
+
handle.channel,
|
|
885
|
+
handle.epoch,
|
|
886
|
+
corr,
|
|
887
|
+
body,
|
|
888
|
+
),
|
|
662
889
|
);
|
|
663
890
|
} else {
|
|
664
|
-
throw new SubcProviderError(
|
|
665
|
-
"provider handler must return a Uint8Array or void",
|
|
666
|
-
"invalid_handler_response",
|
|
667
|
-
);
|
|
891
|
+
throw new SubcProviderError("provider handler must return a Uint8Array or void", "invalid_handler_response");
|
|
668
892
|
}
|
|
669
|
-
} catch (
|
|
893
|
+
} catch (error) {
|
|
894
|
+
if (error instanceof StaleRouteHandleError || controller.signal.aborted) return;
|
|
670
895
|
await this.sendError(
|
|
671
896
|
frame,
|
|
672
|
-
|
|
673
|
-
|
|
897
|
+
error instanceof SubcProviderError && error.code ? error.code : "handler_error",
|
|
898
|
+
error instanceof Error ? error.message : String(error),
|
|
674
899
|
dataFlags,
|
|
675
900
|
sock,
|
|
676
901
|
generation,
|
|
@@ -682,8 +907,8 @@ export class SubcProvider {
|
|
|
682
907
|
}
|
|
683
908
|
|
|
684
909
|
private async handleHealthRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
|
|
685
|
-
const {
|
|
686
|
-
const key =
|
|
910
|
+
const { corr, ver } = frame.header;
|
|
911
|
+
const key = `control:${generation}:${corr}`;
|
|
687
912
|
const controller = new AbortController();
|
|
688
913
|
this.inflight.set(key, controller);
|
|
689
914
|
const releasePermit = await (this.requestGate ?? new AsyncPermitPool(DEFAULT_PROVIDER_HANDLER_CAPACITY)).acquire();
|
|
@@ -697,7 +922,8 @@ export class SubcProvider {
|
|
|
697
922
|
ver,
|
|
698
923
|
FrameType.Response,
|
|
699
924
|
controlFlags(),
|
|
700
|
-
|
|
925
|
+
0,
|
|
926
|
+
0,
|
|
701
927
|
corr,
|
|
702
928
|
encodeJson({
|
|
703
929
|
op: HEALTH_CHECK_OP,
|
|
@@ -707,11 +933,11 @@ export class SubcProvider {
|
|
|
707
933
|
}),
|
|
708
934
|
),
|
|
709
935
|
);
|
|
710
|
-
} catch (
|
|
936
|
+
} catch (error) {
|
|
711
937
|
await this.sendError(
|
|
712
938
|
frame,
|
|
713
|
-
|
|
714
|
-
|
|
939
|
+
error instanceof SubcProviderError && error.code ? error.code : "health_error",
|
|
940
|
+
error instanceof Error ? error.message : String(error),
|
|
715
941
|
controlFlags(),
|
|
716
942
|
sock,
|
|
717
943
|
generation,
|
|
@@ -738,6 +964,7 @@ export class SubcProvider {
|
|
|
738
964
|
FrameType.Error,
|
|
739
965
|
flags,
|
|
740
966
|
frame.header.channel,
|
|
967
|
+
frame.header.epoch,
|
|
741
968
|
frame.header.corr,
|
|
742
969
|
encodeJson({ code, message }),
|
|
743
970
|
),
|
|
@@ -799,13 +1026,16 @@ export class SubcProvider {
|
|
|
799
1026
|
}
|
|
800
1027
|
}
|
|
801
1028
|
|
|
802
|
-
|
|
1029
|
+
private replaceConnection(opened: OpenedProviderConnection): void {
|
|
803
1030
|
this.sock.close();
|
|
804
1031
|
this.sock = opened.sock;
|
|
805
1032
|
this.currentConn = opened.conn;
|
|
806
1033
|
this.storage = opened.ack.storage;
|
|
807
1034
|
this.closedErr = null;
|
|
808
1035
|
this.connectionEpoch += 1;
|
|
1036
|
+
this.connectionToken = newConnectionToken();
|
|
1037
|
+
this.liveRoutes.clear();
|
|
1038
|
+
this.nextCorr = 1n;
|
|
809
1039
|
const generation = this.generation;
|
|
810
1040
|
void this.readLoop(opened.sock, generation);
|
|
811
1041
|
this.scheduleRestored(generation, this.connectionEpoch);
|
|
@@ -869,6 +1099,26 @@ export class SubcProvider {
|
|
|
869
1099
|
}
|
|
870
1100
|
}
|
|
871
1101
|
|
|
1102
|
+
private isLiveHandle(handle: RouteHandle): boolean {
|
|
1103
|
+
return belongsToConnection(handle, this.connectionToken) && this.liveRoutes.get(handle.channel) === handle;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private assertLiveHandle(handle: RouteHandle): void {
|
|
1107
|
+
if (!this.isLiveHandle(handle)) throw new StaleRouteHandleError(handle);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private allocateCorr(): bigint {
|
|
1111
|
+
const maximum = 0xffff_ffff_ffff_ffffn;
|
|
1112
|
+
if (this.nextCorr > maximum) {
|
|
1113
|
+
const error = new SubcProviderError("channel-0 correlation id allocator exhausted", "corr_exhausted");
|
|
1114
|
+
this.handleUnexpectedDrop(this.sock, this.generation, error);
|
|
1115
|
+
throw error;
|
|
1116
|
+
}
|
|
1117
|
+
const corr = this.nextCorr;
|
|
1118
|
+
this.nextCorr += 1n;
|
|
1119
|
+
return corr;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
872
1122
|
private failFatal(err: Error): void {
|
|
873
1123
|
if (!this.closedErr) this.closedErr = err;
|
|
874
1124
|
this.closeStarted = true;
|
|
@@ -883,8 +1133,17 @@ export class SubcProvider {
|
|
|
883
1133
|
}
|
|
884
1134
|
}
|
|
885
1135
|
|
|
886
|
-
function routeKey(
|
|
887
|
-
return `${
|
|
1136
|
+
function routeKey(handle: RouteHandle, corr: bigint): string {
|
|
1137
|
+
return `${handle.channel}:${handle.epoch}:${corr}`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function providerErrorFromFrame(frame: Frame): SubcProviderError {
|
|
1141
|
+
try {
|
|
1142
|
+
const body = parseJson(frame.body) as { code?: string; message?: string };
|
|
1143
|
+
return new SubcProviderError(body.message ?? "subc error", body.code);
|
|
1144
|
+
} catch {
|
|
1145
|
+
return new SubcProviderError(Buffer.from(frame.body).toString("utf8") || "subc error");
|
|
1146
|
+
}
|
|
888
1147
|
}
|
|
889
1148
|
|
|
890
1149
|
function launchNonce(opts: SubcProviderConnectOptions): string | undefined {
|
|
@@ -903,6 +1162,7 @@ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): Norm
|
|
|
903
1162
|
handshakeTimeoutMs: opts.handshakeTimeoutMs,
|
|
904
1163
|
controlOps: opts.controlOps,
|
|
905
1164
|
onBind: opts.onBind,
|
|
1165
|
+
onBound: opts.onBound,
|
|
906
1166
|
onRouteGone: opts.onRouteGone,
|
|
907
1167
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
908
1168
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
@@ -925,6 +1185,7 @@ function buildHelloFrame(opts: NormalizedSubcProviderConnectOptions): Frame {
|
|
|
925
1185
|
FrameType.Hello,
|
|
926
1186
|
controlFlags(),
|
|
927
1187
|
0,
|
|
1188
|
+
0,
|
|
928
1189
|
HELLO_CORR,
|
|
929
1190
|
encodeJson({
|
|
930
1191
|
manifest: normalizeManifest(opts.manifest),
|
|
@@ -977,14 +1238,20 @@ async function sendFrame(sock: SubcSocket, frame: Frame): Promise<void> {
|
|
|
977
1238
|
}
|
|
978
1239
|
|
|
979
1240
|
async function expectHelloAck(sock: SubcSocket, deadline: number): Promise<ModuleHelloAckBody> {
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1241
|
+
const frame = await sock.readFrame(deadline, deadline);
|
|
1242
|
+
switch (frame.header.ty) {
|
|
1243
|
+
case FrameType.HelloAck: {
|
|
1244
|
+
const ack = parseJson(frame.body) as ModuleHelloAckBody;
|
|
1245
|
+
if (ack.negotiated_ver !== PROTOCOL_VERSION) {
|
|
1246
|
+
throw new SubcProviderError(
|
|
1247
|
+
`subc negotiated protocol ${ack.negotiated_ver}; expected exactly ${PROTOCOL_VERSION}`,
|
|
1248
|
+
"unsupported_version",
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
return ack;
|
|
1252
|
+
}
|
|
986
1253
|
case FrameType.Error: {
|
|
987
|
-
const error = parseJson(body) as { code?: string; message?: string };
|
|
1254
|
+
const error = parseJson(frame.body) as { code?: string; message?: string };
|
|
988
1255
|
throw new SubcProviderError(
|
|
989
1256
|
`subc rejected HELLO: ${error.code ?? "unknown"} — ${error.message ?? "subc error"}`,
|
|
990
1257
|
error.code,
|