@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/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
- /** Optional consumer-declared reverse-request capabilities for this route.open. 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". */
113
- consumerCapabilities?: string[];
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 consumerCapabilities = opts.consumerCapabilities;
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 !== undefined ? { consumer_capabilities: 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(target, identity, routeOpenConsumerIdentity(opts));
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 key = routeCacheKey(target, identity, consumerIdentity);
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(channel: number, epoch: number): RouteHandle {
1612
- const handle = createRouteHandle(channel, epoch, this.connectionToken);
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?: 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
- return `${target.kind}\0${target.module_id}\0${identity.project_root}\0${identity.harness}\0${identity.session}\0${consumerPart}`;
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 FLAG_RESERVED_MASK = 0b1000_0000;
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 { RouteHandle, StaleRouteHandleError } from "./route-handle.js";
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,
@@ -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
- private static create(channel: number, epoch: number, token: object): RouteHandle {
25
- return new RouteHandle(channel, epoch, token);
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(channel: number, epoch: number, token: object): RouteHandle {
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. */