@query-farm/vgi-rpc 0.6.4 → 0.7.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.
Files changed (160) hide show
  1. package/dist/access-log.d.ts +50 -0
  2. package/dist/access-log.d.ts.map +1 -0
  3. package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
  4. package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
  5. package/dist/arrow/impl-flechette/index.d.ts +102 -0
  6. package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
  7. package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
  8. package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
  9. package/dist/arrow/index.d.ts +4 -0
  10. package/dist/arrow/index.d.ts.map +1 -0
  11. package/dist/arrow/predicates.d.ts +44 -0
  12. package/dist/arrow/predicates.d.ts.map +1 -0
  13. package/dist/arrow/types.d.ts +62 -0
  14. package/dist/arrow/types.d.ts.map +1 -0
  15. package/dist/client/capabilities.d.ts +25 -0
  16. package/dist/client/capabilities.d.ts.map +1 -0
  17. package/dist/client/connect.d.ts.map +1 -1
  18. package/dist/client/introspect.d.ts +7 -0
  19. package/dist/client/introspect.d.ts.map +1 -1
  20. package/dist/client/ipc.d.ts +8 -2
  21. package/dist/client/ipc.d.ts.map +1 -1
  22. package/dist/client/pipe.d.ts.map +1 -1
  23. package/dist/client/stream.d.ts +11 -2
  24. package/dist/client/stream.d.ts.map +1 -1
  25. package/dist/client/uploadUrl.d.ts +25 -0
  26. package/dist/client/uploadUrl.d.ts.map +1 -0
  27. package/dist/constants.d.ts +15 -1
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/crypto.d.ts +22 -0
  30. package/dist/crypto.d.ts.map +1 -0
  31. package/dist/dispatch/describe.d.ts +10 -6
  32. package/dist/dispatch/describe.d.ts.map +1 -1
  33. package/dist/dispatch/stream.d.ts +2 -2
  34. package/dist/dispatch/stream.d.ts.map +1 -1
  35. package/dist/dispatch/unary.d.ts +2 -2
  36. package/dist/dispatch/unary.d.ts.map +1 -1
  37. package/dist/errors.d.ts +46 -0
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/external.d.ts +25 -5
  40. package/dist/external.d.ts.map +1 -1
  41. package/dist/http/bearer.d.ts.map +1 -1
  42. package/dist/http/common.d.ts +42 -7
  43. package/dist/http/common.d.ts.map +1 -1
  44. package/dist/http/dispatch.d.ts +20 -2
  45. package/dist/http/dispatch.d.ts.map +1 -1
  46. package/dist/http/handler.d.ts.map +1 -1
  47. package/dist/http/index.d.ts +1 -0
  48. package/dist/http/index.d.ts.map +1 -1
  49. package/dist/http/mtls.d.ts +2 -1
  50. package/dist/http/mtls.d.ts.map +1 -1
  51. package/dist/http/oauth-pkce.d.ts +141 -0
  52. package/dist/http/oauth-pkce.d.ts.map +1 -0
  53. package/dist/http/pages.d.ts +3 -0
  54. package/dist/http/pages.d.ts.map +1 -1
  55. package/dist/http/sticky.d.ts +124 -0
  56. package/dist/http/sticky.d.ts.map +1 -0
  57. package/dist/http/token.d.ts +38 -12
  58. package/dist/http/token.d.ts.map +1 -1
  59. package/dist/http/types.d.ts +66 -5
  60. package/dist/http/types.d.ts.map +1 -1
  61. package/dist/index.d.ts +6 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1275 -3511
  64. package/dist/index.js.map +19 -37
  65. package/dist/launcher/hash.d.ts +22 -0
  66. package/dist/launcher/hash.d.ts.map +1 -0
  67. package/dist/launcher/index.d.ts +23 -0
  68. package/dist/launcher/index.d.ts.map +1 -0
  69. package/dist/launcher/launch.d.ts +27 -0
  70. package/dist/launcher/launch.d.ts.map +1 -0
  71. package/dist/launcher/lock.d.ts +19 -0
  72. package/dist/launcher/lock.d.ts.map +1 -0
  73. package/dist/launcher/serve-unix.d.ts +54 -0
  74. package/dist/launcher/serve-unix.d.ts.map +1 -0
  75. package/dist/launcher/state.d.ts +59 -0
  76. package/dist/launcher/state.d.ts.map +1 -0
  77. package/dist/otel.d.ts.map +1 -1
  78. package/dist/protocol.d.ts +16 -2
  79. package/dist/protocol.d.ts.map +1 -1
  80. package/dist/schema.d.ts +45 -18
  81. package/dist/schema.d.ts.map +1 -1
  82. package/dist/server.d.ts +23 -2
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/types.d.ts +216 -12
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/util/gzip.d.ts +10 -0
  87. package/dist/util/gzip.d.ts.map +1 -0
  88. package/dist/util/schema.d.ts +3 -15
  89. package/dist/util/schema.d.ts.map +1 -1
  90. package/dist/util/web-crypto.d.ts +22 -0
  91. package/dist/util/web-crypto.d.ts.map +1 -0
  92. package/dist/util/zstd.d.ts +26 -3
  93. package/dist/util/zstd.d.ts.map +1 -1
  94. package/dist/wire/opaque.d.ts +11 -0
  95. package/dist/wire/opaque.d.ts.map +1 -0
  96. package/dist/wire/reader.d.ts +5 -5
  97. package/dist/wire/reader.d.ts.map +1 -1
  98. package/dist/wire/request.d.ts +11 -3
  99. package/dist/wire/request.d.ts.map +1 -1
  100. package/dist/wire/response.d.ts +6 -6
  101. package/dist/wire/response.d.ts.map +1 -1
  102. package/dist/wire/writer.d.ts +49 -39
  103. package/dist/wire/writer.d.ts.map +1 -1
  104. package/package.json +24 -10
  105. package/src/access-log.ts +195 -0
  106. package/src/arrow/impl-arrowjs/index.ts +433 -0
  107. package/src/arrow/impl-flechette/index.ts +414 -0
  108. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  109. package/src/arrow/index.ts +89 -0
  110. package/src/arrow/predicates.ts +56 -0
  111. package/src/arrow/types.ts +73 -0
  112. package/src/client/capabilities.ts +84 -0
  113. package/src/client/connect.ts +103 -26
  114. package/src/client/introspect.ts +60 -38
  115. package/src/client/ipc.ts +37 -27
  116. package/src/client/pipe.ts +12 -9
  117. package/src/client/stream.ts +34 -19
  118. package/src/client/uploadUrl.ts +169 -0
  119. package/src/constants.ts +18 -1
  120. package/src/crypto.ts +95 -0
  121. package/src/dispatch/describe.ts +146 -107
  122. package/src/dispatch/stream.ts +53 -24
  123. package/src/dispatch/unary.ts +5 -4
  124. package/src/errors.ts +76 -0
  125. package/src/external.ts +43 -29
  126. package/src/http/bearer.ts +2 -5
  127. package/src/http/common.ts +90 -23
  128. package/src/http/dispatch.ts +373 -46
  129. package/src/http/handler.ts +790 -68
  130. package/src/http/index.ts +1 -0
  131. package/src/http/mtls.ts +18 -3
  132. package/src/http/oauth-pkce.ts +1035 -0
  133. package/src/http/pages.ts +30 -15
  134. package/src/http/sticky.ts +429 -0
  135. package/src/http/token.ts +165 -75
  136. package/src/http/types.ts +67 -5
  137. package/src/index.ts +40 -1
  138. package/src/launcher/hash.ts +104 -0
  139. package/src/launcher/index.ts +35 -0
  140. package/src/launcher/launch.ts +284 -0
  141. package/src/launcher/lock.ts +171 -0
  142. package/src/launcher/serve-unix.ts +385 -0
  143. package/src/launcher/state.ts +245 -0
  144. package/src/otel.ts +39 -33
  145. package/src/protocol.ts +27 -3
  146. package/src/schema.ts +107 -56
  147. package/src/server.ts +196 -20
  148. package/src/types.ts +322 -18
  149. package/src/util/gzip.ts +63 -0
  150. package/src/util/schema.ts +4 -22
  151. package/src/util/web-crypto.ts +98 -0
  152. package/src/util/zstd.ts +133 -14
  153. package/src/wire/opaque.ts +37 -0
  154. package/src/wire/reader.ts +5 -4
  155. package/src/wire/request.ts +67 -8
  156. package/src/wire/response.ts +51 -85
  157. package/src/wire/writer.ts +165 -69
  158. package/dist/util/conform.d.ts +0 -18
  159. package/dist/util/conform.d.ts.map +0 -1
  160. package/src/util/conform.ts +0 -94
