@query-farm/vgi-rpc 0.6.4 → 0.7.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.
Files changed (177) hide show
  1. package/dist/access-log.d.ts +55 -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/auth.d.ts +5 -0
  16. package/dist/auth.d.ts.map +1 -1
  17. package/dist/client/capabilities.d.ts +25 -0
  18. package/dist/client/capabilities.d.ts.map +1 -0
  19. package/dist/client/connect.d.ts +10 -0
  20. package/dist/client/connect.d.ts.map +1 -1
  21. package/dist/client/introspect.d.ts +21 -0
  22. package/dist/client/introspect.d.ts.map +1 -1
  23. package/dist/client/ipc.d.ts +8 -2
  24. package/dist/client/ipc.d.ts.map +1 -1
  25. package/dist/client/oauth.d.ts +9 -0
  26. package/dist/client/oauth.d.ts.map +1 -1
  27. package/dist/client/pipe.d.ts +24 -0
  28. package/dist/client/pipe.d.ts.map +1 -1
  29. package/dist/client/stream.d.ts +19 -2
  30. package/dist/client/stream.d.ts.map +1 -1
  31. package/dist/client/types.d.ts +23 -0
  32. package/dist/client/types.d.ts.map +1 -1
  33. package/dist/client/uploadUrl.d.ts +25 -0
  34. package/dist/client/uploadUrl.d.ts.map +1 -0
  35. package/dist/constants.d.ts +30 -2
  36. package/dist/constants.d.ts.map +1 -1
  37. package/dist/crypto.d.ts +22 -0
  38. package/dist/crypto.d.ts.map +1 -0
  39. package/dist/dispatch/describe.d.ts +10 -6
  40. package/dist/dispatch/describe.d.ts.map +1 -1
  41. package/dist/dispatch/stream.d.ts +2 -2
  42. package/dist/dispatch/stream.d.ts.map +1 -1
  43. package/dist/dispatch/unary.d.ts +2 -2
  44. package/dist/dispatch/unary.d.ts.map +1 -1
  45. package/dist/errors.d.ts +64 -1
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/external.d.ts +27 -5
  48. package/dist/external.d.ts.map +1 -1
  49. package/dist/http/auth.d.ts +13 -0
  50. package/dist/http/auth.d.ts.map +1 -1
  51. package/dist/http/bearer.d.ts.map +1 -1
  52. package/dist/http/common.d.ts +43 -7
  53. package/dist/http/common.d.ts.map +1 -1
  54. package/dist/http/dispatch.d.ts +20 -2
  55. package/dist/http/dispatch.d.ts.map +1 -1
  56. package/dist/http/handler.d.ts.map +1 -1
  57. package/dist/http/index.d.ts +1 -0
  58. package/dist/http/index.d.ts.map +1 -1
  59. package/dist/http/jwt.d.ts +1 -0
  60. package/dist/http/jwt.d.ts.map +1 -1
  61. package/dist/http/mtls.d.ts +9 -1
  62. package/dist/http/mtls.d.ts.map +1 -1
  63. package/dist/http/oauth-pkce.d.ts +141 -0
  64. package/dist/http/oauth-pkce.d.ts.map +1 -0
  65. package/dist/http/pages.d.ts +3 -0
  66. package/dist/http/pages.d.ts.map +1 -1
  67. package/dist/http/sticky.d.ts +124 -0
  68. package/dist/http/sticky.d.ts.map +1 -0
  69. package/dist/http/token.d.ts +43 -12
  70. package/dist/http/token.d.ts.map +1 -1
  71. package/dist/http/types.d.ts +68 -5
  72. package/dist/http/types.d.ts.map +1 -1
  73. package/dist/index.d.ts +6 -4
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1275 -3511
  76. package/dist/index.js.map +20 -38
  77. package/dist/launcher/hash.d.ts +22 -0
  78. package/dist/launcher/hash.d.ts.map +1 -0
  79. package/dist/launcher/index.d.ts +23 -0
  80. package/dist/launcher/index.d.ts.map +1 -0
  81. package/dist/launcher/launch.d.ts +27 -0
  82. package/dist/launcher/launch.d.ts.map +1 -0
  83. package/dist/launcher/lock.d.ts +19 -0
  84. package/dist/launcher/lock.d.ts.map +1 -0
  85. package/dist/launcher/serve-unix.d.ts +55 -0
  86. package/dist/launcher/serve-unix.d.ts.map +1 -0
  87. package/dist/launcher/state.d.ts +71 -0
  88. package/dist/launcher/state.d.ts.map +1 -0
  89. package/dist/otel.d.ts.map +1 -1
  90. package/dist/protocol.d.ts +19 -2
  91. package/dist/protocol.d.ts.map +1 -1
  92. package/dist/schema.d.ts +45 -18
  93. package/dist/schema.d.ts.map +1 -1
  94. package/dist/server.d.ts +23 -2
  95. package/dist/server.d.ts.map +1 -1
  96. package/dist/types.d.ts +270 -12
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/util/gzip.d.ts +10 -0
  99. package/dist/util/gzip.d.ts.map +1 -0
  100. package/dist/util/schema.d.ts +3 -15
  101. package/dist/util/schema.d.ts.map +1 -1
  102. package/dist/util/web-crypto.d.ts +22 -0
  103. package/dist/util/web-crypto.d.ts.map +1 -0
  104. package/dist/util/zstd.d.ts +26 -3
  105. package/dist/util/zstd.d.ts.map +1 -1
  106. package/dist/wire/opaque.d.ts +11 -0
  107. package/dist/wire/opaque.d.ts.map +1 -0
  108. package/dist/wire/reader.d.ts +5 -5
  109. package/dist/wire/reader.d.ts.map +1 -1
  110. package/dist/wire/request.d.ts +11 -3
  111. package/dist/wire/request.d.ts.map +1 -1
  112. package/dist/wire/response.d.ts +6 -6
  113. package/dist/wire/response.d.ts.map +1 -1
  114. package/dist/wire/writer.d.ts +49 -39
  115. package/dist/wire/writer.d.ts.map +1 -1
  116. package/package.json +35 -21
  117. package/src/access-log.ts +200 -0
  118. package/src/arrow/impl-arrowjs/index.ts +433 -0
  119. package/src/arrow/impl-flechette/index.ts +414 -0
  120. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  121. package/src/arrow/index.ts +89 -0
  122. package/src/arrow/predicates.ts +56 -0
  123. package/src/arrow/types.ts +73 -0
  124. package/src/auth.ts +5 -0
  125. package/src/client/capabilities.ts +84 -0
  126. package/src/client/connect.ts +113 -26
  127. package/src/client/introspect.ts +74 -38
  128. package/src/client/ipc.ts +37 -27
  129. package/src/client/oauth.ts +9 -0
  130. package/src/client/pipe.ts +36 -9
  131. package/src/client/stream.ts +43 -20
  132. package/src/client/types.ts +23 -0
  133. package/src/client/uploadUrl.ts +169 -0
  134. package/src/constants.ts +34 -2
  135. package/src/crypto.ts +95 -0
  136. package/src/dispatch/describe.ts +146 -107
  137. package/src/dispatch/stream.ts +53 -24
  138. package/src/dispatch/unary.ts +5 -4
  139. package/src/errors.ts +87 -0
  140. package/src/external.ts +49 -30
  141. package/src/http/auth.ts +13 -0
  142. package/src/http/bearer.ts +2 -5
  143. package/src/http/common.ts +91 -23
  144. package/src/http/dispatch.ts +373 -46
  145. package/src/http/handler.ts +790 -68
  146. package/src/http/index.ts +1 -0
  147. package/src/http/jwt.ts +1 -0
  148. package/src/http/mtls.ts +25 -3
  149. package/src/http/oauth-pkce.ts +1035 -0
  150. package/src/http/pages.ts +30 -15
  151. package/src/http/sticky.ts +429 -0
  152. package/src/http/token.ts +170 -75
  153. package/src/http/types.ts +69 -5
  154. package/src/index.ts +40 -1
  155. package/src/launcher/hash.ts +104 -0
  156. package/src/launcher/index.ts +35 -0
  157. package/src/launcher/launch.ts +284 -0
  158. package/src/launcher/lock.ts +171 -0
  159. package/src/launcher/serve-unix.ts +386 -0
  160. package/src/launcher/state.ts +257 -0
  161. package/src/otel.ts +39 -33
  162. package/src/protocol.ts +30 -3
  163. package/src/schema.ts +107 -56
  164. package/src/server.ts +196 -20
  165. package/src/types.ts +376 -18
  166. package/src/util/gzip.ts +63 -0
  167. package/src/util/schema.ts +4 -22
  168. package/src/util/web-crypto.ts +98 -0
  169. package/src/util/zstd.ts +133 -14
  170. package/src/wire/opaque.ts +37 -0
  171. package/src/wire/reader.ts +5 -4
  172. package/src/wire/request.ts +67 -8
  173. package/src/wire/response.ts +51 -85
  174. package/src/wire/writer.ts +165 -69
  175. package/dist/util/conform.d.ts +0 -18
  176. package/dist/util/conform.d.ts.map +0 -1
  177. package/src/util/conform.ts +0 -94
