@query-farm/vgi-rpc 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-log.d.ts +55 -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/auth.d.ts +5 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/client/capabilities.d.ts +25 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/connect.d.ts +10 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +21 -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/oauth.d.ts +9 -0
- package/dist/client/oauth.d.ts.map +1 -1
- package/dist/client/pipe.d.ts +24 -0
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +19 -2
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/types.d.ts +23 -0
- package/dist/client/types.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 +30 -2
- 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 +64 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/external.d.ts +27 -5
- package/dist/external.d.ts.map +1 -1
- package/dist/http/auth.d.ts +13 -0
- package/dist/http/auth.d.ts.map +1 -1
- package/dist/http/bearer.d.ts.map +1 -1
- package/dist/http/common.d.ts +43 -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/jwt.d.ts +1 -0
- package/dist/http/jwt.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +9 -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 +43 -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 -3511
- package/dist/index.js.map +20 -38
- 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 +55 -0
- package/dist/launcher/serve-unix.d.ts.map +1 -0
- package/dist/launcher/state.d.ts +71 -0
- package/dist/launcher/state.d.ts.map +1 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/protocol.d.ts +19 -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 +270 -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 +35 -21
- package/src/access-log.ts +200 -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/auth.ts +5 -0
- package/src/client/capabilities.ts +84 -0
- package/src/client/connect.ts +113 -26
- package/src/client/introspect.ts +74 -38
- package/src/client/ipc.ts +37 -27
- package/src/client/oauth.ts +9 -0
- package/src/client/pipe.ts +36 -9
- package/src/client/stream.ts +43 -20
- package/src/client/types.ts +23 -0
- package/src/client/uploadUrl.ts +169 -0
- package/src/constants.ts +34 -2
- 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 +87 -0
- package/src/external.ts +49 -30
- package/src/http/auth.ts +13 -0
- package/src/http/bearer.ts +2 -5
- package/src/http/common.ts +91 -23
- package/src/http/dispatch.ts +373 -46
- package/src/http/handler.ts +790 -68
- package/src/http/index.ts +1 -0
- package/src/http/jwt.ts +1 -0
- package/src/http/mtls.ts +25 -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 +170 -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 +386 -0
- package/src/launcher/state.ts +257 -0
- package/src/otel.ts +39 -33
- package/src/protocol.ts +30 -3
- package/src/schema.ts +107 -56
- package/src/server.ts +196 -20
- package/src/types.ts +376 -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,23 +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;
|
|
45
113
|
const corsMaxAge = options?.corsMaxAge === undefined ? 7200 : options.corsMaxAge;
|
|
46
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.
|
|
47
128
|
const maxStreamResponseBytes = options?.maxStreamResponseBytes;
|
|
129
|
+
const maxResponseBytes = options?.maxResponseBytes;
|
|
130
|
+
const maxExternalizedResponseBytes = options?.maxExternalizedResponseBytes;
|
|
48
131
|
const serverId = options?.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
49
132
|
|
|
50
|
-
|
|
133
|
+
let authenticate = options?.authenticate;
|
|
51
134
|
const oauthMetadata = options?.oauthResourceMetadata;
|
|
52
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
|
+
|
|
53
164
|
const methods = protocol.getMethods();
|
|
54
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
|
+
|
|
55
229
|
const compressionLevel = options?.compressionLevel;
|
|
56
230
|
const stateSerializer = options?.stateSerializer ?? jsonStateSerializer;
|
|
57
231
|
const dispatchHook = options?.dispatchHook;
|
|
58
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
|
+
|
|
59
267
|
// HTML page configuration
|
|
60
268
|
const enableLandingPage = options?.enableLandingPage ?? true;
|
|
61
269
|
const enableDescribePage = options?.enableDescribePage ?? true;
|
|
@@ -64,49 +272,163 @@ export function createHttpHandler(
|
|
|
64
272
|
const repoUrl = options?.repositoryUrl ?? null;
|
|
65
273
|
|
|
66
274
|
// Pre-render HTML pages for zero per-request overhead
|
|
67
|
-
|
|
275
|
+
let landingHtml = enableLandingPage
|
|
68
276
|
? buildLandingPage(displayName, serverId, enableDescribePage ? `${prefix}/describe` : null, repoUrl)
|
|
69
277
|
: null;
|
|
70
|
-
|
|
278
|
+
let describeHtml = enableDescribePage ? buildDescribePage(displayName, serverId, methods, repoUrl) : null;
|
|
71
279
|
const notFoundHtml = enableNotFoundPage ? buildNotFoundPage(prefix, displayName) : null;
|
|
72
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
|
+
|
|
73
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
|
+
}
|
|
74
374
|
|
|
75
375
|
// ctx is built per-request to include authContext; base fields set here
|
|
76
376
|
const baseCtx = {
|
|
77
|
-
|
|
377
|
+
tokenKey,
|
|
78
378
|
tokenTtl,
|
|
79
379
|
serverId,
|
|
80
380
|
maxStreamResponseBytes,
|
|
381
|
+
maxResponseBytes,
|
|
382
|
+
maxExternalizedResponseBytes,
|
|
81
383
|
stateSerializer,
|
|
82
384
|
externalLocation,
|
|
385
|
+
kind: transportKind,
|
|
83
386
|
};
|
|
84
387
|
|
|
85
|
-
function addCorsHeaders(headers: Headers, isOptions = false): void {
|
|
388
|
+
function addCorsHeaders(headers: Headers, isOptions = false, requestedHeaders?: string | null): void {
|
|
86
389
|
if (corsOrigins) {
|
|
87
390
|
headers.set("Access-Control-Allow-Origin", corsOrigins);
|
|
88
391
|
headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
89
|
-
headers
|
|
90
|
-
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
|
+
);
|
|
91
404
|
if (isOptions && corsMaxAge != null) {
|
|
92
405
|
headers.set("Access-Control-Max-Age", String(corsMaxAge));
|
|
93
406
|
}
|
|
94
407
|
}
|
|
95
408
|
}
|
|
96
409
|
|
|
97
|
-
async function compressIfAccepted(
|
|
98
|
-
|
|
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;
|
|
99
420
|
const responseBody = new Uint8Array(await response.arrayBuffer());
|
|
100
|
-
const compressed =
|
|
421
|
+
const compressed =
|
|
422
|
+
codec === "zstd" ? await zstdCompress(responseBody, compressionLevel) : await gzipCompress(responseBody);
|
|
101
423
|
const headers = new Headers(response.headers);
|
|
102
|
-
headers.set("Content-Encoding",
|
|
424
|
+
headers.set("Content-Encoding", codec);
|
|
103
425
|
return new Response(compressed as unknown as BodyInit, {
|
|
104
426
|
status: response.status,
|
|
105
427
|
headers,
|
|
106
428
|
});
|
|
107
429
|
}
|
|
108
430
|
|
|
109
|
-
function makeErrorResponse(error: Error, statusCode: number, schema:
|
|
431
|
+
function makeErrorResponse(error: Error, statusCode: number, schema: VgiSchema = EMPTY_SCHEMA): Response {
|
|
110
432
|
const errBatch = buildErrorBatch(schema, error, serverId, null);
|
|
111
433
|
const body = serializeIpcStream(schema, [errBatch]);
|
|
112
434
|
const resp = arrowResponse(body, statusCode);
|
|
@@ -114,16 +436,50 @@ export function createHttpHandler(
|
|
|
114
436
|
return resp;
|
|
115
437
|
}
|
|
116
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
|
+
|
|
117
445
|
return async function handler(request: Request): Promise<Response> {
|
|
118
446
|
const url = new URL(request.url);
|
|
119
447
|
const path = url.pathname;
|
|
120
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
|
+
|
|
121
469
|
// Well-known endpoint: RFC 9728 OAuth Protected Resource Metadata
|
|
122
470
|
if (oauthMetadata && path === wellKnownPath(prefix)) {
|
|
123
471
|
if (request.method !== "GET") {
|
|
124
472
|
return new Response("Method Not Allowed", { status: 405 });
|
|
125
473
|
}
|
|
126
|
-
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);
|
|
127
483
|
const headers = new Headers({
|
|
128
484
|
"Content-Type": "application/json",
|
|
129
485
|
"Cache-Control": "public, max-age=60",
|
|
@@ -132,28 +488,57 @@ export function createHttpHandler(
|
|
|
132
488
|
return new Response(body, { status: 200, headers });
|
|
133
489
|
}
|
|
134
490
|
|
|
135
|
-
// CORS preflight
|
|
491
|
+
// CORS preflight + capability discovery
|
|
136
492
|
if (request.method === "OPTIONS") {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
) {
|
|
149
508
|
return new Response(null, { status: 204, headers });
|
|
150
509
|
}
|
|
151
|
-
|
|
152
510
|
return new Response(null, { status: 405 });
|
|
153
511
|
}
|
|
154
512
|
|
|
155
513
|
// HTML pages for GET requests
|
|
156
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
|
+
|
|
157
542
|
// Landing page: GET {prefix}/ or GET {prefix}
|
|
158
543
|
if (landingHtml && (path === prefix || path === `${prefix}/`)) {
|
|
159
544
|
const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -178,37 +563,77 @@ export function createHttpHandler(
|
|
|
178
563
|
return new Response("Not Found", { status: 404 });
|
|
179
564
|
}
|
|
180
565
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|
|
196
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 });
|
|
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 });
|
|
197
622
|
}
|
|
198
623
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// Read body, decompressing if needed
|
|
202
|
-
let body = new Uint8Array(await request.arrayBuffer());
|
|
203
|
-
const contentEncoding = request.headers.get("Content-Encoding");
|
|
204
|
-
if (contentEncoding === "zstd") {
|
|
205
|
-
body = zstdDecompress(body);
|
|
624
|
+
if (request.method !== "POST") {
|
|
625
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
206
626
|
}
|
|
207
627
|
|
|
208
628
|
// Build per-request dispatch context
|
|
209
|
-
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
|
+
};
|
|
210
634
|
|
|
211
|
-
// Authentication
|
|
635
|
+
// Authentication — run before content-type validation so unauthenticated
|
|
636
|
+
// requests get 401 regardless of body shape.
|
|
212
637
|
if (authenticate) {
|
|
213
638
|
try {
|
|
214
639
|
ctx.authContext = await authenticate(request);
|
|
@@ -235,14 +660,213 @@ export function createHttpHandler(
|
|
|
235
660
|
}
|
|
236
661
|
}
|
|
237
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
|
+
|
|
238
857
|
// Route: {prefix}/__describe__
|
|
239
858
|
if (path === `${prefix}/${DESCRIBE_METHOD_NAME}`) {
|
|
240
859
|
try {
|
|
241
|
-
const response = httpDispatchDescribe(
|
|
860
|
+
const response = await httpDispatchDescribe(
|
|
861
|
+
protocol.name,
|
|
862
|
+
methods,
|
|
863
|
+
serverId,
|
|
864
|
+
protocol.protocolVersion || undefined,
|
|
865
|
+
);
|
|
242
866
|
addCorsHeaders(response.headers);
|
|
243
|
-
return compressIfAccepted(response, clientAcceptsZstd);
|
|
867
|
+
return compressIfAccepted(response, clientAcceptsZstd, clientAcceptsGzip);
|
|
244
868
|
} catch (error: any) {
|
|
245
|
-
return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd);
|
|
869
|
+
return compressIfAccepted(makeErrorResponse(error, 500), clientAcceptsZstd, clientAcceptsGzip);
|
|
246
870
|
}
|
|
247
871
|
}
|
|
248
872
|
|
|
@@ -270,12 +894,70 @@ export function createHttpHandler(
|
|
|
270
894
|
const method = methods.get(methodName);
|
|
271
895
|
if (!method) {
|
|
272
896
|
const available = [...methods.keys()].sort();
|
|
273
|
-
const err = new
|
|
274
|
-
|
|
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
|
+
}
|
|
275
934
|
}
|
|
276
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
|
+
|
|
277
941
|
const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
|
|
278
|
-
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
|
+
};
|
|
279
961
|
const stats: CallStatistics = {
|
|
280
962
|
inputBatches: 0,
|
|
281
963
|
outputBatches: 0,
|
|
@@ -320,15 +1002,55 @@ export function createHttpHandler(
|
|
|
320
1002
|
dispatchError = internalError instanceof Error ? internalError : new Error(String(internalError));
|
|
321
1003
|
}
|
|
322
1004
|
addCorsHeaders(response.headers);
|
|
323
|
-
|
|
1005
|
+
addCapabilityHeaders(response.headers);
|
|
1006
|
+
applyStickyResponseHeaders(response.headers, stickySink);
|
|
1007
|
+
return compressIfAccepted(response, clientAcceptsZstd, clientAcceptsGzip);
|
|
324
1008
|
} catch (error: any) {
|
|
325
1009
|
dispatchError = error instanceof Error ? error : new Error(String(error));
|
|
326
1010
|
if (error instanceof HttpRpcError) {
|
|
327
|
-
|
|
1011
|
+
const r = makeErrorResponse(error, error.statusCode);
|
|
1012
|
+
addCapabilityHeaders(r.headers);
|
|
1013
|
+
applyStickyResponseHeaders(r.headers, stickySink);
|
|
1014
|
+
return compressIfAccepted(r, clientAcceptsZstd, clientAcceptsGzip);
|
|
328
1015
|
}
|
|
329
|
-
|
|
1016
|
+
const r = makeErrorResponse(error, 500);
|
|
1017
|
+
addCapabilityHeaders(r.headers);
|
|
1018
|
+
applyStickyResponseHeaders(r.headers, stickySink);
|
|
1019
|
+
return compressIfAccepted(r, clientAcceptsZstd, clientAcceptsGzip);
|
|
330
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
|
+
}
|
|
331
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
|
+
}
|
|
332
1037
|
}
|
|
333
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
|
+
}
|
|
334
1056
|
}
|