@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
@@ -1,26 +1,94 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { randomBytes } from "node:crypto";
5
- import { Schema } from "@query-farm/apache-arrow";
4
+ import {
5
+ batchFromColumns,
6
+ deserializeBatch,
7
+ field,
8
+ schema as makeSchema,
9
+ timestampMicro,
10
+ utf8,
11
+ type VgiSchema,
12
+ } from "../arrow/index.js";
6
13
  import type { AuthContext } from "../auth.js";
7
- import { DESCRIBE_METHOD_NAME } from "../constants.js";
14
+ import { DESCRIBE_METHOD_NAME, PROTOCOL_HASH_KEY, PROTOCOL_VERSION_KEY, RPC_ERROR_HEADER } from "../constants.js";
15
+ import { buildDescribeBatch } from "../dispatch/describe.js";
16
+ import { MethodNotImplementedError, ProtocolVersionError, parseProtocolVersion, SessionLostError } from "../errors.js";
8
17
  import type { Protocol } from "../protocol.js";
9
- import { type CallStatistics, type DispatchInfo, MethodType } from "../types.js";
10
- import { zstdCompress, zstdDecompress } from "../util/zstd.js";
18
+ import { type CallStatistics, type DispatchInfo, MethodType, type ServeStartHook, TransportKind } from "../types.js";
19
+ import { gzipCompress, gzipDecompress } from "../util/gzip.js";
20
+ import { randomBytes } from "../util/web-crypto.js";
21
+ import { isZstdCompressAvailable, zstdCompress, zstdDecompress } from "../util/zstd.js";
22
+ import { parseRequest } from "../wire/request.js";
11
23
  import { buildErrorBatch } from "../wire/response.js";
12
24
  import { buildWwwAuthenticateHeader, oauthResourceMetadataToJson, wellKnownPath } from "./auth.js";
13
- import { ARROW_CONTENT_TYPE, arrowResponse, HttpRpcError, serializeIpcStream } from "./common.js";
25
+ import { chainAuthenticate } from "./bearer.js";
26
+ import {
27
+ ARROW_CONTENT_TYPE,
28
+ arrowResponse,
29
+ ECHO_HEADER_PREFIX,
30
+ HttpRpcError,
31
+ readRequestFromBody as readRequestFromBodyImported,
32
+ SESSION_ACCEPT_HEADER,
33
+ SESSION_CLOSE_HEADER,
34
+ SESSION_ENDPOINT,
35
+ SESSION_HEADER,
36
+ STICKY_DEFAULT_TTL_HEADER,
37
+ STICKY_ECHO_HEADERS_HEADER,
38
+ STICKY_ENABLED_HEADER,
39
+ serializeIpcStream,
40
+ } from "./common.js";
14
41
  import {
15
42
  httpDispatchDescribe,
16
43
  httpDispatchStreamExchange,
17
44
  httpDispatchStreamInit,
18
45
  httpDispatchUnary,
19
46
  } from "./dispatch.js";
47
+ import {
48
+ configureOAuthPkce,
49
+ handleBrowserGetRedirect,
50
+ handleEarlyReturnTo,
51
+ handleOAuthCallback,
52
+ handleOAuthLogout,
53
+ handleOAuthTokenProxy,
54
+ type OAuthPkceConfig,
55
+ resolvePkceScope,
56
+ } from "./oauth-pkce.js";
20
57
  import { buildDescribePage, buildLandingPage, buildNotFoundPage } from "./pages.js";
58
+ import {
59
+ makeDrainHandle,
60
+ openSessionToken,
61
+ SessionRegistry,
62
+ type StickySink,
63
+ sealSessionToken,
64
+ sessionIdHex,
65
+ sessionPrincipalKey,
66
+ startSessionReaper,
67
+ } from "./sticky.js";
68
+ import { computeAad } from "./token.js";
21
69
  import { type HttpHandlerOptions, jsonStateSerializer } from "./types.js";
22
70
 
23
- const EMPTY_SCHEMA = new Schema([]);
71
+ const EMPTY_SCHEMA: VgiSchema = makeSchema([]);
72
+
73
+ const EMPTY_COOKIES: ReadonlyMap<string, string> = new Map();
74
+
75
+ /**
76
+ * Parse the Cookie request header into a Map. Returns an empty map when
77
+ * the header is absent.
78
+ */
79
+ function parseRequestCookies(request: Request): ReadonlyMap<string, string> {
80
+ const header = request.headers.get("cookie");
81
+ if (!header) return EMPTY_COOKIES;
82
+ const out = new Map<string, string>();
83
+ for (const part of header.split(";")) {
84
+ const pair = part.trim();
85
+ if (!pair) continue;
86
+ const eq = pair.indexOf("=");
87
+ if (eq < 0) continue;
88
+ out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
89
+ }
90
+ return out;
91
+ }
24
92
 