package/src/http/token.ts CHANGED
@@ -1,112 +1,210 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { createHmac, timingSafeEqual } from "node:crypto";
4
+ import { openBytes, SealError, sealBytes } from "../crypto.js";
5
5
 
6
- const TOKEN_VERSION = 2;
7
- const HMAC_LEN = 32;
8
- // 1 (version) + 8 (created_at) + 4*3 (three length prefixes) + 32 (hmac)
9
- const MIN_TOKEN_LEN = 1 + 8 + 12 + HMAC_LEN;
6
+ const _UTF8 = new TextEncoder();
7
+
8
+ const TOKEN_VERSION = 4;
9
+
10
+ const AAD_PREFIX = _UTF8.encode("vgi_rpc.state.v4\0");
11
+
12
+ /**
13
+ * Build the AEAD associated data that binds a state token to its issuing
14
+ * principal. Anonymous and authenticated tokens produce distinct AAD
15
+ * strings, so an anonymous token cannot be opened by a named identity
16
+ * (and vice versa).
17
+ */
18
+ export function computeAad(principal: string | null | undefined): Uint8Array {
19
+ if (!principal) {
20
+ const tail = _UTF8.encode("\0anonymous");
21
+ return concatBytes(AAD_PREFIX, tail);
22
+ }
23
+ const pBytes = _UTF8.encode(principal);
24
+ const tail = new Uint8Array(1 + pBytes.length);
25
+ tail[0] = 0x01;
26
+ tail.set(pBytes, 1);
27
+ return concatBytes(AAD_PREFIX, tail);
28
+ }
29
+
30
+ // Base64 helpers — `btoa`/`atob` exist on Node 16+, Bun, and workerd; we work
31
+ // in chunks to stay below the per-call argument limit (Latin-1 only, so we
32
+ // move byte-by-byte through `String.fromCharCode`).
33
+
34
+ export function bytesToBase64(bytes: Uint8Array): string {
35
+ let s = "";
36
+ for (let i = 0; i < bytes.length; i += 0x8000) {
37
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
38
+ }
39
+ return btoa(s);
40
+ }
41
+
42
+ export function base64ToBytes(b64: string): Uint8Array {
43
+ const bin = atob(b64);
44
+ const out = new Uint8Array(bin.length);
45
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
46
+ return out;
47
+ }
48
+
49
+ // Little-endian writers/readers — Buffer was the previous abstraction; we now
50
+ // touch DataView for portability across Node, Bun, and workerd.
51
+
52
+ function writeU32LE(view: DataView, offset: number, value: number): void {
53
+ view.setUint32(offset, value, /* littleEndian */ true);
54
+ }
55
+
56
+ function writeU64LE(view: DataView, offset: number, value: bigint): void {
57
+ view.setBigUint64(offset, value, /* littleEndian */ true);
58
+ }
59
+
60
+ function readU32LE(view: DataView, offset: number): number {
61
+ return view.getUint32(offset, /* littleEndian */ true);
62
+ }
63
+
64
+ function readU64LE(view: DataView, offset: number): bigint {
65
+ return view.getBigUint64(offset, /* littleEndian */ true);
66
+ }
67
+
68
+ function concatBytes(...parts: Uint8Array[]): Uint8Array {
69
+ let total = 0;
70
+ for (const p of parts) total += p.length;
71
+ const out = new Uint8Array(total);
72
+ let offset = 0;
73
+ for (const p of parts) {
74
+ out.set(p, offset);
75
+ offset += p.length;
76
+ }
77
+ return out;
78
+ }
10
79
 
