@tonyclaw/agent-inspector 3.1.20 → 3.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.output/backend/nitro.json +1 -1
  2. package/.output/cli.js +137 -20
  3. package/.output/server/_ssr/index.mjs +2 -2
  4. package/.output/server/_ssr/{router-DpXchCXm.mjs → router-DKFaWvAr.mjs} +2371 -1171
  5. package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
  6. package/.output/server/index.mjs +1 -1
  7. package/.output/ui/assets/{CompareDrawer-4i1QHaQf.js → CompareDrawer-pPcsyvc6.js} +1 -1
  8. package/.output/ui/assets/{InspectorPet-Cw6VJLr7.js → InspectorPet-DR3JEqze.js} +1 -1
  9. package/.output/ui/assets/ProxyViewerContainer-DOyYRnb-.js +60 -0
  10. package/.output/ui/assets/{ReplayDialog-DTs575iW.js → ReplayDialog-Z-1-gUDm.js} +1 -1
  11. package/.output/ui/assets/{RequestAnatomy-Cl6Es3gv.js → RequestAnatomy-BsnZjE5G.js} +1 -1
  12. package/.output/ui/assets/{ResponseView-DNCCLkzG.js → ResponseView-HY64_ZY2.js} +1 -1
  13. package/.output/ui/assets/{StreamingChunkSequence-DKaxRL9U.js → StreamingChunkSequence-CfQUFbTA.js} +1 -1
  14. package/.output/ui/assets/{_sessionId-rkR3WgRq.js → _sessionId-Dq97lD86.js} +1 -1
  15. package/.output/ui/assets/{_sessionId-C-YjWz50.js → _sessionId-_F9wwa3a.js} +1 -1
  16. package/.output/ui/assets/index-B-Qod-aA.css +1 -0
  17. package/.output/ui/assets/{index-DxiecWHw.js → index-B2yLwOEu.js} +1 -1
  18. package/.output/ui/assets/{index-D0d-V67x.js → index-CN-gZDqV.js} +1 -1
  19. package/.output/ui/assets/{index-DYbzZTDM.js → index-DOAGzrq2.js} +1 -1
  20. package/.output/ui/assets/{index-B48RD1-5.js → index-DYnqmBBx.js} +2 -2
  21. package/.output/ui/assets/{json-viewer-CjAwZhHA.js → json-viewer-B2SQN0Lg.js} +1 -1
  22. package/.output/ui/assets/{jszip.min-DEGEN1HP.js → jszip.min-Dpks67u2.js} +1 -1
  23. package/.output/ui/index.html +2 -2
  24. package/.output/workers/logFinalizer.worker.js +258 -0
  25. package/.output/workers/sessionWorkerEntry.js +258 -0
  26. package/package.json +13 -6
  27. package/src/backend/routes/api/logs.$id.replay.ts +6 -1
  28. package/src/backend/routes/api/logs.stream.ts +7 -0
  29. package/src/backend/routes/api/providers.$providerId.model-metadata.ts +15 -4
  30. package/src/backend/routes/api/storage.ts +56 -0
  31. package/src/backend/routes/metrics.ts +14 -0
  32. package/src/cli/doctor.ts +135 -2
  33. package/src/cli.ts +7 -4
  34. package/src/components/ProxyViewerContainer.tsx +41 -24
  35. package/src/components/providers/SettingsDialog.tsx +261 -70
  36. package/src/components/proxy-viewer/LogEntry.tsx +19 -5
  37. package/src/components/proxy-viewer/bodyHydration.ts +67 -0
  38. package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +78 -0
  39. package/src/knowledge/openclawClient.ts +11 -2
  40. package/src/knowledge/openclawGatewayClient.ts +6 -1
  41. package/src/lib/resourceLimits.ts +191 -0
  42. package/src/lib/safeFetch.ts +428 -20
  43. package/src/proxy/ecosystemTasks.ts +20 -0
  44. package/src/proxy/handler.ts +17 -4
  45. package/src/proxy/identityProxy.ts +36 -16
  46. package/src/proxy/logIndex.ts +38 -6
  47. package/src/proxy/logger.ts +4 -0
  48. package/src/proxy/rawStreamCapture.ts +16 -1
  49. package/src/proxy/runtimeHealth.ts +4 -2
  50. package/src/proxy/runtimeMetrics.ts +149 -0
  51. package/src/proxy/sessionRuntime.ts +38 -0
  52. package/src/proxy/sqliteLogIndex.ts +51 -0
  53. package/src/proxy/storageLifecycle.ts +432 -0
  54. package/src/proxy/store.ts +62 -12
  55. package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
  56. package/.output/ui/assets/ProxyViewerContainer-BST0B7lY.js +0 -60
  57. package/.output/ui/assets/index-IBEDJoV_.css +0 -1
