From c69d9a8b8dcd8caf0925df630d6761f7746f2b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 21:17:50 +0200 Subject: [PATCH 1/5] refactor(proxy): export the Node adapter from the main entry point createDaemonProxyServer and createDaemonProxyRequestListener move from @agent-device/proxy/node into @agent-device/proxy, so the package has one entry point. The subpath has not been released yet. The Node adapter now answers CONNECT, TRACE and TRACK with 404, as the proxy did before the Fetch rewrite. Before this change it dropped the socket, because Fetch refuses to construct a Request with those methods. --- packages/proxy/README.md | 2 +- packages/proxy/src/index.ts | 1 + packages/proxy/src/node-http.test.ts | 30 ++++++++++++++++++- packages/proxy/src/node-http.ts | 28 ++++++++++++----- packages/proxy/src/node.ts | 1 - packages/proxy/tsdown.config.ts | 2 +- src/__tests__/daemon-proxy.test.ts | 2 +- src/cli/commands/proxy.ts | 2 +- .../__tests__/daemon-client-transport.test.ts | 2 +- .../remote-daemon-client.test.ts | 2 +- .../remote-proxy-artifact-tenant.test.ts | 2 +- .../remote-proxy-parity.test.ts | 2 +- .../remote-proxy-request-diagnostics.test.ts | 2 +- 13 files changed, 60 insertions(+), 18 deletions(-) delete mode 100644 packages/proxy/src/node.ts diff --git a/packages/proxy/README.md b/packages/proxy/README.md index afc44005da..94e2700fdc 100644 --- a/packages/proxy/README.md +++ b/packages/proxy/README.md @@ -25,7 +25,7 @@ different RPC protocol version, before any command runs. ## Serve the proxy over HTTP ```ts -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; const server = createDaemonProxyServer({ upstreamBaseUrl: 'http://127.0.0.1:4310', diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index ecfd70a2b7..a828b818b9 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -4,3 +4,4 @@ export { type DaemonProxyOptions, type DaemonProxyUpstreamFetch, } from './daemon-proxy.ts'; +export { createDaemonProxyRequestListener, createDaemonProxyServer } from './node-http.ts'; diff --git a/packages/proxy/src/node-http.test.ts b/packages/proxy/src/node-http.test.ts index f81ed642c1..bc9b1706c1 100644 --- a/packages/proxy/src/node-http.test.ts +++ b/packages/proxy/src/node-http.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events'; -import type { IncomingMessage, ServerResponse } from 'node:http'; +import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { Socket } from 'node:net'; import { Readable } from 'node:stream'; import { TLSSocket } from 'node:tls'; @@ -54,3 +55,30 @@ test('a Host header that cannot form a URL is answered with 400 instead of a dro const served = await serveThroughListener(new Socket(), 'gateway example'); expect(served).toEqual({ seenUrl: null, status: 400 }); }); + +test('a method Fetch refuses is answered with 404 instead of a dropped socket', async () => { + const proxy: DaemonProxy = { + instanceId: 'test', + handle: async () => new Response(null, { status: 204 }), + }; + const server = http.createServer(createDaemonProxyRequestListener(proxy)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address() as AddressInfo; + const status = await new Promise((resolve, reject) => { + http + .request( + { port, host: '127.0.0.1', method: 'TRACE', path: '/agent-device/health' }, + (res) => { + res.resume(); + resolve(res.statusCode); + }, + ) + .on('error', reject) + .end(); + }); + expect(status).toBe(404); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/packages/proxy/src/node-http.ts b/packages/proxy/src/node-http.ts index c02a2c5ac2..09775ae8b9 100644 --- a/packages/proxy/src/node-http.ts +++ b/packages/proxy/src/node-http.ts @@ -22,10 +22,17 @@ async function serveProxyRequest( req: IncomingMessage, res: ServerResponse, ): Promise { - const request = toWebRequest(req, res); - if (!request) { - res.statusCode = 400; - res.end(); + const url = requestUrl(req); + if (!url) { + endWithStatus(res, 400); + return; + } + let request: Request; + try { + request = toWebRequest(url, req, res); + } catch { + // Fetch refuses CONNECT, TRACE and TRACK; the daemon serves none of them. + endWithStatus(res, 404); return; } const response = await proxy.handle(request); @@ -38,11 +45,18 @@ async function serveProxyRequest( await pipeline(Readable.fromWeb(response.body as Parameters[0]), res); } +function endWithStatus(res: ServerResponse, status: number): void { + res.statusCode = status; + res.end(); +} + /** `null` when the Host header and path do not form a URL. */ -function toWebRequest(req: IncomingMessage, res: ServerResponse): Request | null { +function requestUrl(req: IncomingMessage): URL | null { const scheme = req.socket instanceof TLSSocket ? 'https' : 'http'; - const url = URL.parse(req.url ?? '/', `${scheme}://${req.headers.host ?? '127.0.0.1'}`); - if (!url) return null; + return URL.parse(req.url ?? '/', `${scheme}://${req.headers.host ?? '127.0.0.1'}`); +} + +function toWebRequest(url: URL, req: IncomingMessage, res: ServerResponse): Request { const method = req.method ?? 'GET'; const hasBody = method !== 'GET' && method !== 'HEAD'; return new Request(url, { diff --git a/packages/proxy/src/node.ts b/packages/proxy/src/node.ts deleted file mode 100644 index 15dfb5f1d0..0000000000 --- a/packages/proxy/src/node.ts +++ /dev/null @@ -1 +0,0 @@ -export { createDaemonProxyRequestListener, createDaemonProxyServer } from './node-http.ts'; diff --git a/packages/proxy/tsdown.config.ts b/packages/proxy/tsdown.config.ts index a940c2a1da..2b4fe28c98 100644 --- a/packages/proxy/tsdown.config.ts +++ b/packages/proxy/tsdown.config.ts @@ -6,7 +6,7 @@ const { default: getTypeScript7ExePath } = await import( ); export default defineConfig({ - entry: { index: 'src/index.ts', node: 'src/node.ts' }, + entry: { index: 'src/index.ts' }, outDir: 'dist', format: 'esm', platform: 'node', diff --git a/src/__tests__/daemon-proxy.test.ts b/src/__tests__/daemon-proxy.test.ts index a4e23d2cee..fa1b4282bd 100644 --- a/src/__tests__/daemon-proxy.test.ts +++ b/src/__tests__/daemon-proxy.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import http from 'node:http'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { createDaemonHttpServer } from '../daemon/server/http-server.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { readHostCpuArch } from '@agent-device/host-kit/process'; diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 30be066cc1..0d3036ab71 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http'; import { ensureDaemon, diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/src/daemon-client/__tests__/daemon-client-transport.test.ts index 038678ae3a..c5f3d9d773 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/src/daemon-client/__tests__/daemon-client-transport.test.ts @@ -21,7 +21,7 @@ import { sendRequest, } from '../daemon-client-transport.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { closeLoopbackServer, listenOnLoopback, diff --git a/test/integration/provider-scenarios/remote-daemon-client.test.ts b/test/integration/provider-scenarios/remote-daemon-client.test.ts index 20b45f5119..4e3374c161 100644 --- a/test/integration/provider-scenarios/remote-daemon-client.test.ts +++ b/test/integration/provider-scenarios/remote-daemon-client.test.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { test } from 'vitest'; import { createAgentDeviceClient } from '../../../src/agent-device-client.ts'; import { prepareRemoteRequestArtifacts } from '../../../src/remote/daemon-artifacts.ts'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { normalizeAgentDeviceError } from '@agent-device/kernel/errors'; import { closeLoopbackServer, diff --git a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts index fffbcf3748..2b4e0e007f 100644 --- a/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-artifact-tenant.test.ts @@ -12,7 +12,7 @@ import { finalizeDaemonResponse } from '../../../src/daemon/request-finalization import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; import { normalizeAgentDeviceError } from '@agent-device/kernel/errors'; import { downloadRemoteArtifact } from '../../../src/remote/daemon-artifacts.ts'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { closeLoopbackServer, listenOnLoopback, diff --git a/test/integration/provider-scenarios/remote-proxy-parity.test.ts b/test/integration/provider-scenarios/remote-proxy-parity.test.ts index b7c1dc78b4..79b2af300d 100644 --- a/test/integration/provider-scenarios/remote-proxy-parity.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-parity.test.ts @@ -9,7 +9,7 @@ import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; import { resolveSessionRequestLogPath } from '../../../src/daemon/session-artifact-paths.ts'; import type { DaemonRequest, DaemonResponse } from '../../../src/daemon/daemon-request.ts'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { AppError, type DaemonError } from '@agent-device/kernel/errors'; import { closeLoopbackServer, diff --git a/test/integration/provider-scenarios/remote-proxy-request-diagnostics.test.ts b/test/integration/provider-scenarios/remote-proxy-request-diagnostics.test.ts index 3c726ad475..acecd744a0 100644 --- a/test/integration/provider-scenarios/remote-proxy-request-diagnostics.test.ts +++ b/test/integration/provider-scenarios/remote-proxy-request-diagnostics.test.ts @@ -16,7 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; -import { createDaemonProxyServer } from '@agent-device/proxy/node'; +import { createDaemonProxyServer } from '@agent-device/proxy'; import { localizeRemoteDaemonError } from '../../../src/remote/remote-request-diagnostics.ts'; import { resolveSessionRequestLogPath } from '../../../src/daemon/session-artifact-paths.ts'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; From 5932ceda08e7427f18e00bb8689ec953e318a291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 21:17:50 +0200 Subject: [PATCH 2/5] chore(gates): drop the ./node proxy entry from exports and fallow The package has one entry point now, so the exports map and the fallow entry list name only src/index.ts. --- .fallowrc.json | 3 +-- packages/proxy/package.json | 8 -------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/.fallowrc.json b/.fallowrc.json index c6505c8327..a39923f0a0 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -16,7 +16,6 @@ "src/sdk/finders.ts", "src/ai-sdk/index.ts", "packages/proxy/src/index.ts", - "packages/proxy/src/node.ts", "packages/proxy/tsdown.config.ts", "src/bin.ts", "src/client/companion-tunnel.ts", @@ -280,7 +279,7 @@ }, { "comment": "Published package surfaces (see each package.json#exports); consumers may be external or reach them through workspace package exports Fallow does not follow.", - "file": "{src/sdk/*.ts,src/ai-sdk/index.ts,packages/proxy/src/index.ts,packages/proxy/src/node.ts}", + "file": "{src/sdk/*.ts,src/ai-sdk/index.ts,packages/proxy/src/index.ts}", "exports": ["*"] }, { diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 5d41b8660a..557955e131 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -19,10 +19,6 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./node": { - "types": "./src/node.ts", - "default": "./src/node.ts" - }, "./package.json": "./package.json" }, "files": [ @@ -43,10 +39,6 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, - "./node": { - "types": "./dist/node.d.mts", - "import": "./dist/node.mjs" - }, "./package.json": "./package.json" } } From 9fb473522bfdb9a127abf60351f319d4df707bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 21:28:21 +0200 Subject: [PATCH 3/5] fix(proxy): answer only TRACE with 404 and reject credentialed URLs with 400 Node never hands CONNECT or TRACK to the request listener, so the catch-all 404 only reached TRACE, and it misreported credentialed URLs, which Fetch also refuses, as 404. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/proxy/src/node-http.test.ts | 5 +++++ packages/proxy/src/node-http.ts | 21 ++++++++++----------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/proxy/src/node-http.test.ts b/packages/proxy/src/node-http.test.ts index bc9b1706c1..0176da76a0 100644 --- a/packages/proxy/src/node-http.test.ts +++ b/packages/proxy/src/node-http.test.ts @@ -56,6 +56,11 @@ test('a Host header that cannot form a URL is answered with 400 instead of a dro expect(served).toEqual({ seenUrl: null, status: 400 }); }); +test('a URL carrying credentials is answered with 400 instead of a dropped socket', async () => { + const served = await serveThroughListener(new Socket(), 'user:secret@gateway.example.test'); + expect(served).toEqual({ seenUrl: null, status: 400 }); +}); + test('a method Fetch refuses is answered with 404 instead of a dropped socket', async () => { const proxy: DaemonProxy = { instanceId: 'test', diff --git a/packages/proxy/src/node-http.ts b/packages/proxy/src/node-http.ts index 09775ae8b9..ede826291e 100644 --- a/packages/proxy/src/node-http.ts +++ b/packages/proxy/src/node-http.ts @@ -22,20 +22,17 @@ async function serveProxyRequest( req: IncomingMessage, res: ServerResponse, ): Promise { + // Fetch refuses TRACE. node:http rejects TRACK and routes CONNECT to the 'connect' event. + if (req.method === 'TRACE') { + endWithStatus(res, 404); + return; + } const url = requestUrl(req); if (!url) { endWithStatus(res, 400); return; } - let request: Request; - try { - request = toWebRequest(url, req, res); - } catch { - // Fetch refuses CONNECT, TRACE and TRACK; the daemon serves none of them. - endWithStatus(res, 404); - return; - } - const response = await proxy.handle(request); + const response = await proxy.handle(toWebRequest(url, req, res)); res.statusCode = response.status; for (const [name, value] of response.headers) res.setHeader(name, value); if (!response.body) { @@ -50,10 +47,12 @@ function endWithStatus(res: ServerResponse, status: number): void { res.end(); } -/** `null` when the Host header and path do not form a URL. */ +/** `null` when the Host header and path do not form a URL that Fetch accepts. */ function requestUrl(req: IncomingMessage): URL | null { const scheme = req.socket instanceof TLSSocket ? 'https' : 'http'; - return URL.parse(req.url ?? '/', `${scheme}://${req.headers.host ?? '127.0.0.1'}`); + const url = URL.parse(req.url ?? '/', `${scheme}://${req.headers.host ?? '127.0.0.1'}`); + if (!url || url.username || url.password) return null; + return url; } function toWebRequest(url: URL, req: IncomingMessage, res: ServerResponse): Request { From 5ed888a3ff70d6ed9d5d24ac4a0a8a42148609db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 22:31:15 +0200 Subject: [PATCH 4/5] fix(proxy): load the Node adapter on the first request Exporting the adapter from the main entry made it evaluate one more module on import, which the eager-closure gate refuses. The server and listener now live beside createDaemonProxy and import node-http.ts on the first request, as health helpers already do. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/proxy/src/daemon-proxy.ts | 16 ++++++++++++++++ packages/proxy/src/index.ts | 3 ++- packages/proxy/src/node-http.test.ts | 10 +++------- packages/proxy/src/node-http.ts | 19 +++---------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/proxy/src/daemon-proxy.ts b/packages/proxy/src/daemon-proxy.ts index 24e8c38a68..5314c08e75 100644 --- a/packages/proxy/src/daemon-proxy.ts +++ b/packages/proxy/src/daemon-proxy.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import http from 'node:http'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { timingSafeStringEqual } from '@agent-device/host-kit/transport'; import { @@ -86,6 +87,21 @@ export function createDaemonProxy(options: DaemonProxyOptions): DaemonProxy { }; } +export function createDaemonProxyServer(options: DaemonProxyOptions): http.Server { + return http.createServer(createDaemonProxyRequestListener(createDaemonProxy(options))); +} + +/** Serves a proxy from any `node:http` or `node:https` server; upload tickets follow its scheme. */ +export function createDaemonProxyRequestListener(proxy: DaemonProxy): http.RequestListener { + return (req, res) => { + void import('./node-http.ts') + .then(({ serveProxyRequest }) => serveProxyRequest(proxy, req, res)) + .catch((error: unknown) => { + if (!res.destroyed) res.destroy(error instanceof Error ? error : undefined); + }); + }; +} + async function handleProxyRequest( request: Request, options: NormalizedProxyOptions, diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index a828b818b9..dfccde8ea1 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -1,7 +1,8 @@ export { createDaemonProxy, + createDaemonProxyRequestListener, + createDaemonProxyServer, type DaemonProxy, type DaemonProxyOptions, type DaemonProxyUpstreamFetch, } from './daemon-proxy.ts'; -export { createDaemonProxyRequestListener, createDaemonProxyServer } from './node-http.ts'; diff --git a/packages/proxy/src/node-http.test.ts b/packages/proxy/src/node-http.test.ts index 0176da76a0..3af549838c 100644 --- a/packages/proxy/src/node-http.test.ts +++ b/packages/proxy/src/node-http.test.ts @@ -6,7 +6,7 @@ import { Readable } from 'node:stream'; import { TLSSocket } from 'node:tls'; import { expect, test } from 'vitest'; import type { DaemonProxy } from './daemon-proxy.ts'; -import { createDaemonProxyRequestListener } from './node-http.ts'; +import { serveProxyRequest } from './node-http.ts'; async function serveThroughListener( socket: Socket, @@ -33,11 +33,7 @@ async function serveThroughListener( setHeader: () => {}, end: () => {}, }); - const ended = new Promise((resolve) => { - res.end = () => resolve(); - }); - createDaemonProxyRequestListener(proxy)(req, res as unknown as ServerResponse); - await ended; + await serveProxyRequest(proxy, req, res as unknown as ServerResponse); return { seenUrl, status: res.statusCode }; } @@ -66,7 +62,7 @@ test('a method Fetch refuses is answered with 404 instead of a dropped socket', instanceId: 'test', handle: async () => new Response(null, { status: 204 }), }; - const server = http.createServer(createDaemonProxyRequestListener(proxy)); + const server = http.createServer((req, res) => void serveProxyRequest(proxy, req, res)); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); try { const { port } = server.address() as AddressInfo; diff --git a/packages/proxy/src/node-http.ts b/packages/proxy/src/node-http.ts index ede826291e..90cdd7fcdf 100644 --- a/packages/proxy/src/node-http.ts +++ b/packages/proxy/src/node-http.ts @@ -1,23 +1,10 @@ -import http, { type IncomingMessage, type ServerResponse } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { TLSSocket } from 'node:tls'; -import { createDaemonProxy, type DaemonProxy, type DaemonProxyOptions } from './daemon-proxy.ts'; +import type { DaemonProxy } from './daemon-proxy.ts'; -export function createDaemonProxyServer(options: DaemonProxyOptions): http.Server { - return http.createServer(createDaemonProxyRequestListener(createDaemonProxy(options))); -} - -/** Serves a proxy from any `node:http` or `node:https` server; upload tickets follow its scheme. */ -export function createDaemonProxyRequestListener(proxy: DaemonProxy): http.RequestListener { - return (req, res) => { - void serveProxyRequest(proxy, req, res).catch((error: unknown) => { - if (!res.destroyed) res.destroy(error instanceof Error ? error : undefined); - }); - }; -} - -async function serveProxyRequest( +export async function serveProxyRequest( proxy: DaemonProxy, req: IncomingMessage, res: ServerResponse, From b8c0ef347e79b2e1acaba71dc884037662bc3eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 6 Oct 2026 22:38:46 +0200 Subject: [PATCH 5/5] fix(proxy): abort a request whose client left before the adapter loaded Loading node-http.ts on the first request lets the response close before the disconnect listeners attach, so the signal also checks res.closed. Adapter tests assert the response ends again. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/proxy/src/node-http.test.ts | 24 +++++++++++++++++++----- packages/proxy/src/node-http.ts | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/proxy/src/node-http.test.ts b/packages/proxy/src/node-http.test.ts index 3af549838c..71af99de00 100644 --- a/packages/proxy/src/node-http.test.ts +++ b/packages/proxy/src/node-http.test.ts @@ -11,12 +11,16 @@ import { serveProxyRequest } from './node-http.ts'; async function serveThroughListener( socket: Socket, host: string, -): Promise<{ seenUrl: string | null; status: number }> { + { responseClosed = false } = {}, +): Promise<{ seenUrl: string | null; clientGone: boolean; status: number; ended: boolean }> { let seenUrl: string | null = null; + let clientGone = false; + let ended = false; const proxy: DaemonProxy = { instanceId: 'test', handle: async (request) => { seenUrl = request.url; + clientGone = request.signal.aborted; return new Response(null, { status: 204 }); }, }; @@ -30,11 +34,14 @@ async function serveThroughListener( statusCode: 200, writableFinished: false, destroyed: false, + closed: responseClosed, setHeader: () => {}, - end: () => {}, + end: () => { + ended = true; + }, }); await serveProxyRequest(proxy, req, res as unknown as ServerResponse); - return { seenUrl, status: res.statusCode }; + return { seenUrl, clientGone, status: res.statusCode, ended }; } test('requests arriving over TLS reach the proxy with an https URL', async () => { @@ -49,12 +56,19 @@ test('plain requests reach the proxy with an http URL', async () => { test('a Host header that cannot form a URL is answered with 400 instead of a dropped socket', async () => { const served = await serveThroughListener(new Socket(), 'gateway example'); - expect(served).toEqual({ seenUrl: null, status: 400 }); + expect(served).toMatchObject({ seenUrl: null, status: 400, ended: true }); }); test('a URL carrying credentials is answered with 400 instead of a dropped socket', async () => { const served = await serveThroughListener(new Socket(), 'user:secret@gateway.example.test'); - expect(served).toEqual({ seenUrl: null, status: 400 }); + expect(served).toMatchObject({ seenUrl: null, status: 400, ended: true }); +}); + +test('a client gone before the adapter loads reaches the proxy as an aborted request', async () => { + const served = await serveThroughListener(new Socket(), 'gateway.example.test', { + responseClosed: true, + }); + expect(served.clientGone).toBe(true); }); test('a method Fetch refuses is answered with 404 instead of a dropped socket', async () => { diff --git a/packages/proxy/src/node-http.ts b/packages/proxy/src/node-http.ts index 90cdd7fcdf..1fce2890d3 100644 --- a/packages/proxy/src/node-http.ts +++ b/packages/proxy/src/node-http.ts @@ -72,5 +72,6 @@ function clientGoneSignal(req: IncomingMessage, res: ServerResponse): AbortSigna }; req.on('aborted', abortIfResponseIncomplete); res.on('close', abortIfResponseIncomplete); + if (res.closed) abortIfResponseIncomplete(); return clientGone.signal; }