package/src/http/pages.ts CHANGED
@@ -8,17 +8,38 @@
8
8
 
9
9
  import type { MethodDefinition } from "../types.js";
10
10
 
11
- const LOGO_URL = "https://vgi-rpc-python.query.farm/assets/logo-hero.png";
11
+ export const LOGO_URL = "https://vgi-rpc-python.query.farm/assets/logo-hero.png";
12
12
 
13
- const FONTS = `<link rel="preconnect" href="https://fonts.googleapis.com">
13
+ export const FONTS = `<link rel="preconnect" href="https://fonts.googleapis.com">
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">`;
16
16
 
17
+ export const ERROR_PAGE_STYLE = `<style>
18
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 600px;
19
+ margin: 0 auto; padding: 60px 20px 0; color: #2c2c1e; text-align: center;
20
+ background: #faf8f0; }
21
+ .logo { margin-bottom: 24px; }
22
+ .logo img { width: 120px; height: 120px; border-radius: 50%;
23
+ box-shadow: 0 4px 24px rgba(0,0,0,0.12); }
24
+ h1 { color: #2d5016; margin-bottom: 8px; font-weight: 700; }
25
+ code { font-family: 'JetBrains Mono', monospace; background: #f0ece0;
26
+ padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #2c2c1e; }
27
+ a { color: #2d5016; text-decoration: none; }
28
+ a:hover { color: #4a7c23; }
29
+ p { line-height: 1.7; color: #6b6b5a; }
30
+ .detail { margin-top: 12px; padding: 12px 16px; background: #f0ece0;
31
+ border-radius: 6px; font-size: 0.9em; color: #6b6b5a; }
32
+ footer { margin-top: 48px; padding: 20px 0; border-top: 1px solid #f0ece0;
33
+ color: #6b6b5a; font-size: 0.85em; line-height: 1.8; }
34
+ footer a { color: #2d5016; font-weight: 600; }
35
+ footer a:hover { color: #4a7c23; }
36
+ </style>`;
37
+
17
38
  function escapeHtml(s: string): string {
18
39
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19
40
  }