@@ -11,6 +11,197 @@ export const MAX_ACTIVE_PROVIDER_TESTS = 2;
11
11
  export const MAX_PROVIDER_TEST_CONCURRENCY = 4;
12
12
  export const MAX_ACTIVE_PROXY_REQUESTS = 16;
13
13
 
14
+ const positiveInteger = z.number().int().positive();
15
+
16
+ export const RuntimeBudgetPolicySchema = z
17
+ .object({
18
+ proxy: z.object({
19
+ requestBytes: positiveInteger,
20
+ responseBytes: positiveInteger,
21
+ headerBytes: positiveInteger,
22
+ deadlineMs: positiveInteger,
23
+ idleTimeoutMs: positiveInteger,
24
+ activeRequests: positiveInteger,
25
+ }),
26
+ control: z.object({
27
+ requestBytes: positiveInteger,
28
+ responseBytes: positiveInteger,
29
+ headerBytes: positiveInteger,
30
+ deadlineMs: positiveInteger,
31
+ idleTimeoutMs: positiveInteger,
32
+ activeRequests: positiveInteger,
33
+ }),
34
+ upstream: z.object({
35
+ fetchResponseBytes: positiveInteger,
36
+ fetchDeadlineMs: positiveInteger,
37
+ replayResponseBytes: positiveInteger,
38
+ replayDeadlineMs: positiveInteger,
39
+ metadataResponseBytes: positiveInteger,
40
+ metadataDeadlineMs: positiveInteger,
41
+ connectTimeoutMs: positiveInteger,
42
+ idleTimeoutMs: positiveInteger,
43
+ }),
44
+ pagination: z.object({
45
+ restPageSize: positiveInteger,
46
+ searchQueryChars: positiveInteger,
47
+ searchProjectChars: positiveInteger,
48
+ }),
49
+ sse: z.object({
50
+ clients: positiveInteger,
51
+ initLogs: positiveInteger,
52
+ heartbeatMs: positiveInteger,
53
+ }),
54
+ imports: z.object({
55
+ archiveBytes: positiveInteger,
56
+ entries: positiveInteger,
57
+ entryBytes: positiveInteger,
58
+ uncompressedBytes: positiveInteger,
59
+ expansionRatio: positiveInteger,
60
+ }),
61
+ fanOut: z.object({
62
+ providerModels: positiveInteger,
63
+ providerModelMetadata: positiveInteger,
64
+ providerTestConcurrency: positiveInteger,
65
+ activeProviderTests: positiveInteger,
66
+ }),
67
+ queues: z.object({
68
+ proxyRequests: positiveInteger,
69
+ finalizerJobs: positiveInteger,
70
+ rawStreamMessages: positiveInteger,
71
+ rawStreamBytes: positiveInteger,
72
+ backgroundWorkers: positiveInteger,
73
+ }),
74
+ })
75
+ .superRefine((policy, context) => {
76
+ if (policy.control.requestBytes > policy.proxy.requestBytes) {
77
+ context.addIssue({
78
+ code: "custom",
79
+ path: ["control", "requestBytes"],
80
+ message: "control request budget must not exceed proxy request budget",
81
+ });
82
+ }
83
+ if (policy.control.responseBytes > policy.proxy.responseBytes) {
84
+ context.addIssue({
85
+ code: "custom",
86
+ path: ["control", "responseBytes"],
87
+ message: "control response budget must not exceed proxy response budget",
88
+ });
89
+ }
90
+ if (policy.control.activeRequests > policy.proxy.activeRequests) {
91
+ context.addIssue({
92
+ code: "custom",
93
+ path: ["control", "activeRequests"],
94
+ message: "control active request budget must not exceed proxy active request budget",
95
+ });
96
+ }
97
+ if (policy.upstream.fetchResponseBytes > policy.proxy.responseBytes) {
98
+ context.addIssue({
99
+ code: "custom",
100
+ path: ["upstream", "fetchResponseBytes"],
101
+ message: "upstream fetch response budget must not exceed proxy response budget",
102
+ });
103
+ }
104
+ if (policy.upstream.replayResponseBytes > policy.proxy.responseBytes) {
105
+ context.addIssue({
106
+ code: "custom",
107
+ path: ["upstream", "replayResponseBytes"],
108
+ message: "upstream replay response budget must not exceed proxy response budget",
109
+ });
110
+ }
111
+ if (policy.upstream.metadataResponseBytes > policy.control.responseBytes) {
112
+ context.addIssue({
113
+ code: "custom",
114
+ path: ["upstream", "metadataResponseBytes"],
115
+ message: "upstream metadata response budget must not exceed control response budget",
116
+ });
117
+ }
118
+ if (policy.upstream.metadataDeadlineMs > policy.control.deadlineMs) {
119
+ context.addIssue({
120
+ code: "custom",
121
+ path: ["upstream", "metadataDeadlineMs"],
122
+ message: "upstream metadata deadline must not exceed control deadline",
123
+ });
124
+ }
125
+ if (policy.queues.proxyRequests < policy.proxy.activeRequests) {
126
+ context.addIssue({
127
+ code: "custom",
128
+ path: ["queues", "proxyRequests"],
129
+ message: "proxy request queue budget must cover active proxy requests",
130
+ });
131
+ }
132
+ });
133
+
134
+ export type RuntimeBudgetPolicy = z.infer<typeof RuntimeBudgetPolicySchema>;
135
+
136
+ export const DEFAULT_RUNTIME_BUDGET_POLICY = RuntimeBudgetPolicySchema.parse({
137
+ proxy: {
138
+ requestBytes: 64 * 1024 * 1024,
139
+ responseBytes: 256 * 1024 * 1024,
140
+ headerBytes: 64 * 1024,
141
+ deadlineMs: 120_000,
142
+ idleTimeoutMs: 30_000,
143
+ activeRequests: MAX_ACTIVE_PROXY_REQUESTS,
144
+ },
145
+ control: {
146
+ requestBytes: 8 * 1024 * 1024,
147
+ responseBytes: 64 * 1024 * 1024,
148
+ headerBytes: 32 * 1024,
149
+ deadlineMs: 30_000,
150
+ idleTimeoutMs: 10_000,
151
+ activeRequests: 8,
152
+ },
153
+ upstream: {
154
+ fetchResponseBytes: 256 * 1024 * 1024,
155
+ fetchDeadlineMs: 120_000,
156
+ replayResponseBytes: 256 * 1024 * 1024,
157
+ replayDeadlineMs: 120_000,
158
+ metadataResponseBytes: 10 * 1024 * 1024,
159
+ metadataDeadlineMs: 30_000,
160
+ connectTimeoutMs: 10_000,
161
+ idleTimeoutMs: 30_000,
162
+ },
163
+ pagination: {
164
+ restPageSize: MAX_REST_LOG_PAGE_SIZE,
165
+ searchQueryChars: MAX_SEARCH_QUERY_CHARS,
166
+ searchProjectChars: MAX_SEARCH_PROJECT_CHARS,
167
+ },
168
+ sse: {
169
+ clients: MAX_SSE_CLIENTS,
170
+ initLogs: 500,
171
+ heartbeatMs: 15_000,
172
+ },
173
+ imports: {
174
+ archiveBytes: 64 * 1024 * 1024,
175
+ entries: 5_000,
176
+ entryBytes: 32 * 1024 * 1024,
177
+ uncompressedBytes: 256 * 1024 * 1024,
178
+ expansionRatio: 20,
179
+ },
180
+ fanOut: {
181
+ providerModels: MAX_PROVIDER_MODELS,
182
+ providerModelMetadata: MAX_PROVIDER_MODEL_METADATA,
183
+ providerTestConcurrency: MAX_PROVIDER_TEST_CONCURRENCY,
184
+ activeProviderTests: MAX_ACTIVE_PROVIDER_TESTS,
185
+ },
186
+ queues: {
187
+ proxyRequests: MAX_ACTIVE_PROXY_REQUESTS,
188
+ finalizerJobs: 1_000,
189
+ rawStreamMessages: 16_384,
190
+ rawStreamBytes: 64 * 1024 * 1024,
191
+ backgroundWorkers: 4,
192
+ },
193
+ });
194
+
195
+ export function validateRuntimeBudgetPolicy(input: unknown): RuntimeBudgetPolicy {
196
+ return RuntimeBudgetPolicySchema.parse(input);
197
+ }
198
+
199
+ export function runtimeBudgetPolicySnapshot(
200
+ policy: RuntimeBudgetPolicy = DEFAULT_RUNTIME_BUDGET_POLICY,
201
+ ): RuntimeBudgetPolicy {
202
+ return RuntimeBudgetPolicySchema.parse(policy);
203
+ }
204
+
14
205
  export const ResourceLimitErrorCodeSchema = z.enum([
15
206
  "invalid-page-request",
16
207
  "search-limit-exceeded",
@@ -1,5 +1,8 @@
1
1
  import { lookup } from "node:dns/promises";
2
+ import { request as requestHttp } from "node:http";
3
+ import { request as requestHttps } from "node:https";
2
4
  import { isIP } from "node:net";
5
+ import type { IncomingMessage, RequestOptions } from "node:http";
3
6
  import { evaluateUpstreamUrl, isInternalHost, type SsrfGuardOptions } from "./ssrfGuard";
4
7
 
5
8
  export type ResolvedAddress = {
@@ -11,11 +14,18 @@ export type HostResolver = (hostname: string) => Promise<readonly ResolvedAddres
11
14
 
12
15
  export type SafeFetchOptions = SsrfGuardOptions & {
13
16
  resolver?: HostResolver;
17
+ deadlineMs?: number;
18
+ responseBytes?: number;
19
+ pinDns?: boolean;
14
20
  };
15
21
 
16
22
  export type SafeFetchResult =
17
23
  | { ok: true; response: Response }
18
- | { ok: false; kind: "network-policy" | "redirect"; reason: string };
24
+ | {
25
+ ok: false;
26
+ kind: "network-policy" | "redirect" | "resource-limit" | "timeout";
27
+ reason: string;
28
+ };
19
29
 
20
30
  async function defaultResolver(hostname: string): Promise<readonly ResolvedAddress[]> {
21
31
  return lookup(hostname, { all: true, verbatim: true });
@@ -35,12 +45,28 @@ export async function evaluateResolvedUpstreamUrl(
35
45
  rawUrl: string,
36
46
  options: SafeFetchOptions = {},
37
47
  ): Promise<{ allow: true } | { allow: false; reason: string }> {
48
+ const decision = await evaluateResolvedUpstreamUrlForFetch(rawUrl, options);
49
+ return decision.allow ? { allow: true } : { allow: false, reason: decision.reason };
50
+ }
51
+
52
+ type ResolvedUpstreamUrlDecision =
53
+ | { allow: true; hostname: string; addresses: readonly ResolvedAddress[] }
54
+ | { allow: false; reason: string };
55
+
56
+ async function evaluateResolvedUpstreamUrlForFetch(
57
+ rawUrl: string,
58
+ options: SafeFetchOptions,
59
+ ): Promise<ResolvedUpstreamUrlDecision> {
38
60
  const initial = evaluateUpstreamUrl(rawUrl, options);
39
- if (!initial.allow || options.bypass === true) return initial;
61
+ if (!initial.allow) return initial;
40
62
 
41
63
  const parsed = new URL(rawUrl);
42
64
  const hostname = parsed.hostname.replace(/^\[|]$/g, "");
43
- if (isIP(hostname) !== 0 || hostname.toLowerCase() === "localhost") return initial;
65
+ if (options.bypass === true) return { allow: true, hostname, addresses: [] };
66
+
67
+ const ipFamily = isIP(hostname);
68
+ if (ipFamily !== 0)
69
+ return { allow: true, hostname, addresses: [{ address: hostname, family: ipFamily }] };
44
70
 
45
71
  let addresses: readonly ResolvedAddress[];
46
72
  try {
@@ -65,36 +91,418 @@ export async function evaluateResolvedUpstreamUrl(
65
91
  };
66
92
  }
67
93
 
68
- return { allow: true };
94
+ return { allow: true, hostname, addresses };
69
95
  }
70
96
 
71
97
  function isRedirectStatus(status: number): boolean {
72
98
  return status >= 300 && status < 400;
73
99
  }
74
100
 
101
+ function formatByteLimit(limit: number): string {
102
+ return `${String(limit)} bytes`;
103
+ }
104
+
105
+ function parseContentLength(value: string | null): number | null {
106
+ if (value === null) return null;
107
+ const parsed = Number(value);
108
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
109
+ return parsed;
110
+ }
111
+
112
+ function isPinnedDnsEnabled(options: SafeFetchOptions): boolean {
113
+ const disabledForTest =
114
+ typeof process !== "undefined" &&
115
+ process.env["NODE_ENV"] === "test" &&
116
+ process.env["AGENT_INSPECTOR_DISABLE_DNS_PINNING_FOR_TESTS"] === "1";
117
+ if (disabledForTest) return false;
118
+ return options.pinDns !== false && options.bypass !== true;
119
+ }
120
+
121
+ function choosePinnedAddress(addresses: readonly ResolvedAddress[]): ResolvedAddress | null {
122
+ return addresses[0] ?? null;
123
+ }
124
+
125
+ function hostHeaderValue(parsed: URL): string {
126
+ return parsed.port.length === 0 ? parsed.host : `${parsed.hostname}:${parsed.port}`;
127
+ }
128
+
129
+ function headersToRecord(headers: HeadersInit | undefined, parsed: URL): Record<string, string> {
130
+ const normalized = new Headers(headers);
131
+ if (!normalized.has("host")) {
132
+ normalized.set("host", hostHeaderValue(parsed));
133
+ }
134
+ const result: Record<string, string> = {};
135
+ normalized.forEach((value, key) => {
136
+ result[key] = value;
137
+ });
138
+ return result;
139
+ }
140
+
141
+ async function requestBodyToUint8Array(
142
+ body: BodyInit | null | undefined,
143
+ ): Promise<
144
+ { ok: true; body: Uint8Array | null; contentType: string | null } | { ok: false; reason: string }
145
+ > {
146
+ if (body === undefined || body === null) return { ok: true, body: null, contentType: null };
147
+ if (typeof body === "string") {
148
+ return { ok: true, body: new TextEncoder().encode(body), contentType: null };
149
+ }
150
+ if (body instanceof URLSearchParams) {
151
+ return {
152
+ ok: true,
153
+ body: new TextEncoder().encode(body.toString()),
154
+ contentType: "application/x-www-form-urlencoded;charset=UTF-8",
155
+ };
156
+ }
157
+ if (body instanceof Uint8Array) return { ok: true, body, contentType: null };
158
+ if (body instanceof ArrayBuffer) {
159
+ return { ok: true, body: new Uint8Array(body), contentType: null };
160
+ }
161
+ if (body instanceof Blob) {
162
+ return { ok: true, body: new Uint8Array(await body.arrayBuffer()), contentType: body.type };
163
+ }
164
+ return {
165
+ ok: false,
166
+ reason: "Pinned upstream fetch only supports buffered request bodies",
167
+ };
168
+ }
169
+
170
+ function pinnedLookup(
171
+ pinnedAddress: ResolvedAddress,
172
+ ): (
173
+ hostname: string,
174
+ options: { all?: boolean },
175
+ callback: (error: Error | null, address: string | ResolvedAddress[], family?: number) => void,
176
+ ) => void {
177
+ return (_hostname, options, callback) => {
178
+ if (options.all === true) {
179
+ callback(null, [pinnedAddress]);
180
+ return;
181
+ }
182
+ callback(null, pinnedAddress.address, pinnedAddress.family);
183
+ };
184
+ }
185
+
186
+ function requestOptionsForPinnedFetch(
187
+ parsed: URL,
188
+ init: RequestInit,
189
+ pinnedAddress: ResolvedAddress,
190
+ contentType: string | null,
191
+ ): RequestOptions {
192
+ const headers = headersToRecord(init.headers, parsed);
193
+ if (contentType !== null && !new Headers(headers).has("content-type")) {
194
+ headers["content-type"] = contentType;
195
+ }
196
+
197
+ return {
198
+ protocol: parsed.protocol,
199
+ hostname: parsed.hostname,
200
+ port: parsed.port,
201
+ path: `${parsed.pathname}${parsed.search}`,
202
+ method: init.method ?? "GET",
203
+ headers,
204
+ lookup: pinnedLookup(pinnedAddress),
205
+ };
206
+ }
207
+
208
+ function streamFromIncomingMessage(
209
+ incoming: IncomingMessage,
210
+ maxBytes: number | undefined,
211
+ ): ReadableStream<Uint8Array> {
212
+ let seenBytes = 0;
213
+ return new ReadableStream<Uint8Array>({
214
+ start(controller) {
215
+ incoming.on("data", (chunk: Uint8Array) => {
216
+ seenBytes += chunk.byteLength;
217
+ if (maxBytes !== undefined && seenBytes > maxBytes) {
218
+ incoming.destroy(
219
+ new Error(
220
+ `Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`,
221
+ ),
222
+ );
223
+ controller.error(
224
+ new Error(
225
+ `Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`,
226
+ ),
227
+ );
228
+ return;
229
+ }
230
+ controller.enqueue(chunk);
231
+ });
232
+ incoming.on("end", () => {
233
+ controller.close();
234
+ });
235
+ incoming.on("error", (error) => {
236
+ controller.error(error);
237
+ });
238
+ },
239
+ cancel() {
240
+ incoming.destroy();
241
+ },
242
+ });
243
+ }
244
+
245
+ async function cancelBody(response: Response): Promise<void> {
246
+ if (response.body === null) return;
247
+ await response.body.cancel().catch(() => undefined);
248
+ }
249
+
250
+ function responseWithByteLimit(response: Response, maxBytes: number): Response {
251
+ if (response.body === null) return response;
252
+
253
+ let seenBytes = 0;
254
+ const reader = response.body.getReader();
255
+ const body = new ReadableStream<Uint8Array>({
256
+ async pull(controller) {
257
+ const chunk = await reader.read();
258
+ if (chunk.done) {
259
+ controller.close();
260
+ return;
261
+ }
262
+
263
+ seenBytes += chunk.value.byteLength;
264
+ if (seenBytes > maxBytes) {
265
+ void reader.cancel().catch(() => undefined);
266
+ controller.error(
267
+ new Error(`Upstream response exceeded configured limit of ${formatByteLimit(maxBytes)}`),
268
+ );
269
+ return;
270
+ }
271
+
272
+ controller.enqueue(chunk.value);
273
+ },
274
+ cancel(reason) {
275
+ return reader.cancel(reason);
276
+ },
277
+ });
278
+
279
+ return new Response(body, {
280
+ status: response.status,
281
+ statusText: response.statusText,
282
+ headers: response.headers,
283
+ });
284
+ }
285
+
286
+ function shouldUseBudgetedSignal(init: RequestInit, deadlineMs: number | undefined): boolean {
287
+ return deadlineMs !== undefined || init.signal !== undefined;
288
+ }
289
+
290
+ async function pinnedNodeFetch(
291
+ rawUrl: string,
292
+ init: RequestInit,
293
+ options: SafeFetchOptions,
294
+ decision: Extract<ResolvedUpstreamUrlDecision, { allow: true }>,
295
+ ): Promise<SafeFetchResult> {
296
+ const parsed = new URL(rawUrl);
297
+ const pinnedAddress = choosePinnedAddress(decision.addresses);
298
+ if (pinnedAddress === null) {
299
+ return {
300
+ ok: false,
301
+ kind: "network-policy",
302
+ reason: `Could not pin a resolved upstream address for "${decision.hostname}"`,
303
+ };
304
+ }
305
+
306
+ const encodedBody = await requestBodyToUint8Array(init.body);
307
+ if (!encodedBody.ok) {
308
+ return { ok: false, kind: "resource-limit", reason: encodedBody.reason };
309
+ }
310
+
311
+ return await new Promise<SafeFetchResult>((resolve, reject) => {
312
+ const linkedSignal = init.signal ?? undefined;
313
+ const transport = parsed.protocol === "https:" ? requestHttps : requestHttp;
314
+ const requestOptions = requestOptionsForPinnedFetch(
315
+ parsed,
316
+ init,
317
+ pinnedAddress,
318
+ encodedBody.contentType,
319
+ );
320
+ const request = transport(requestOptions, (incoming) => {
321
+ const status = incoming.statusCode ?? 0;
322
+ if (isRedirectStatus(status)) {
323
+ incoming.resume();
324
+ const location = incoming.headers.location;
325
+ resolve({
326
+ ok: false,
327
+ kind: "redirect",
328
+ reason:
329
+ location === undefined
330
+ ? `Upstream returned blocked redirect HTTP ${String(status)}`
331
+ : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
332
+ });
333
+ return;
334
+ }
335
+
336
+ const maxBytes = options.responseBytes;
337
+ const headers = new Headers();
338
+ for (const [key, value] of Object.entries(incoming.headers)) {
339
+ if (Array.isArray(value)) {
340
+ headers.set(key, value.join(", "));
341
+ continue;
342
+ }
343
+ if (value !== undefined) headers.set(key, value);
344
+ }
345
+
346
+ if (maxBytes !== undefined) {
347
+ const declaredBytes = parseContentLength(headers.get("content-length"));
348
+ if (declaredBytes !== null && declaredBytes > maxBytes) {
349
+ incoming.destroy();
350
+ resolve({
351
+ ok: false,
352
+ kind: "resource-limit",
353
+ reason: `Upstream declared ${String(declaredBytes)} response bytes, exceeding configured limit of ${formatByteLimit(maxBytes)}`,
354
+ });
355
+ return;
356
+ }
357
+ }
358
+
359
+ resolve({
360
+ ok: true,
361
+ response: new Response(streamFromIncomingMessage(incoming, maxBytes), {
362
+ status,
363
+ statusText: incoming.statusMessage,
364
+ headers,
365
+ }),
366
+ });
367
+ });
368
+
369
+ let timedOut = false;
370
+ let timeout: ReturnType<typeof setTimeout> | undefined;
371
+ const finishAbort = (): void => {
372
+ request.destroy(new DOMException("Safe fetch aborted", "AbortError"));
373
+ };
374
+
375
+ if (linkedSignal !== undefined) {
376
+ if (linkedSignal.aborted) {
377
+ finishAbort();
378
+ } else {
379
+ linkedSignal.addEventListener("abort", finishAbort, { once: true });
380
+ }
381
+ }
382
+
383
+ if (options.deadlineMs !== undefined) {
384
+ timeout = setTimeout(() => {
385
+ timedOut = true;
386
+ request.destroy(new DOMException("Safe fetch deadline exceeded", "TimeoutError"));
387
+ }, options.deadlineMs);
388
+ }
389
+
390
+ request.on("error", (error) => {
391
+ if (timeout !== undefined) clearTimeout(timeout);
392
+ if (linkedSignal !== undefined) {
393
+ linkedSignal.removeEventListener("abort", finishAbort);
394
+ }
395
+ if (timedOut) {
396
+ resolve({
397
+ ok: false,
398
+ kind: "timeout",
399
+ reason: `Upstream request exceeded configured deadline of ${String(options.deadlineMs)} ms`,
400
+ });
401
+ return;
402
+ }
403
+ reject(error);
404
+ });
405
+
406
+ request.on("close", () => {
407
+ if (timeout !== undefined) clearTimeout(timeout);
408
+ if (linkedSignal !== undefined) {
409
+ linkedSignal.removeEventListener("abort", finishAbort);
410
+ }
411
+ });
412
+
413
+ if (encodedBody.body !== null) request.write(encodedBody.body);
414
+ request.end();
415
+ });
416
+ }
417
+
75
418
  export async function safeFetch(
76
419
  rawUrl: string,
77
420
  init: RequestInit = {},
78
421
  options: SafeFetchOptions = {},
79
422
  ): Promise<SafeFetchResult> {
80
- const decision = await evaluateResolvedUpstreamUrl(rawUrl, options);
81
- if (!decision.allow) {
82
- return { ok: false, kind: "network-policy", reason: decision.reason };
423
+ const resolvedDecision = await evaluateResolvedUpstreamUrlForFetch(rawUrl, options);
424
+ if (!resolvedDecision.allow) {
425
+ return { ok: false, kind: "network-policy", reason: resolvedDecision.reason };
83
426
  }
84
427
 
85
- const response = await fetch(rawUrl, { ...init, redirect: "manual" });
86
- if (!isRedirectStatus(response.status)) return { ok: true, response };
87
-
88
- if (response.body !== null) {
89
- await response.body.cancel().catch(() => undefined);
428
+ if (isPinnedDnsEnabled(options)) {
429
+ return await pinnedNodeFetch(rawUrl, init, options, resolvedDecision);
90
430
  }
91
- const location = response.headers.get("location");
92
- return {
93
- ok: false,
94
- kind: "redirect",
95
- reason:
96
- location === null
97
- ? `Upstream returned blocked redirect HTTP ${String(response.status)}`
98
- : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
431
+
432
+ const deadlineMs = options.deadlineMs;
433
+ const useBudgetedSignal = shouldUseBudgetedSignal(init, deadlineMs);
434
+ const controller = useBudgetedSignal ? new AbortController() : undefined;
435
+ let timedOut = false;
436
+ let timeout: ReturnType<typeof setTimeout> | undefined;
437
+ const linkedSignal = init.signal ?? undefined;
438
+ const forwardAbort = (): void => {
439
+ controller?.abort(linkedSignal?.reason);
99
440
  };
441
+
442
+ if (controller !== undefined && linkedSignal !== undefined) {
443
+ if (linkedSignal.aborted) {
444
+ forwardAbort();
445
+ } else {
446
+ linkedSignal.addEventListener("abort", forwardAbort, { once: true });
447
+ }
448
+ }
449
+
450
+ if (controller !== undefined && deadlineMs !== undefined) {
451
+ timeout = setTimeout(() => {
452
+ timedOut = true;
453
+ controller.abort(new DOMException("Safe fetch deadline exceeded", "TimeoutError"));
454
+ }, deadlineMs);
455
+ }
456
+
457
+ try {
458
+ const response = await fetch(rawUrl, {
459
+ ...init,
460
+ redirect: "manual",
461
+ signal: controller?.signal ?? init.signal,
462
+ });
463
+ if (!isRedirectStatus(response.status)) {
464
+ const maxBytes = options.responseBytes;
465
+ if (maxBytes === undefined) return { ok: true, response };
466
+
467
+ const declaredBytes = parseContentLength(response.headers.get("content-length"));
468
+ if (declaredBytes !== null && declaredBytes > maxBytes) {
469
+ await cancelBody(response);
470
+ return {
471
+ ok: false,
472
+ kind: "resource-limit",
473
+ reason: `Upstream declared ${String(declaredBytes)} response bytes, exceeding configured limit of ${formatByteLimit(maxBytes)}`,
474
+ };
475
+ }
476
+
477
+ return { ok: true, response: responseWithByteLimit(response, maxBytes) };
478
+ }
479
+
480
+ await cancelBody(response);
481
+ const location = response.headers.get("location");
482
+ return {
483
+ ok: false,
484
+ kind: "redirect",
485
+ reason:
486
+ location === null
487
+ ? `Upstream returned blocked redirect HTTP ${String(response.status)}`
488
+ : `Upstream redirect to "${location}" is blocked; configure the final API URL directly`,
489
+ };
490
+ } catch (error) {
491
+ if (timedOut) {
492
+ return {
493
+ ok: false,
494
+ kind: "timeout",
495
+ reason:
496
+ deadlineMs === undefined
497
+ ? "Upstream request exceeded its configured deadline"
498
+ : `Upstream request exceeded configured deadline of ${String(deadlineMs)} ms`,
499
+ };
500
+ }
501
+ return Promise.reject(error);
502
+ } finally {
503
+ if (timeout !== undefined) clearTimeout(timeout);
504
+ if (controller !== undefined && linkedSignal !== undefined) {
505
+ linkedSignal.removeEventListener("abort", forwardAbort);
506
+ }
507
+ }
100
508
  }