25
93
  /**
26
94
  * Create a fetch-compatible HTTP handler for a vgi-rpc Protocol.
@@ -39,22 +107,163 @@ export function createHttpHandler(
39
107
  options?: HttpHandlerOptions,
40
108
  ): (request: Request) => Response | Promise<Response> {
41
109
  const prefix = (options?.prefix ?? "").replace(/\/+$/, "");
42
- const signingKey = options?.signingKey ?? randomBytes(32);
110
+ const tokenKey = options?.tokenKey ?? randomBytes(32);
43
111
  const tokenTtl = options?.tokenTtl ?? 3600;
44
112
  const corsOrigins = options?.corsOrigins;
113
+ const corsMaxAge = options?.corsMaxAge === undefined ? 7200 : options.corsMaxAge;
45
114
  const maxRequestBytes = options?.maxRequestBytes;
115
+ // Bomb-cap on `Content-Encoding: zstd` decompression. Default to
116
+ // 16x maxRequestBytes when the operator set one — generous for normal
117
+ // Arrow IPC zstd ratios on legitimate payloads, tight enough that a
118
+ // tiny compressed body cannot inflate to hundreds of MB. When
119
+ // maxRequestBytes is unset the cap stays unbounded (operator-chosen,
120
+ // explicit). Mirrors Python's make_wsgi_app default.
121
+ const maxDecompressedRequestBytes =
122
+ options?.maxDecompressedRequestBytes ??
123
+ (options?.maxRequestBytes != null ? options.maxRequestBytes * 16 : undefined);
124
+ // ``maxStreamResponseBytes`` was the producer-only soft cap. Keep it
125
+ // distinct from ``maxResponseBytes`` (the new hard cap that also applies
126
+ // to unary/exchange) — falling one through to the other would turn the
127
+ // producer hack into an unintended hard cap on every response.
46
128
  const maxStreamResponseBytes = options?.maxStreamResponseBytes;
129
+ const maxResponseBytes = options?.maxResponseBytes;
130
+ const maxExternalizedResponseBytes = options?.maxExternalizedResponseBytes;
47
131
  const serverId = options?.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
48
132
 
49
- const authenticate = options?.authenticate;
133
+ let authenticate = options?.authenticate;
50
134
  const oauthMetadata = options?.oauthResourceMetadata;
51
135
 
136
+ // PKCE setup: when both authenticate and oauthMetadata.clientId are present
137
+ let pkceConfig: OAuthPkceConfig | null = null;
138
+ if (authenticate && oauthMetadata?.clientId) {
139
+ const resourceUrl = new URL(oauthMetadata.resource);
140
+ const secureCookie = resourceUrl.protocol === "https:";
141
+ const redirectUri = `${oauthMetadata.resource.replace(/\/+$/, "")}${prefix}/_oauth/callback`;
142
+ const issuer = oauthMetadata.authorizationServers[0];
143
+ if (issuer) {
144
+ const originalAuth = authenticate;
145
+ pkceConfig = configureOAuthPkce(
146
+ {
147
+ signingKey: tokenKey,
148
+ issuer,
149
+ clientId: oauthMetadata.clientId,
150
+ clientSecret: oauthMetadata.clientSecret,
151
+ useIdToken: oauthMetadata.useIdTokenAsBearer,
152
+ prefix,
153
+ secureCookie,
154
+ redirectUri,
155
+ scope: resolvePkceScope(oauthMetadata.scopesSupported, options?.oauthPkceScope),
156
+ allowedReturnOrigins: options?.allowedReturnOrigins,
157
+ },
158
+ originalAuth,
159
+ );
160
+ authenticate = chainAuthenticate(originalAuth, pkceConfig.cookieAuthenticate);
161
+ }
162
+ }
163
+
52
164
  const methods = protocol.getMethods();
53
165
 
166
+ // Lazily compute the protocol hash once; it's the SHA-256 over the
167
+ // canonical __describe__ payload and is derived from buildDescribeBatch's
168
+ // metadata. Async because Web Crypto digests are async. Used to stamp
169
+ // every dispatched access-log record with `protocol_hash`.
170
+ let protocolHashPromise: Promise<string> | null = null;
171
+ function getProtocolHash(): Promise<string> {
172
+ if (!protocolHashPromise) {
173
+ protocolHashPromise = buildDescribeBatch(
174
+ protocol.name,
175
+ methods,
176
+ serverId,
177
+ protocol.protocolVersion || undefined,
178
+ ).then(({ metadata }) => metadata.get(PROTOCOL_HASH_KEY) ?? "");
179
+ }
180
+ return protocolHashPromise;
181
+ }
182
+ const protocolVersion = protocol.protocolVersion || options?.protocolVersion || "";
183
+
184
+ // Dispatch-boundary protocol_version check, fires only when the Protocol
185
+ // declares a `protocolVersion`. Mirrors Python's HTTP _app_unary /
186
+ // _app_stream gate added after the dispatch-loop bypass was caught in
187
+ // review. Throws ProtocolVersionError so the existing catch turns it into
188
+ // a buffered error stream rather than a raw HTTP 500.
189
+ function enforceProtocolVersion(reqBatchMeta: ReadonlyMap<string, string> | undefined): void {
190
+ const parts = protocol.protocolVersionParts;
191
+ if (parts === null) return;
192
+ const serverVersion = protocol.protocolVersion;
193
+ const clientVersion = reqBatchMeta?.get(PROTOCOL_VERSION_KEY);
194
+ if (clientVersion === undefined) {
195
+ throw new ProtocolVersionError(
196
+ "VGI client/worker protocol_version mismatch.\n" +
197
+ " Client: <not declared>\n" +
198
+ ` Server: ${serverVersion}\n` +
199
+ " Direction: the client did not send a vgi_rpc.protocol_version " +
200
+ "metadata key. This is either a vgi-rpc framework bug or a " +
201
+ "non-VGI client connecting to a VGI worker.",
202
+ );
203
+ }
204
+ let clientParts: readonly [number, number, number];
205
+ try {
206
+ clientParts = parseProtocolVersion(clientVersion);
207
+ } catch {
208
+ throw new ProtocolVersionError(
209
+ "VGI client/worker protocol_version mismatch.\n" +
210
+ ` Client: ${clientVersion}\n` +
211
+ ` Server: ${serverVersion}\n` +
212
+ " Direction: client sent a malformed protocol_version. " +
213
+ "Expected canonical semver MAJOR.MINOR.PATCH.",
214
+ );
215
+ }
216
+ if (clientParts[0] === parts[0] && clientParts[1] === parts[1]) return;
217
+ const clientOlder = clientParts[0] < parts[0] || (clientParts[0] === parts[0] && clientParts[1] < parts[1]);
218
+ const direction = clientOlder
219
+ ? `client is too old; upgrade the VGI extension/client to a version supporting protocol_version ${serverVersion}.`
220
+ : `server is too old; upgrade the VGI worker to a version supporting protocol_version ${clientVersion}.`;
221
+ throw new ProtocolVersionError(
222
+ "VGI client/worker protocol_version mismatch.\n" +
223
+ ` Client: ${clientVersion}\n` +
224
+ ` Server: ${serverVersion}\n` +
225
+ ` Direction: ${direction}`,
226
+ );
227
+ }
228
+
54
229
  const compressionLevel = options?.compressionLevel;
55
230
  const stateSerializer = options?.stateSerializer ?? jsonStateSerializer;
56
231
  const dispatchHook = options?.dispatchHook;
57
232
 
233
+ // Lazy on_serve_start firing — mirrors Python's middleware shape.
234
+ // The bind is committed only after the hook returns successfully so a
235
+ // transient failure on the first request leaves it un-fired and the
236
+ // next request retries (matches Python 7b3999c). Multiple
237
+ // simultaneous first-callers serialize on the in-flight promise.
238
+ const onServeStart: ServeStartHook | null = options?.onServeStart ?? null;
239
+ let serveStartFired = false;
240
+ let serveStartInFlight: Promise<void> | null = null;
241
+ // The transport kind reported to access-log + dispatch hooks. Default
242
+ // to HTTP; the launcher path (createUnixHandler) overrides this in a
243
+ // future commit.
244
+ const transportKind: TransportKind =
245
+ (options as { _transportKind?: TransportKind })?._transportKind ?? TransportKind.HTTP;
246
+ async function notifyTransport(kind: TransportKind): Promise<void> {
247
+ if (serveStartFired) return;
248
+ if (serveStartInFlight) {
249
+ await serveStartInFlight;
250
+ return;
251
+ }
252
+ if (!onServeStart) {
253
+ serveStartFired = true;
254
+ return;
255
+ }
256
+ serveStartInFlight = (async () => {
257
+ try {
258
+ await onServeStart(kind);
259
+ serveStartFired = true;
260
+ } finally {
261
+ serveStartInFlight = null;
262
+ }
263
+ })();
264
+ await serveStartInFlight;
265
+ }
266
+
58
267
  // HTML page configuration
59
268
  const enableLandingPage = options?.enableLandingPage ?? true;
60
269
  const enableDescribePage = options?.enableDescribePage ?? true;
@@ -63,46 +272,163 @@ export function createHttpHandler(
63
272
  const repoUrl = options?.repositoryUrl ?? null;
64
273
 
65
274
  // Pre-render HTML pages for zero per-request overhead
66
- const landingHtml = enableLandingPage
275
+ let landingHtml = enableLandingPage
67
276
  ? buildLandingPage(displayName, serverId, enableDescribePage ? `${prefix}/describe` : null, repoUrl)
68
277
  : null;
69
- const describeHtml = enableDescribePage ? buildDescribePage(displayName, serverId, methods, repoUrl) : null;
278
+ let describeHtml = enableDescribePage ? buildDescribePage(displayName, serverId, methods, repoUrl) : null;
70
279
  const notFoundHtml = enableNotFoundPage ? buildNotFoundPage(prefix, displayName) : null;
71
280
 
281
+ // Inject user-info HTML snippet when PKCE is active
282
+ if (pkceConfig) {
283
+ const snippet = pkceConfig.userInfoHtml;
284
+ if (landingHtml) {
285
+ landingHtml = landingHtml.replace("</body>", `${snippet}\n</body>`);
286
+ }
287
+ if (describeHtml) {
288
+ describeHtml = describeHtml.replace("</body>", `${snippet}\n</body>`);
289
+ }
290
+ }
291
+
72
292
  const externalLocation = options?.externalLocation;
293
+ const uploadUrlProvider = options?.uploadUrlProvider;
294
+ const maxUploadBytes = options?.maxUploadBytes;
295
+
296
+ // Pre-built response schema for the synthetic __upload_url__ endpoint.
297
+ const UPLOAD_URL_RESPONSE_SCHEMA = makeSchema([
298
+ field("upload_url", utf8(), false),
299
+ field("download_url", utf8(), false),
300
+ field("expires_at", timestampMicro("UTC"), false),
301
+ ]);
302
+ const UPLOAD_URL_METHOD = "__upload_url__";
303
+ const MAX_UPLOAD_URL_COUNT = 100;
304
+
305
+ // -------- Sticky session machinery --------
306
+ const stickyEnabled = options?.enableSticky === true;
307
+ const stickyDefaultTtl = options?.stickyDefaultTtl ?? 300;
308
+ // Frozen snapshot so per-response emission doesn't re-read a mutable
309
+ // operator dict mid-request.
310
+ const stickyEchoHeadersArr: Array<[string, string]> = stickyEnabled
311
+ ? Object.entries(options?.stickyEchoHeaders ?? {})
312
+ : [];
313
+ const sessionRegistry = stickyEnabled ? new SessionRegistry(stickyDefaultTtl) : null;
314
+ // Reaper interval is unref'd (won't block process exit) but is still a live
315
+ // resource — `DrainHandle.shutdown()` clears it via this stop fn so callers
316
+ // that drain explicitly (tests, worker-exit hooks) don't leak the interval.
317
+ const stopReaper = sessionRegistry ? startSessionReaper(sessionRegistry) : null;
318
+ if (options?._onStickyHandle && sessionRegistry) {
319
+ options._onStickyHandle(makeDrainHandle(sessionRegistry, stopReaper ?? undefined));
320
+ }
321
+
322
+ // Encodings the server can produce on the response side. Mirrors
323
+ // Python's `VGI-Supported-Encodings` header from `_codec.py`.
324
+ // `compressionLevel` gates response compression overall; zstd is only
325
+ // advertised when the runtime can actually encode it (Bun, Node ≥22.15,
326
+ // Deno ≥2.6.9 — workerd lacks an encoder). gzip is always available via
327
+ // Web `CompressionStream`.
328
+ const supportedResponseEncodings: string[] = [];
329
+ const zstdResponseAvailable = compressionLevel != null && isZstdCompressAvailable();
330
+ if (zstdResponseAvailable) {
331
+ supportedResponseEncodings.push("zstd");
332
+ }
333
+ if (compressionLevel != null) {
334
+ supportedResponseEncodings.push("gzip");
335
+ }
336
+
337
+ /** Append capability headers (advertised on every response when configured). */
338
+ function addCapabilityHeaders(headers: Headers, isOptions = false): void {
339
+ if (supportedResponseEncodings.length) {
340
+ headers.set("VGI-Supported-Encodings", supportedResponseEncodings.join(", "));
341
+ }
342
+ if (maxRequestBytes != null) {
343
+ headers.set("VGI-Max-Request-Bytes", String(maxRequestBytes));
344
+ }
345
+ if (maxResponseBytes != null) {
346
+ headers.set("VGI-Max-Response-Bytes", String(maxResponseBytes));
347
+ }
348
+ if (maxExternalizedResponseBytes != null) {
349
+ headers.set("VGI-Max-Externalized-Response-Bytes", String(maxExternalizedResponseBytes));
350
+ }
351
+ // Always present so capability-aware clients can decide whether to
352
+ // expect externalised payloads.
353
+ headers.set("VGI-Externalization-Enabled", externalLocation?.storage ? "true" : "false");
354
+ if (uploadUrlProvider) {
355
+ headers.set("VGI-Upload-URL-Support", "true");
356
+ if (maxUploadBytes != null) {
357
+ headers.set("VGI-Max-Upload-Bytes", String(maxUploadBytes));
358
+ }
359
+ }
360
+ if (stickyEnabled) {
361
+ headers.set(STICKY_ENABLED_HEADER, "true");
362
+ headers.set(STICKY_DEFAULT_TTL_HEADER, String(Math.floor(stickyDefaultTtl)));
363
+ if (stickyEchoHeadersArr.length > 0) {
364
+ headers.set(STICKY_ECHO_HEADERS_HEADER, stickyEchoHeadersArr.map(([k]) => k).join(", "));
365
+ }
366
+ }
367
+ if (isOptions && (maxRequestBytes != null || uploadUrlProvider || stickyEnabled)) {
368
+ // Match Python: cache discovery results for 5 minutes.
369
+ if (!headers.has("Cache-Control")) {
370
+ headers.set("Cache-Control", "public, max-age=300");
371
+ }
372
+ }
373
+ }
73
374
 