20
41
 
21
- function arrowTypeToString(type: import("@query-farm/apache-arrow").DataType): string {
42
+ function arrowTypeToString(type: import("../arrow/index.js").VgiDataType): string {
22
43
  const id = type.typeId;
23
44
  // Match the human-friendly type names used by the Python reference implementation
24
45
  if (id === 5) return "str"; // Utf8
@@ -114,17 +135,9 @@ export function buildNotFoundPage(prefix: string, protocolName: string): string
114
135
  <head>
115
136
  <meta charset="utf-8">
116
137
  <meta name="viewport" content="width=device-width, initial-scale=1">
117
- <title>404 \u2014 vgi-rpc endpoint</title>
118
- <style>
119
- body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px;
120
- margin: 60px auto; padding: 0 20px; color: #333; text-align: center; }
121
- .logo { margin-bottom: 24px; }
122
- .logo img { width: 120px; height: 120px; }
123
- h1 { color: #555; }
124
- code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.95em; }
125
- a { color: #0066cc; }
126
- p { line-height: 1.6; }
127
- </style>
138
+ <title>404 \u2014 vgi-rpc</title>
139
+ ${FONTS}
140
+ ${ERROR_PAGE_STYLE}
128
141
  </head>
129
142
  <body>
130
143
  <div class="logo">
@@ -133,7 +146,9 @@ p { line-height: 1.6; }
133
146
  <h1>404 \u2014 Not Found</h1>
134
147
  <p>This is a <code>vgi-rpc</code> service endpoint${nameFragment}.</p>
135
148
  <p>RPC methods are available under <code>${escapeHtml(prefixDisplay)}/&lt;method&gt;</code>.</p>
136
- <p>Learn more at <a href="https://vgi-rpc.query.farm">vgi-rpc.query.farm</a>.</p>
149
+ <footer>
150
+ Powered by <a href="https://vgi-rpc.query.farm"><code>vgi-rpc</code></a>
151
+ </footer>
137
152
  </body>
138
153
  </html>`;
139
154
  }
@@ -0,0 +1,429 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Sticky-session machinery for the HTTP transport.
6
+ *
7
+ * Sticky sessions let an RPC method bind a state object (a DB cursor, a
8
+ * loaded model, an open file handle) to the worker process that opened it,
9
+ * keyed by a short-lived AEAD-sealed token. Subsequent requests carrying
10
+ * the same `VGI-Session` header restore the object as `ctx.session`;
11
+ * requests that miss (wrong worker, expired, evicted) surface as a typed
12
+ * {@link SessionLostError}.
13
+ *
14
+ * HTTP-only and opt-in: the non-sticky wire path is byte-identical to the
15
+ * pre-sticky framework.
16
+ *
17
+ * Mirrors Python's `vgi_rpc/http/server/_sticky.py`.
18
+ */
19
+
20
+ import { openBytes, SealError, sealBytes } from "../crypto.js";
21
+ import { ServerDrainingError, SessionLostError } from "../errors.js";
22
+ import { randomBytes } from "../util/web-crypto.js";
23
+
24
+ const _UTF8 = new TextEncoder();
25
+ const _UTF8DEC = new TextDecoder("utf-8", { fatal: false });
26
+
27
+ const TOKEN_VERSION = 1;
28
+ const SESSION_ID_LEN = 12; // bytes — matches Python's `_SESSION_ID_LEN`
29
+
30
+ // Plaintext frame layout (little-endian):
31
+ // [u64 created_at]
32
+ // [u8 server_id_len]
33
+ // [server_id_len bytes ASCII server_id]
34
+ // [12B session_id]
35
+ // [u64 expires_at]
36
+ const PREFIX_LEN = 8 + 1; // created_at + server_id_len
37
+ const SUFFIX_LEN = 8; // expires_at
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // base64url helpers (no padding) — `VGI-Session` is header-safe
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function base64UrlEncode(bytes: Uint8Array): string {
44
+ let s = "";
45
+ for (let i = 0; i < bytes.length; i += 0x8000) {
46
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
47
+ }
48
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
49
+ }
50
+
51
+ function base64UrlDecode(s: string): Uint8Array {
52
+ let b64 = s.replace(/-/g, "+").replace(/_/g, "/");
53
+ // Re-pad to multiple of 4 for `atob`.
54
+ const pad = b64.length % 4;
55
+ if (pad === 2) b64 += "==";
56
+ else if (pad === 3) b64 += "=";
57
+ else if (pad === 1) throw new Error("invalid base64url length");
58
+ const bin = atob(b64);
59
+ const out = new Uint8Array(bin.length);
60
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
61
+ return out;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Token sealing
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** Seal a sticky-session token. Returns the base64url-encoded value for the
69
+ * `VGI-Session` header. */
70
+ export function sealSessionToken(
71
+ serverId: string,
72
+ sessionId: Uint8Array,
73
+ expiresAt: number,
74
+ tokenKey: Uint8Array,
75
+ aad: Uint8Array,
76
+ now?: number,
77
+ ): string {
78
+ if (sessionId.length !== SESSION_ID_LEN) {
79
+ throw new Error(`session_id must be ${SESSION_ID_LEN} bytes, got ${sessionId.length}`);
80
+ }
81
+ const serverIdBytes = _UTF8.encode(serverId);
82
+ if (serverIdBytes.length > 255) {
83
+ throw new Error(`server_id too long (${serverIdBytes.length} bytes); max 255`);
84
+ }
85
+ const plaintext = new Uint8Array(PREFIX_LEN + serverIdBytes.length + SESSION_ID_LEN + SUFFIX_LEN);
86
+ const view = new DataView(plaintext.buffer);
87
+ let offset = 0;
88
+ view.setBigUint64(offset, BigInt(now ?? Math.floor(Date.now() / 1000)), true);
89
+ offset += 8;
90
+ plaintext[offset] = serverIdBytes.length;
91
+ offset += 1;
92
+ plaintext.set(serverIdBytes, offset);
93
+ offset += serverIdBytes.length;
94
+ plaintext.set(sessionId, offset);
95
+ offset += SESSION_ID_LEN;
96
+ view.setBigUint64(offset, BigInt(expiresAt), true);
97
+ const sealed = sealBytes(plaintext, tokenKey, { aad, version: TOKEN_VERSION });
98
+ return base64UrlEncode(sealed);
99
+ }
100
+
101
+ export interface OpenedSessionToken {
102
+ serverId: string;
103
+ sessionId: Uint8Array;
104
+ expiresAt: number;
105
+ }
106
+
107
+ /** Open a sticky-session token. Raises {@link SessionLostError} on any failure
108
+ * — wrong AAD (cross-principal replay) is indistinguishable from garbage. */
109
+ export function openSessionToken(token: string, tokenKey: Uint8Array, aad: Uint8Array): OpenedSessionToken {
110
+ let raw: Uint8Array;
111
+ try {
112
+ raw = base64UrlDecode(token);
113
+ } catch {
114
+ throw new SessionLostError("malformed session token");
115
+ }
116
+ let plaintext: Uint8Array;
117
+ try {
118
+ plaintext = openBytes(raw, tokenKey, { aad, version: TOKEN_VERSION });
119
+ } catch (err) {
120
+ if (err instanceof SealError) {
121
+ throw new SessionLostError("session token verification failed");
122
+ }
123
+ throw err;
124
+ }
125
+ if (plaintext.length < PREFIX_LEN) {
126
+ throw new SessionLostError("malformed session token");
127
+ }
128
+ const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);
129
+ const serverIdLen = plaintext[8];
130
+ const sidPos = PREFIX_LEN + serverIdLen;
131
+ const endPos = sidPos + SESSION_ID_LEN + SUFFIX_LEN;
132
+ if (plaintext.length !== endPos) {
133
+ throw new SessionLostError("malformed session token");
134
+ }
135
+ const serverId = _UTF8DEC.decode(plaintext.subarray(PREFIX_LEN, sidPos));
136
+ // Copy session_id into a fresh buffer so callers can hold it without
137
+ // pinning the larger plaintext buffer.
138
+ const sessionId = new Uint8Array(SESSION_ID_LEN);
139
+ sessionId.set(plaintext.subarray(sidPos, sidPos + SESSION_ID_LEN));
140
+ const expiresAt = Number(view.getBigUint64(sidPos + SESSION_ID_LEN, true));
141
+ return { serverId, sessionId, expiresAt };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Async mutex — serializes same-session calls
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Minimal promise-based mutex. The HTTP handler awaits `acquire()` before
149
+ * dispatching on a resumed session and calls the returned release in a
150
+ * `finally` so concurrent calls on the same session run sequentially. */
151
+ class AsyncMutex {
152
+ private locked = false;
153
+ private waiters: Array<() => void> = [];
154
+
155
+ async acquire(): Promise<() => void> {
156
+ if (!this.locked) {
157
+ this.locked = true;
158
+ return () => this.release();
159
+ }
160
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
161
+ this.locked = true;
162
+ return () => this.release();
163
+ }
164
+
165
+ private release(): void {
166
+ const next = this.waiters.shift();
167
+ if (next) {
168
+ // Hand the lock straight to the next waiter (still `locked = true`).
169
+ next();
170
+ } else {
171
+ this.locked = false;
172
+ }
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Session registry
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /** A live session in the per-worker registry. */
181
+ export interface SessionEntry {
182
+ state: unknown;
183
+ expiresAt: number; // seconds since epoch
184
+ principalKey: string;
185
+ lock: AsyncMutex;
186
+ }
187
+
188
+ /**
189
+ * Derive the registry partition key for a request principal.
190
+ *
191
+ * Both the dispatch path and the `DELETE /__session__` teardown path MUST
192
+ * compute this identically — otherwise a session opened on one path can't
193
+ * be looked up on the other. The NUL separator (rather than a space)
194
+ * keeps `{domain:"a", principal:"b "}` from colliding with
195
+ * `{domain:"a ", principal:"b"}`. Anonymous requests collapse to a
196
+ * single sentinel.
197
+ *
198
+ * `domain` / `principal` are the authenticated identity fields, or
199
+ * null/undefined for anonymous.
200
+ */
201
+ export function sessionPrincipalKey(
202
+ authenticated: boolean,
203
+ domain: string | null | undefined,
204
+ principal: string | null | undefined,
205
+ ): string {
206
+ if (!authenticated) return "\u0000anonymous";
207
+ return `${domain ?? ""}\u0000${principal ?? ""}`;
208
+ }
209
+
210
+ /** Hex-encode a session_id Uint8Array (24-char lowercase hex). */
211
+ export function sessionIdHex(sessionId: Uint8Array): string {
212
+ let s = "";
213
+ for (let i = 0; i < sessionId.length; i++) s += sessionId[i].toString(16).padStart(2, "0");
214
+ return s;
215
+ }
216
+
217
+ /** Compare two byte arrays for equality. */
218
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
219
+ if (a.length !== b.length) return false;
220
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
221
+ return true;
222
+ }
223
+
224
+ /** Per-worker in-process map of live sticky sessions. */
225
+ export class SessionRegistry {
226
+ // Use a Map keyed by hex id (Uint8Array isn't a valid Map key for equality).
227
+ private entries = new Map<string, { id: Uint8Array; entry: SessionEntry }>();
228
+ private _draining = false;
229
+
230
+ constructor(public readonly defaultTtl: number) {}
231
+
232
+ get draining(): boolean {
233
+ return this._draining;
234
+ }
235
+
236
+ setDraining(value: boolean): void {
237
+ this._draining = value;
238
+ }
239
+
240
+ /** Register a session. Throws {@link ServerDrainingError} when draining. */
241
+ open(state: unknown, ttl: number | undefined, principalKey: string): { sessionId: Uint8Array; expiresAt: number } {
242
+ if (this._draining) {
243
+ throw new ServerDrainingError("server is draining — new sessions are rejected");
244
+ }
245
+ const effective = ttl ?? this.defaultTtl;
246
+ const expiresAt = Math.floor(Date.now() / 1000) + effective;
247
+ const sessionId = randomBytes(SESSION_ID_LEN);
248
+ const key = sessionIdHex(sessionId);
249
+ this.entries.set(key, {
250
+ id: sessionId,
251
+ entry: { state, expiresAt, principalKey, lock: new AsyncMutex() },
252
+ });
253
+ return { sessionId, expiresAt };
254
+ }
255
+
256
+ /** Look up a session. Returns null on miss, expiry, or principal mismatch.
257
+ * Expired entries are evicted in-line (and `state.close?.()` invoked). */
258
+ get(sessionId: Uint8Array, principalKey: string): SessionEntry | null {
259
+ const key = sessionIdHex(sessionId);
260
+ const slot = this.entries.get(key);
261
+ if (!slot) return null;
262
+ if (!bytesEqual(slot.id, sessionId)) return null;
263
+ const now = Math.floor(Date.now() / 1000);
264
+ if (slot.entry.expiresAt < now) {
265
+ this.entries.delete(key);
266
+ closeStateSuppressed(slot.entry.state);
267
+ return null;
268
+ }
269
+ if (slot.entry.principalKey !== principalKey) return null;
270
+ return slot.entry;
271
+ }
272
+
273
+ /** Remove a session and invoke `state.close?.()`. Returns true on hit. */
274
+ close(sessionId: Uint8Array): boolean {
275
+ const key = sessionIdHex(sessionId);
276
+ const slot = this.entries.get(key);
277
+ if (!slot || !bytesEqual(slot.id, sessionId)) return false;
278
+ this.entries.delete(key);
279
+ closeStateSuppressed(slot.entry.state);
280
+ return true;
281
+ }
282
+
283
+ /** Evict every entry past its TTL. Returns the eviction count. */
284
+ drainExpired(now?: number): number {
285
+ const cutoff = now ?? Math.floor(Date.now() / 1000);
286
+ let count = 0;
287
+ for (const [key, slot] of this.entries) {
288
+ if (slot.entry.expiresAt < cutoff) {
289
+ this.entries.delete(key);
290
+ closeStateSuppressed(slot.entry.state);
291
+ count++;
292
+ }
293
+ }
294
+ return count;
295
+ }
296
+
297
+ /** Invoke `state.close?.()` on every live session and clear the registry. */
298
+ shutdown(): void {
299
+ const slots = Array.from(this.entries.values());
300
+ this.entries.clear();
301
+ for (const slot of slots) closeStateSuppressed(slot.entry.state);
302
+ }
303
+
304
+ get size(): number {
305
+ return this.entries.size;
306
+ }
307
+ }
308
+
309
+ function closeStateSuppressed(state: unknown): void {
310
+ const closer = (state as { close?: unknown })?.close;
311
+ if (typeof closer !== "function") return;
312
+ try {
313
+ (closer as () => unknown).call(state);
314
+ } catch {
315
+ // Suppress — eviction must not crash the reaper.
316
+ }
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Reaper
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /** Start a periodic reaper that evicts expired sessions. Returns a stop fn.
324
+ * Uses `setInterval().unref()` where available so the reaper does not keep
325
+ * the process alive. */
326
+ export function startSessionReaper(registry: SessionRegistry, tickMs = 1000): () => void {
327
+ const handle = setInterval(() => {
328
+ try {
329
+ registry.drainExpired();
330
+ } catch {
331
+ // never crash the reaper
332
+ }
333
+ }, tickMs);
334
+ (handle as { unref?: () => void }).unref?.();
335
+ return () => clearInterval(handle);
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Per-request sticky sink — bridges CallContext to the handler
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /** Per-request handle that the HTTP handler installs on the OutputCollector.
343
+ * `CallContext.openSession` / `closeSession` / `session` read and mutate
344
+ * this object; the handler then emits the resulting headers on the
345
+ * response. */
346
+ export interface StickySink {
347
+ /** True iff the request carried `VGI-Session-Accept: true`. */
348
+ acceptOpens: boolean;
349
+ /** Live session state (resumed or just-opened). Null until `openSession`
350
+ * or a successful resume populates it. */
351
+ state: unknown | null;
352
+ /** Hex session_id for the access log. Populated on resume + open;
353
+ * preserved across `closeSession` so close records still carry the id. */
354
+ sessionId: string | null;
355
+ /** Set by `openSession` so `process_response` emits `VGI-Session: <token>`. */
356
+ mintToken: string | null;
357
+ /** Set by `closeSession` so `process_response` emits `VGI-Session-Close: true`. */
358
+ closed: boolean;
359
+ /** Sticky-session lifecycle action observed during dispatch — one of
360
+ * "none" / "resume" / "open" / "close". Surfaced on the access log. */
361
+ action: "none" | "resume" | "open" | "close";
362
+ /** Bound by the handler: registers `state` in the registry, mints the
363
+ * AEAD-sealed token, and stamps `mintToken` + `sessionId`. */
364
+ _open(state: unknown, ttl: number | undefined): void;
365
+ /** Bound by the handler: removes the registry entry + invokes
366
+ * `state.close?.()`. Idempotent. */
367
+ _close(): void;
368
+ }
369
+
370
+ /** Build a `StickySink` for a request without sticky support — `_open` /
371
+ * `_close` throw the same RuntimeError shape as Python's implementation so
372
+ * call sites get a clear message. */
373
+ export function unavailableStickySink(): StickySink {
374
+ return {
375
+ acceptOpens: false,
376
+ state: null,
377
+ sessionId: null,
378
+ mintToken: null,
379
+ closed: false,
380
+ action: "none",
381
+ _open() {
382
+ throw new Error("sticky sessions not available on this transport");
383
+ },
384
+ _close() {
385
+ throw new Error("sticky sessions not available on this transport");
386
+ },
387
+ };
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Drain handle — operator-facing API for graceful shutdown
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /** Operator-facing handle returned by `createHttpHandler` (when sticky is
395
+ * enabled) so SIGTERM hooks / worker-exit hooks can drain in-flight
396
+ * sessions cleanly. Mirrors Python's `DrainHandle`. */
397
+ export interface DrainHandle {
398
+ /** Flip the registry's drain flag — subsequent `ctx.openSession` raises
399
+ * {@link ServerDrainingError}. Existing sessions continue. */
400
+ drain(): void;
401
+ /** Invoke `state.close?.()` on every live session and clear the registry. */
402
+ shutdown(): void;
403
+ /** Return whether `drain()` has been invoked. */
404
+ isDraining(): boolean;
405
+ /** Test-only / advanced: flip the drain flag back. Production deployments
406
+ * only ever drain in one direction; conformance tests use this to clean
407
+ * up the fixture between tests. */
408
+ setDraining(value: boolean): void;
409
+ }
410
+
411
+ /** Build a {@link DrainHandle} for *registry*. `stopReaper`, when supplied,
412
+ * is invoked by `shutdown()` so the periodic reaper interval is cleared and
413
+ * the handle fully releases its resources. */
414
+ export function makeDrainHandle(registry: SessionRegistry, stopReaper?: () => void): DrainHandle {
415
+ return {
416
+ drain: () => registry.setDraining(true),
417
+ shutdown: () => {
418
+ stopReaper?.();
419
+ registry.shutdown();
420
+ },
421
+ isDraining: () => registry.draining,
422
+ setDraining: (v) => registry.setDraining(v),
423
+ };
424
+ }
425
+
426
+ // AAD: sticky tokens reuse the existing `computeAad` from `./token.ts` so the
427
+ // principal binding is identical for state and session tokens. The envelope
428
+ // version byte (`TOKEN_VERSION = 1` here vs. `TOKEN_VERSION = 4` for state
429
+ // tokens) keeps the two formats discriminated under the same key.