@agent-device/proxy 0.0.0-stage → 0.21.23

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/index.mjs ADDED
@@ -0,0 +1,463 @@
1
+ import { r as normalizeError, t as AppError } from "./errors.mjs";
2
+ import crypto, { randomUUID } from "node:crypto";
3
+ import http from "node:http";
4
+ //#region ../host-kit/src/internal/transport.ts
5
+ /**
6
+ * Compares two secret strings in constant time. Hashing both inputs first
7
+ * keeps the comparison length-independent, so unequal-length tokens neither
8
+ * throw nor leak length via timing.
9
+ */
10
+ function timingSafeStringEqual(a, b) {
11
+ const hashA = crypto.createHash("sha256").update(a).digest();
12
+ const hashB = crypto.createHash("sha256").update(b).digest();
13
+ return crypto.timingSafeEqual(hashA, hashB);
14
+ }
15
+ //#endregion
16
+ //#region ../contracts/src/daemon-http.ts
17
+ const DAEMON_HTTP_BASE_PATH = "/agent-device";
18
+ const DAEMON_HTTP_TENANT_HEADER = "x-agent-device-tenant";
19
+ const DAEMON_HTTP_NETWORK_ACCESS_HEADER = "x-agent-device-network-access";
20
+ const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = "public-only";
21
+ function buildDaemonHttpUrl(baseUrl, route) {
22
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
23
+ return new URL(route.replace(/^\/+/, ""), normalizedBase).toString();
24
+ }
25
+ function buildDaemonHttpAuthHeaders(token) {
26
+ const normalizedToken = token?.trim();
27
+ if (!normalizedToken) return {};
28
+ return {
29
+ authorization: `Bearer ${normalizedToken}`,
30
+ "x-agent-device-token": normalizedToken
31
+ };
32
+ }
33
+ const DAEMON_HTTP_INSTANCE_HEADER = "x-agent-device-instance";
34
+ const DAEMON_HTTP_UPSTREAM_INSTANCE_HEADER = "x-agent-device-upstream-instance";
35
+ const DAEMON_HTTP_INSTANCE_MISMATCH_HEADER = "x-agent-device-instance-mismatch";
36
+ function buildDaemonInstanceMismatchRpcResponse(id, message, data) {
37
+ return {
38
+ jsonrpc: "2.0",
39
+ id,
40
+ error: {
41
+ code: -32001,
42
+ message,
43
+ data
44
+ }
45
+ };
46
+ }
47
+ function buildDaemonHealthPayload(service, version, options = {}) {
48
+ return {
49
+ ok: true,
50
+ service,
51
+ version,
52
+ rpcProtocolVersion: 2,
53
+ ...options.instanceId !== void 0 ? { instanceId: options.instanceId } : {},
54
+ ...options.hostArch !== void 0 ? { hostArch: options.hostArch } : {},
55
+ ...options.leaseBackends !== void 0 ? { leaseBackends: options.leaseBackends } : {},
56
+ ...options.upstream !== void 0 ? { upstream: options.upstream } : {}
57
+ };
58
+ }
59
+ //#endregion
60
+ //#region src/install-source-admission.ts
61
+ function carriesUnbackedHostPathInstallSource(rpcBody) {
62
+ const params = readRpcParams(rpcBody);
63
+ if (!params) return false;
64
+ const meta = readRecord(params.meta);
65
+ if (isBackedByUploadedArtifact(meta)) return false;
66
+ return isHostPathInstallSource(params.source) || isHostPathInstallSource(meta?.installSource);
67
+ }
68
+ function hostPathInstallSourceRefusedResponse(rpcId) {
69
+ const error = new AppError("INVALID_ARGS", "Invalid params: an install source of kind \"path\" names a file on the daemon host and is not accepted through the proxy", { hint: "Upload the artifact, or use a \"url\" or \"github-actions-artifact\" source." });
70
+ return Response.json({
71
+ jsonrpc: "2.0",
72
+ id: rpcId,
73
+ error: {
74
+ code: -32602,
75
+ message: error.message,
76
+ data: normalizeError(error)
77
+ }
78
+ }, { status: 400 });
79
+ }
80
+ function readRpcParams(rpcBody) {
81
+ if (!rpcBody) return void 0;
82
+ try {
83
+ return readRecord(readRecord(JSON.parse(rpcBody))?.params);
84
+ } catch {
85
+ return;
86
+ }
87
+ }
88
+ function isBackedByUploadedArtifact(meta) {
89
+ const uploadedArtifactId = meta?.uploadedArtifactId;
90
+ return typeof uploadedArtifactId === "string" && uploadedArtifactId.trim().length > 0;
91
+ }
92
+ function isHostPathInstallSource(source) {
93
+ return readRecord(source)?.kind === "path";
94
+ }
95
+ function readRecord(value) {
96
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
97
+ }
98
+ //#endregion
99
+ //#region src/daemon-proxy.ts
100
+ const DEFAULT_MAX_RPC_BODY_BYTES = 1048576;
101
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 3e5;
102
+ const DAEMON_PROXY_PREFIX = `${DAEMON_HTTP_BASE_PATH}/`;
103
+ const FORWARDED_REQUEST_HEADERS = [
104
+ "content-type",
105
+ "content-range",
106
+ "x-artifact-type",
107
+ "x-artifact-filename",
108
+ "x-artifact-hash",
109
+ "x-artifact-hash-algorithm",
110
+ DAEMON_HTTP_TENANT_HEADER
111
+ ];
112
+ const FORWARDED_RESPONSE_HEADERS = [
113
+ "content-type",
114
+ "content-disposition",
115
+ "x-request-id",
116
+ DAEMON_HTTP_INSTANCE_MISMATCH_HEADER
117
+ ];
118
+ function createDaemonProxy(options) {
119
+ const normalized = normalizeProxyOptions(options);
120
+ const instanceId = randomUUID();
121
+ return {
122
+ instanceId,
123
+ handle: async (request) => {
124
+ try {
125
+ return await handleProxyRequest(request, normalized, instanceId);
126
+ } catch (error) {
127
+ return proxyErrorResponse(error);
128
+ }
129
+ }
130
+ };
131
+ }
132
+ function createDaemonProxyServer(options) {
133
+ return http.createServer(createDaemonProxyRequestListener(createDaemonProxy(options)));
134
+ }
135
+ /** Serves a proxy from any `node:http` or `node:https` server; upload tickets follow its scheme. */
136
+ function createDaemonProxyRequestListener(proxy) {
137
+ return (req, res) => {
138
+ import("./node-http.mjs").then(({ serveProxyRequest }) => serveProxyRequest(proxy, req, res)).catch((error) => {
139
+ if (!res.destroyed) res.destroy(error instanceof Error ? error : void 0);
140
+ });
141
+ };
142
+ }
143
+ async function handleProxyRequest(request, options, instanceId) {
144
+ const route = resolveProxyRoute(request.url);
145
+ if (request.method === "GET" && route === "/health") return await proxyHealthResponse(request, options, instanceId);
146
+ if (!isSupportedDaemonRoute(route, request.method)) return new Response(new TextEncoder().encode("Not found"), { status: 404 });
147
+ let rpcBody;
148
+ if (route === "/rpc") rpcBody = await readRequestText(request, options.maxRpcBodyBytes, "Proxy request body is too large.");
149
+ if (!isAuthorized(request, options.clientToken, rpcBody)) return unauthorizedResponse(route, readJsonRpcId(rpcBody));
150
+ const staleInstance = refuseStaleProxyInstance(request, route, readJsonRpcId(rpcBody), instanceId);
151
+ if (staleInstance) return staleInstance;
152
+ if (carriesUnbackedHostPathInstallSource(rpcBody)) return hostPathInstallSourceRefusedResponse(readJsonRpcId(rpcBody));
153
+ return await forwardProxyRequest({
154
+ request,
155
+ route,
156
+ options,
157
+ rpcBody
158
+ });
159
+ }
160
+ async function proxyHealthResponse(request, options, instanceId) {
161
+ const [upstream, { readVersion }, { readHostCpuArch }] = await Promise.all([
162
+ readUpstreamHealth(request, options),
163
+ import("./version.mjs"),
164
+ import("./process.mjs")
165
+ ]);
166
+ return Response.json(buildDaemonHealthPayload("agent-device-proxy", readVersion(), {
167
+ upstream,
168
+ instanceId,
169
+ hostArch: await readHostCpuArch()
170
+ }));
171
+ }
172
+ async function readUpstreamHealth(request, options) {
173
+ const response = await options.upstreamFetch(new Request(buildDaemonHttpUrl(options.upstreamBaseUrl, "health"), {
174
+ method: "GET",
175
+ headers: buildUpstreamHeaders(new Headers(), options.upstreamToken, "/health"),
176
+ signal: upstreamSignal(request, options)
177
+ }));
178
+ const text = await response.text();
179
+ try {
180
+ return text ? JSON.parse(text) : {
181
+ ok: response.ok,
182
+ status: response.status
183
+ };
184
+ } catch {
185
+ return {
186
+ ok: response.ok,
187
+ status: response.status
188
+ };
189
+ }
190
+ }
191
+ async function forwardProxyRequest(params) {
192
+ const { request, route, options, rpcBody } = params;
193
+ const body = resolveUpstreamBody(request, route, rpcBody, options.upstreamToken);
194
+ const upstreamResponse = await options.upstreamFetch(new Request(buildUpstreamUrl(options.upstreamBaseUrl, route, request.url), {
195
+ method: request.method,
196
+ headers: buildUpstreamHeaders(request.headers, options.upstreamToken, route),
197
+ signal: upstreamSignal(request, options),
198
+ ...body ? {
199
+ body,
200
+ duplex: "half"
201
+ } : {}
202
+ }));
203
+ const headers = copyProxyResponseHeaders(upstreamResponse, request);
204
+ if (isUploadPreflightRoute(route)) return await rewrittenUploadPreflightResponse({
205
+ request,
206
+ upstreamResponse,
207
+ headers,
208
+ clientToken: options.clientToken
209
+ });
210
+ return new Response(upstreamResponse.body, {
211
+ status: upstreamResponse.status,
212
+ headers
213
+ });
214
+ }
215
+ /**
216
+ * An upstream exchange lives exactly as long as the client keeps waiting for it: the daemon
217
+ * turns a dropped upstream request into cancellation of in-flight runner work.
218
+ */
219
+ function upstreamSignal(request, options) {
220
+ return AbortSignal.any([request.signal, AbortSignal.timeout(options.upstreamTimeoutMs)]);
221
+ }
222
+ function copyProxyResponseHeaders(upstreamResponse, request) {
223
+ const headers = new Headers();
224
+ for (const name of FORWARDED_RESPONSE_HEADERS) {
225
+ const value = upstreamResponse.headers.get(name);
226
+ if (value) headers.set(name, value);
227
+ }
228
+ if (!headers.has("x-request-id")) headers.set("x-request-id", resolveRequestId(request));
229
+ return headers;
230
+ }
231
+ async function rewrittenUploadPreflightResponse(params) {
232
+ const { request, upstreamResponse, headers, clientToken } = params;
233
+ const text = await upstreamResponse.text();
234
+ headers.set("content-type", upstreamResponse.headers.get("content-type") ?? "application/json");
235
+ return new Response(rewriteUploadPreflightResponse(text, request, clientToken), {
236
+ status: upstreamResponse.status,
237
+ headers
238
+ });
239
+ }
240
+ function rewriteUploadPreflightResponse(body, request, clientToken) {
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(body);
244
+ } catch {
245
+ return body;
246
+ }
247
+ if (!parsed || typeof parsed !== "object") return body;
248
+ const record = parsed;
249
+ if (!record.upload || typeof record.upload.url !== "string") return body;
250
+ const rewrittenUrl = rewriteUploadDirectUrl(record.upload.url, request);
251
+ if (!rewrittenUrl) return body;
252
+ const headers = record.upload.headers && typeof record.upload.headers === "object" ? { ...record.upload.headers } : {};
253
+ Object.assign(headers, buildDaemonHttpAuthHeaders(clientToken));
254
+ return JSON.stringify({
255
+ ...parsed,
256
+ upload: {
257
+ ...record.upload,
258
+ url: rewrittenUrl,
259
+ headers
260
+ }
261
+ });
262
+ }
263
+ function rewriteUploadDirectUrl(upstreamUrl, request) {
264
+ let parsed;
265
+ try {
266
+ parsed = new URL(upstreamUrl);
267
+ } catch {
268
+ return null;
269
+ }
270
+ if (!parsed.pathname.startsWith("/upload/")) return null;
271
+ const requestUrl = new URL(request.url);
272
+ const uploadIndex = requestUrl.pathname.lastIndexOf("/upload/preflight");
273
+ const uploadPrefix = uploadIndex >= 0 ? requestUrl.pathname.slice(0, uploadIndex) : "";
274
+ const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
275
+ const rewritten = new URL(`${forwardedProto || requestUrl.protocol.replace(/:$/, "")}://${requestUrl.host}`);
276
+ rewritten.pathname = `${uploadPrefix}${parsed.pathname}`;
277
+ rewritten.search = parsed.search;
278
+ return rewritten.toString();
279
+ }
280
+ function normalizeProxyOptions(options) {
281
+ return {
282
+ upstreamBaseUrl: normalizeBaseUrl(options.upstreamBaseUrl, "upstreamBaseUrl"),
283
+ upstreamToken: normalizeToken(options.upstreamToken, "upstreamToken"),
284
+ clientToken: normalizeToken(options.clientToken, "clientToken"),
285
+ maxRpcBodyBytes: options.maxRpcBodyBytes ?? DEFAULT_MAX_RPC_BODY_BYTES,
286
+ upstreamTimeoutMs: options.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS,
287
+ upstreamFetch: options.upstreamFetch ?? ((request) => fetch(request))
288
+ };
289
+ }
290
+ function normalizeBaseUrl(value, label) {
291
+ try {
292
+ const parsed = new URL(value);
293
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("unsupported protocol");
294
+ return parsed.toString().replace(/\/+$/, "");
295
+ } catch (error) {
296
+ throw new AppError("INVALID_ARGS", `Invalid ${label}`, { [label]: value }, error);
297
+ }
298
+ }
299
+ function normalizeToken(value, label) {
300
+ const token = value.trim();
301
+ if (!token) throw new AppError("INVALID_ARGS", `Proxy ${label} is required.`);
302
+ return token;
303
+ }
304
+ function resolveProxyRoute(requestUrl) {
305
+ const pathname = new URL(requestUrl).pathname;
306
+ if (pathname === "/agent-device") return "/";
307
+ if (pathname.startsWith(DAEMON_PROXY_PREFIX)) return `/${pathname.slice(DAEMON_PROXY_PREFIX.length)}`;
308
+ return pathname;
309
+ }
310
+ function isSupportedDaemonRoute(route, method) {
311
+ if (route === "/rpc") return method === "POST";
312
+ if (isSupportedUploadRoute(route, method)) return true;
313
+ if (route === "/artifacts" || route === "/artifacts/") return method === "GET";
314
+ if (route.startsWith("/artifacts/")) return method === "GET";
315
+ if (isRequestDiagnosticsRoute(route)) return method === "GET";
316
+ return false;
317
+ }
318
+ /**
319
+ * `GET /sessions/<session>/requests/<requestId>/diagnostics` (#1801): the record a failed
320
+ * command names. A remote client localizes its `logPath` from it, so a client behind the
321
+ * proxy keeps exactly the failure envelope a client on the daemon host gets.
322
+ */
323
+ function isRequestDiagnosticsRoute(route) {
324
+ const segments = route.split("/");
325
+ return segments.length === 6 && segments[1] === "sessions" && segments[3] === "requests" && segments[5] === "diagnostics" && segments[2] !== "" && segments[4] !== "";
326
+ }
327
+ function isSupportedUploadRoute(route, method) {
328
+ if (route === "/upload") return method === "POST";
329
+ if (isUploadPreflightRoute(route)) return method === "POST";
330
+ if (route === "/upload/finalize") return method === "POST";
331
+ if (route.startsWith("/upload/direct/")) return method === "PUT";
332
+ return false;
333
+ }
334
+ function isUploadPreflightRoute(route) {
335
+ return route === "/upload/preflight";
336
+ }
337
+ function buildUpstreamUrl(upstreamBaseUrl, route, requestUrl) {
338
+ const upstreamUrl = new URL(buildDaemonHttpUrl(upstreamBaseUrl, route));
339
+ upstreamUrl.search = new URL(requestUrl).search;
340
+ return upstreamUrl;
341
+ }
342
+ function buildUpstreamInstancePreconditionHeaders(requestHeaders) {
343
+ const expectedUpstreamInstance = requestHeaders.get(DAEMON_HTTP_UPSTREAM_INSTANCE_HEADER);
344
+ return expectedUpstreamInstance !== null ? { [DAEMON_HTTP_INSTANCE_HEADER]: expectedUpstreamInstance } : {};
345
+ }
346
+ function buildUpstreamHeaders(requestHeaders, upstreamToken, route) {
347
+ const headers = new Headers();
348
+ for (const name of FORWARDED_REQUEST_HEADERS) {
349
+ const value = requestHeaders.get(name);
350
+ if (value?.trim()) headers.set(name, value);
351
+ }
352
+ if (route === "/rpc" && !headers.has("content-type")) headers.set("content-type", "application/json");
353
+ if (route === "/rpc") {
354
+ headers.set(DAEMON_HTTP_NETWORK_ACCESS_HEADER, DAEMON_HTTP_PUBLIC_NETWORK_ACCESS);
355
+ for (const [name, value] of Object.entries(buildUpstreamInstancePreconditionHeaders(requestHeaders))) headers.set(name, value);
356
+ }
357
+ for (const [name, value] of Object.entries(buildDaemonHttpAuthHeaders(upstreamToken))) headers.set(name, value);
358
+ return headers;
359
+ }
360
+ function resolveUpstreamBody(request, route, rpcBody, upstreamToken) {
361
+ if (request.method === "GET" || request.method === "HEAD") return null;
362
+ if (route === "/rpc") return rewriteRpcToken(rpcBody ?? "", upstreamToken);
363
+ return request.body;
364
+ }
365
+ async function readRequestText(request, maxBodyBytes, tooLargeMessage) {
366
+ if (!request.body) return "";
367
+ const reader = request.body.getReader();
368
+ const chunks = [];
369
+ let bodyBytes = 0;
370
+ for (;;) {
371
+ const { done, value } = await reader.read();
372
+ if (done) break;
373
+ bodyBytes += value.byteLength;
374
+ if (bodyBytes > maxBodyBytes) {
375
+ await reader.cancel();
376
+ throw new AppError("INVALID_ARGS", tooLargeMessage);
377
+ }
378
+ chunks.push(value);
379
+ }
380
+ return Buffer.concat(chunks).toString("utf8");
381
+ }
382
+ function isAuthorized(request, expectedToken, rpcBody) {
383
+ const requestToken = resolveRequestToken(request, rpcBody);
384
+ return requestToken.length > 0 && timingSafeStringEqual(requestToken, expectedToken);
385
+ }
386
+ function resolveRequestToken(request, rpcBody) {
387
+ const authHeader = request.headers.get("authorization") ?? "";
388
+ if (authHeader.toLowerCase().startsWith("bearer ")) return authHeader.slice(7);
389
+ const tokenHeader = request.headers.get("x-agent-device-token");
390
+ if (tokenHeader !== null) return tokenHeader;
391
+ if (rpcBody) {
392
+ const bodyToken = readJsonRpcToken(rpcBody);
393
+ if (bodyToken) return bodyToken;
394
+ }
395
+ return "";
396
+ }
397
+ function rewriteRpcToken(body, upstreamToken) {
398
+ const parsed = JSON.parse(body);
399
+ parsed.params = {
400
+ ...parsed.params ?? {},
401
+ token: upstreamToken
402
+ };
403
+ return JSON.stringify(parsed);
404
+ }
405
+ function readJsonRpcToken(body) {
406
+ try {
407
+ const parsed = JSON.parse(body);
408
+ return typeof parsed.params?.token === "string" ? parsed.params.token : "";
409
+ } catch {
410
+ return "";
411
+ }
412
+ }
413
+ function readJsonRpcId(body) {
414
+ if (!body) return null;
415
+ try {
416
+ return JSON.parse(body).id ?? null;
417
+ } catch {
418
+ return null;
419
+ }
420
+ }
421
+ function resolveRequestId(request) {
422
+ const header = request.headers.get("x-request-id");
423
+ if (header?.trim()) return header.trim().slice(0, 128);
424
+ return randomUUID();
425
+ }
426
+ function refuseStaleProxyInstance(request, route, rpcId, instanceId) {
427
+ if (route !== "/rpc") return null;
428
+ const expectedInstanceId = request.headers.get(DAEMON_HTTP_INSTANCE_HEADER);
429
+ if (expectedInstanceId === null || expectedInstanceId === instanceId) return null;
430
+ return sendInstanceMismatch(rpcId);
431
+ }
432
+ function sendInstanceMismatch(rpcId) {
433
+ return Response.json(buildDaemonInstanceMismatchRpcResponse(rpcId, "Proxy instance changed", normalizeError(new AppError("COMMAND_FAILED", "Proxy instance changed", { reason: "remote_instance_mismatch" }))), {
434
+ status: 409,
435
+ headers: { [DAEMON_HTTP_INSTANCE_MISMATCH_HEADER]: "true" }
436
+ });
437
+ }
438
+ function unauthorizedResponse(route, rpcId) {
439
+ if (route === "/rpc") return Response.json({
440
+ jsonrpc: "2.0",
441
+ id: rpcId,
442
+ error: {
443
+ code: -32001,
444
+ message: "Invalid proxy token",
445
+ data: normalizeError(new AppError("UNAUTHORIZED", "Invalid proxy token"))
446
+ }
447
+ }, { status: 401 });
448
+ return Response.json({
449
+ ok: false,
450
+ error: "Invalid proxy token",
451
+ code: "UNAUTHORIZED"
452
+ }, { status: 401 });
453
+ }
454
+ function proxyErrorResponse(error) {
455
+ const normalized = normalizeError(error);
456
+ return Response.json({
457
+ ok: false,
458
+ error: normalized.message,
459
+ code: normalized.code
460
+ }, { status: normalized.code === "INVALID_ARGS" ? 400 : 500 });
461
+ }
462
+ //#endregion
463
+ export { createDaemonProxy, createDaemonProxyRequestListener, createDaemonProxyServer };
@@ -0,0 +1,68 @@
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { TLSSocket } from "node:tls";
4
+ //#region src/node-http.ts
5
+ async function serveProxyRequest(proxy, req, res) {
6
+ if (req.method === "TRACE") {
7
+ endWithStatus(res, 404);
8
+ return;
9
+ }
10
+ const url = requestUrl(req);
11
+ if (!url) {
12
+ endWithStatus(res, 400);
13
+ return;
14
+ }
15
+ const response = await proxy.handle(toWebRequest(url, req, res));
16
+ res.statusCode = response.status;
17
+ for (const [name, value] of response.headers) res.setHeader(name, value);
18
+ if (!response.body) {
19
+ res.end();
20
+ return;
21
+ }
22
+ await pipeline(Readable.fromWeb(response.body), res);
23
+ }
24
+ function endWithStatus(res, status) {
25
+ res.statusCode = status;
26
+ res.end();
27
+ }
28
+ /** `null` when the Host header and path do not form a URL that Fetch accepts. */
29
+ function requestUrl(req) {
30
+ const scheme = req.socket instanceof TLSSocket ? "https" : "http";
31
+ const url = URL.parse(req.url ?? "/", `${scheme}://${req.headers.host ?? "127.0.0.1"}`);
32
+ if (!url || url.username || url.password) return null;
33
+ return url;
34
+ }
35
+ function toWebRequest(url, req, res) {
36
+ const method = req.method ?? "GET";
37
+ const hasBody = method !== "GET" && method !== "HEAD";
38
+ return new Request(url, {
39
+ method,
40
+ headers: toWebHeaders(req),
41
+ signal: clientGoneSignal(req, res),
42
+ ...hasBody ? {
43
+ body: Readable.toWeb(req),
44
+ duplex: "half"
45
+ } : {}
46
+ });
47
+ }
48
+ function toWebHeaders(req) {
49
+ const headers = new Headers();
50
+ for (const [name, value] of Object.entries(req.headers)) {
51
+ if (value === void 0) continue;
52
+ for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item);
53
+ }
54
+ return headers;
55
+ }
56
+ function clientGoneSignal(req, res) {
57
+ const clientGone = new AbortController();
58
+ const abortIfResponseIncomplete = () => {
59
+ if (res.writableFinished || clientGone.signal.aborted) return;
60
+ clientGone.abort(/* @__PURE__ */ new Error("Proxy client disconnected before the response ended"));
61
+ };
62
+ req.on("aborted", abortIfResponseIncomplete);
63
+ res.on("close", abortIfResponseIncomplete);
64
+ if (res.closed) abortIfResponseIncomplete();
65
+ return clientGone.signal;
66
+ }
67
+ //#endregion
68
+ export { serveProxyRequest };