11
80
  /**
12
- * Pack a state token matching the Python v2 wire format.
81
+ * Seal a state token with XChaCha20-Poly1305 AEAD (v4 wire format).
82
+ *
83
+ * Layout (base64-encoded):
84
+ *
85
+ * ```
86
+ * [1B version=4]
87
+ * [24B XChaCha20-Poly1305 nonce (random)]
88
+ * [.. ciphertext + 16B Poly1305 tag]
89
+ * plaintext:
90
+ * [8B created_at uint64 LE]
91
+ * [4B state_len uint32 LE] [state_len bytes]
92
+ * [4B schema_len uint32 LE] [schema_len bytes]
93
+ * [4B input_schema_len LE] [input_schema_len bytes]
94
+ * ```
13
95
  *
14
- * Layout:
15
- * [1B version=2]
16
- * [8B created_at uint64 LE (seconds since epoch)]
17
- * [4B state_len uint32 LE] [state_len bytes]
18
- * [4B schema_len uint32 LE] [schema_len bytes]
19
- * [4B input_schema_len uint32 LE] [input_schema_len bytes]
20
- * [32B HMAC-SHA256(signing_key, all above bytes)]
96
+ * `created_at` lives inside the ciphertext so TTL enforcement runs after
97
+ * authenticity. The version byte is informational (a self-describing
98
+ * format marker); a tampered version byte still fails decryption because
99
+ * we use the matching algorithm for that version. `principal` is bound
100
+ * via AEAD associated data so a token minted for one identity fails
101
+ * decryption when presented by another.
21
102
  */
