@query-farm/vgi-rpc 0.6.3 → 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 +68 -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 -3507
  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 +794 -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 +69 -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/token.ts CHANGED
@@ -1,68 +1,142 @@
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
 
68
142
  export interface UnpackedToken {
@@ -73,40 +147,59 @@ export interface UnpackedToken {
73
147
  }
74
148
 
75
149
  /**
76
- * Unpack and verify a state token.
77
- * Throws on tampered, expired, or malformed tokens.
150
+ * Open and verify a state token. Decryption (which checks the Poly1305
151
+ * tag) authenticates the payload; any tampering, wrong key, or AAD
152
+ * mismatch (e.g. cross-principal replay) surfaces as a uniform
153
+ * "signature verification failed" error so callers cannot distinguish
154
+ * failure modes via timing or message content.
155
+ *
156
+ * Throws on tampered, expired, malformed, or unknown-version tokens.
78
157
  */
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");
158
+ export function unpackStateToken(
159
+ tokenBase64: string,
160
+ tokenKey: Uint8Array,
161
+ tokenTtl: number,
162
+ principal: string | null | undefined,
163
+ ): UnpackedToken {
164
+ let raw: Uint8Array;
165
+ try {
166
+ raw = base64ToBytes(tokenBase64);
167
+ } catch {
168
+ throw new Error("Malformed state token");
84
169
  }
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");
170
+ // Pre-check the envelope version separately so callers can distinguish
171
+ // "wrong format" from "tampered". Mirrors the pre-refactor error shape.
172
+ if (raw.length >= 1 && raw[0] !== TOKEN_VERSION) {
173
+ throw new Error(`Unsupported state token version: ${raw[0]}`);
174
+ }
175
+ let plaintext: Uint8Array;
176
+ try {
177
+ plaintext = openBytes(raw, tokenKey, { aad: computeAad(principal), version: TOKEN_VERSION });
178
+ } catch (err) {
179
+ if (err instanceof SealError) {
180
+ throw new Error("State token signature verification failed");
181
+ }
182
+ throw err;
183
+ }
184
+ if (plaintext.length < 8) {
185
+ throw new Error("State token truncated");
94
186
  }
95
187
 
188
+ // Copy each bytes section into a freshly-allocated Uint8Array with
189
+ // byteOffset=0. arrow-js's schema deserializer wraps the result as Int32Array
190
+ // and throws 'RangeError: Byte offset is not aligned' if the slice happens
191
+ // to start at a non-4-aligned offset. Copying normalizes the alignment.
192
+ const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);
96
193
  let offset = 0;
194
+ const copyAligned = (start: number, len: number) => {
195
+ const out = new Uint8Array(len);
196
+ out.set(plaintext.subarray(start, start + len));
197
+ return out;
198
+ };
97
199
 
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));
200
+ const createdAt = Number(readU64LE(view, offset));
107
201
  offset += 8;
108
202
 
109
- // TTL check
110
203
  if (tokenTtl > 0) {
111
204
  const now = Math.floor(Date.now() / 1000);
112
205
  if (now - createdAt > tokenTtl) {
@@ -114,31 +207,28 @@ export function unpackStateToken(tokenBase64: string, signingKey: Uint8Array, to
114
207
  }
115
208
  }
116
209
 
117
- // state bytes
118
- const stateLen = payload.readUInt32LE(offset);
210
+ const stateLen = readU32LE(view, offset);
119
211
  offset += 4;
120
- if (offset + stateLen > payload.length) {
212
+ if (offset + stateLen > plaintext.length) {
121
213
  throw new Error("State token truncated (state)");
122
214
  }
123
- const stateBytes = payload.slice(offset, offset + stateLen);
215
+ const stateBytes = copyAligned(offset, stateLen);
124
216
  offset += stateLen;
125
217
 
126
- // output schema bytes
127
- const schemaLen = payload.readUInt32LE(offset);
218
+ const schemaLen = readU32LE(view, offset);
128
219
  offset += 4;
129
- if (offset + schemaLen > payload.length) {
220
+ if (offset + schemaLen > plaintext.length) {
130
221
  throw new Error("State token truncated (schema)");
131
222
  }
132
- const schemaBytes = payload.slice(offset, offset + schemaLen);
223
+ const schemaBytes = copyAligned(offset, schemaLen);
133
224
  offset += schemaLen;
134
225
 
135
- // input schema bytes
136
- const inputSchemaLen = payload.readUInt32LE(offset);
226
+ const inputSchemaLen = readU32LE(view, offset);
137
227
  offset += 4;
138
- if (offset + inputSchemaLen > payload.length) {
228
+ if (offset + inputSchemaLen > plaintext.length) {
139
229
  throw new Error("State token truncated (input schema)");
140
230
  }
141
- const inputSchemaBytes = payload.slice(offset, offset + inputSchemaLen);
231
+ const inputSchemaBytes = copyAligned(offset, inputSchemaLen);
142
232
 
143
233
  return { stateBytes, schemaBytes, inputSchemaBytes, createdAt };
144
234
  }
package/src/http/types.ts CHANGED
@@ -1,24 +1,49 @@
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. */
17
19
  corsOrigins?: string;
20
+ /** Access-Control-Max-Age value in seconds for preflight OPTIONS responses. Default: 7200 (2 hours). null omits the header. */
21
+ corsMaxAge?: number | null;
18
22
  /** Maximum request body size in bytes. Advertised via VGI-Max-Request-Bytes header. */
19
23
  maxRequestBytes?: number;
20
- /** 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
+ */
21
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;
22
47
  /** Server ID included in response metadata. Random if omitted. */
23
48
  serverId?: string;
24
49
  /** Custom state serializer for stream state objects. Default: JSON with BigInt support. */
@@ -32,18 +57,57 @@ export interface HttpHandlerOptions {
32
57
  oauthResourceMetadata?: OAuthResourceMetadata;
33
58
  /** Optional dispatch hook for observability (tracing, metrics). */
34
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;
35
64
  /** Enable HTML landing page at GET {prefix}/. Default: true. */
36
65
  enableLandingPage?: boolean;
37
66
  /** Enable HTML describe/API reference page at GET {prefix}/describe. Default: true. */
38
67
  enableDescribePage?: boolean;
39
68
  /** Enable HTML 404 page for unmatched GET routes. Default: true. */
40
69
  enableNotFoundPage?: boolean;
70
+ /** Enable JSON health endpoint at GET {prefix}/health. Default: true. */
71
+ enableHealthEndpoint?: boolean;
41
72
  /** Protocol name shown in HTML pages. Defaults to the Protocol's name. */
42
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;
43
79
  /** URL to service's source repository, shown in landing/describe pages. */
44
80
  repositoryUrl?: string;
45
81
  /** External storage config for externalizing large response batches. */
46
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;
47
111
  }
48
112
 
49
113
  /** Serializer for stream state objects stored in state tokens. */
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";