@cortexkit/subc-client 0.3.4 → 0.4.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.
@@ -0,0 +1,60 @@
1
+ const connectionToken = new WeakMap<RouteHandle, object>();
2
+
3
+ /**
4
+ * Immutable identity for one route binding on one live socket. Only channel and
5
+ * epoch cross the wire; the connection token remains private to this SDK.
6
+ */
7
+ export class RouteHandle {
8
+ readonly channel: number;
9
+ readonly epoch: number;
10
+
11
+ private constructor(channel: number, epoch: number, token: object) {
12
+ if (!Number.isInteger(channel) || channel <= 0 || channel > 0xffff) {
13
+ throw new RangeError(`route channel must be an integer in 1..65535, got ${channel}`);
14
+ }
15
+ if (!Number.isInteger(epoch) || epoch <= 0 || epoch > 0xffff_ffff) {
16
+ throw new RangeError(`route epoch must be an integer in 1..4294967295, got ${epoch}`);
17
+ }
18
+ this.channel = channel;
19
+ this.epoch = epoch;
20
+ connectionToken.set(this, token);
21
+ Object.freeze(this);
22
+ }
23
+
24
+ private static create(channel: number, epoch: number, token: object): RouteHandle {
25
+ return new RouteHandle(channel, epoch, token);
26
+ }
27
+ }
28
+
29
+ /** @internal SDK factory; not re-exported from the package surface. */
30
+ export function createRouteHandle(channel: number, epoch: number, token: object): RouteHandle {
31
+ const factory = RouteHandle as unknown as {
32
+ create(channel: number, epoch: number, token: object): RouteHandle;
33
+ };
34
+ return factory.create(channel, epoch, token);
35
+ }
36
+
37
+ /** A route handle belongs to another connection or is no longer installed. */
38
+ export class StaleRouteHandleError extends Error {
39
+ readonly code = "stale_route_handle";
40
+
41
+ constructor(readonly handle: RouteHandle) {
42
+ super(`route handle (${handle.channel}, ${handle.epoch}) is not live on the current connection`);
43
+ this.name = "StaleRouteHandleError";
44
+ }
45
+ }
46
+
47
+ /** @internal */
48
+ export function newConnectionToken(): object {
49
+ return Object.freeze({});
50
+ }
51
+
52
+ /** @internal */
53
+ export function belongsToConnection(handle: RouteHandle, token: object): boolean {
54
+ return connectionToken.get(handle) === token;
55
+ }
56
+
57
+ /** @internal */
58
+ export function sameRouteHandle(left: RouteHandle, right: RouteHandle): boolean {
59
+ return left === right;
60
+ }
package/src/socket.ts CHANGED
@@ -6,6 +6,16 @@
6
6
 
7
7
  import net from "node:net";
8
8
 
9
+ import {
10
+ decodeHeader,
11
+ DecodeError,
12
+ FROZEN_PREFIX_LEN,
13
+ HEADER_LEN,
14
+ MAX_FRAME_BODY_LEN,
15
+ PROTOCOL_VERSION,
16
+ type Frame,
17
+ } from "./envelope.js";
18
+
9
19
  export class SocketClosedError extends Error {}
10
20
  export class SocketTimeoutError extends Error {}
11
21
 
@@ -99,6 +109,44 @@ export class SubcSocket {
99
109
  });
100
110
  }
101
111
 
112
+ /**
113
+ * Read one envelope frame. The frozen five-byte prefix is validated before
114
+ * waiting for the rest of the header, so a stale 17-byte v1 sender fails
115
+ * promptly instead of leaving this reader blocked for four bytes.
116
+ *
117
+ * `bodyDeadline` is either an absolute epoch-ms deadline (a single total
118
+ * budget shared with the header read — the bounded handshake read) or
119
+ * `{ afterHeaderMs }` to start the body budget from header arrival. A
120
+ * background frame loop waits for the header with an infinite deadline, so it
121
+ * MUST use `afterHeaderMs`: anchoring the body budget before that idle wait
122
+ * makes a frame arriving after a quiet stretch longer than the body timeout
123
+ * instant-reject ("timed out waiting for N bytes") even though its body is
124
+ * arriving normally.
125
+ */
126
+ async readFrame(
127
+ headerDeadlineMs: number,
128
+ bodyDeadline: number | { afterHeaderMs: number },
129
+ onHeader?: () => void,
130
+ ): Promise<Frame> {
131
+ const prefix = await this.readExact(FROZEN_PREFIX_LEN, headerDeadlineMs);
132
+ const version = prefix[4]!;
133
+ if (version !== PROTOCOL_VERSION) throw new DecodeError(`unsupported envelope version ${version}`, "unsupported_version");
134
+
135
+ const remainder = await this.readExact(HEADER_LEN - FROZEN_PREFIX_LEN, headerDeadlineMs);
136
+ const headerBytes = new Uint8Array(HEADER_LEN);
137
+ headerBytes.set(prefix);
138
+ headerBytes.set(remainder, FROZEN_PREFIX_LEN);
139
+ const header = decodeHeader(headerBytes);
140
+ if (header.len > MAX_FRAME_BODY_LEN) {
141
+ throw new DecodeError(`frame body ${header.len} exceeds max ${MAX_FRAME_BODY_LEN}`, "frame_body_too_large");
142
+ }
143
+ onHeader?.();
144
+ const bodyDeadlineMs =
145
+ typeof bodyDeadline === "number" ? bodyDeadline : Date.now() + bodyDeadline.afterHeaderMs;
146
+ const body = header.len === 0 ? new Uint8Array(0) : await this.readExact(header.len, bodyDeadlineMs);
147
+ return { header, body };
148
+ }
149
+
102
150
  /** Read exactly `n` bytes, rejecting if `deadlineMs` (epoch ms) passes first. */
103
151
  readExact(n: number, deadlineMs: number): Promise<Uint8Array> {
104
152
  if (this.waiter) {