22
103
  export function packStateToken(
23
104
  stateBytes: Uint8Array,
24
105
  schemaBytes: Uint8Array,
25
106
  inputSchemaBytes: Uint8Array,
26
- signingKey: Uint8Array,
107
+ tokenKey: Uint8Array,
108
+ principal: string | null | undefined,
27
109
  createdAt?: number,
28
110
  ): string {
111
+ if (tokenKey.length !== 32) {
112
+ throw new Error("XChaCha20-Poly1305 token key must be 32 bytes");
113
+ }
29
114
  const now = createdAt ?? Math.floor(Date.now() / 1000);
30
115
 
31
- const payloadLen = 1 + 8 + 4 + stateBytes.length + 4 + schemaBytes.length + 4 + inputSchemaBytes.length;
32
- const buf = Buffer.alloc(payloadLen);
116
+ const plaintextLen = 8 + 4 + stateBytes.length + 4 + schemaBytes.length + 4 + inputSchemaBytes.length;
117
+ const plaintext = new Uint8Array(plaintextLen);
118
+ const view = new DataView(plaintext.buffer);
33
119
  let offset = 0;
34
120
 
35
- // version
36
- buf.writeUInt8(TOKEN_VERSION, offset);
37
- offset += 1;
38
-
39
- // created_at as uint64 LE
40
- buf.writeBigUInt64LE(BigInt(now), offset);
121
+ writeU64LE(view, offset, BigInt(now));
41
122
  offset += 8;
42
123
 
43
- // state
44
- buf.writeUInt32LE(stateBytes.length, offset);
124
+ writeU32LE(view, offset, stateBytes.length);
45
125
  offset += 4;
46
- buf.set(stateBytes, offset);
126
+ plaintext.set(stateBytes, offset);
47
127
  offset += stateBytes.length;
48
128
 
49
- // output schema
50
- buf.writeUInt32LE(schemaBytes.length, offset);
129
+ writeU32LE(view, offset, schemaBytes.length);
51
130
  offset += 4;
52
- buf.set(schemaBytes, offset);
131
+ plaintext.set(schemaBytes, offset);
53
132
  offset += schemaBytes.length;
54
133
 
55
- // input schema
56
- buf.writeUInt32LE(inputSchemaBytes.length, offset);
134
+ writeU32LE(view, offset, inputSchemaBytes.length);
57
135
  offset += 4;
58
- buf.set(inputSchemaBytes, offset);
59
- offset += inputSchemaBytes.length;
136
+ plaintext.set(inputSchemaBytes, offset);
60
137
 
61
- // HMAC
62
- const mac = createHmac("sha256", signingKey).update(buf).digest();
63
- const token = Buffer.concat([buf, mac]);
64
-
65
- return token.toString("base64");
138
+ const wire = sealBytes(plaintext, tokenKey, { aad: computeAad(principal), version: TOKEN_VERSION });
139
+ return bytesToBase64(wire);
66
140
  }
67
141
 
142
+ /** Decrypted payload of a state token, as returned by {@link unpackStateToken}. */
68
143
  export interface UnpackedToken {
144
+ /** Serialized stream-state bytes carried by the token. */
69
145
  stateBytes: Uint8Array;
146
+ /** Serialized output-schema IPC bytes. */
70
147
  schemaBytes: Uint8Array;
148
+ /** Serialized input-schema IPC bytes (exchange streams). */
71
149
  inputSchemaBytes: Uint8Array;
150
+ /** Unix epoch seconds at which the token was minted (used for TTL checks). */
72
151
  createdAt: number;
73
152
  }
74
153
 
75
154
  /**
76
- * Unpack and verify a state token.
77
- * Throws on tampered, expired, or malformed tokens.
155
+ * Open and verify a state token. Decryption (which checks the Poly1305
156
+ * tag) authenticates the payload; any tampering, wrong key, or AAD
157
+ * mismatch (e.g. cross-principal replay) surfaces as a uniform
158
+ * "signature verification failed" error so callers cannot distinguish
159
+ * failure modes via timing or message content.
160
+ *
161
+ * Throws on tampered, expired, malformed, or unknown-version tokens.
78
162
  */
