@cortexkit/subc-client 0.11.1 → 0.13.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/dist/client.d.ts +43 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +134 -20
- package/dist/client.js.map +1 -1
- package/dist/envelope.d.ts +1 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +2 -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/route-handle.d.ts +32 -1
- package/dist/route-handle.d.ts.map +1 -1
- package/dist/route-handle.js +61 -5
- package/dist/route-handle.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +217 -16
- package/src/envelope.ts +2 -1
- package/src/index.ts +9 -1
- package/src/route-handle.ts +89 -6
package/src/client.ts
CHANGED
|
@@ -22,12 +22,15 @@ import {
|
|
|
22
22
|
FrameType,
|
|
23
23
|
hasBinary,
|
|
24
24
|
Priority,
|
|
25
|
+
SUBSCRIPTION_FLAG,
|
|
25
26
|
type Frame,
|
|
26
27
|
} from "./envelope.js";
|
|
27
28
|
import {
|
|
28
29
|
belongsToConnection,
|
|
29
30
|
createRouteHandle,
|
|
30
31
|
newConnectionToken,
|
|
32
|
+
reverseRequestHandler,
|
|
33
|
+
ReverseRequestRegistry,
|
|
31
34
|
RouteHandle,
|
|
32
35
|
sameRouteHandle,
|
|
33
36
|
StaleRouteHandleError,
|
|
@@ -45,7 +48,7 @@ import {
|
|
|
45
48
|
const debug = debuglog("subc-client");
|
|
46
49
|
|
|
47
50
|
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
48
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
51
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
49
52
|
// When a request-timeout timer fires, its reply may already be sitting in the
|
|
50
53
|
// socket read buffer, unprocessed only because the event loop was starved (Node
|
|
51
54
|
// runs the TIMERS phase before the POLL phase, so an expired timer can beat an
|
|
@@ -56,8 +59,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
56
59
|
// one loop turn. This is a demux tiebreak for a reply that RACED the deadline,
|
|
57
60
|
// NOT a deadline extension: an absent reply still settles right after the check
|
|
58
61
|
// phase. Capped so it can never approach BODY_READ_TIMEOUT_MS.
|
|
59
|
-
const TIMEOUT_ARBITRATION_GRACE_MS = 50;
|
|
60
|
-
const LIVENESS_PROBE_WINDOW_MS = 2000;
|
|
62
|
+
export const TIMEOUT_ARBITRATION_GRACE_MS = 50;
|
|
63
|
+
export const LIVENESS_PROBE_WINDOW_MS = 2000;
|
|
61
64
|
// Internal marker set as the `code` on the SubcError a request-deadline timeout
|
|
62
65
|
// rejects with, so the managed classifier can tell a deadline (reply may simply
|
|
63
66
|
// not have been read in time) from an actual connection drop. Never surfaced to
|
|
@@ -79,7 +82,7 @@ const DEADLINE_NO_DROP_CODE = "deadline_exceeded_no_drop_observed";
|
|
|
79
82
|
// rejects with module_timeout, so the deadline must leave room for MULTIPLE
|
|
80
83
|
// full relay waits or one slow bind exhausts the whole retry clock. 30s allows
|
|
81
84
|
// ~2 full relay timeouts plus backoff before surfacing not_sent.
|
|
82
|
-
const ROUTE_OPEN_RETRY_DEADLINE_MS = 30_000;
|
|
85
|
+
export const ROUTE_OPEN_RETRY_DEADLINE_MS = 30_000;
|
|
83
86
|
// Once a header arrives, its body must follow promptly; bound it so a truncated
|
|
84
87
|
// frame cannot wedge the read loop forever.
|
|
85
88
|
const BODY_READ_TIMEOUT_MS = 30_000;
|
|
@@ -109,8 +112,11 @@ export interface ConsumerIdentity {
|
|
|
109
112
|
export interface RouteOpenOptions {
|
|
110
113
|
/** 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. */
|
|
111
114
|
consumerIdentity?: ConsumerIdentity | null;
|
|
112
|
-
/**
|
|
113
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Handlers prepared before route.open. The client derives consumer_capabilities
|
|
117
|
+
* from this registry; callers cannot declare a capability without a handler.
|
|
118
|
+
*/
|
|
119
|
+
reverseRequests?: ReverseRequestRegistry;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
export interface CatalogCapabilityRequirement {
|
|
@@ -168,6 +174,22 @@ export interface RequestOptions {
|
|
|
168
174
|
binary?: boolean;
|
|
169
175
|
/** Called for each interim PUSH / StreamData frame before the terminal reply. */
|
|
170
176
|
onProgress?: (body: Uint8Array) => void;
|
|
177
|
+
/**
|
|
178
|
+
* Abort the request: when this fires while the request is still in flight, the
|
|
179
|
+
* client emits a CANCEL frame carrying that request's own corr.
|
|
180
|
+
*
|
|
181
|
+
* The signal exists instead of exposing corr because corr lifetime is already
|
|
182
|
+
* tracked here to settle replies; handing it out would make every caller keep a
|
|
183
|
+
* second map that can disagree with this one, and would let a caller cancel a
|
|
184
|
+
* corr it does not own.
|
|
185
|
+
*
|
|
186
|
+
* Cancellation is best-effort by the wire's construction: the daemon forwards
|
|
187
|
+
* the frame on a live route and silently drops it on a departed one, and no
|
|
188
|
+
* acknowledgement exists at any layer. Only the module's own reply proves what
|
|
189
|
+
* happened, so the promise is NOT rejected here -- it settles however the module
|
|
190
|
+
* answers, which may be a normal completion that beat the cancel.
|
|
191
|
+
*/
|
|
192
|
+
signal?: AbortSignal;
|
|
171
193
|
}
|
|
172
194
|
|
|
173
195
|
export interface ManagedCallOptions extends RequestOptions {
|
|
@@ -177,6 +199,8 @@ export interface ManagedCallOptions extends RequestOptions {
|
|
|
177
199
|
targetKind?: ManagedRouteKind;
|
|
178
200
|
/** 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. */
|
|
179
201
|
consumerIdentity?: ConsumerIdentity | null;
|
|
202
|
+
/** Reverse-request handlers used by the managed route and all of its reconnect reopens. */
|
|
203
|
+
reverseRequests?: ReverseRequestRegistry;
|
|
180
204
|
}
|
|
181
205
|
|
|
182
206
|
export interface SubscribeOptions {
|
|
@@ -205,6 +229,8 @@ export interface CloseRouteOptions {
|
|
|
205
229
|
export interface ManagedCloseRouteOptions extends CloseRouteOptions {
|
|
206
230
|
/** Consumer identity used by the cached managed route. */
|
|
207
231
|
consumerIdentity?: ConsumerIdentity | null;
|
|
232
|
+
/** The registry whose derived capability set identifies the cached route. */
|
|
233
|
+
reverseRequests?: ReverseRequestRegistry;
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
/**
|
|
@@ -452,6 +478,7 @@ interface CachedRoute {
|
|
|
452
478
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>;
|
|
453
479
|
identity: BindIdentity;
|
|
454
480
|
consumerIdentity?: ConsumerIdentity;
|
|
481
|
+
reverseRequests: ReverseRequestRegistry;
|
|
455
482
|
handle: RouteHandle | null;
|
|
456
483
|
opening: Promise<RouteHandle> | null;
|
|
457
484
|
/**
|
|
@@ -550,13 +577,15 @@ export class SubcClient {
|
|
|
550
577
|
/** Open a route and return its connection-bound immutable handle. */
|
|
551
578
|
async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
|
|
552
579
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
553
|
-
const
|
|
580
|
+
const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
|
|
581
|
+
reverseRequests.seal();
|
|
582
|
+
const consumerCapabilities = reverseRequests.capabilities();
|
|
554
583
|
const body = this.encode({
|
|
555
584
|
op: "route.open",
|
|
556
585
|
target,
|
|
557
586
|
identity,
|
|
558
587
|
...(consumerIdentity ? { consumer_identity: consumerIdentity } : {}),
|
|
559
|
-
...(consumerCapabilities
|
|
588
|
+
...(consumerCapabilities.length > 0 ? { consumer_capabilities: consumerCapabilities } : {}),
|
|
560
589
|
});
|
|
561
590
|
|
|
562
591
|
let installed: RouteHandle | null = null;
|
|
@@ -566,7 +595,7 @@ export class SubcClient {
|
|
|
566
595
|
if (typeof parsed.route_channel !== "number" || typeof parsed.route_epoch !== "number") {
|
|
567
596
|
throw new SubcError(`route.open returned no route handle: ${JSON.stringify(parsed)}`);
|
|
568
597
|
}
|
|
569
|
-
installed = this.installRoute(parsed.route_channel, parsed.route_epoch);
|
|
598
|
+
installed = this.installRoute(parsed.route_channel, parsed.route_epoch, reverseRequests);
|
|
570
599
|
return true;
|
|
571
600
|
};
|
|
572
601
|
const closeLateRoute = (frame: Frame): void => {
|
|
@@ -605,6 +634,7 @@ export class SubcClient {
|
|
|
605
634
|
undefined,
|
|
606
635
|
undefined,
|
|
607
636
|
binary,
|
|
637
|
+
opts.signal,
|
|
608
638
|
);
|
|
609
639
|
return this.decodeReply(reply);
|
|
610
640
|
}
|
|
@@ -732,7 +762,7 @@ export class SubcClient {
|
|
|
732
762
|
this.pending.set(key, subscriptionPending);
|
|
733
763
|
const frame = buildFrame(
|
|
734
764
|
FrameType.Request,
|
|
735
|
-
buildFlags(false, priority, false, admission),
|
|
765
|
+
buildFlags(false, priority, false, admission) | SUBSCRIPTION_FLAG,
|
|
736
766
|
handle.channel,
|
|
737
767
|
handle.epoch,
|
|
738
768
|
corr,
|
|
@@ -754,6 +784,39 @@ export class SubcClient {
|
|
|
754
784
|
return { unsubscribe, closed };
|
|
755
785
|
}
|
|
756
786
|
|
|
787
|
+
/**
|
|
788
|
+
* Emit CANCEL for one in-flight request when its caller's signal fires.
|
|
789
|
+
*
|
|
790
|
+
* Emits only while the request is unsettled by this client's own book, which is
|
|
791
|
+
* a fact it owns and can check without racing. It deliberately does NOT check
|
|
792
|
+
* whether the route still exists: that would be a guess, and a wrong guess turns
|
|
793
|
+
* a frame the daemon would harmlessly drop into a frame never sent -- removing
|
|
794
|
+
* the caller's only chance at the cost of saving nothing.
|
|
795
|
+
*/
|
|
796
|
+
private attachCancelSignal(
|
|
797
|
+
signal: AbortSignal,
|
|
798
|
+
key: string,
|
|
799
|
+
handle: RouteHandle | null,
|
|
800
|
+
corr: bigint,
|
|
801
|
+
priority: Priority,
|
|
802
|
+
): void {
|
|
803
|
+
if (!handle) return;
|
|
804
|
+
const emit = (): void => {
|
|
805
|
+
if (!this.pending.has(key)) return;
|
|
806
|
+
try {
|
|
807
|
+
this.cancel(handle, corr, priority);
|
|
808
|
+
} catch {
|
|
809
|
+
// A stale handle or a closed socket cannot be acted on, and the caller
|
|
810
|
+
// already treats an unanswered cancel as unknown rather than as failure.
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
if (signal.aborted) {
|
|
814
|
+
emit();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
signal.addEventListener("abort", emit, { once: true });
|
|
818
|
+
}
|
|
819
|
+
|
|
757
820
|
/** Send a pure-header cancellation for an in-flight request. */
|
|
758
821
|
cancel(handle: RouteHandle, corr: bigint, priority: Priority = Priority.Interactive): void {
|
|
759
822
|
this.assertLiveHandle(handle);
|
|
@@ -807,7 +870,12 @@ export class SubcClient {
|
|
|
807
870
|
identity: BindIdentity,
|
|
808
871
|
opts: ManagedCloseRouteOptions = {},
|
|
809
872
|
): Promise<void> {
|
|
810
|
-
const key = routeCacheKey(
|
|
873
|
+
const key = routeCacheKey(
|
|
874
|
+
target,
|
|
875
|
+
identity,
|
|
876
|
+
routeOpenConsumerIdentity(opts),
|
|
877
|
+
(opts.reverseRequests ?? new ReverseRequestRegistry()).capabilities(),
|
|
878
|
+
);
|
|
811
879
|
const cached = this.routes.get(key);
|
|
812
880
|
if (!cached) return;
|
|
813
881
|
cached.closed = true;
|
|
@@ -899,6 +967,7 @@ export class SubcClient {
|
|
|
899
967
|
acceptFrame?: (frame: Frame) => boolean,
|
|
900
968
|
onLateResponse?: (frame: Frame) => void,
|
|
901
969
|
binary = false,
|
|
970
|
+
signal?: AbortSignal,
|
|
902
971
|
): Promise<Frame> {
|
|
903
972
|
if (handle) this.assertLiveHandle(handle);
|
|
904
973
|
if (this.closedErr) return Promise.reject(this.closedErr);
|
|
@@ -933,6 +1002,7 @@ export class SubcClient {
|
|
|
933
1002
|
};
|
|
934
1003
|
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, channel, corr, ms), ms);
|
|
935
1004
|
this.pending.set(key, pending);
|
|
1005
|
+
if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
|
|
936
1006
|
writeBorrowed(this.sock, encodeFrame(frame), Date.now() + ms).catch((error) => {
|
|
937
1007
|
const current = this.pending.get(key);
|
|
938
1008
|
if (current) this.rejectPending(key, current, error instanceof Error ? error : new SubcError(String(error)));
|
|
@@ -991,6 +1061,7 @@ export class SubcClient {
|
|
|
991
1061
|
opts.timeoutMs,
|
|
992
1062
|
opts.onProgress,
|
|
993
1063
|
binary,
|
|
1064
|
+
opts.signal,
|
|
994
1065
|
);
|
|
995
1066
|
return this.decodeReply(reply);
|
|
996
1067
|
} catch (error) {
|
|
@@ -1007,6 +1078,7 @@ export class SubcClient {
|
|
|
1007
1078
|
timeoutMs: number | undefined,
|
|
1008
1079
|
onProgress: ((body: Uint8Array) => void) | undefined,
|
|
1009
1080
|
binary = false,
|
|
1081
|
+
signal?: AbortSignal,
|
|
1010
1082
|
): Promise<Frame> {
|
|
1011
1083
|
try {
|
|
1012
1084
|
this.assertLiveHandle(handle);
|
|
@@ -1061,6 +1133,7 @@ export class SubcClient {
|
|
|
1061
1133
|
};
|
|
1062
1134
|
pending.timer = setTimeout(() => this.arbitrateTimeout(key, pending, handle.channel, corr, ms), ms);
|
|
1063
1135
|
this.pending.set(key, pending);
|
|
1136
|
+
if (signal) this.attachCancelSignal(signal, key, handle, corr, priority);
|
|
1064
1137
|
const write = writeTrackedBorrowed(this.sock, encodeFrame(frame), Date.now() + ms);
|
|
1065
1138
|
handedToSocket = write.queued;
|
|
1066
1139
|
write.completed.catch((error) => {
|
|
@@ -1084,7 +1157,9 @@ export class SubcClient {
|
|
|
1084
1157
|
{ kind: ManagedRouteKind }
|
|
1085
1158
|
>;
|
|
1086
1159
|
const consumerIdentity = routeOpenConsumerIdentity(opts);
|
|
1087
|
-
const
|
|
1160
|
+
const reverseRequests = opts.reverseRequests ?? new ReverseRequestRegistry();
|
|
1161
|
+
reverseRequests.seal();
|
|
1162
|
+
const key = routeCacheKey(target, identity, consumerIdentity, reverseRequests.capabilities());
|
|
1088
1163
|
let cached = this.routes.get(key);
|
|
1089
1164
|
if (!cached) {
|
|
1090
1165
|
cached = {
|
|
@@ -1093,6 +1168,7 @@ export class SubcClient {
|
|
|
1093
1168
|
target,
|
|
1094
1169
|
identity,
|
|
1095
1170
|
consumerIdentity,
|
|
1171
|
+
reverseRequests,
|
|
1096
1172
|
handle: null,
|
|
1097
1173
|
opening: null,
|
|
1098
1174
|
};
|
|
@@ -1123,6 +1199,7 @@ export class SubcClient {
|
|
|
1123
1199
|
try {
|
|
1124
1200
|
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
1125
1201
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
1202
|
+
reverseRequests: cached.reverseRequests,
|
|
1126
1203
|
});
|
|
1127
1204
|
if (cached.closed) {
|
|
1128
1205
|
this.liveRoutes.delete(handle.channel);
|
|
@@ -1346,6 +1423,7 @@ export class SubcClient {
|
|
|
1346
1423
|
try {
|
|
1347
1424
|
const handle = await this.routeOpen(cached.target, cached.identity, {
|
|
1348
1425
|
consumerIdentity: cached.consumerIdentity ?? null,
|
|
1426
|
+
reverseRequests: cached.reverseRequests,
|
|
1349
1427
|
});
|
|
1350
1428
|
if (cached.closed) {
|
|
1351
1429
|
this.liveRoutes.delete(handle.channel);
|
|
@@ -1449,6 +1527,11 @@ export class SubcClient {
|
|
|
1449
1527
|
}
|
|
1450
1528
|
}
|
|
1451
1529
|
|
|
1530
|
+
if (frame.header.ty === FrameType.Request && handle) {
|
|
1531
|
+
this.dispatchReverseRequest(frame, handle);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1452
1535
|
const key = pendingKey(handle, frame.header.corr);
|
|
1453
1536
|
const pending = this.pending.get(key);
|
|
1454
1537
|
if (pending) {
|
|
@@ -1552,6 +1635,109 @@ export class SubcClient {
|
|
|
1552
1635
|
this.settle(key, pending, () => pending.reject(pending.classifyFailure?.(err) ?? err));
|
|
1553
1636
|
}
|
|
1554
1637
|
|
|
1638
|
+
private dispatchReverseRequest(frame: Frame, handle: RouteHandle): void {
|
|
1639
|
+
const method = reverseRequestMethod(frame.body);
|
|
1640
|
+
const methodFamily = method?.split("/", 1)[0];
|
|
1641
|
+
const handler = methodFamily ? reverseRequestHandler(handle, methodFamily) : undefined;
|
|
1642
|
+
const sock = this.sock;
|
|
1643
|
+
const generation = this.generation;
|
|
1644
|
+
|
|
1645
|
+
// ONE CODE, TWO SITES, ON PURPOSE. `reverse_request_unhandled` is emitted
|
|
1646
|
+
// both here (no handler registered) and below (a handler that threw), with
|
|
1647
|
+
// different MESSAGES and the same CODE. That is a ruling, not an oversight:
|
|
1648
|
+
// the distinction between "nobody listening" and "the listener broke" is
|
|
1649
|
+
// ours to log, and every provider's decision is identical for both -- not
|
|
1650
|
+
// granted, retry later. A second code would be a second thing every
|
|
1651
|
+
// consumer's decoder must learn for a branch none of them takes. If one ever
|
|
1652
|
+
// needs it, the message carries it. (Settled with prefrontal, the lane's
|
|
1653
|
+
// first consumer, before anyone pinned on it.)
|
|
1654
|
+
//
|
|
1655
|
+
// What must NOT join them: a handler that RETURNS bytes declining the
|
|
1656
|
+
// request answers with an ordinary Response in the provider's own
|
|
1657
|
+
// vocabulary. Error means "the consumer did not answer", never "the consumer
|
|
1658
|
+
// said no" -- that separation is the reason this lane exists.
|
|
1659
|
+
const reply = async (): Promise<void> => {
|
|
1660
|
+
if (!handler || !method) {
|
|
1661
|
+
await this.sendReverseError(
|
|
1662
|
+
frame,
|
|
1663
|
+
handle,
|
|
1664
|
+
"reverse_request_unhandled",
|
|
1665
|
+
methodFamily
|
|
1666
|
+
? `no reverse-request handler is registered for ${methodFamily}`
|
|
1667
|
+
: "reverse request has no valid method",
|
|
1668
|
+
sock,
|
|
1669
|
+
generation,
|
|
1670
|
+
);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
let body: Uint8Array;
|
|
1675
|
+
try {
|
|
1676
|
+
body = await handler(frame.body, { corr: frame.header.corr, method });
|
|
1677
|
+
if (!(body instanceof Uint8Array)) {
|
|
1678
|
+
throw new TypeError("reverse-request handler must return a Uint8Array");
|
|
1679
|
+
}
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
await this.sendReverseError(
|
|
1682
|
+
frame,
|
|
1683
|
+
handle,
|
|
1684
|
+
"reverse_request_unhandled",
|
|
1685
|
+
error instanceof Error ? error.message : String(error),
|
|
1686
|
+
sock,
|
|
1687
|
+
generation,
|
|
1688
|
+
);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
await this.sendReverseReply(frame, handle, FrameType.Response, body, sock, generation);
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
void reply().catch((error) => {
|
|
1696
|
+
if (this.sock === sock && this.generation === generation && !this.closeStarted) {
|
|
1697
|
+
debug("failed to answer reverse request: %s", causeMessage(error));
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
private async sendReverseError(
|
|
1703
|
+
request: Frame,
|
|
1704
|
+
handle: RouteHandle,
|
|
1705
|
+
code: string,
|
|
1706
|
+
message: string,
|
|
1707
|
+
sock: SubcSocket,
|
|
1708
|
+
generation: number,
|
|
1709
|
+
): Promise<void> {
|
|
1710
|
+
await this.sendReverseReply(request, handle, FrameType.Error, this.encode({ code, message }), sock, generation);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
private async sendReverseReply(
|
|
1714
|
+
request: Frame,
|
|
1715
|
+
handle: RouteHandle,
|
|
1716
|
+
type: FrameType.Response | FrameType.Error,
|
|
1717
|
+
body: Uint8Array,
|
|
1718
|
+
sock: SubcSocket,
|
|
1719
|
+
generation: number,
|
|
1720
|
+
): Promise<void> {
|
|
1721
|
+
if (
|
|
1722
|
+
this.sock !== sock ||
|
|
1723
|
+
this.generation !== generation ||
|
|
1724
|
+
!this.isLiveHandle(handle) ||
|
|
1725
|
+
this.closeStarted ||
|
|
1726
|
+
this.closedErr
|
|
1727
|
+
) {
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
const frame = buildFrame(
|
|
1731
|
+
type,
|
|
1732
|
+
buildFlags(false, Priority.Interactive, false),
|
|
1733
|
+
handle.channel,
|
|
1734
|
+
handle.epoch,
|
|
1735
|
+
request.header.corr,
|
|
1736
|
+
body,
|
|
1737
|
+
);
|
|
1738
|
+
await writeBorrowed(sock, encodeFrame(frame), Date.now() + DEFAULT_REQUEST_TIMEOUT_MS);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1555
1741
|
private errorFromFrame(frame: Frame): SubcError {
|
|
1556
1742
|
try {
|
|
1557
1743
|
const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8")) as {
|
|
@@ -1608,8 +1794,12 @@ export class SubcClient {
|
|
|
1608
1794
|
return this.ingressEpochDropCount;
|
|
1609
1795
|
}
|
|
1610
1796
|
|
|
1611
|
-
private installRoute(
|
|
1612
|
-
|
|
1797
|
+
private installRoute(
|
|
1798
|
+
channel: number,
|
|
1799
|
+
epoch: number,
|
|
1800
|
+
reverseRequests = new ReverseRequestRegistry(),
|
|
1801
|
+
): RouteHandle {
|
|
1802
|
+
const handle = createRouteHandle(channel, epoch, this.connectionToken, reverseRequests);
|
|
1613
1803
|
this.liveRoutes.set(channel, handle);
|
|
1614
1804
|
return handle;
|
|
1615
1805
|
}
|
|
@@ -1758,12 +1948,14 @@ export function isValidCapabilityIdentifier(identifier: string): boolean {
|
|
|
1758
1948
|
function routeCacheKey(
|
|
1759
1949
|
target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
|
|
1760
1950
|
identity: BindIdentity,
|
|
1761
|
-
consumerIdentity
|
|
1951
|
+
consumerIdentity: ConsumerIdentity | undefined,
|
|
1952
|
+
consumerCapabilities: readonly string[],
|
|
1762
1953
|
): string {
|
|
1763
1954
|
const consumerPart = consumerIdentity
|
|
1764
1955
|
? `${consumerIdentity.module_id}\0${consumerIdentity.launch_nonce}`
|
|
1765
1956
|
: "";
|
|
1766
|
-
|
|
1957
|
+
const capabilitiesPart = [...consumerCapabilities].sort().join("\0");
|
|
1958
|
+
return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}\0${capabilitiesPart}`;
|
|
1767
1959
|
}
|
|
1768
1960
|
|
|
1769
1961
|
function routeOpenConsumerIdentity(opts: RouteOpenOptions = {}): ConsumerIdentity | undefined {
|
|
@@ -1790,3 +1982,12 @@ function causeMessage(cause: unknown): string {
|
|
|
1790
1982
|
function pendingKey(handle: RouteHandle | null, corr: bigint): string {
|
|
1791
1983
|
return handle ? `${handle.channel}:${handle.epoch}:${corr}` : `0:0:${corr}`;
|
|
1792
1984
|
}
|
|
1985
|
+
|
|
1986
|
+
function reverseRequestMethod(body: Uint8Array): string | undefined {
|
|
1987
|
+
try {
|
|
1988
|
+
const parsed = JSON.parse(Buffer.from(body).toString("utf8")) as { method?: unknown };
|
|
1989
|
+
return typeof parsed.method === "string" && parsed.method.length > 0 ? parsed.method : undefined;
|
|
1990
|
+
} catch {
|
|
1991
|
+
return undefined;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
package/src/envelope.ts
CHANGED
|
@@ -51,7 +51,8 @@ const FLAG_LAST = 0b0000_1000;
|
|
|
51
51
|
const FLAG_ADMISSION_MASK = 0b0011_0000;
|
|
52
52
|
const FLAG_ADMISSION_SHIFT = 4;
|
|
53
53
|
export const DAEMON_ORIGIN_FLAG = 0x40;
|
|
54
|
-
const
|
|
54
|
+
export const SUBSCRIPTION_FLAG = 0x80;
|
|
55
|
+
const FLAG_RESERVED_MASK = 0;
|
|
55
56
|
|
|
56
57
|
/** Build flags from typed components. Admission defaults to NORMAL. */
|
|
57
58
|
export function buildFlags(
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,14 @@ export {
|
|
|
35
35
|
type RoutePollKind,
|
|
36
36
|
type RoutePollResult,
|
|
37
37
|
} from "./client.js";
|
|
38
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
ReverseRequestRegistry,
|
|
40
|
+
ReverseRequestRegistrationError,
|
|
41
|
+
RouteHandle,
|
|
42
|
+
StaleRouteHandleError,
|
|
43
|
+
type ReverseRequestContext,
|
|
44
|
+
type ReverseRequestHandler,
|
|
45
|
+
} from "./route-handle.js";
|
|
39
46
|
export {
|
|
40
47
|
readConnectionFile,
|
|
41
48
|
ConnectionFileError,
|
|
@@ -47,6 +54,7 @@ export {
|
|
|
47
54
|
Priority,
|
|
48
55
|
AdmissionClass,
|
|
49
56
|
DAEMON_ORIGIN_FLAG,
|
|
57
|
+
SUBSCRIPTION_FLAG,
|
|
50
58
|
PROTOCOL_VERSION,
|
|
51
59
|
HEADER_LEN,
|
|
52
60
|
buildFrame,
|
package/src/route-handle.ts
CHANGED
|
@@ -1,4 +1,61 @@
|
|
|
1
1
|
const connectionToken = new WeakMap<RouteHandle, object>();
|
|
2
|
+
const reverseRequests = new WeakMap<RouteHandle, ReverseRequestRegistry>();
|
|
3
|
+
|
|
4
|
+
export interface ReverseRequestContext {
|
|
5
|
+
readonly corr: bigint;
|
|
6
|
+
readonly method: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ReverseRequestHandler = (
|
|
10
|
+
body: Uint8Array,
|
|
11
|
+
ctx: ReverseRequestContext,
|
|
12
|
+
) => Promise<Uint8Array> | Uint8Array;
|
|
13
|
+
|
|
14
|
+
/** A reverse-request registration cannot add a capability after route.open declared its set. */
|
|
15
|
+
export class ReverseRequestRegistrationError extends Error {
|
|
16
|
+
readonly code = "reverse_request_capability_not_declared";
|
|
17
|
+
|
|
18
|
+
constructor(readonly methodFamily: string) {
|
|
19
|
+
super(`reverse-request capability ${JSON.stringify(methodFamily)} was not registered before route.open`);
|
|
20
|
+
this.name = "ReverseRequestRegistrationError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mutable handler registry prepared before route.open. The client derives
|
|
26
|
+
* consumer_capabilities from its method families and seals that set when the
|
|
27
|
+
* route opens; handlers for declared families may still be replaced afterward.
|
|
28
|
+
*/
|
|
29
|
+
export class ReverseRequestRegistry {
|
|
30
|
+
private readonly handlers = new Map<string, ReverseRequestHandler>();
|
|
31
|
+
private declaredFamilies: ReadonlySet<string> | null = null;
|
|
32
|
+
|
|
33
|
+
onRequest(methodFamily: string, handler: ReverseRequestHandler): void {
|
|
34
|
+
if (!isMethodFamily(methodFamily)) {
|
|
35
|
+
throw new TypeError(`reverse-request method family must be a capability identifier, got ${JSON.stringify(methodFamily)}`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof handler !== "function") throw new TypeError("reverse-request handler must be a function");
|
|
38
|
+
if (this.declaredFamilies && !this.declaredFamilies.has(methodFamily)) {
|
|
39
|
+
throw new ReverseRequestRegistrationError(methodFamily);
|
|
40
|
+
}
|
|
41
|
+
this.handlers.set(methodFamily, handler);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @internal */
|
|
45
|
+
capabilities(): string[] {
|
|
46
|
+
return [...this.handlers.keys()].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @internal */
|
|
50
|
+
handler(methodFamily: string): ReverseRequestHandler | undefined {
|
|
51
|
+
return this.handlers.get(methodFamily);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal */
|
|
55
|
+
seal(): void {
|
|
56
|
+
this.declaredFamilies ??= new Set(this.handlers.keys());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
2
59
|
|
|
3
60
|
/**
|
|
4
61
|
* Immutable identity for one route binding on one live socket. Only channel and
|
|
@@ -8,7 +65,7 @@ export class RouteHandle {
|
|
|
8
65
|
readonly channel: number;
|
|
9
66
|
readonly epoch: number;
|
|
10
67
|
|
|
11
|
-
private constructor(channel: number, epoch: number, token: object) {
|
|
68
|
+
private constructor(channel: number, epoch: number, token: object, registry: ReverseRequestRegistry) {
|
|
12
69
|
if (!Number.isInteger(channel) || channel <= 0 || channel > 0xffff) {
|
|
13
70
|
throw new RangeError(`route channel must be an integer in 1..65535, got ${channel}`);
|
|
14
71
|
}
|
|
@@ -17,21 +74,47 @@ export class RouteHandle {
|
|
|
17
74
|
}
|
|
18
75
|
this.channel = channel;
|
|
19
76
|
this.epoch = epoch;
|
|
77
|
+
registry.seal();
|
|
20
78
|
connectionToken.set(this, token);
|
|
79
|
+
reverseRequests.set(this, registry);
|
|
21
80
|
Object.freeze(this);
|
|
22
81
|
}
|
|
23
82
|
|
|
24
|
-
|
|
25
|
-
|
|
83
|
+
/** Replace the handler for a capability declared when this route opened. */
|
|
84
|
+
onRequest(methodFamily: string, handler: ReverseRequestHandler): void {
|
|
85
|
+
reverseRequests.get(this)!.onRequest(methodFamily, handler);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private static create(
|
|
89
|
+
channel: number,
|
|
90
|
+
epoch: number,
|
|
91
|
+
token: object,
|
|
92
|
+
registry: ReverseRequestRegistry,
|
|
93
|
+
): RouteHandle {
|
|
94
|
+
return new RouteHandle(channel, epoch, token, registry);
|
|
26
95
|
}
|
|
27
96
|
}
|
|
28
97
|
|
|
29
98
|
/** @internal SDK factory; not re-exported from the package surface. */
|
|
30
|
-
export function createRouteHandle(
|
|
99
|
+
export function createRouteHandle(
|
|
100
|
+
channel: number,
|
|
101
|
+
epoch: number,
|
|
102
|
+
token: object,
|
|
103
|
+
registry = new ReverseRequestRegistry(),
|
|
104
|
+
): RouteHandle {
|
|
31
105
|
const factory = RouteHandle as unknown as {
|
|
32
|
-
create(channel: number, epoch: number, token: object): RouteHandle;
|
|
106
|
+
create(channel: number, epoch: number, token: object, registry: ReverseRequestRegistry): RouteHandle;
|
|
33
107
|
};
|
|
34
|
-
return factory.create(channel, epoch, token);
|
|
108
|
+
return factory.create(channel, epoch, token, registry);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal */
|
|
112
|
+
export function reverseRequestHandler(handle: RouteHandle, methodFamily: string): ReverseRequestHandler | undefined {
|
|
113
|
+
return reverseRequests.get(handle)?.handler(methodFamily);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isMethodFamily(value: string): boolean {
|
|
117
|
+
return /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(value);
|
|
35
118
|
}
|
|
36
119
|
|
|
37
120
|
/** A route handle belongs to another connection or is no longer installed. */
|