74
375
  // ctx is built per-request to include authContext; base fields set here
75
376
  const baseCtx = {
76
- signingKey,
377
+ tokenKey,
77
378
  tokenTtl,
78
379
  serverId,
79
380
  maxStreamResponseBytes,
381
+ maxResponseBytes,
382
+ maxExternalizedResponseBytes,
80
383
  stateSerializer,
81
384
  externalLocation,
385
+ kind: transportKind,
82
386
  };
83
387
 
84
- function addCorsHeaders(headers: Headers): void {
388
+ function addCorsHeaders(headers: Headers, isOptions = false, requestedHeaders?: string | null): void {
85
389
  if (corsOrigins) {
86
390
  headers.set("Access-Control-Allow-Origin", corsOrigins);
87
391
  headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
88
- headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
89
- headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, X-Request-ID, X-VGI-Content-Encoding");
392
+ // Reflect the preflight's requested headers so clients may send custom
393
+ // VGI request headers (e.g. x-vgi-accept-encoding, VGI-Session) without a
394
+ // hard-coded allow-list. Mirrors the Python framework, whose preflight
395
+ // echoes Access-Control-Request-Headers. Falls back to the common pair.
396
+ headers.set(
397
+ "Access-Control-Allow-Headers",
398
+ requestedHeaders && requestedHeaders.length > 0 ? requestedHeaders : "Content-Type, Authorization",
399
+ );
400
+ headers.set(
401
+ "Access-Control-Expose-Headers",
402
+ `WWW-Authenticate, X-Request-ID, X-VGI-Content-Encoding, ${RPC_ERROR_HEADER}, VGI-Max-Response-Bytes, VGI-Max-Externalized-Response-Bytes, VGI-Externalization-Enabled`,
403
+ );
404
+ if (isOptions && corsMaxAge != null) {
405
+ headers.set("Access-Control-Max-Age", String(corsMaxAge));
406
+ }
90
407
  }
91
408
  }
92
409
 
93
- async function compressIfAccepted(response: Response, clientAcceptsZstd: boolean): Promise<Response> {
94
- if (compressionLevel == null || !clientAcceptsZstd) return response;
410
+ async function compressIfAccepted(
411
+ response: Response,
412
+ clientAcceptsZstd: boolean,
413
+ clientAcceptsGzip: boolean,
414
+ ): Promise<Response> {
415
+ if (compressionLevel == null) return response;
416
+ // Honour client preference: zstd preferred over gzip when the runtime
417
+ // can actually produce zstd. Fall through to gzip otherwise.
418
+ const codec = clientAcceptsZstd && zstdResponseAvailable ? "zstd" : clientAcceptsGzip ? "gzip" : null;
419
+ if (!codec) return response;
95
420
  const responseBody = new Uint8Array(await response.arrayBuffer());
96
- const compressed = zstdCompress(responseBody, compressionLevel);
421
+ const compressed =
422
+ codec === "zstd" ? await zstdCompress(responseBody, compressionLevel) : await gzipCompress(responseBody);
97
423
  const headers = new Headers(response.headers);
98
- headers.set("Content-Encoding", "zstd");
424
+ headers.set("Content-Encoding", codec);
99
425
  return new Response(compressed as unknown as BodyInit, {
100
426
  status: response.status,
101
427
  headers,
102
428
  });
103
429
  }
104
430
 
105
- function makeErrorResponse(error: Error, statusCode: number, schema: Schema = EMPTY_SCHEMA): Response {
431
+ function makeErrorResponse(error: Error, statusCode: number, schema: VgiSchema = EMPTY_SCHEMA): Response {
106
432
  const errBatch = buildErrorBatch(schema, error, serverId, null);
107
433
  const body = serializeIpcStream(schema, [errBatch]);
108
434
  const resp = arrowResponse(body, statusCode);
@@ -110,16 +436,50 @@ export function createHttpHandler(
110
436
  return resp;
111
437
  }
112
438
 
439
+ const enableHealthEndpoint = options?.enableHealthEndpoint ?? true;
440
+ const healthPath = `${prefix}/health`;
441
+ const healthBody = enableHealthEndpoint
442
+ ? JSON.stringify({ status: "ok", server_id: serverId, protocol: displayName })
443
+ : null;
444
+
113
445
  return async function handler(request: Request): Promise<Response> {
114
446
  const url = new URL(request.url);
115
447
  const path = url.pathname;
116
448
 
449
+ // OAuth token-exchange proxy — exempt from auth (it's the mechanism by
450
+ // which a client *gets* an auth token). Handled before the global OPTIONS
451
+ // catch-all so the proxy can apply its own Origin-allowlist CORS.
452
+ if (
453
+ pkceConfig &&
454
+ path === `${prefix}/_oauth/token` &&
455
+ (request.method === "POST" || request.method === "OPTIONS")
456
+ ) {
457
+ return handleOAuthTokenProxy(request, pkceConfig);
458
+ }
459
+
460
+ // Health endpoint — exempt from authentication so orchestrators / load
461
+ // balancers can probe even when every RPC endpoint requires auth.
462
+ if (healthBody !== null && request.method === "GET" && path === healthPath) {
463
+ const headers = new Headers({ "Content-Type": "application/json" });
464
+ addCorsHeaders(headers);
465
+ addCapabilityHeaders(headers);
466
+ return new Response(healthBody, { status: 200, headers });
467
+ }
468
+
117
469
  // Well-known endpoint: RFC 9728 OAuth Protected Resource Metadata
118
470
  if (oauthMetadata && path === wellKnownPath(prefix)) {
119
471
  if (request.method !== "GET") {
120
472
  return new Response("Method Not Allowed", { status: 405 });
121
473
  }
122
- const body = JSON.stringify(oauthResourceMetadataToJson(oauthMetadata));
474
+ const metaJson = oauthResourceMetadataToJson(oauthMetadata);
475
+ // When PKCE + a server-side client_secret are configured, advertise the
476
+ // token-proxy URL so SPA PKCE clients can complete token exchanges
477
+ // without holding the secret themselves.
478
+ if (pkceConfig && oauthMetadata.clientSecret) {
479
+ const resourceUrl = new URL(oauthMetadata.resource);
480
+ metaJson.token_endpoint = `${resourceUrl.protocol}//${resourceUrl.host}${prefix}/_oauth/token`;
481
+ }
482
+ const body = JSON.stringify(metaJson);
123
483
  const headers = new Headers({
124
484
  "Content-Type": "application/json",
125
485
  "Cache-Control": "public, max-age=60",
@@ -128,28 +488,57 @@ export function createHttpHandler(
128
488
  return new Response(body, { status: 200, headers });
129
489
  }
130
490
 
131
- // CORS preflight
491
+ // CORS preflight + capability discovery
132
492
  if (request.method === "OPTIONS") {
133
- if (path === `${prefix}/__capabilities__`) {
134
- const headers = new Headers();
135
- addCorsHeaders(headers);
136
- if (maxRequestBytes != null) {
137
- headers.set("VGI-Max-Request-Bytes", String(maxRequestBytes));
138
- }
139
- return new Response(null, { status: 204, headers });
140
- }
141
-
142
- if (corsOrigins) {
143
- const headers = new Headers();
144
- addCorsHeaders(headers);
493
+ const headers = new Headers();
494
+ addCorsHeaders(headers, true, request.headers.get("Access-Control-Request-Headers"));
495
+ addCapabilityHeaders(headers, true);
496
+ // Always answer OPTIONS so capability discovery via OPTIONS /health (or
497
+ // any other path) works even when CORS isn't enabled. Falls back to
498
+ // 405 only if no capability/CORS configuration exists.
499
+ if (
500
+ corsOrigins ||
501
+ maxRequestBytes != null ||
502
+ maxResponseBytes != null ||
503
+ maxExternalizedResponseBytes != null ||
504
+ uploadUrlProvider ||
505
+ stickyEnabled ||
506
+ path === `${prefix}/__capabilities__`
507
+ ) {
145
508
  return new Response(null, { status: 204, headers });
146
509
  }
147
-
148
510
  return new Response(null, { status: 405 });
149
511
  }
150
512
 
151
513
  // HTML pages for GET requests
152
514
  if (request.method === "GET") {
515
+ // OAuth callback and logout routes (exempt from auth)
516
+ if (pkceConfig) {
517
+ if (path === `${prefix}/_oauth/callback`) {
518
+ return handleOAuthCallback(request, pkceConfig);
519
+ }
520
+ if (path === `${prefix}/_oauth/logout`) {
521
+ return handleOAuthLogout(request, pkceConfig);
522
+ }
523
+
524
+ // Early return-to redirect for already-authenticated users
525
+ const earlyRedirect = handleEarlyReturnTo(request, pkceConfig);
526
+ if (earlyRedirect) return earlyRedirect;
527
+ }
528
+
529
+ // If authenticate is configured, try to authenticate GET requests for pages
530
+ // On auth failure with PKCE, redirect browsers to OAuth instead of 401
531
+ if (authenticate && pkceConfig) {
532
+ try {
533
+ await authenticate(request);
534
+ } catch {
535
+ // Auth failed — redirect browser GETs to OAuth authorization
536
+ const redirect = await handleBrowserGetRedirect(request, pkceConfig);
537
+ if (redirect) return redirect;
538
+ // Not a browser or OIDC discovery failed — fall through to normal page serving
539
+ }
540
+ }
541
+
153
542
  // Landing page: GET {prefix}/ or GET {prefix}
154
543
  if (landingHtml && (path === prefix || path === `${prefix}/`)) {
155
544
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
@@ -174,37 +563,77 @@ export function createHttpHandler(
174
563
  return new Response("Not Found", { status: 404 });
175
564
  }
176
565
 
177
- if (request.method !== "POST") {
178
- return new Response("Method Not Allowed", { status: 405 });
179
- }
180
-
181
- // Validate Content-Type
182
- const contentType = request.headers.get("Content-Type");
183
- if (!contentType || !contentType.includes(ARROW_CONTENT_TYPE)) {
184
- return new Response(`Unsupported Media Type: expected ${ARROW_CONTENT_TYPE}`, { status: 415 });
185
- }
186
-
187
- // Check request body size
188
- if (maxRequestBytes != null) {
189
- const contentLength = request.headers.get("Content-Length");
190
- if (contentLength && parseInt(contentLength, 10) > maxRequestBytes) {
191
- return new Response("Request body too large", { status: 413 });
566
+ // DELETE {prefix}/__session__ idempotent sticky-session teardown.
567
+ // Mirrors Python's `_SessionResource.on_delete`: missing token /
568
+ // malformed / wrong server_id / wrong principal / registry miss all
569
+ // return 200 (so the endpoint cannot be used to probe for live
570
+ // sessions); a successful close returns 204 + VGI-Session-Close: true.
571
+ if (request.method === "DELETE" && stickyEnabled && sessionRegistry && path === `${prefix}/${SESSION_ENDPOINT}`) {
572
+ const headers = new Headers();
573
+ addCorsHeaders(headers);
574
+ addCapabilityHeaders(headers);
575
+ const tokenHeader = (request.headers.get(SESSION_HEADER) ?? "").trim();
576
+ if (!tokenHeader) {
577
+ return new Response(null, { status: 200, headers });
578
+ }
579
+ // Optional auth re-uses the same authenticate path so principal
580
+ // binding is consistent with the dispatch flow. AAD uses only the
581
+ // authenticated principal (matching `computeAad` in `token.ts`);
582
+ // the registry's principalKey compounds domain+principal as
583
+ // defense-in-depth.
584
+ let principalKey = sessionPrincipalKey(false, null, null);
585
+ let aadPrincipal: string | null = null;
586
+ if (authenticate) {
587
+ try {
588
+ const auth = await authenticate(request);
589
+ if (auth?.authenticated) {
590
+ aadPrincipal = auth.principal ?? "";
591
+ principalKey = sessionPrincipalKey(true, auth.domain, auth.principal);
592
+ }
593
+ } catch {
594
+ // Anonymous principal — stale / forged tokens already won't
595
+ // decrypt under a real principal's AAD, so the auth failure
596
+ // here is harmless; treat as anonymous and let the next steps
597
+ // 200 out idempotently.
598
+ }
599
+ }
600
+ const aad = computeAad(aadPrincipal);
601
+ let opened: { serverId: string; sessionId: Uint8Array };
602
+ try {
603
+ opened = openSessionToken(tokenHeader, tokenKey, aad);
604
+ } catch {
605
+ return new Response(null, { status: 200, headers });
606
+ }
607
+ if (opened.serverId !== serverId) {
608
+ return new Response(null, { status: 200, headers });
609
+ }
610
+ const entry = sessionRegistry.get(opened.sessionId, principalKey);
611
+ if (!entry) {
612
+ return new Response(null, { status: 200, headers });
192
613
  }
614
+ const release = await entry.lock.acquire();
615
+ try {
616
+ sessionRegistry.close(opened.sessionId);
617
+ } finally {
618
+ release();
619
+ }
620
+ headers.set(SESSION_CLOSE_HEADER, "true");
621
+ return new Response(null, { status: 204, headers });
193
622
  }
194
623
 
195
- const clientAcceptsZstd = (request.headers.get("Accept-Encoding") ?? "").includes("zstd");
196
-
197
- // Read body, decompressing if needed
198
- let body = new Uint8Array(await request.arrayBuffer());
199
- const contentEncoding = request.headers.get("Content-Encoding");
200
- if (contentEncoding === "zstd") {
201
- body = zstdDecompress(body);
624
+ if (request.method !== "POST") {
625
+ return new Response("Method Not Allowed", { status: 405 });
202
626
  }
203
627
 
204
628
  // Build per-request dispatch context
205
- const ctx = { ...baseCtx } as typeof baseCtx & { authContext?: AuthContext };
629
+ const ctx = { ...baseCtx, cookies: parseRequestCookies(request) } as typeof baseCtx & {
630
+ authContext?: AuthContext;
631
+ cookies: ReadonlyMap<string, string>;
632
+ stickyContext?: StickySink;
633
+ };
206
634
 
207
- // Authentication
635
+ // Authentication — run before content-type validation so unauthenticated
636
+ // requests get 401 regardless of body shape.
208
637
  if (authenticate) {
209
638
  try {
210
639
  ctx.authContext = await authenticate(request);
@@ -231,14 +660,213 @@ export function createHttpHandler(
231
660
  }
232
661
  }
233
662
 
663
+ // Hoisted ahead of sticky resolution so the SessionLost path's
664
+ // `compressIfAccepted` call can see them.
665
+ const acceptEncodingEarly = (request.headers.get("Accept-Encoding") ?? "").toLowerCase();
666
+ const clientAcceptsZstdEarly = acceptEncodingEarly.includes("zstd");
667
+ const clientAcceptsGzipEarly = acceptEncodingEarly.includes("gzip");
668
+
669
+ // -------- Sticky session resolution --------
670
+ // Mirrors `_StickyMiddleware.process_request` in Python: read
671
+ // VGI-Session-Accept + VGI-Session, decrypt the token (AAD-bound to
672
+ // the request's principal), look up the registry entry, and acquire
673
+ // the per-session lock so concurrent calls on the same session
674
+ // serialize. On any failure we surface a typed SessionLostError as
675
+ // a 500 + EXCEPTION-batch response (the same wire shape a
676
+ // dispatch-time throw would produce).
677
+ let stickyLockRelease: (() => void) | null = null;
678
+ let stickySink: StickySink | null = null;
679
+ if (stickyEnabled && sessionRegistry) {
680
+ const auth = ctx.authContext;
681
+ const aadPrincipal = auth?.authenticated ? (auth.principal ?? "") : null;
682
+ const principalKey = sessionPrincipalKey(!!auth?.authenticated, auth?.domain, auth?.principal);
683
+ const aad = computeAad(aadPrincipal);
684
+ const acceptOpens = (request.headers.get(SESSION_ACCEPT_HEADER) ?? "").trim().toLowerCase() === "true";
685
+ const sessionHeader = (request.headers.get(SESSION_HEADER) ?? "").trim();
686
+
687
+ let resumeState: unknown | null = null;
688
+ let resumeSessionId: string | null = null;
689
+
690
+ if (sessionHeader) {
691
+ let opened: { serverId: string; sessionId: Uint8Array };
692
+ try {
693
+ opened = openSessionToken(sessionHeader, tokenKey, aad);
694
+ if (opened.serverId !== serverId) {
695
+ throw new SessionLostError("session token was issued by a different worker (server_id mismatch)");
696
+ }
697
+ } catch (err) {
698
+ // Wrong-AAD / wrong-key / malformed → SessionLostError.
699
+ const e = err instanceof Error ? err : new Error(String(err));
700
+ const r = makeErrorResponse(e, 500);
701
+ addCapabilityHeaders(r.headers);
702
+ return compressIfAccepted(r, clientAcceptsZstdEarly, clientAcceptsGzipEarly);
703
+ }
704
+ const entry = sessionRegistry.get(opened.sessionId, principalKey);
705
+ if (!entry) {
706
+ const r = makeErrorResponse(new SessionLostError("session not found, expired, or principal mismatch"), 500);
707
+ addCapabilityHeaders(r.headers);
708
+ return compressIfAccepted(r, clientAcceptsZstdEarly, clientAcceptsGzipEarly);
709
+ }
710
+ stickyLockRelease = await entry.lock.acquire();
711
+ resumeState = entry.state;
712
+ resumeSessionId = sessionIdHex(opened.sessionId);
713
+ }
714
+
715
+ // Build the sink. Captures `principalKey` and `aad` so `_open`
716
+ // can register a new entry and mint a token bound to the same
717
+ // principal as the request.
718
+ const sink: StickySink = {
719
+ acceptOpens,
720
+ state: resumeState,
721
+ sessionId: resumeSessionId,
722
+ mintToken: null,
723
+ closed: false,
724
+ action: sessionHeader ? "resume" : "none",
725
+ _open(state: unknown, ttl?: number) {
726
+ const { sessionId, expiresAt } = sessionRegistry!.open(state, ttl, principalKey);
727
+ sink.sessionId = sessionIdHex(sessionId);
728
+ sink.state = state;
729
+ sink.mintToken = sealSessionToken(serverId, sessionId, expiresAt, tokenKey, aad);
730
+ },
731
+ _close() {
732
+ if (sink.closed) return;
733
+ const sid = sink.sessionId;
734
+ if (!sid) return;
735
+ // Decode hex back to bytes for registry lookup.
736
+ const bytes = new Uint8Array(sid.length / 2);
737
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(sid.slice(i * 2, i * 2 + 2), 16);
738
+ // Release the lock before close so the resource isn't held
739
+ // while close() runs.
740
+ if (stickyLockRelease) {
741
+ stickyLockRelease();
742
+ stickyLockRelease = null;
743
+ }
744
+ sessionRegistry!.close(bytes);
745
+ sink.state = null;
746
+ sink.closed = true;
747
+ },
748
+ };
749
+ stickySink = sink;
750
+ ctx.stickyContext = sink;
751
+ }
752
+
753
+ // Validate Content-Type
754
+ const contentType = request.headers.get("Content-Type");
755
+ if (!contentType || !contentType.includes(ARROW_CONTENT_TYPE)) {
756
+ if (stickyLockRelease) stickyLockRelease();
757
+ return new Response(`Unsupported Media Type: expected ${ARROW_CONTENT_TYPE}`, { status: 415 });
758
+ }
759
+
760
+ // Check request body size (exempt the upload-URL and health endpoints —
761
+ // their payloads are intrinsically tiny, and __upload_url__ exists
762
+ // precisely to escape this limit).
763
+ const exemptFromMaxBytes =
764
+ path === healthPath || path === `${prefix}/${UPLOAD_URL_METHOD}/init` || path === `${prefix}/__capabilities__`;
765
+ if (maxRequestBytes != null && !exemptFromMaxBytes) {
766
+ const contentLength = request.headers.get("Content-Length");
767
+ if (contentLength && parseInt(contentLength, 10) > maxRequestBytes) {
768
+ return new Response("Request body too large", { status: 413 });
769
+ }
770
+ }
771
+
772
+ const clientAcceptsZstd = clientAcceptsZstdEarly;
773
+ const clientAcceptsGzip = clientAcceptsGzipEarly;
774
+
775
+ // Read body, decompressing if needed
776
+ let body = new Uint8Array(await request.arrayBuffer());
777
+ if (maxRequestBytes != null && !exemptFromMaxBytes && body.byteLength > maxRequestBytes) {
778
+ return new Response("Request body too large", { status: 413 });
779
+ }
780
+ const contentEncoding = (request.headers.get("Content-Encoding") ?? "").trim().toLowerCase();
781
+ if (contentEncoding === "zstd" || contentEncoding === "gzip") {
782
+ try {
783
+ body =
784
+ contentEncoding === "zstd"
785
+ ? await zstdDecompress(body, maxDecompressedRequestBytes)
786
+ : await gzipDecompress(body, maxDecompressedRequestBytes);
787
+ } catch (error: any) {
788
+ // Decompression-bomb refusal surfaces as 413 (the wire-cap
789
+ // sibling of maxRequestBytes); other decode errors are 400.
790
+ const message = error?.message ?? `${contentEncoding} decompression failed`;
791
+ const status = message.includes("exceed") || message.includes("cap") ? 413 : 400;
792
+ const headers = new Headers({ "Content-Type": "text/plain" });
793
+ addCorsHeaders(headers);
794
+ addCapabilityHeaders(headers);
795
+ return new Response(message, { status, headers });
796
+ }
797
+ } else if (contentEncoding) {
798
+ const headers = new Headers({ "Content-Type": "text/plain" });
799
+ addCorsHeaders(headers);
800
+ addCapabilityHeaders(headers);
801
+ return new Response(`Unsupported Content-Encoding: ${contentEncoding}`, { status: 415, headers });
802
+ }
803
+
804
+ // Route: {prefix}/__upload_url__/init — vend pre-signed upload URL pairs
805
+ if (path === `${prefix}/${UPLOAD_URL_METHOD}/init`) {
806
+ if (!uploadUrlProvider) {
807
+ return new Response("Not Found", { status: 404 });
808
+ }
809
+ try {
810
+ const { schema: reqSchema, batch: reqBatch } = await readRequestFromBodyImported(body);
811
+ const parsed = parseRequest(reqSchema, reqBatch);
812
+ if (parsed.methodName !== UPLOAD_URL_METHOD) {
813
+ throw new HttpRpcError(
814
+ `Method name in request '${parsed.methodName}' does not match URL '${UPLOAD_URL_METHOD}'`,
815
+ 400,
816
+ );
817
+ }
818
+ const rawCount = parsed.params.count;
819
+ let count = typeof rawCount === "bigint" ? Number(rawCount) : Number(rawCount ?? 1);
820
+ if (!Number.isFinite(count) || count < 1) count = 1;
821
+ if (count > MAX_UPLOAD_URL_COUNT) count = MAX_UPLOAD_URL_COUNT;
822
+
823
+ const urls: { uploadUrl: string; downloadUrl: string; expiresAt: Date }[] = [];
824
+ for (let i = 0; i < count; i++) {
825
+ urls.push(await uploadUrlProvider.generateUploadUrl());
826
+ }
827
+
828
+ // Timestamp(MICROSECOND) — arrow-js's `setTimestampMicrosecond`
829
+ // visitor internally does `BigInt(value * 1000)`, so we have to
830
+ // pass a Number of milliseconds. Passing a BigInt of microseconds
831
+ // would trip "Invalid mix of BigInt and other type in
832
+ // multiplication". The Date.getTime() ms value fits in Number
833
+ // safely for the next ~285k years.
834
+ const expiresAt = urls.map((u) => u.expiresAt.getTime());
835
+ const resultBatch = batchFromColumns(UPLOAD_URL_RESPONSE_SCHEMA, {
836
+ upload_url: urls.map((u) => u.uploadUrl),
837
+ download_url: urls.map((u) => u.downloadUrl),
838
+ expires_at: expiresAt,
839
+ });
840
+ const responseBody = serializeIpcStream(UPLOAD_URL_RESPONSE_SCHEMA, [resultBatch]);
841
+ const response = arrowResponse(responseBody);
842
+ addCorsHeaders(response.headers);
843
+ addCapabilityHeaders(response.headers);
844
+ return compressIfAccepted(response, clientAcceptsZstd, clientAcceptsGzip);
845
+ } catch (error: any) {
846
+ if (error instanceof HttpRpcError) {
847
+ const r = makeErrorResponse(error, error.statusCode, UPLOAD_URL_RESPONSE_SCHEMA);
848
+ addCapabilityHeaders(r.headers);
849
+ return compressIfAccepted(r, clientAcceptsZstd, clientAcceptsGzip);
850
+ }
851
+ const r = makeErrorResponse(error, 500, UPLOAD_URL_RESPONSE_SCHEMA);
852
+ addCapabilityHeaders(r.headers);
853
+ return compressIfAccepted(r, clientAcceptsZstd, clientAcceptsGzip);
854
+ }
855
+ }
856
+
234
857
  // Route: {prefix}/__describe__
235
858
  if (path === `${prefix}/${DESCRIBE_METHOD_NAME}`) {
236
859
  try {
237
- const response = httpDispatchDescribe(protocol.name, methods, serverId);
860
+ const response = await httpDispatchDescribe(
861
+ protocol.name,
862
+ methods,
863
+ serverId,
864
+ protocol.protocolVersion || undefined,
865
+ );
238
866
  addCorsHeaders(response.headers);
239
- return compressIfAccepted(response, clientAcceptsZstd);
867
+ return compressIfAccepted(response, clientAcceptsZstd, clientAcceptsGzip);
240
868
  } catch (error: any) {
241
- return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd);
869
+ return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd, clientAcceptsGzip);
242
870
  }
243
871
  }
244
872
 
@@ -266,12 +894,70 @@ export function createHttpHandler(
266
894
  const method = methods.get(methodName);
267
895
  if (!method) {
268
896
  const available = [...methods.keys()].sort();
269
- const err = new Error(`Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`);
270
- return compressIfAccepted(makeErrorResponse(err, 404), clientAcceptsZstd);
897
+ const err = new MethodNotImplementedError(
898
+ `Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`,
899
+ );
900
+ return compressIfAccepted(makeErrorResponse(err, 404), clientAcceptsZstd, clientAcceptsGzip);
901
+ }
902
+
903
+ // Application-protocol-version gate (HTTP dispatch path). Fires only
904
+ // when the Protocol declared a `protocolVersion`, on unary calls and
905
+ // stream init. `/exchange` continuations skip the gate — the Python
906
+ // client (and parity TS client) only emits `vgi_rpc.protocol_version`
907
+ // on the dispatch-entry request, not on follow-up exchange batches.
908
+ // `__describe__` is exempt — diagnostic path for mismatched clients to
909
+ // discover the server's version. Mirrors Python's _app_unary /
910
+ // _app_stream gate.
911
+ if (protocol.protocolVersionParts !== null && methodName !== DESCRIBE_METHOD_NAME && action !== "exchange") {
912
+ try {
913
+ // Peek at request batch metadata without consuming the body — the
914
+ // dispatch helpers re-deserialize. Cost is one extra deserialize
915
+ // per protocol-versioned dispatch; acceptable for a typed gate.
916
+ let reqMeta: ReadonlyMap<string, string> | undefined;
917
+ try {
918
+ const peeked = deserializeBatch(body);
919
+ reqMeta = peeked.metadata ?? undefined;
920
+ } catch {
921
+ // Malformed body — fall through to the dispatch helper, which
922
+ // will surface the parse error properly.
923
+ }
924
+ enforceProtocolVersion(reqMeta);
925
+ } catch (exc) {
926
+ const errSchema = method.type === MethodType.UNARY ? method.resultSchema : EMPTY_SCHEMA;
927
+ const errBatch = buildErrorBatch(errSchema, exc as Error, serverId, null);
928
+ const errBody = serializeIpcStream(errSchema, [errBatch]);
929
+ const response = arrowResponse(errBody, 400);
930
+ addCorsHeaders(response.headers);
931
+ addCapabilityHeaders(response.headers);
932
+ return compressIfAccepted(response, clientAcceptsZstd, clientAcceptsGzip);
933
+ }
271
934
  }
272
935
 
936
+ // Fire on_serve_start lazily (idempotent on success). A failure here
937
+ // propagates as a 500 to the client, leaves the bind un-fired, and
938
+ // the next request retries.
939
+ await notifyTransport(transportKind);
940
+
273
941
  const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
274
- const info: DispatchInfo = { method: methodName, methodType, serverId, requestId: null };
942
+ const protocolHash = await getProtocolHash();
943
+ const auth = ctx.authContext;
944
+ const info: DispatchInfo = {
945
+ method: methodName,
946
+ methodType,
947
+ serverId,
948
+ requestId: null,
949
+ protocol: protocol.name,
950
+ protocolHash,
951
+ protocolVersion,
952
+ kind: transportKind,
953
+ principal: auth?.principal ?? "",
954
+ authDomain: auth?.domain ?? "",
955
+ authenticated: auth?.authenticated ?? false,
956
+ // Self-contained Arrow IPC stream of the request batch — the body
957
+ // we already buffered. Best-effort: the access-log can still emit
958
+ // even if we couldn't capture it.
959
+ requestData: action === "call" ? body : undefined,
960
+ };
275
961
  const stats: CallStatistics = {
276
962
  inputBatches: 0,
277
963
  outputBatches: 0,
@@ -316,15 +1002,55 @@ export function createHttpHandler(
316
1002
  dispatchError = internalError instanceof Error ? internalError : new Error(String(internalError));
317
1003
  }
318
1004
  addCorsHeaders(response.headers);
319
- return compressIfAccepted(response, clientAcceptsZstd);
1005
+ addCapabilityHeaders(response.headers);
1006
+ applyStickyResponseHeaders(response.headers, stickySink);
1007
+ return compressIfAccepted(response, clientAcceptsZstd, clientAcceptsGzip);
320
1008
  } catch (error: any) {
321
1009
  dispatchError = error instanceof Error ? error : new Error(String(error));
322
1010
  if (error instanceof HttpRpcError) {
323
- return compressIfAccepted(makeErrorResponse(error, error.statusCode), clientAcceptsZstd);
1011
+ const r = makeErrorResponse(error, error.statusCode);
1012
+ addCapabilityHeaders(r.headers);
1013
+ applyStickyResponseHeaders(r.headers, stickySink);
1014
+ return compressIfAccepted(r, clientAcceptsZstd, clientAcceptsGzip);
324
1015
  }
325
- return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd);
1016
+ const r = makeErrorResponse(error, 500);
1017
+ addCapabilityHeaders(r.headers);
1018
+ applyStickyResponseHeaders(r.headers, stickySink);
1019
+ return compressIfAccepted(r, clientAcceptsZstd, clientAcceptsGzip);
326
1020
  } finally {
1021
+ // Surface sticky lifecycle on the access log.
1022
+ if (stickySink) {
1023
+ if (stickySink.sessionId) info.sessionId = stickySink.sessionId;
1024
+ info.sessionAction = stickySink.action;
1025
+ }
327
1026
  dispatchHook?.onDispatchEnd(hookToken, info, stats, dispatchError);
1027
+ // Release the per-session lock if dispatch held it and the handler
1028
+ // didn't already release it via close_session.
1029
+ if (stickyLockRelease) {
1030
+ try {
1031
+ stickyLockRelease();
1032
+ } catch {
1033
+ // ignore — mutex release is best-effort
1034
+ }
1035
+ stickyLockRelease = null;
1036
+ }
328
1037
  }
329
1038
  };
1039
+
1040
+ /** Emit sticky-session response headers based on the sink's per-request state. */
1041
+ function applyStickyResponseHeaders(headers: Headers, sink: StickySink | null): void {
1042
+ if (!sink) return;
1043
+ if (sink.mintToken !== null) {
1044
+ headers.set(SESSION_HEADER, sink.mintToken);
1045
+ // Echo headers — emitted only on the session-opening response. The
1046
+ // client captures `VGI-Echo-<name>` and replays `<name>` for the
1047
+ // remainder of the session view.
1048
+ for (const [name, value] of stickyEchoHeadersArr) {
1049
+ headers.set(`${ECHO_HEADER_PREFIX}${name}`, value);
1050
+ }
1051
+ }
1052
+ if (sink.closed) {
1053
+ headers.set(SESSION_CLOSE_HEADER, "true");
1054
+ }
1055
+ }
330
1056
  }