79
- export function unpackStateToken(tokenBase64: string, signingKey: Uint8Array, tokenTtl: number): UnpackedToken {
80
- const token = Buffer.from(tokenBase64, "base64");
81
-
82
- if (token.length < MIN_TOKEN_LEN) {
83
- throw new Error("State token too short");
163
+ export function unpackStateToken(
164
+ tokenBase64: string,
165
+ tokenKey: Uint8Array,
166
+ tokenTtl: number,
167
+ principal: string | null | undefined,
168
+ ): UnpackedToken {
169
+ let raw: Uint8Array;
170
+ try {
171
+ raw = base64ToBytes(tokenBase64);
172
+ } catch {
173
+ throw new Error("Malformed state token");
84
174
  }
85
-
86
- // Split payload and mac
87
- const payload = token.subarray(0, token.length - HMAC_LEN);
88
- const receivedMac = token.subarray(token.length - HMAC_LEN);
89
-
90
- // Verify HMAC first (before inspecting any fields)
91
- const expectedMac = createHmac("sha256", signingKey).update(payload).digest();
92
- if (!timingSafeEqual(receivedMac, expectedMac)) {
93
- throw new Error("State token HMAC verification failed");
175
+ // Pre-check the envelope version separately so callers can distinguish
176
+ // "wrong format" from "tampered". Mirrors the pre-refactor error shape.
177
+ if (raw.length >= 1 && raw[0] !== TOKEN_VERSION) {
178
+ throw new Error(`Unsupported state token version: ${raw[0]}`);
179
+ }
180
+ let plaintext: Uint8Array;
181
+ try {
182
+ plaintext = openBytes(raw, tokenKey, { aad: computeAad(principal), version: TOKEN_VERSION });
183
+ } catch (err) {
184
+ if (err instanceof SealError) {
185
+ throw new Error("State token signature verification failed");
186
+ }
187
+ throw err;
188
+ }
189
+ if (plaintext.length < 8) {
190
+ throw new Error("State token truncated");
94
191
  }
95
192
 
193
+ // Copy each bytes section into a freshly-allocated Uint8Array with
194
+ // byteOffset=0. arrow-js's schema deserializer wraps the result as Int32Array
195
+ // and throws 'RangeError: Byte offset is not aligned' if the slice happens
196
+ // to start at a non-4-aligned offset. Copying normalizes the alignment.
197
+ const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);
96
198
  let offset = 0;
199
+ const copyAligned = (start: number, len: number) => {
200
+ const out = new Uint8Array(len);
201
+ out.set(plaintext.subarray(start, start + len));
202
+ return out;
203
+ };
97
204
 
98
- // Version
99
- const version = payload.readUInt8(offset);
100
- offset += 1;
101
- if (version !== TOKEN_VERSION) {
102
- throw new Error(`Unsupported state token version: ${version}`);
103
- }
104
-
105
- // created_at
106
- const createdAt = Number(payload.readBigUInt64LE(offset));
205
+ const createdAt = Number(readU64LE(view, offset));
107
206
  offset += 8;
108
207
 
109
- // TTL check
110
208
  if (tokenTtl > 0) {
111
209
  const now = Math.floor(Date.now() / 1000);
112
210
  if (now - createdAt > tokenTtl) {
@@ -114,31 +212,28 @@ export function unpackStateToken(tokenBase64: string, signingKey: Uint8Array, to
114
212
  }
115
213
  }
116
214
 
117
- // state bytes
118
- const stateLen = payload.readUInt32LE(offset);
215
+ const stateLen = readU32LE(view, offset);
119
216
  offset += 4;
120
- if (offset + stateLen > payload.length) {
217
+ if (offset + stateLen > plaintext.length) {
121
218
  throw new Error("State token truncated (state)");
122
219
  }
123
- const stateBytes = payload.slice(offset, offset + stateLen);
220
+ const stateBytes = copyAligned(offset, stateLen);
124
221
  offset += stateLen;
125
222
 
126
- // output schema bytes
127
- const schemaLen = payload.readUInt32LE(offset);
223
+ const schemaLen = readU32LE(view, offset);
128
224
  offset += 4;
129
- if (offset + schemaLen > payload.length) {
225
+ if (offset + schemaLen > plaintext.length) {
130
226
  throw new Error("State token truncated (schema)");
131
227
  }
132
- const schemaBytes = payload.slice(offset, offset + schemaLen);
228
+ const schemaBytes = copyAligned(offset, schemaLen);
133
229
  offset += schemaLen;
134
230
 
135
- // input schema bytes
136
- const inputSchemaLen = payload.readUInt32LE(offset);
231
+ const inputSchemaLen = readU32LE(view, offset);
137
232
  offset += 4;
138
- if (offset + inputSchemaLen > payload.length) {
233
+ if (offset + inputSchemaLen > plaintext.length) {
139
234
  throw new Error("State token truncated (input schema)");
140
235
  }
141
- const inputSchemaBytes = payload.slice(offset, offset + inputSchemaLen);
236
+ const inputSchemaBytes = copyAligned(offset, inputSchemaLen);
142
237
 
143
238
  return { stateBytes, schemaBytes, inputSchemaBytes, createdAt };
144
239
  }
package/src/http/types.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { ExternalLocationConfig } from "../external.js";
5
- import type { DispatchHook } from "../types.js";
4
+ import type { ExternalLocationConfig, UploadUrlProvider } from "../external.js";
5
+ import type { DispatchHook, ServeStartHook } from "../types.js";
6
6
  import type { AuthenticateFn, OAuthResourceMetadata } from "./auth.js";
7
7
 
8
8
  /** Configuration options for createHttpHandler(). */
9
9
  export interface HttpHandlerOptions {
10
10
  /** URL path prefix for all endpoints. Default: "" (root). */
11
11
  prefix?: string;
12
- /** HMAC-SHA256 signing key for state tokens. Random 32 bytes if omitted. */
13
- signingKey?: Uint8Array;
12
+ /** XChaCha20-Poly1305 master key (32 bytes) used to seal stream state
13
+ * tokens. A random 32-byte key is generated if omitted (tokens won't
14
+ * survive a restart or load-balance across workers). */
15
+ tokenKey?: Uint8Array;
14
16
  /** State token time-to-live in seconds. Default: 3600 (1 hour). 0 disables TTL checks. */
15
17
  tokenTtl?: number;
16
18
  /** CORS allowed origins. If set, CORS headers are added to all responses. */
@@ -19,8 +21,29 @@ export interface HttpHandlerOptions {
19
21
  corsMaxAge?: number | null;
20
22
  /** Maximum request body size in bytes. Advertised via VGI-Max-Request-Bytes header. */
21
23
  maxRequestBytes?: number;
22
- /** Maximum bytes before a producer stream emits a continuation token. */
24
+ /** Cap on the post-decompression size of a `Content-Encoding: zstd`
25
+ * request body, in bytes. Defends against zstd decompression bombs:
26
+ * a tiny compressed frame can declare a huge decompressed size and
27
+ * blow up the server before {@link maxRequestBytes} ever sees the
28
+ * payload. When omitted, defaults to `maxRequestBytes * 16` if that
29
+ * is set, otherwise unbounded. */
30
+ maxDecompressedRequestBytes?: number;
31
+ /** Maximum bytes before a producer stream emits a continuation token.
32
+ *
33
+ * @deprecated Use {@link maxResponseBytes} instead. The cap now governs all
34
+ * HTTP method responses (unary, exchange, producer), not just producer streams.
35
+ */
23
36
  maxStreamResponseBytes?: number;
37
+ /** HTTP body cap. Hard for unary and stream-exchange (overshoot surfaces
38
+ * as 200 + X-VGI-RPC-Error EXCEPTION batch). Soft for producer streams
39
+ * (overshoot mints a continuation token). Externalised payloads do not
40
+ * count toward this — they leave only tiny pointer batches on the wire.
41
+ * Advertised via VGI-Max-Response-Bytes. Undefined = unbounded. */
42
+ maxResponseBytes?: number;
43
+ /** Cap on bytes uploaded to external storage during one HTTP response.
44
+ * Always hard — externalised uploads have no escape valve. Advertised via
45
+ * VGI-Max-Externalized-Response-Bytes. Undefined = unbounded. */
46
+ maxExternalizedResponseBytes?: number;
24
47
  /** Server ID included in response metadata. Random if omitted. */
25
48
  serverId?: string;
26
49
  /** Custom state serializer for stream state objects. Default: JSON with BigInt support. */
@@ -34,23 +57,64 @@ export interface HttpHandlerOptions {
34
57
  oauthResourceMetadata?: OAuthResourceMetadata;
35
58
  /** Optional dispatch hook for observability (tracing, metrics). */
36
59
  dispatchHook?: DispatchHook;
60
+ /** Optional lifecycle hook fired once on the first dispatched request.
61
+ * Mirrors Python's on_serve_start; lazy-firing keeps it fork-safe for
62
+ * pre-fork servers. */
63
+ onServeStart?: ServeStartHook;
37
64
  /** Enable HTML landing page at GET {prefix}/. Default: true. */
38
65
  enableLandingPage?: boolean;
39
66
  /** Enable HTML describe/API reference page at GET {prefix}/describe. Default: true. */
40
67
  enableDescribePage?: boolean;
41
68
  /** Enable HTML 404 page for unmatched GET routes. Default: true. */
42
69
  enableNotFoundPage?: boolean;
70
+ /** Enable JSON health endpoint at GET {prefix}/health. Default: true. */
71
+ enableHealthEndpoint?: boolean;
43
72
  /** Protocol name shown in HTML pages. Defaults to the Protocol's name. */
44
73
  protocolName?: string;
74
+ /** Operator-supplied protocol-contract version label, surfaced on every
75
+ * access-log record so dashboards and alerts can key off contract
76
+ * changes. Mirrors the Python `RpcServer(..., protocol_version=...)`
77
+ * argument. */
78
+ protocolVersion?: string;
45
79
  /** URL to service's source repository, shown in landing/describe pages. */
46
80
  repositoryUrl?: string;
47
81
  /** External storage config for externalizing large response batches. */
48
82
  externalLocation?: ExternalLocationConfig;
83
+ /** Provider for vending pre-signed upload URLs to clients via {prefix}/__upload_url__/init. */
84
+ uploadUrlProvider?: UploadUrlProvider;
85
+ /** Optional advertised maximum upload size, surfaced via VGI-Max-Upload-Bytes. */
86
+ maxUploadBytes?: number;
87
+ /** OAuth scope for PKCE authorization requests. Default: "openid email". */
88
+ oauthPkceScope?: string;
89
+ /** Allowed return-to origins for external frontend redirects. Default: Set(["https://cupola.query-farm.services"]). */
90
+ allowedReturnOrigins?: ReadonlySet<string>;
91
+
92
+ /** Enable opt-in sticky sessions on this HTTP handler. When enabled the
93
+ * server advertises `VGI-Sticky-Enabled: true` (capability discovery),
94
+ * honours `VGI-Session` / `VGI-Session-Accept` headers, and exposes a
95
+ * `DELETE {prefix}/__session__` teardown endpoint. Default: false. */
96
+ enableSticky?: boolean;
97
+ /** Default session TTL in seconds when `ctx.openSession` is called without
98
+ * an explicit `ttl` override. Default: 300. */
99
+ stickyDefaultTtl?: number;
100
+ /** Headers the server emits as `VGI-Echo-<name>: <value>` on the
101
+ * session-opening response. A conformant client captures them and replays
102
+ * them on every subsequent request in the session — used for
103
+ * client-driven routing (e.g. `fly-force-instance-id` on Fly.io). */
104
+ stickyEchoHeaders?: Record<string, string>;
105
+ /** Internal — invoked once at handler creation with a {@link DrainHandle}
106
+ * when sticky is enabled. Conformance fixtures use this to wire up the
107
+ * test-only `/__test_drain__` admin endpoint without the library
108
+ * exposing the registry directly. Production code should hold the
109
+ * handle returned by a future `createHttpHandlerWithDrainHandle` helper. */
110
+ _onStickyHandle?: (handle: import("./sticky.js").DrainHandle) => void;
49
111
  }
50
112
 
51
113
  /** Serializer for stream state objects stored in state tokens. */
52
114
  export interface StateSerializer {
115
+ /** Encode a stream-state object into the bytes sealed inside a state token. */
53
116
  serialize(state: any): Uint8Array;
117
+ /** Decode the bytes recovered from a state token back into a state object. */
54
118
  deserialize(bytes: Uint8Array): any;
55
119
  }
56
120
 
package/src/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ export { AccessLogHook, type AccessLogSink, FdSink } from "./access-log.js";
4
5
  export { AuthContext } from "./auth.js";
5
6
  export * from "./client/index.js";
6
7
  export {
7
8
  DESCRIBE_METHOD_NAME,
8
9
  DESCRIBE_VERSION,
9
10
  DESCRIBE_VERSION_KEY,
11
+ ERROR_KIND_KEY,
10
12
  LOG_EXTRA_KEY,
11
13
  LOG_LEVEL_KEY,
12
14
  LOG_MESSAGE_KEY,
@@ -14,11 +16,21 @@ export {
14
16
  REQUEST_ID_KEY,
15
17
  REQUEST_VERSION,
16
18
  REQUEST_VERSION_KEY,
19
+ RPC_ERROR_HEADER,
17
20
  RPC_METHOD_KEY,
18
21
  SERVER_ID_KEY,
19
22
  STATE_KEY,
20
23
  } from "./constants.js";
21
- export { RpcError, VersionError } from "./errors.js";
24
+ export {
25
+ ERROR_KIND_METHOD_NOT_IMPLEMENTED,
26
+ ERROR_KIND_SERVER_DRAINING,
27
+ ERROR_KIND_SESSION_LOST,
28
+ MethodNotImplementedError,
29
+ RpcError,
30
+ ServerDrainingError,
31
+ SessionLostError,
32
+ VersionError,
33
+ } from "./errors.js";
22
34
  export {
23
35
  type ExternalLocationConfig,
24
36
  type ExternalStorage,
@@ -54,6 +66,25 @@ export {
54
66
  type XfccElement,
55
67
  type XfccValidateFn,
56
68
  } from "./http/index.js";
69
+ export {
70
+ acquireLock,
71
+ computeHash as launcherComputeHash,
72
+ defaultStateDir,
73
+ type FileLockHandle,
74
+ type GcResult,
75
+ gcStateDir,
76
+ type LaunchConfig,
77
+ launch,
78
+ probeSocket,
79
+ type ServeUnixHandle,
80
+ type ServeUnixOptions,
81
+ type SocketPaths,
82
+ type StatusRow,
83
+ serveUnix,
84
+ socketPaths,
85
+ statusRows,
86
+ tryAcquireLock,
87
+ } from "./launcher/index.js";
57
88
  export { Protocol } from "./protocol.js";
58
89
  export {
59
90
  bool,
@@ -62,10 +93,16 @@ export {
62
93
  float32,
63
94
  inferParamTypes,
64
95
  int,
96
+ int8,
97
+ int16,
65
98
  int32,
66
99
  type SchemaLike,
67
100
  str,
68
101
  toSchema,
102
+ uint8,
103
+ uint16,
104
+ uint32,
105
+ uint64,
69
106
  } from "./schema.js";
70
107
  export { VgiRpcServer } from "./server.js";
71
108
  export {
@@ -83,5 +120,7 @@ export {
83
120
  OutputCollector,
84
121
  type ProducerFn,
85
122
  type ProducerInit,
123
+ type ServeStartHook,
124
+ TransportKind,
86
125
  type UnaryHandler,
87
126
  } from "./types.js";
@@ -0,0 +1,104 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Hash a worker tuple (argv + cwd + filtered env) into a deterministic
6
+ * 16-hex-character identifier.
7
+ *
8
+ * Cross-language contract — must match Python's `vgi_rpc.launcher.compute_hash`
9
+ * byte-for-byte so the same worker tuple resolves to the same socket path
10
+ * regardless of which language's launcher discovered it first. The
11
+ * canonical form is:
12
+ *
13
+ * ```python
14
+ * canonical = {
15
+ * "cmd": list(worker_argv),
16
+ * "cwd": cwd if cwd is not None else os.getcwd(),
17
+ * "env": {k: v for k, v in sorted(os.environ.items()) if k.startswith("VGI_RPC_")},
18
+ * }
19
+ * payload = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
20
+ * sha256(payload).hexdigest()[:16]
21
+ * ```
22
+ *
23
+ * `scripts/regenerate_launcher_parity_vectors.py` in vgi-rpc-python emits a
24
+ * golden vector table; the parity test in `test/launcher.hash.test.ts`
25
+ * asserts byte equality against it.
26
+ */
27
+
28
+ const HASH_LEN = 16;
29
+
30
+ /** Recursively stringify with sorted object keys and `,`/`:` separators —
31
+ * the JS equivalent of `json.dumps(..., sort_keys=True, separators=(",",":"))`.
32
+ * We can't reuse `JSON.stringify` because the V8 implementation preserves
33
+ * insertion order rather than sorting. */
34
+ function canonicalJson(value: unknown): string {
35
+ if (value === null) return "null";
36
+ if (typeof value === "boolean") return value ? "true" : "false";
37
+ if (typeof value === "number") {
38
+ // Python json emits integers without a trailing `.0` and floats with the
39
+ // shortest round-trippable form. JS `JSON.stringify` matches this for
40
+ // both integer-valued and finite floats; `Infinity`/`NaN` would diverge
41
+ // (Python raises) but they shouldn't occur in launcher payloads.
42
+ return JSON.stringify(value);
43
+ }
44
+ if (typeof value === "string") return JSON.stringify(value);
45
+ if (Array.isArray(value)) {
46
+ return `[${value.map(canonicalJson).join(",")}]`;
47
+ }
48
+ if (typeof value === "object") {
49
+ const keys = Object.keys(value as Record<string, unknown>).sort();
50
+ const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`);
51
+ return `{${parts.join(",")}}`;
52
+ }
53
+ throw new TypeError(`canonicalJson: unsupported type ${typeof value}`);
54
+ }
55
+
56
+ async function sha256Hex(data: Uint8Array): Promise<string> {
57
+ // Web Crypto digest accepts BufferSource; copy into a fresh ArrayBuffer
58
+ // to dodge SharedArrayBuffer constraints in some runtimes.
59
+ const buf = new ArrayBuffer(data.byteLength);
60
+ new Uint8Array(buf).set(data);
61
+ const digest = await crypto.subtle.digest("SHA-256", buf);
62
+ return Array.from(new Uint8Array(digest))
63
+ .map((b) => b.toString(16).padStart(2, "0"))
64
+ .join("");
65
+ }
66
+
67
+ /**
68
+ * Compute the 16-hex-char tuple hash for a worker.
69
+ *
70
+ * @param workerArgv The worker command and its arguments.
71
+ * @param cwd Working directory; defaults to `process.cwd()`.
72
+ * @param env Process environment; defaults to `process.env`. Only
73
+ * keys starting with `VGI_RPC_` participate in the hash —
74
+ * workers that differ only in unrelated env (PATH,
75
+ * HOME, …) intentionally share a worker.
76
+ */
77
+ export async function computeHash(
78
+ workerArgv: readonly string[],
79
+ cwd?: string,
80
+ env?: Record<string, string | undefined>,
81
+ ): Promise<string> {
82
+ const cwdValue = cwd !== undefined ? cwd : process.cwd();
83
+ const sourceEnv = env ?? (process.env as Record<string, string | undefined>);
84
+
85
+ const filteredEnv: Record<string, string> = {};
86
+ for (const key of Object.keys(sourceEnv)) {
87
+ if (key.startsWith("VGI_RPC_")) {
88
+ const v = sourceEnv[key];
89
+ if (v !== undefined) filteredEnv[key] = v;
90
+ }
91
+ }
92
+
93
+ const canonical = {
94
+ cmd: [...workerArgv],
95
+ cwd: cwdValue,
96
+ env: filteredEnv,
97
+ };
98
+ const payload = new TextEncoder().encode(canonicalJson(canonical));
99
+ const hex = await sha256Hex(payload);
100
+ return hex.slice(0, HASH_LEN);
101
+ }
102
+
103
+ /** Exposed for parity-test fixtures that need to inspect the canonical form. */
104
+ export const _internal = { canonicalJson };
@@ -0,0 +1,35 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * AF_UNIX worker launcher — TypeScript port of `vgi_rpc.launcher`.
6
+ *
7
+ * Two halves:
8
+ *
9
+ * - **Coordination** ({@link launch}, {@link computeHash}, {@link gcStateDir},
10
+ * {@link statusRows}): spawn-or-reuse a long-running worker process for a
11
+ * given command tuple, returning the AF_UNIX socket path the caller
12
+ * should connect to. Hash and on-disk layout match the Python
13
+ * implementation byte-for-byte so workers in any language under the
14
+ * same tuple resolve to the same socket.
15
+ *
16
+ * - **Worker runner** ({@link serveUnix}): bind a Unix socket and serve a
17
+ * {@link Protocol} via per-connection IPC streams. Implements the
18
+ * `--unix PATH` / `--idle-timeout SEC` / `UNIX:<path>` contract so
19
+ * launchers (Python or TS) can spawn TS workers transparently.
20
+ */
21
+
22
+ export { computeHash } from "./hash.js";
23
+ export { type LaunchConfig, launch } from "./launch.js";
24
+ export { acquireLock, type FileLockHandle, tryAcquireLock } from "./lock.js";
25
+ export { type ServeUnixHandle, type ServeUnixOptions, serveUnix } from "./serve-unix.js";
26
+ export {
27
+ defaultStateDir,
28
+ type GcResult,
29
+ gcStateDir,
30
+ probeSocket,
31
+ type SocketPaths,
32
+ type StatusRow,
33
+ socketPaths,
34
+ statusRows,
35
+ } from "./state.js";