@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.
- package/dist/access-log.d.ts +50 -0
- package/dist/access-log.d.ts.map +1 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
- package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/index.d.ts +102 -0
- package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
- package/dist/arrow/index.d.ts +4 -0
- package/dist/arrow/index.d.ts.map +1 -0
- package/dist/arrow/predicates.d.ts +44 -0
- package/dist/arrow/predicates.d.ts.map +1 -0
- package/dist/arrow/types.d.ts +62 -0
- package/dist/arrow/types.d.ts.map +1 -0
- package/dist/client/capabilities.d.ts +25 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +7 -0
- package/dist/client/introspect.d.ts.map +1 -1
- package/dist/client/ipc.d.ts +8 -2
- package/dist/client/ipc.d.ts.map +1 -1
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +11 -2
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/uploadUrl.d.ts +25 -0
- package/dist/client/uploadUrl.d.ts.map +1 -0
- package/dist/constants.d.ts +15 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/crypto.d.ts +22 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/dispatch/describe.d.ts +10 -6
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -2
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -2
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/external.d.ts +25 -5
- package/dist/external.d.ts.map +1 -1
- package/dist/http/bearer.d.ts.map +1 -1
- package/dist/http/common.d.ts +42 -7
- package/dist/http/common.d.ts.map +1 -1
- package/dist/http/dispatch.d.ts +20 -2
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +2 -1
- package/dist/http/mtls.d.ts.map +1 -1
- package/dist/http/oauth-pkce.d.ts +141 -0
- package/dist/http/oauth-pkce.d.ts.map +1 -0
- package/dist/http/pages.d.ts +3 -0
- package/dist/http/pages.d.ts.map +1 -1
- package/dist/http/sticky.d.ts +124 -0
- package/dist/http/sticky.d.ts.map +1 -0
- package/dist/http/token.d.ts +38 -12
- package/dist/http/token.d.ts.map +1 -1
- package/dist/http/types.d.ts +68 -5
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1275 -3507
- package/dist/index.js.map +19 -37
- package/dist/launcher/hash.d.ts +22 -0
- package/dist/launcher/hash.d.ts.map +1 -0
- package/dist/launcher/index.d.ts +23 -0
- package/dist/launcher/index.d.ts.map +1 -0
- package/dist/launcher/launch.d.ts +27 -0
- package/dist/launcher/launch.d.ts.map +1 -0
- package/dist/launcher/lock.d.ts +19 -0
- package/dist/launcher/lock.d.ts.map +1 -0
- package/dist/launcher/serve-unix.d.ts +54 -0
- package/dist/launcher/serve-unix.d.ts.map +1 -0
- package/dist/launcher/state.d.ts +59 -0
- package/dist/launcher/state.d.ts.map +1 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/protocol.d.ts +16 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/schema.d.ts +45 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/server.d.ts +23 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +216 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/util/gzip.d.ts +10 -0
- package/dist/util/gzip.d.ts.map +1 -0
- package/dist/util/schema.d.ts +3 -15
- package/dist/util/schema.d.ts.map +1 -1
- package/dist/util/web-crypto.d.ts +22 -0
- package/dist/util/web-crypto.d.ts.map +1 -0
- package/dist/util/zstd.d.ts +26 -3
- package/dist/util/zstd.d.ts.map +1 -1
- package/dist/wire/opaque.d.ts +11 -0
- package/dist/wire/opaque.d.ts.map +1 -0
- package/dist/wire/reader.d.ts +5 -5
- package/dist/wire/reader.d.ts.map +1 -1
- package/dist/wire/request.d.ts +11 -3
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/response.d.ts +6 -6
- package/dist/wire/response.d.ts.map +1 -1
- package/dist/wire/writer.d.ts +49 -39
- package/dist/wire/writer.d.ts.map +1 -1
- package/package.json +24 -10
- package/src/access-log.ts +195 -0
- package/src/arrow/impl-arrowjs/index.ts +433 -0
- package/src/arrow/impl-flechette/index.ts +414 -0
- package/src/arrow/impl-flechette/message-meta.ts +174 -0
- package/src/arrow/index.ts +89 -0
- package/src/arrow/predicates.ts +56 -0
- package/src/arrow/types.ts +73 -0
- package/src/client/capabilities.ts +84 -0
- package/src/client/connect.ts +103 -26
- package/src/client/introspect.ts +60 -38
- package/src/client/ipc.ts +37 -27
- package/src/client/pipe.ts +12 -9
- package/src/client/stream.ts +34 -19
- package/src/client/uploadUrl.ts +169 -0
- package/src/constants.ts +18 -1
- package/src/crypto.ts +95 -0
- package/src/dispatch/describe.ts +146 -107
- package/src/dispatch/stream.ts +53 -24
- package/src/dispatch/unary.ts +5 -4
- package/src/errors.ts +76 -0
- package/src/external.ts +43 -29
- package/src/http/bearer.ts +2 -5
- package/src/http/common.ts +90 -23
- package/src/http/dispatch.ts +373 -46
- package/src/http/handler.ts +794 -68
- package/src/http/index.ts +1 -0
- package/src/http/mtls.ts +18 -3
- package/src/http/oauth-pkce.ts +1035 -0
- package/src/http/pages.ts +30 -15
- package/src/http/sticky.ts +429 -0
- package/src/http/token.ts +165 -75
- package/src/http/types.ts +69 -5
- package/src/index.ts +40 -1
- package/src/launcher/hash.ts +104 -0
- package/src/launcher/index.ts +35 -0
- package/src/launcher/launch.ts +284 -0
- package/src/launcher/lock.ts +171 -0
- package/src/launcher/serve-unix.ts +385 -0
- package/src/launcher/state.ts +245 -0
- package/src/otel.ts +39 -33
- package/src/protocol.ts +27 -3
- package/src/schema.ts +107 -56
- package/src/server.ts +196 -20
- package/src/types.ts +322 -18
- package/src/util/gzip.ts +63 -0
- package/src/util/schema.ts +4 -22
- package/src/util/web-crypto.ts +98 -0
- package/src/util/zstd.ts +133 -14
- package/src/wire/opaque.ts +37 -0
- package/src/wire/reader.ts +5 -4
- package/src/wire/request.ts +67 -8
- package/src/wire/response.ts +51 -85
- package/src/wire/writer.ts +165 -69
- package/dist/util/conform.d.ts +0 -18
- package/dist/util/conform.d.ts.map +0 -1
- package/src/util/conform.ts +0 -94
package/src/http/handler.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
275
|
+
let landingHtml = enableLandingPage
|
|
67
276
|
? buildLandingPage(displayName, serverId, enableDescribePage ? `${prefix}/describe` : null, repoUrl)
|
|
68
277
|
: null;
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
headers.
|
|
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(
|
|
94
|
-
|
|
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 =
|
|
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",
|
|
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:
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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 & {
|
|
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(
|
|
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
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|