@cortexkit/subc-client 0.8.1 → 0.11.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/dist/client.d.ts +119 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +204 -33
- package/dist/client.js.map +1 -1
- package/dist/envelope.d.ts +4 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +9 -1
- package/dist/envelope.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +5 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +6 -2
- package/dist/provider.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +301 -30
- package/src/envelope.ts +11 -1
- package/src/index.ts +12 -0
- package/src/provider.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/subc-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
"bun-types": "^1.3.14",
|
|
31
31
|
"typescript": "^5.6.0"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
buildFlags,
|
|
21
21
|
encodeFrame,
|
|
22
22
|
FrameType,
|
|
23
|
+
hasBinary,
|
|
23
24
|
Priority,
|
|
24
25
|
type Frame,
|
|
25
26
|
} from "./envelope.js";
|
|
@@ -112,16 +113,59 @@ export interface RouteOpenOptions {
|
|
|
112
113
|
consumerCapabilities?: string[];
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
export interface CatalogCapabilityRequirement {
|
|
117
|
+
capability: string;
|
|
118
|
+
need: "required" | "optional";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CatalogCapabilities {
|
|
122
|
+
provides: string[];
|
|
123
|
+
requires: CatalogCapabilityRequirement[];
|
|
124
|
+
must_never_reach: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A self-signal's anchor relative to the external surface it shapes. */
|
|
128
|
+
export type CatalogSignalAnchor =
|
|
129
|
+
| "fixed_interval"
|
|
130
|
+
| { event: { event: string } };
|
|
131
|
+
|
|
132
|
+
/** The source of a self-signal's effective cadence. */
|
|
133
|
+
export type CatalogSignalCadence =
|
|
134
|
+
| { literal: { interval_ms: number } }
|
|
135
|
+
| { derived: { source: string } };
|
|
136
|
+
|
|
137
|
+
/** A self-signal declaration mirrored from a registered module manifest. */
|
|
138
|
+
export interface CatalogSelfSignalDeclaration {
|
|
139
|
+
name: string;
|
|
140
|
+
/** Open string for forward-compatible self-signal kinds. */
|
|
141
|
+
kind: string;
|
|
142
|
+
effect: "observe" | "mutate";
|
|
143
|
+
anchored_to: CatalogSignalAnchor;
|
|
144
|
+
cadence?: CatalogSignalCadence | null;
|
|
145
|
+
domain?: string | null;
|
|
146
|
+
note?: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
115
149
|
export interface CatalogEntry {
|
|
116
150
|
module_id: string;
|
|
117
151
|
roles: unknown[];
|
|
118
152
|
control_ops: string[];
|
|
153
|
+
/** Static capability claims mirrored from the registered module manifest. */
|
|
154
|
+
capabilities?: CatalogCapabilities | null;
|
|
155
|
+
/** Self-signal declarations mirrored verbatim from the registered manifest. */
|
|
156
|
+
self_signals?: CatalogSelfSignalDeclaration[] | null;
|
|
119
157
|
}
|
|
120
158
|
|
|
121
159
|
export interface RequestOptions {
|
|
122
160
|
priority?: Priority;
|
|
123
161
|
admissionClass?: AdmissionClass;
|
|
124
162
|
timeoutMs?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Set when the request body is opaque bytes rather than JSON. This flag is the
|
|
165
|
+
* receiver's only signal about the body representation; reply decoding follows
|
|
166
|
+
* the response frame's flag instead of this request option.
|
|
167
|
+
*/
|
|
168
|
+
binary?: boolean;
|
|
125
169
|
/** Called for each interim PUSH / StreamData frame before the terminal reply. */
|
|
126
170
|
onProgress?: (body: Uint8Array) => void;
|
|
127
171
|
}
|
|
@@ -208,6 +252,16 @@ export class SubcCallError extends Error {
|
|
|
208
252
|
super(message);
|
|
209
253
|
this.name = "SubcCallError";
|
|
210
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Machine-parsable payload from the wire `ErrorBody.detail`, surfaced from
|
|
258
|
+
* the underlying Error frame when one caused this failure. Typed refusal
|
|
259
|
+
* reasons (for example certification refusals) ride this field; consumers
|
|
260
|
+
* classify on it and must not have to reach into `cause`.
|
|
261
|
+
*/
|
|
262
|
+
get detail(): unknown {
|
|
263
|
+
return this.cause instanceof SubcError ? this.cause.detail : undefined;
|
|
264
|
+
}
|
|
211
265
|
}
|
|
212
266
|
|
|
213
267
|
/**
|
|
@@ -228,11 +282,24 @@ export class SubcError extends Error {
|
|
|
228
282
|
constructor(
|
|
229
283
|
message: string,
|
|
230
284
|
readonly code?: string,
|
|
285
|
+
/**
|
|
286
|
+
* Machine-parsable payload from the wire `ErrorBody.detail` field, carried
|
|
287
|
+
* verbatim. Providers use it for typed refusal reasons (for example
|
|
288
|
+
* synapse's certification-refusal reasons); dropping it here would strand
|
|
289
|
+
* those reasons at the transport boundary.
|
|
290
|
+
*/
|
|
291
|
+
readonly detail?: unknown,
|
|
231
292
|
) {
|
|
232
293
|
super(message);
|
|
233
294
|
}
|
|
234
295
|
}
|
|
235
296
|
|
|
297
|
+
function requireBinaryBody(body: unknown): Uint8Array {
|
|
298
|
+
if (body instanceof Uint8Array) return body;
|
|
299
|
+
const type = body === null ? "null" : Array.isArray(body) ? "array" : typeof body;
|
|
300
|
+
throw new SubcError(`binary request body must be a Uint8Array; got ${type}`, "binary_body_required");
|
|
301
|
+
}
|
|
302
|
+
|
|
236
303
|
interface Pending {
|
|
237
304
|
handle: RouteHandle | null;
|
|
238
305
|
resolve: (frame: Frame) => void;
|
|
@@ -261,6 +328,14 @@ export interface ConnectOptions {
|
|
|
261
328
|
reconnectBackoff?: ReconnectBackoff;
|
|
262
329
|
/** Injectable sleep for timer-free reconnect tests. */
|
|
263
330
|
sleep?: (ms: number) => Promise<void>;
|
|
331
|
+
/**
|
|
332
|
+
* How long managed calls keep retrying route.open in-place on retryable
|
|
333
|
+
* refusals (module_reloading, module_warming, target_unavailable, …) before
|
|
334
|
+
* settling not_sent. This deadline is the ONLY binder on those retries —
|
|
335
|
+
* module reloads legitimately take tens of seconds (the daemon's drain alone
|
|
336
|
+
* defaults to 30s), so the default matches the daemon's drain ceiling.
|
|
337
|
+
*/
|
|
338
|
+
routeOpenRetryDeadlineMs?: number;
|
|
264
339
|
/**
|
|
265
340
|
* Window the post-deadline liveness probe waits for ANY inbound frame after
|
|
266
341
|
* sending its channel-0 Ping before convicting the socket as half-open.
|
|
@@ -300,6 +375,59 @@ export interface ControlPush {
|
|
|
300
375
|
body: Record<string, unknown>;
|
|
301
376
|
}
|
|
302
377
|
|
|
378
|
+
/** A known route-close reason, excluding forward-compatible unknown values. */
|
|
379
|
+
export type KnownRouteCloseReason =
|
|
380
|
+
| "reload"
|
|
381
|
+
| "restart"
|
|
382
|
+
| "disable"
|
|
383
|
+
| "crash"
|
|
384
|
+
| "capability_denied";
|
|
385
|
+
|
|
386
|
+
/** A route-close reason decoded from a daemon control push. */
|
|
387
|
+
export type RouteCloseReason = KnownRouteCloseReason | "unknown";
|
|
388
|
+
|
|
389
|
+
/** Whether the close reason alone permits reopening a route automatically. */
|
|
390
|
+
export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Decode a daemon close reason without rejecting a future wire value.
|
|
394
|
+
*
|
|
395
|
+
* An unknown reason remains observable as `unknown`; callers must use the
|
|
396
|
+
* strict disposition instead of treating an unrecognized policy change as safe.
|
|
397
|
+
*/
|
|
398
|
+
export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
|
|
399
|
+
switch (reason) {
|
|
400
|
+
case "reload":
|
|
401
|
+
case "restart":
|
|
402
|
+
case "disable":
|
|
403
|
+
case "crash":
|
|
404
|
+
case "capability_denied":
|
|
405
|
+
return reason;
|
|
406
|
+
default:
|
|
407
|
+
return "unknown";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Unknown close reasons take the strictest action and must not trigger a reopen. */
|
|
412
|
+
export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition {
|
|
413
|
+
switch (parseRouteCloseReason(reason)) {
|
|
414
|
+
case "reload":
|
|
415
|
+
case "restart":
|
|
416
|
+
return "may_reopen";
|
|
417
|
+
case "disable":
|
|
418
|
+
case "crash":
|
|
419
|
+
case "capability_denied":
|
|
420
|
+
case "unknown":
|
|
421
|
+
return "must_not_reopen";
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Decode a close reason from a route lifecycle control push, if present. */
|
|
426
|
+
export function routeCloseReason(push: ControlPush): RouteCloseReason | undefined {
|
|
427
|
+
if (push.op !== "route.closing" && push.op !== "route.closed") return undefined;
|
|
428
|
+
return parseRouteCloseReason(push.body.reason);
|
|
429
|
+
}
|
|
430
|
+
|
|
303
431
|
interface NormalizedConnectOptions {
|
|
304
432
|
connectionFile: string;
|
|
305
433
|
handshakeTimeoutMs?: number;
|
|
@@ -307,6 +435,7 @@ interface NormalizedConnectOptions {
|
|
|
307
435
|
targetKind: ManagedRouteKind;
|
|
308
436
|
reconnectBackoff: ReconnectBackoff;
|
|
309
437
|
sleep: (ms: number) => Promise<void>;
|
|
438
|
+
routeOpenRetryDeadlineMs: number;
|
|
310
439
|
timeoutArbitrationGraceMs: number;
|
|
311
440
|
livenessProbeWindowMs: number;
|
|
312
441
|
onControlPush?: (push: ControlPush) => void;
|
|
@@ -383,7 +512,42 @@ export class SubcClient {
|
|
|
383
512
|
return parsed.modules ?? [];
|
|
384
513
|
}
|
|
385
514
|
|
|
386
|
-
|
|
515
|
+
/**
|
|
516
|
+
* Resolve the sole catalog claimant for a capability.
|
|
517
|
+
*
|
|
518
|
+
* This expresses singular intent even when the fleet permits plural claims;
|
|
519
|
+
* ambiguity is returned instead of selecting an arbitrary module.
|
|
520
|
+
*/
|
|
521
|
+
async resolveProvider(capability: string): Promise<string> {
|
|
522
|
+
const claimants = await this.resolveProviders(capability);
|
|
523
|
+
if (claimants.length === 0) {
|
|
524
|
+
throw new SubcError(`no catalog claimant for capability ${capability}`, "capability_unprovided");
|
|
525
|
+
}
|
|
526
|
+
if (claimants.length > 1) {
|
|
527
|
+
throw new SubcError(
|
|
528
|
+
`multiple catalog claimants for capability ${capability}: ${claimants.join(", ")}`,
|
|
529
|
+
"capability_ambiguous",
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return claimants[0]!;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Resolve every catalog claimant in deterministic module-id order. */
|
|
536
|
+
async resolveProviders(capability: string): Promise<string[]> {
|
|
537
|
+
if (!isValidCapabilityIdentifier(capability)) {
|
|
538
|
+
throw new SubcError(
|
|
539
|
+
`malformed capability identifier ${JSON.stringify(capability)}`,
|
|
540
|
+
"invalid_capability_identifier",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const modules = await this.catalogList();
|
|
544
|
+
return modules
|
|
545
|
+
.filter((module) => module.capabilities?.provides.includes(capability) ?? false)
|
|
546
|
+
.map((module) => module.module_id)
|
|
547
|
+
.sort();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Open a route and return its connection-bound immutable handle. */
|
|
387
551
|
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
|
|
388
552
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
389
553
|
const consumerCapabilities = opts.consumerCapabilities;
|
|
@@ -424,14 +588,25 @@ export class SubcClient {
|
|
|
424
588
|
return installed;
|
|
425
589
|
}
|
|
426
590
|
|
|
427
|
-
|
|
591
|
+
/** Send a data-plane request on exactly the supplied route generation. */
|
|
428
592
|
async request(handle: RouteHandle, body: unknown, opts: RequestOptions = {}): Promise<unknown> {
|
|
429
593
|
this.assertLiveHandle(handle);
|
|
430
|
-
const
|
|
594
|
+
const binary = opts.binary ?? false;
|
|
595
|
+
const bytes = binary ? requireBinaryBody(body) : body instanceof Uint8Array ? body : this.encode(body);
|
|
431
596
|
const priority = opts.priority ?? Priority.Interactive;
|
|
432
597
|
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
433
|
-
const reply = await this.send(
|
|
434
|
-
|
|
598
|
+
const reply = await this.send(
|
|
599
|
+
handle,
|
|
600
|
+
bytes,
|
|
601
|
+
priority,
|
|
602
|
+
admission,
|
|
603
|
+
opts.timeoutMs,
|
|
604
|
+
opts.onProgress,
|
|
605
|
+
undefined,
|
|
606
|
+
undefined,
|
|
607
|
+
binary,
|
|
608
|
+
);
|
|
609
|
+
return this.decodeReply(reply);
|
|
435
610
|
}
|
|
436
611
|
|
|
437
612
|
/**
|
|
@@ -444,13 +619,37 @@ export class SubcClient {
|
|
|
444
619
|
params?: unknown,
|
|
445
620
|
opts: ManagedCallOptions = {},
|
|
446
621
|
): Promise<Response> {
|
|
622
|
+
if (opts.binary) {
|
|
623
|
+
throw new SubcError(
|
|
624
|
+
"call() builds a JSON body and cannot send a binary request; use callBinary(moduleId, body, opts) with a Uint8Array",
|
|
625
|
+
"binary_call_requires_call_binary",
|
|
626
|
+
);
|
|
627
|
+
}
|
|
447
628
|
const body = params === undefined ? { method } : { method, params };
|
|
629
|
+
return (await this.managedCall(moduleId, body, opts)) as Response;
|
|
630
|
+
}
|
|
448
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Managed route + raw opaque-body request convenience. Unlike call(), this
|
|
634
|
+
* sends bytes without a JSON method envelope because the BINARY flag is the
|
|
635
|
+
* receiver's only signal that the body is not JSON. A BINARY reply resolves
|
|
636
|
+
* to Uint8Array; a non-binary reply is decoded as JSON.
|
|
637
|
+
*/
|
|
638
|
+
async callBinary(
|
|
639
|
+
moduleId: string,
|
|
640
|
+
body: Uint8Array,
|
|
641
|
+
opts: ManagedCallOptions = {},
|
|
642
|
+
): Promise<Uint8Array | unknown> {
|
|
643
|
+
const bytes = requireBinaryBody(body);
|
|
644
|
+
return this.managedCall(moduleId, bytes, { ...opts, binary: true });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private async managedCall(moduleId: string, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
|
|
449
648
|
let retriedUnknownChannel = false;
|
|
450
649
|
for (;;) {
|
|
451
650
|
const routeHandle = await this.cachedRouteHandle(moduleId, opts);
|
|
452
651
|
try {
|
|
453
|
-
return
|
|
652
|
+
return await this.managedRequest(routeHandle, body, opts);
|
|
454
653
|
} catch (err) {
|
|
455
654
|
if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
|
|
456
655
|
// unknown_channel is the daemon ROUTER refusing an unrouted channel — the
|
|
@@ -699,6 +898,7 @@ export class SubcClient {
|
|
|
699
898
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
700
899
|
acceptFrame?: (frame: Frame) => boolean,
|
|
701
900
|
onLateResponse?: (frame: Frame) => void,
|
|
901
|
+
binary = false,
|
|
702
902
|
): Promise<Frame> {
|
|
703
903
|
if (handle) this.assertLiveHandle(handle);
|
|
704
904
|
if (this.closedErr) return Promise.reject(this.closedErr);
|
|
@@ -713,7 +913,7 @@ export class SubcClient {
|
|
|
713
913
|
const epoch = handle?.epoch ?? 0;
|
|
714
914
|
const frame = buildFrame(
|
|
715
915
|
FrameType.Request,
|
|
716
|
-
buildFlags(
|
|
916
|
+
buildFlags(binary, priority, false, admission),
|
|
717
917
|
channel,
|
|
718
918
|
epoch,
|
|
719
919
|
corr,
|
|
@@ -778,12 +978,21 @@ export class SubcClient {
|
|
|
778
978
|
}
|
|
779
979
|
|
|
780
980
|
private async managedRequest(handle: RouteHandle, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
|
|
781
|
-
const
|
|
981
|
+
const binary = opts.binary ?? false;
|
|
982
|
+
const bytes = binary ? requireBinaryBody(body) : body instanceof Uint8Array ? body : this.encode(body);
|
|
782
983
|
const priority = opts.priority ?? Priority.Interactive;
|
|
783
984
|
const admission = opts.admissionClass ?? AdmissionClass.Normal;
|
|
784
985
|
try {
|
|
785
|
-
const reply = await this.sendManaged(
|
|
786
|
-
|
|
986
|
+
const reply = await this.sendManaged(
|
|
987
|
+
handle,
|
|
988
|
+
bytes,
|
|
989
|
+
priority,
|
|
990
|
+
admission,
|
|
991
|
+
opts.timeoutMs,
|
|
992
|
+
opts.onProgress,
|
|
993
|
+
binary,
|
|
994
|
+
);
|
|
995
|
+
return this.decodeReply(reply);
|
|
787
996
|
} catch (error) {
|
|
788
997
|
if (error instanceof SubcCallError) throw error;
|
|
789
998
|
throw this.terminalCallError("managed call failed", error);
|
|
@@ -797,6 +1006,7 @@ export class SubcClient {
|
|
|
797
1006
|
admission: AdmissionClass,
|
|
798
1007
|
timeoutMs: number | undefined,
|
|
799
1008
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
1009
|
+
binary = false,
|
|
800
1010
|
): Promise<Frame> {
|
|
801
1011
|
try {
|
|
802
1012
|
this.assertLiveHandle(handle);
|
|
@@ -818,7 +1028,7 @@ export class SubcClient {
|
|
|
818
1028
|
const key = pendingKey(handle, corr);
|
|
819
1029
|
const frame = buildFrame(
|
|
820
1030
|
FrameType.Request,
|
|
821
|
-
buildFlags(
|
|
1031
|
+
buildFlags(binary, priority, false, admission),
|
|
822
1032
|
handle.channel,
|
|
823
1033
|
handle.epoch,
|
|
824
1034
|
corr,
|
|
@@ -898,7 +1108,7 @@ export class SubcClient {
|
|
|
898
1108
|
}
|
|
899
1109
|
|
|
900
1110
|
private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
|
|
901
|
-
const routeRetryDeadline = Date.now() +
|
|
1111
|
+
const routeRetryDeadline = Date.now() + this.opts.routeOpenRetryDeadlineMs;
|
|
902
1112
|
let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
|
|
903
1113
|
let routeRetryAttempt = 0;
|
|
904
1114
|
for (;;) {
|
|
@@ -932,14 +1142,21 @@ export class SubcClient {
|
|
|
932
1142
|
continue;
|
|
933
1143
|
}
|
|
934
1144
|
if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
|
|
1145
|
+
// The deadline is the ONLY binder here. An attempt cap used to share
|
|
1146
|
+
// this condition, and because the capped backoff sums to ~3.1s it
|
|
1147
|
+
// strictly dominated the 30s deadline — the advertised reload
|
|
1148
|
+
// patience was never delivered, so every module restart whose reload
|
|
1149
|
+
// exceeded ~3s failed managed callers with module_reloading. Module
|
|
1150
|
+
// reloads legitimately take tens of seconds (drain alone defaults to
|
|
1151
|
+
// 30s); the backoff cap below bounds retry pressure instead.
|
|
935
1152
|
routeRetryAttempt += 1;
|
|
936
|
-
if (
|
|
1153
|
+
if (Date.now() < routeRetryDeadline) {
|
|
937
1154
|
await this.opts.sleep(routeRetryDelay);
|
|
938
1155
|
routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
|
|
939
1156
|
continue;
|
|
940
1157
|
}
|
|
941
1158
|
throw this.notSentCallError(
|
|
942
|
-
`route.open failed for module ${cached.moduleId}: ${error.code} (retry
|
|
1159
|
+
`route.open failed for module ${cached.moduleId}: ${error.code} (retry deadline exhausted after ${routeRetryAttempt} attempts)`,
|
|
943
1160
|
error,
|
|
944
1161
|
);
|
|
945
1162
|
}
|
|
@@ -1118,18 +1335,31 @@ export class SubcClient {
|
|
|
1118
1335
|
}
|
|
1119
1336
|
|
|
1120
1337
|
private async reopenCachedRoutes(): Promise<void> {
|
|
1121
|
-
|
|
1122
|
-
for (const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1338
|
+
const routeKeys = [...this.routes.keys()];
|
|
1339
|
+
for (const key of routeKeys) {
|
|
1340
|
+
const cached = this.routes.get(key);
|
|
1341
|
+
if (cached) cached.handle = null;
|
|
1342
|
+
}
|
|
1343
|
+
for (const key of routeKeys) {
|
|
1344
|
+
const cached = this.routes.get(key);
|
|
1345
|
+
if (!cached || cached.closed) continue;
|
|
1346
|
+
try {
|
|
1347
|
+
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
1348
|
+
consumerIdentity: cached.consumerIdentity ?? null,
|
|
1349
|
+
});
|
|
1350
|
+
if (cached.closed) {
|
|
1351
|
+
this.liveRoutes.delete(handle.channel);
|
|
1352
|
+
this.sendRouteGoodbye(handle);
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
cached.handle = handle;
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
if (isRouteOpenRefusal(error)) {
|
|
1358
|
+
this.routes.delete(key);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
throw error;
|
|
1131
1362
|
}
|
|
1132
|
-
cached.handle = handle;
|
|
1133
1363
|
}
|
|
1134
1364
|
}
|
|
1135
1365
|
|
|
@@ -1176,6 +1406,13 @@ export class SubcClient {
|
|
|
1176
1406
|
// post-deadline liveness probe reads this stamp. Stamped after readLoop's
|
|
1177
1407
|
// generation gate, so a stale socket's late frames never vouch for the
|
|
1178
1408
|
// live one.
|
|
1409
|
+
//
|
|
1410
|
+
// PLACEMENT IS LOAD-BEARING: every inbound frame passes this one point
|
|
1411
|
+
// before demux, which is the whole reason a single stamp works. A future
|
|
1412
|
+
// fast path or drain-and-dispatch refactor that routes frames around
|
|
1413
|
+
// dispatch() makes the stamp skippable, and the liveness watermark
|
|
1414
|
+
// quietly stops meaning "the link delivered bytes" -- the cheapest
|
|
1415
|
+
// correctness property here is also the easiest to lose in a refactor.
|
|
1179
1416
|
this.lastInboundAtMs = Date.now();
|
|
1180
1417
|
if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
|
|
1181
1418
|
// Daemon-originated control push (route.closing / route.closed / future
|
|
@@ -1320,8 +1557,9 @@ export class SubcClient {
|
|
|
1320
1557
|
const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8")) as {
|
|
1321
1558
|
code?: string;
|
|
1322
1559
|
message?: string;
|
|
1560
|
+
detail?: unknown;
|
|
1323
1561
|
};
|
|
1324
|
-
return new SubcError(parsed.message ?? "subc error", parsed.code);
|
|
1562
|
+
return new SubcError(parsed.message ?? "subc error", parsed.code, parsed.detail);
|
|
1325
1563
|
} catch {
|
|
1326
1564
|
return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
|
|
1327
1565
|
}
|
|
@@ -1413,6 +1651,11 @@ export class SubcClient {
|
|
|
1413
1651
|
return Buffer.from(JSON.stringify(value), "utf8");
|
|
1414
1652
|
}
|
|
1415
1653
|
|
|
1654
|
+
/** Decode a terminal response according to the representation on the wire. */
|
|
1655
|
+
private decodeReply(frame: Frame): unknown {
|
|
1656
|
+
return hasBinary(frame.header.flags) ? frame.body : this.parseJson(frame);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1416
1659
|
private parseJson(frame: Frame): unknown {
|
|
1417
1660
|
const b = frame.body;
|
|
1418
1661
|
return JSON.parse(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("utf8"));
|
|
@@ -1439,19 +1682,31 @@ export function isConsumerReconnectTransient(err: unknown): boolean {
|
|
|
1439
1682
|
return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOENT";
|
|
1440
1683
|
}
|
|
1441
1684
|
|
|
1685
|
+
/**
|
|
1686
|
+
* A coded SubcError came from a complete wire Error frame, so the connection
|
|
1687
|
+
* survived long enough to refuse only this route. An uncoded SubcError can mean
|
|
1688
|
+
* the socket or protocol exchange failed mid-open and must still fail reconnect.
|
|
1689
|
+
*/
|
|
1690
|
+
function isRouteOpenRefusal(err: unknown): err is SubcError & { code: string } {
|
|
1691
|
+
return err instanceof SubcError && typeof err.code === "string";
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1442
1694
|
/**
|
|
1443
1695
|
* The closed set of route.open rejection codes that mean "the target is
|
|
1444
1696
|
* momentarily unavailable but the request could succeed on retry" — the target
|
|
1445
1697
|
* is booting, mid-reload, transiently absent, or the bind relay timed out. A
|
|
1446
1698
|
* daemon-rejected route.open is provably pre-send (no data frame ever left the
|
|
1447
1699
|
* client), so these classify as not_sent; the managed path retries them in-place
|
|
1448
|
-
* within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (
|
|
1449
|
-
* config_divergence, unknown_target, ...) are excluded — they are pre-send but
|
|
1450
|
-
* would never succeed, so retrying them would only storm the daemon.
|
|
1451
|
-
*
|
|
1700
|
+
* within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (module_removed,
|
|
1701
|
+
* bad_consumer_identity, config_divergence, unknown_target, ...) are excluded — they are pre-send but
|
|
1702
|
+
* would never succeed, so retrying them would only storm the daemon. In
|
|
1703
|
+
* particular, capability_forbidden is an explicit non-retryable policy refusal.
|
|
1704
|
+
* Kept byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
|
|
1452
1705
|
* classification parity.
|
|
1453
1706
|
*/
|
|
1454
1707
|
export function isRetryableRouteOpenCode(code: string | undefined): boolean {
|
|
1708
|
+
// A capability deny is policy, never a transient target-availability failure.
|
|
1709
|
+
if (code === "capability_forbidden") return false;
|
|
1455
1710
|
return (
|
|
1456
1711
|
code === "unknown_module" ||
|
|
1457
1712
|
code === "module_reloading" ||
|
|
@@ -1478,12 +1733,28 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
|
|
|
1478
1733
|
targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
|
|
1479
1734
|
reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
|
|
1480
1735
|
sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
1736
|
+
routeOpenRetryDeadlineMs: opts.routeOpenRetryDeadlineMs ?? ROUTE_OPEN_RETRY_DEADLINE_MS,
|
|
1481
1737
|
timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
|
|
1482
1738
|
livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
|
|
1483
1739
|
onControlPush: opts.onControlPush,
|
|
1484
1740
|
};
|
|
1485
1741
|
}
|
|
1486
1742
|
|
|
1743
|
+
/** Validate the exact capability-grammar identifier before catalog I/O. */
|
|
1744
|
+
export function isValidCapabilityIdentifier(identifier: string): boolean {
|
|
1745
|
+
if (/\s/u.test(identifier)) return false;
|
|
1746
|
+
const separator = identifier.indexOf("/v");
|
|
1747
|
+
if (separator <= 0 || identifier.indexOf("/v", separator + 1) !== -1) return false;
|
|
1748
|
+
const name = identifier.slice(0, separator);
|
|
1749
|
+
const version = identifier.slice(separator + 2);
|
|
1750
|
+
if (new TextEncoder().encode(name).byteLength > 64 || version.length === 0) return false;
|
|
1751
|
+
if (!/^[a-z][a-z0-9-]*[a-z0-9]$/u.test(name) && !/^[a-z]$/u.test(name)) return false;
|
|
1752
|
+
if (name.includes("--")) return false;
|
|
1753
|
+
if (!/^\d+$/u.test(version) || (version.length > 1 && version.startsWith("0"))) return false;
|
|
1754
|
+
const numericVersion = Number(version);
|
|
1755
|
+
return Number.isSafeInteger(numericVersion) && numericVersion >= 1 && numericVersion <= 0xffff_ffff;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1487
1758
|
function routeCacheKey(
|
|
1488
1759
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
1489
1760
|
identity: BindIdentity,
|
package/src/envelope.ts
CHANGED
|
@@ -50,7 +50,8 @@ const FLAG_PRIORITY_SHIFT = 1;
|
|
|
50
50
|
const FLAG_LAST = 0b0000_1000;
|
|
51
51
|
const FLAG_ADMISSION_MASK = 0b0011_0000;
|
|
52
52
|
const FLAG_ADMISSION_SHIFT = 4;
|
|
53
|
-
const
|
|
53
|
+
export const DAEMON_ORIGIN_FLAG = 0x40;
|
|
54
|
+
const FLAG_RESERVED_MASK = 0b1000_0000;
|
|
54
55
|
|
|
55
56
|
/** Build flags from typed components. Admission defaults to NORMAL. */
|
|
56
57
|
export function buildFlags(
|
|
@@ -71,6 +72,15 @@ export function admissionClass(flags: number): AdmissionClass {
|
|
|
71
72
|
return ((flags & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT) as AdmissionClass;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** True when the body is opaque bytes rather than a JSON payload. */
|
|
76
|
+
export function hasBinary(flags: number): boolean {
|
|
77
|
+
return (flags & FLAG_BINARY) !== 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hasDaemonOrigin(flags: number): boolean {
|
|
81
|
+
return (flags & DAEMON_ORIGIN_FLAG) !== 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
export interface EnvelopeHeader {
|
|
75
85
|
len: number;
|
|
76
86
|
ver: number;
|
package/src/index.ts
CHANGED
|
@@ -6,17 +6,26 @@ export {
|
|
|
6
6
|
SUBC_MODULE_ID_ENV,
|
|
7
7
|
SUBC_LAUNCH_NONCE_ENV,
|
|
8
8
|
isConsumerReconnectTransient,
|
|
9
|
+
parseRouteCloseReason,
|
|
10
|
+
classifyRouteCloseReason,
|
|
11
|
+
routeCloseReason,
|
|
12
|
+
isValidCapabilityIdentifier,
|
|
9
13
|
connectionFileExists,
|
|
10
14
|
type BindIdentity,
|
|
11
15
|
type RouteTarget,
|
|
12
16
|
type ManagedRouteKind,
|
|
13
17
|
type ConsumerIdentity,
|
|
14
18
|
type RouteOpenOptions,
|
|
19
|
+
type CatalogCapabilities,
|
|
20
|
+
type CatalogCapabilityRequirement,
|
|
15
21
|
type CatalogEntry,
|
|
16
22
|
type RequestOptions,
|
|
17
23
|
type ManagedCallOptions,
|
|
18
24
|
type ConnectOptions,
|
|
19
25
|
type ControlPush,
|
|
26
|
+
type KnownRouteCloseReason,
|
|
27
|
+
type RouteCloseReason,
|
|
28
|
+
type RouteCloseDisposition,
|
|
20
29
|
type ReconnectBackoff,
|
|
21
30
|
type SubcCallErrorKind,
|
|
22
31
|
type SubscribeOptions,
|
|
@@ -37,6 +46,7 @@ export {
|
|
|
37
46
|
FrameType,
|
|
38
47
|
Priority,
|
|
39
48
|
AdmissionClass,
|
|
49
|
+
DAEMON_ORIGIN_FLAG,
|
|
40
50
|
PROTOCOL_VERSION,
|
|
41
51
|
HEADER_LEN,
|
|
42
52
|
buildFrame,
|
|
@@ -44,6 +54,8 @@ export {
|
|
|
44
54
|
buildFlags,
|
|
45
55
|
encodeFrame,
|
|
46
56
|
decodeHeader,
|
|
57
|
+
hasBinary,
|
|
58
|
+
hasDaemonOrigin,
|
|
47
59
|
encodeHeader,
|
|
48
60
|
DecodeError,
|
|
49
61
|
type DecodeErrorCode,
|
package/src/provider.ts
CHANGED
|
@@ -352,6 +352,8 @@ export class SubcProviderError extends Error {
|
|
|
352
352
|
message: string,
|
|
353
353
|
readonly code?: string,
|
|
354
354
|
readonly kind: SubcCallErrorKind = "terminal",
|
|
355
|
+
/** Wire `ErrorBody.detail` carried verbatim (typed refusal payloads). */
|
|
356
|
+
readonly detail?: unknown,
|
|
355
357
|
) {
|
|
356
358
|
super(message);
|
|
357
359
|
}
|
|
@@ -1222,11 +1224,12 @@ function routeKey(handle: RouteHandle, corr: bigint): string {
|
|
|
1222
1224
|
|
|
1223
1225
|
function providerErrorFromFrame(frame: Frame): SubcProviderError {
|
|
1224
1226
|
try {
|
|
1225
|
-
const body = parseJson(frame.body) as { code?: string; message?: string };
|
|
1227
|
+
const body = parseJson(frame.body) as { code?: string; message?: string; detail?: unknown };
|
|
1226
1228
|
return new SubcProviderError(
|
|
1227
1229
|
body.message ?? "subc error",
|
|
1228
1230
|
body.code,
|
|
1229
1231
|
body.code === "stale_route_epoch" || body.code === "unknown_channel" ? "not_sent" : "terminal",
|
|
1232
|
+
body.detail,
|
|
1230
1233
|
);
|
|
1231
1234
|
} catch {
|
|
1232
1235
|
return new SubcProviderError(Buffer.from(frame.body).toString("utf8") || "subc error");
|