@cortexkit/subc-client 0.1.0 → 0.2.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/package.json +1 -1
- package/src/client.ts +69 -10
- package/src/index.ts +5 -0
- package/src/provider.ts +7 -0
- package/src/socket.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/subc-client",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "TypeScript client for the subc daemon. Wire-compatible (byte-for-byte) with the Rust subc-transport handshake and subc-protocol envelope.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/client.ts
CHANGED
|
@@ -38,6 +38,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
38
38
|
const BODY_READ_TIMEOUT_MS = 30_000;
|
|
39
39
|
const EMPTY_BODY = new Uint8Array(0);
|
|
40
40
|
const DEFAULT_MANAGED_TARGET_KIND: ManagedRouteKind = "management_surface";
|
|
41
|
+
export const SUBC_MODULE_ID_ENV = "SUBC_MODULE_ID";
|
|
42
|
+
export const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
|
|
41
43
|
|
|
42
44
|
export interface BindIdentity {
|
|
43
45
|
project_root: string;
|
|
@@ -52,6 +54,16 @@ export type RouteTarget =
|
|
|
52
54
|
|
|
53
55
|
export type ManagedRouteKind = "management_surface" | "tool_provider";
|
|
54
56
|
|
|
57
|
+
export interface ConsumerIdentity {
|
|
58
|
+
module_id: string;
|
|
59
|
+
launch_nonce: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RouteOpenOptions {
|
|
63
|
+
/** Optional override for the consumer identity; by default the SUBC_MODULE_ID and SUBC_LAUNCH_NONCE environment variables are used when both are non-empty. Set null to send route.open without consumer_identity. */
|
|
64
|
+
consumerIdentity?: ConsumerIdentity | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
export interface CatalogEntry {
|
|
56
68
|
module_id: string;
|
|
57
69
|
roles: unknown[];
|
|
@@ -70,6 +82,8 @@ export interface ManagedCallOptions extends RequestOptions {
|
|
|
70
82
|
identity?: BindIdentity;
|
|
71
83
|
/** Defaults to management_surface, matching the store/host management APIs. */
|
|
72
84
|
targetKind?: ManagedRouteKind;
|
|
85
|
+
/** Optional override for the consumer identity; by default the SUBC_MODULE_ID and SUBC_LAUNCH_NONCE environment variables are used when both are non-empty. Set null to send route.open without consumer_identity. */
|
|
86
|
+
consumerIdentity?: ConsumerIdentity | null;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
export interface SubscribeOptions {
|
|
@@ -83,6 +97,8 @@ export interface CloseRouteOptions {
|
|
|
83
97
|
* Defaults to false: close immediately, aborting everything in flight.
|
|
84
98
|
*/
|
|
85
99
|
drain?: boolean;
|
|
100
|
+
/** Consumer identity to use when looking up the route to close; only needed for routes opened with a non-default consumer identity. */
|
|
101
|
+
consumerIdentity?: ConsumerIdentity | null;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
/**
|
|
@@ -199,6 +215,7 @@ interface CachedRoute {
|
|
|
199
215
|
moduleId: string;
|
|
200
216
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
|
|
201
217
|
identity: BindIdentity;
|
|
218
|
+
consumerIdentity?: ConsumerIdentity;
|
|
202
219
|
channel: number | null;
|
|
203
220
|
generation: number;
|
|
204
221
|
opening: Promise<number> | null;
|
|
@@ -252,8 +269,14 @@ export class SubcClient {
|
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
/** Open a route to a provider (channel-0 route.open); returns the route channel. */
|
|
255
|
-
async routeOpen(target: RouteTarget, identity: BindIdentity): Promise<number> {
|
|
256
|
-
const
|
|
272
|
+
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<number> {
|
|
273
|
+
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
274
|
+
const body = this.encode({
|
|
275
|
+
op: "route.open",
|
|
276
|
+
target,
|
|
277
|
+
identity,
|
|
278
|
+
...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
|
|
279
|
+
});
|
|
257
280
|
const reply = await this.controlRpc(body);
|
|
258
281
|
const parsed = this.parseJson(reply) as { op: string; route_channel?: number };
|
|
259
282
|
if (typeof parsed.route_channel !== "number") {
|
|
@@ -380,7 +403,7 @@ export class SubcClient {
|
|
|
380
403
|
identity: BindIdentity,
|
|
381
404
|
opts: CloseRouteOptions = {},
|
|
382
405
|
): Promise<void> {
|
|
383
|
-
const key = routeCacheKey(target, identity);
|
|
406
|
+
const key = routeCacheKey(target, identity, routeOpenConsumerIdentity(opts));
|
|
384
407
|
const cached = this.routes.get(key);
|
|
385
408
|
if (!cached) return; // never opened / already closed — idempotent no-op.
|
|
386
409
|
// Generation guard: an in-flight openCachedRoute holds this same object and
|
|
@@ -498,7 +521,7 @@ export class SubcClient {
|
|
|
498
521
|
timer: null,
|
|
499
522
|
};
|
|
500
523
|
pending.timer = setTimeout(() => {
|
|
501
|
-
this.rejectPending(key, pending, new SubcError(
|
|
524
|
+
this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms)));
|
|
502
525
|
}, ms);
|
|
503
526
|
this.pending.set(key, pending);
|
|
504
527
|
this.sock.write(encodeFrame(frame), Date.now() + ms).catch((err) => {
|
|
@@ -563,7 +586,7 @@ export class SubcClient {
|
|
|
563
586
|
classifyFailure,
|
|
564
587
|
};
|
|
565
588
|
pending.timer = setTimeout(() => {
|
|
566
|
-
this.rejectPending(key, pending, new SubcError(
|
|
589
|
+
this.rejectPending(key, pending, new SubcError(this.timeoutMessage(channel, corr, ms)));
|
|
567
590
|
}, ms);
|
|
568
591
|
this.pending.set(key, pending);
|
|
569
592
|
|
|
@@ -590,7 +613,8 @@ export class SubcClient {
|
|
|
590
613
|
RouteTarget,
|
|
591
614
|
{ kind: ManagedRouteKind }
|
|
592
615
|
>;
|
|
593
|
-
const
|
|
616
|
+
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
617
|
+
const key = routeCacheKey(target, identity, consumerIdentity);
|
|
594
618
|
let cached = this.routes.get(key);
|
|
595
619
|
if (!cached) {
|
|
596
620
|
cached = {
|
|
@@ -598,6 +622,7 @@ export class SubcClient {
|
|
|
598
622
|
moduleId,
|
|
599
623
|
target,
|
|
600
624
|
identity,
|
|
625
|
+
consumerIdentity,
|
|
601
626
|
channel: null,
|
|
602
627
|
generation: 0,
|
|
603
628
|
opening: null,
|
|
@@ -630,7 +655,9 @@ export class SubcClient {
|
|
|
630
655
|
}
|
|
631
656
|
|
|
632
657
|
try {
|
|
633
|
-
const channel = await this.routeOpen(cached.target, cached.identity
|
|
658
|
+
const channel = await this.routeOpen(cached.target, cached.identity, {
|
|
659
|
+
consumerIdentity: cached.consumerIdentity ?? null,
|
|
660
|
+
});
|
|
634
661
|
// Generation guard: a closeRoute may have flipped the tombstone WHILE this
|
|
635
662
|
// route.open was in flight. If so, close wins — do NOT install the channel
|
|
636
663
|
// into the (already-removed) cache entry; GOODBYE the channel we just opened
|
|
@@ -725,7 +752,14 @@ export class SubcClient {
|
|
|
725
752
|
}
|
|
726
753
|
for (const cached of this.routes.values()) {
|
|
727
754
|
if (cached.closed) continue; // closed concurrently with reconnect — don't reopen.
|
|
728
|
-
|
|
755
|
+
// Thread the route's consumer identity through the reopen, exactly as the
|
|
756
|
+
// lazy per-call path (openCachedRoute) does. Dropping it here would make a
|
|
757
|
+
// route reopened after a reconnect send route.open with no consumer_identity,
|
|
758
|
+
// so the daemon would re-stamp it with a different (weaker) principal than the
|
|
759
|
+
// one it was originally bound under — a silent post-reconnect trust downgrade.
|
|
760
|
+
const channel = await this.routeOpen(cached.target, cached.identity, {
|
|
761
|
+
consumerIdentity: cached.consumerIdentity ?? null,
|
|
762
|
+
});
|
|
729
763
|
// A closeRoute may have raced this reopen (flipping the tombstone during the
|
|
730
764
|
// route.open await). If so, GOODBYE the channel instead of installing it, so the
|
|
731
765
|
// closed route isn't silently re-established on the new connection.
|
|
@@ -738,6 +772,16 @@ export class SubcClient {
|
|
|
738
772
|
}
|
|
739
773
|
}
|
|
740
774
|
|
|
775
|
+
// A request timeout carries the local socket port and (channel, corr) so a
|
|
776
|
+
// packet capture can pinpoint the exact on-wire exchange — the decisive evidence
|
|
777
|
+
// for whether a "timed out" reply was actually delivered to this socket (a
|
|
778
|
+
// client-local demux problem) or never sent (a daemon/module problem).
|
|
779
|
+
private timeoutMessage(channel: number, corr: bigint, ms: number): string {
|
|
780
|
+
const port = this.sock.localPort();
|
|
781
|
+
const where = port === null ? "channel" : `local_port=${port} channel`;
|
|
782
|
+
return `request on ${where} ${channel} corr ${corr} timed out after ${ms}ms`;
|
|
783
|
+
}
|
|
784
|
+
|
|
741
785
|
private routeClosedDuringOpen(): SubcCallError {
|
|
742
786
|
return new SubcCallError("not_sent", "route was closed before route.open completed", "route_closed");
|
|
743
787
|
}
|
|
@@ -885,8 +929,23 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
|
|
|
885
929
|
};
|
|
886
930
|
}
|
|
887
931
|
|
|
888
|
-
function routeCacheKey(
|
|
889
|
-
|
|
932
|
+
function routeCacheKey(
|
|
933
|
+
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
934
|
+
identity: BindIdentity,
|
|
935
|
+
consumerIdentity?: ConsumerIdentity,
|
|
936
|
+
): string {
|
|
937
|
+
const consumerPart = consumerIdentity
|
|
938
|
+
? `${consumerIdentity.module_id}\0${consumerIdentity.launch_nonce}`
|
|
939
|
+
: "";
|
|
940
|
+
return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentity | undefined {
|
|
944
|
+
if (opts.consumerIdentity !== undefined) return opts.consumerIdentity ?? undefined;
|
|
945
|
+
const moduleId = process.env[SUBC_MODULE_ID_ENV];
|
|
946
|
+
const launchNonce = process.env[SUBC_LAUNCH_NONCE_ENV];
|
|
947
|
+
if (!moduleId || !launchNonce) return undefined;
|
|
948
|
+
return { module_id: moduleId, launch_nonce: launchNonce };
|
|
890
949
|
}
|
|
891
950
|
|
|
892
951
|
function errorCode(err: unknown): string | undefined {
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,15 @@ export {
|
|
|
3
3
|
SubcError,
|
|
4
4
|
SubcCallError,
|
|
5
5
|
DEFAULT_RECONNECT_BACKOFF,
|
|
6
|
+
SUBC_MODULE_ID_ENV,
|
|
7
|
+
SUBC_LAUNCH_NONCE_ENV,
|
|
6
8
|
isConsumerReconnectTransient,
|
|
7
9
|
connectionFileExists,
|
|
8
10
|
type BindIdentity,
|
|
9
11
|
type RouteTarget,
|
|
10
12
|
type ManagedRouteKind,
|
|
13
|
+
type ConsumerIdentity,
|
|
14
|
+
type RouteOpenOptions,
|
|
11
15
|
type CatalogEntry,
|
|
12
16
|
type RequestOptions,
|
|
13
17
|
type ManagedCallOptions,
|
|
@@ -57,6 +61,7 @@ export {
|
|
|
57
61
|
jsonProviderHandler,
|
|
58
62
|
type ProviderRequestContext,
|
|
59
63
|
type BindDecision,
|
|
64
|
+
type Principal,
|
|
60
65
|
type BindingsInput,
|
|
61
66
|
type CircuitBreakerInput,
|
|
62
67
|
type Concurrency,
|
package/src/provider.ts
CHANGED
|
@@ -202,10 +202,16 @@ export type ProviderHandler = (
|
|
|
202
202
|
ctx: ProviderRequestContext,
|
|
203
203
|
) => Promise<Uint8Array | void> | Uint8Array | void;
|
|
204
204
|
|
|
205
|
+
export type Principal =
|
|
206
|
+
| { kind: "reserved"; module_id: string }
|
|
207
|
+
| { kind: "direct" }
|
|
208
|
+
| { kind: "unverified" };
|
|
209
|
+
|
|
205
210
|
export interface RouteBindRequest {
|
|
206
211
|
route_channel: number;
|
|
207
212
|
target: RouteTarget;
|
|
208
213
|
identity: BindIdentity;
|
|
214
|
+
principal?: Principal;
|
|
209
215
|
}
|
|
210
216
|
|
|
211
217
|
export type BindDecision =
|
|
@@ -533,6 +539,7 @@ export class SubcProvider {
|
|
|
533
539
|
route_channel: numberField(request.route_channel, "route_channel"),
|
|
534
540
|
target: request.target as RouteTarget,
|
|
535
541
|
identity: request.identity as BindIdentity,
|
|
542
|
+
principal: request.principal as Principal | undefined,
|
|
536
543
|
};
|
|
537
544
|
|
|
538
545
|
const decision = await this.opts.onBind?.(bindRequest);
|
package/src/socket.ts
CHANGED
|
@@ -64,6 +64,15 @@ export class SubcSocket {
|
|
|
64
64
|
sock.on("close", () => fail(new SocketClosedError("subc connection closed")));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The OS-assigned local TCP port of this connection, or null if not yet
|
|
69
|
+
* connected/closed. Used to correlate a client-side timeout with a specific
|
|
70
|
+
* socket in a packet capture when diagnosing reply-delivery issues.
|
|
71
|
+
*/
|
|
72
|
+
localPort(): number | null {
|
|
73
|
+
return this.sock.localPort ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
static connect(host: string, port: number, deadlineMs: number): Promise<SubcSocket> {
|
|
68
77
|
return new Promise((resolve, reject) => {
|
|
69
78
|
const sock = net.connect({ host, port });
|