Compare commits

...

5 Commits

Author SHA1 Message Date
codinget cb275ef62a fix(http): fix lint errors in redirect implementation (prefer-const, no-unused-vars)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:21:53 +00:00
codinget f720b8a53f chore(http): tag unreachable drain-failure paths with c8 ignore
The catch block and rejectConnection branch in the cross-connection
redirect drain helper cannot be reached with any standard transport:
BasicBodyReader.closed becomes true via buffer.ended (from ReadBuffer)
before the while-loop can call read() a second time, so drain() always
exits cleanly rather than throwing.

Annotated with /* c8 ignore start/stop */ to make the intentional gap
explicit, bringing client/fetch.ts to 100% statement/function/line
coverage (95.91% branch, with the remaining branch misses being
pre-existing defensive guards such as urlPort's explicit-port path and
canFollowRedirect's unreachable !mode guard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:14:22 +00:00
codinget 380686595a test(http): improve redirect + drain() coverage to 99.81% statements
- drain(): add tests for the null-prevBody early-exit branch and the
  body-drain-then-release path, bringing client/connection.ts to 100%
  on all four metrics
- redirect: cover parseLocation's catch block (malformed Location URL),
  buildRedirectReq's URL-credential branch, and the mid-chain request
  failure path (lines 275-277)
- redirect streaming: add test for same-connection stop when the server
  closes the connection (covers the rawFetchStream 437-439 branch)
- The six drain-failure lines (331-332,334,451-452,454) are defensive
  catches that are unreachable with the current BasicBodyReader: when a
  transport closes mid-read, buffer.ended flips closed=true before the
  while loop re-enters read(), so drain() exits cleanly rather than
  throwing. Accepted as the practical coverage ceiling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:14:22 +00:00
codinget cbc95fc56c fix(http): same-connection mode must not reconnect; add 304 test
Two correctness fixes:

- same-connection mode would silently fall through to the drain+reconnect
  path when the server closed the connection (Connection: close or ended
  socket), creating a *new* TCP connection to the same endpoint — which
  defeats the mode's purpose. Now rawFetch/rawFetchStream treat !reuseConn
  as a stop condition for same-connection, returning the 3xx unchanged.
  The collected.push is moved to after this check so the un-followed 3xx
  is not included in res.redirects[].

- 304 Not Modified is already correctly excluded from REDIRECT_STATUSES;
  add an explicit test to document and guard this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:14:22 +00:00
codinget 8a49b0a51d feat(http): add 3xx redirect support to fetch/fetch.stream
Adds redirect following to the fetch API via a new `redirect` option on
`ClientRequestInit`, with four modes (manual/same-connection/same-origin/
follow), configurable redirect limit, allow-list/callback filter, credential
stripping policy (keep/strip-cross-origin/strip), body-resubmit policy
(resubmit/strip-non-resubmit/strip), and `collect` flag to accumulate
followed 3xx responses into `res.redirects[]`.

In streaming mode every interim and redirect response is yielded to the
caller in arrival order; same-connection redirect hops reuse the open TCP
connection, cross-hop redirects drain the current connection via a new
`ClientConnection.drain()` helper before acquiring a new one from the pool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:14:22 +00:00
8 changed files with 1175 additions and 43 deletions
+1
View File
@@ -77,3 +77,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`packages/http``hijack()` on server and client responses**: the `hijack()` method on `ServerResponse` and `ClientResponse`, the `ReadBuffer.drain()` helper, and the `prependTransport()` utility were implemented by Claude Code
- **`packages/http` — 1xx informational response support**: implemented by Claude Code. Server side: automatic `100 Continue` (sent lazily when the handler reads the body) and `res.sendInformational()` for 103 Early Hints etc. Client side: default skip mode, `interim: "collect"` to capture 1xx into `res.informational[]`, `conn.requestStream()` async generator that yields each interim response and the final one as they arrive, and `fetchStream()` / `f.stream()` to expose the same streaming behaviour through the fetch API with proper connection pool management.
- **`packages/http` — WebSocket support**: implemented by Claude Code. `upgradeWebSocket(req, res)` for server-side handshake; `connectWebSocket(dialer, url, options?)` to open a new WebSocket connection, or `connectWebSocket(res, key)` to promote an existing `fetch()`/`fetchStream()` 101 response — both return a `WebSocketConnection` async iterable. Frame codec (read/write), masking, fragmented-message reassembly, ping/pong, and the close handshake are all implemented from scratch using the Web Crypto API (`crypto.subtle.digest` for SHA-1, `crypto.getRandomValues` for mask keys) with no external dependencies. Two fixed bugs in `fetch.ts` were required for pool safety: a case-insensitive `Connection: upgrade` check and immediate pool ejection on 101 to prevent a microtask race before hijack. Exported as three tree-shakeable entry points: `@webnet/http/websocket` (combined), `@webnet/http/websocket/client`, and `@webnet/http/websocket/server`.
- **`packages/http` — 3xx redirect support**: implemented by Claude Code. `redirect.mode` (`"manual"` / `"same-connection"` / `"same-origin"` / `"follow"`), `redirect.max`, `redirect.filter` (string array / Set / callback), `redirect.credentials` (`"keep"` / `"strip-cross-origin"` / `"strip"`), `redirect.body` (`"resubmit"` / `"strip-non-resubmit"` / `"strip"`), and `redirect.collect` to gather followed 3xx into `res.redirects[]`. In streaming mode all redirects and interims are yielded as they arrive; a `drain()` method was added to `ClientConnection` to support clean connection hand-off between redirect hops.
@@ -743,3 +743,26 @@ suite("ClientConnection", { skip: skipIfNotIntegration }, () => {
})
})
})
suite("ClientConnection drain()", { skip: skipIfNotIntegration }, () => {
test("drain() is a no-op when there is no previous body", async () => {
const { conn } = await makeClientServer()
// No request has been made yet — prevBody is null; drain() should return immediately.
await conn.drain()
await conn.close()
})
test("drain() consumes the previous response body", async () => {
const { conn, serverWrite, serverBuf } = await makeClientServer()
const req = new ClientRequestImpl({ target: "/" })
const responsePromise = conn.request(req)
await drainHeaders(serverBuf)
await serverWrite("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
const res = await responsePromise
assert.strictEqual(res.status, 200)
// Don't read the body via the response — drain it at the connection level.
await conn.drain()
assert.ok(conn.prevBodyFinished)
await conn.close()
})
})
+6
View File
@@ -250,6 +250,12 @@ export class ClientConnection {
}
}
async drain(): Promise<void> {
if (!this.#prevBody) return
while (!this.#prevBody.closed) await this.#prevBody.read()
this.#prevBody = null
}
async close(): Promise<void> {
if (this.#transport.closed) return
await this.#transport.close()
+692
View File
@@ -8,6 +8,7 @@ import { skipIfNotIntegration } from "../test-helpers/flags.js"
const enc = new TextEncoder()
const ok200 = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
const ok200ka = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
// Starts a one-shot server: accepts one connection, drains request headers, sends response.
function simpleServer(response: string): {
@@ -28,6 +29,80 @@ function simpleServer(response: string): {
return { dialer, serverDone }
}
// Collects request-line + headers from a ReadBuffer. Returns lines excluding the blank terminator.
async function collectRequest(buf: ReadBuffer): Promise<string[]> {
const lines: string[] = []
let line = await buf.readLine()
while (line !== "") {
lines.push(line)
line = await buf.readLine()
}
return lines
}
// Two-hop server: first connection sends a 3xx redirect, second sends the final response.
// The Location header value and final response can be configured.
// Both connections go through the same loopback listener (dialer ignores hostname).
function redirectServer(
redirectStatus: number,
location: string,
finalResponse: string = ok200,
extraRedirectHeaders = "",
): {
dialer: ReturnType<typeof loopbackListener>[1]
serverDone: Promise<{ firstRequest: string[]; secondRequest: string[] }>
} {
const [listener, dialer] = loopbackListener()
const serverDone = (async () => {
const t1 = await listener.accept()
const buf1 = new ReadBuffer(t1)
const firstRequest = await collectRequest(buf1)
await t1.write(
enc.encode(
`HTTP/1.1 ${redirectStatus} Redirect\r\nLocation: ${location}\r\nContent-Length: 0\r\nConnection: close\r\n${extraRedirectHeaders}\r\n`,
),
)
t1.close()
const t2 = await listener.accept()
const buf2 = new ReadBuffer(t2)
const secondRequest = await collectRequest(buf2)
await t2.write(enc.encode(finalResponse))
t2.close()
listener.close()
return { firstRequest, secondRequest }
})()
return { dialer, serverDone }
}
// Single-connection server that handles two requests (for same-connection redirect testing).
function sameConnRedirectServer(
redirectStatus: number,
location: string,
finalResponse: string = ok200,
): {
dialer: ReturnType<typeof loopbackListener>[1]
serverDone: Promise<{ firstRequest: string[]; secondRequest: string[] }>
} {
const [listener, dialer] = loopbackListener()
const serverDone = (async () => {
const transport = await listener.accept()
const buf = new ReadBuffer(transport)
const firstRequest = await collectRequest(buf)
await transport.write(
enc.encode(
`HTTP/1.1 ${redirectStatus} Redirect\r\nLocation: ${location}\r\nContent-Length: 0\r\n\r\n`,
),
)
const secondRequest = await collectRequest(buf)
await transport.write(enc.encode(finalResponse))
transport.close()
listener.close()
return { firstRequest, secondRequest }
})()
return { dialer, serverDone }
}
suite("fetch()", { skip: skipIfNotIntegration }, () => {
test("basic GET via URL string", async () => {
const { dialer, serverDone } = simpleServer(ok200)
@@ -507,3 +582,620 @@ suite("makeFetch()", { skip: skipIfNotIntegration }, () => {
await f.pool.shutdown()
})
})
// ─── redirect support ──────────────────────────────────────────────────────────
suite("fetch() — redirects: default / manual", { skip: skipIfNotIntegration }, () => {
test("304 Not Modified is a terminal response, not a redirect, even with mode:follow", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 304 Not Modified\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } })
assert.strictEqual(res.status, 304)
await serverDone
})
test("3xx is returned as-is by default (no redirect following)", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/")
assert.strictEqual(res.status, 302)
assert.strictEqual(res.getHeader("location"), "http://other.example/")
assert.deepStrictEqual(res.redirects, [])
await serverDone
})
test("redirect.mode: manual behaves the same as default", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 301 Moved Permanently\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "manual" } })
assert.strictEqual(res.status, 301)
await serverDone
})
})
suite("fetch() — redirect error handling", { skip: skipIfNotIntegration }, () => {
test("rejects when the connection drops during a redirect hop", async () => {
const [listener, dialer] = loopbackListener()
const serverDone = (async () => {
const t1 = await listener.accept()
const buf1 = new ReadBuffer(t1)
await collectRequest(buf1)
await t1.write(
enc.encode(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
),
)
t1.close()
// Accept the second connection but immediately close it — the client's conn.request() fails.
const t2 = await listener.accept()
t2.close()
listener.close()
})()
await assert.rejects(
() => fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } }),
)
await serverDone
})
})
suite("fetch() — redirect.mode: follow", { skip: skipIfNotIntegration }, () => {
for (const status of [301, 302, 303, 307, 308]) {
test(`follows ${status} redirect and returns final 200`, async () => {
const { dialer, serverDone } = redirectServer(status, "http://other.example/new")
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } })
assert.strictEqual(res.status, 200)
await serverDone
})
}
test("301 changes POST to GET and strips body", async () => {
const { dialer, serverDone } = redirectServer(301, "http://other.example/")
const res = await fetch(dialer, "http://localhost/", {
method: "POST",
body: "data",
redirect: { mode: "follow" },
})
assert.strictEqual(res.status, 200)
const { secondRequest } = await serverDone
assert.ok(secondRequest[0].startsWith("GET "), `expected GET, got: ${secondRequest[0]}`)
})
test("303 always changes method to GET", async () => {
const { dialer, serverDone } = redirectServer(303, "http://other.example/")
const res = await fetch(dialer, "http://localhost/", {
method: "POST",
body: "payload",
redirect: { mode: "follow" },
})
assert.strictEqual(res.status, 200)
const { secondRequest } = await serverDone
assert.ok(secondRequest[0].startsWith("GET "), `expected GET, got: ${secondRequest[0]}`)
})
test("307 preserves method and body", async () => {
const { dialer, serverDone } = redirectServer(307, "http://other.example/")
const res = await fetch(dialer, "http://localhost/", {
method: "POST",
body: "payload",
redirect: { mode: "follow", body: "resubmit" },
})
assert.strictEqual(res.status, 200)
const { secondRequest } = await serverDone
assert.ok(secondRequest[0].startsWith("POST "), `expected POST, got: ${secondRequest[0]}`)
})
test("stops at redirect.max and returns the last 3xx", async () => {
// Server sends one redirect; with max:0 it should stop immediately.
const { dialer } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "follow", max: 0 } })
assert.strictEqual(res.status, 302)
})
test("follows redirect to same path via a new connection (cross-hop)", async () => {
const { dialer, serverDone } = redirectServer(302, "http://localhost/final")
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } })
assert.strictEqual(res.status, 200)
const { secondRequest } = await serverDone
assert.ok(secondRequest[0].includes("/final"), `expected /final, got: ${secondRequest[0]}`)
})
})
suite("fetch() — redirect.mode: same-origin", { skip: skipIfNotIntegration }, () => {
test("follows redirect to same origin (same scheme+host+port)", async () => {
// Redirect to same origin — fetch() creates a new TCP connection per hop (keepAlive=false).
const { dialer, serverDone } = redirectServer(302, "http://localhost/new")
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "same-origin" } })
assert.strictEqual(res.status, 200)
await serverDone
})
test("stops at cross-origin redirect and returns the 3xx", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "same-origin" } })
assert.strictEqual(res.status, 302)
await serverDone
})
})
suite("fetch() — redirect.mode: same-connection", { skip: skipIfNotIntegration }, () => {
test("follows redirect that stays on the same TCP endpoint (requires keepAlive)", async () => {
// fetch() uses Connection: close which prevents reuse; use makeFetch with keepAlive so the
// redirect can happen on the same open TCP connection.
const { dialer, serverDone } = sameConnRedirectServer(302, "http://localhost/new", ok200ka)
const f = makeFetch(dialer)
const res = await f("http://localhost/", { redirect: { mode: "same-connection" } })
assert.strictEqual(res.status, 200)
await f.pool.shutdown()
await serverDone
})
test("stops at a redirect to a different host", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "same-connection" },
})
assert.strictEqual(res.status, 302)
await serverDone
})
test("stops and returns the 3xx when the server closes the connection (Connection: close)", async () => {
// Even though the Location points to the same endpoint, the server closed the connection.
// same-connection must NOT reconnect — it returns the 3xx unchanged.
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://localhost/new\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "same-connection" },
})
assert.strictEqual(res.status, 302, "should return 3xx, not follow on a new connection")
await serverDone
})
})
suite("fetch() — redirect.filter", { skip: skipIfNotIntegration }, () => {
test("filter: string[] allows listed host", async () => {
const { dialer, serverDone } = redirectServer(302, "http://allowed.example/")
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", filter: ["allowed.example"] },
})
assert.strictEqual(res.status, 200)
await serverDone
})
test("filter: string[] blocks unlisted host", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://blocked.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", filter: ["allowed.example"] },
})
assert.strictEqual(res.status, 302)
await serverDone
})
test("filter: Set<string> works the same as array", async () => {
const { dialer, serverDone } = redirectServer(302, "http://allowed.example/")
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", filter: new Set(["allowed.example"]) },
})
assert.strictEqual(res.status, 200)
await serverDone
})
test("filter: callback receives correct context and can deny", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const contexts: { fromHost: string; toHost: string; status: number }[] = []
const res = await fetch(dialer, "http://localhost/", {
redirect: {
mode: "follow",
filter: (ctx) => {
contexts.push({ fromHost: ctx.fromUrl.host, toHost: ctx.toUrl.host, status: ctx.status })
return false
},
},
})
assert.strictEqual(res.status, 302)
assert.strictEqual(contexts.length, 1)
assert.strictEqual(contexts[0].fromHost, "localhost")
assert.strictEqual(contexts[0].toHost, "other.example")
assert.strictEqual(contexts[0].status, 302)
await serverDone
})
test("filter: callback can allow based on context", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", filter: (ctx) => ctx.toUrl.hostname === "other.example" },
})
assert.strictEqual(res.status, 200)
await serverDone
})
})
suite("fetch() — redirect credentials", { skip: skipIfNotIntegration }, () => {
test("strip-cross-origin (default): strips Authorization on cross-origin redirect", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
await fetch(dialer, "http://localhost/", {
headers: { Authorization: "Bearer secret" },
redirect: { mode: "follow" },
})
const { secondRequest } = await serverDone
const hasAuth = secondRequest.some((l) => l.toLowerCase().startsWith("authorization:"))
assert.ok(!hasAuth, "Authorization should be stripped on cross-origin redirect")
})
test("strip-cross-origin (default): preserves Authorization on same-origin redirect", async () => {
const { dialer, serverDone } = redirectServer(302, "http://localhost/new")
await fetch(dialer, "http://localhost/", {
headers: { Authorization: "Bearer secret" },
redirect: { mode: "follow" },
})
const { secondRequest } = await serverDone
const hasAuth = secondRequest.some((l) => l.toLowerCase().startsWith("authorization:"))
assert.ok(hasAuth, "Authorization should be kept on same-origin redirect")
})
test("credentials: keep always forwards Authorization", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
await fetch(dialer, "http://localhost/", {
headers: { Authorization: "Bearer secret" },
redirect: { mode: "follow", credentials: "keep" },
})
const { secondRequest } = await serverDone
const hasAuth = secondRequest.some((l) => l.toLowerCase().startsWith("authorization:"))
assert.ok(hasAuth, "Authorization should be kept with credentials: keep")
})
test("credentials: strip always removes Authorization (same-origin)", async () => {
const { dialer, serverDone } = redirectServer(302, "http://localhost/new")
await fetch(dialer, "http://localhost/", {
headers: { Authorization: "Bearer secret" },
redirect: { mode: "follow", credentials: "strip" },
})
const { secondRequest } = await serverDone
const hasAuth = secondRequest.some((l) => l.toLowerCase().startsWith("authorization:"))
assert.ok(!hasAuth, "Authorization should be stripped with credentials: strip")
})
test("Cookie header is stripped on cross-origin redirect by default", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
await fetch(dialer, "http://localhost/", {
headers: { Cookie: "session=abc" },
redirect: { mode: "follow" },
})
const { secondRequest } = await serverDone
const hasCookie = secondRequest.some((l) => l.toLowerCase().startsWith("cookie:"))
assert.ok(!hasCookie, "Cookie should be stripped on cross-origin redirect")
})
})
suite("fetch() — redirect body policy", { skip: skipIfNotIntegration }, () => {
test("body: strip-non-resubmit (default) strips body on 302 (POST→GET, no Content-Length)", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
await fetch(dialer, "http://localhost/", {
method: "POST",
body: "payload",
redirect: { mode: "follow" },
})
const { secondRequest } = await serverDone
// 302 POST→GET means no body; GET has requestBody=false so Content-Length is absent
assert.ok(secondRequest[0].startsWith("GET "), `expected GET, got: ${secondRequest[0]}`)
const hasContentLength = secondRequest.some((l) => l.toLowerCase().startsWith("content-length:"))
assert.ok(!hasContentLength, "GET redirect should have no Content-Length")
})
test("body: resubmit preserves method and resends body on 302", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
await fetch(dialer, "http://localhost/", {
method: "POST",
body: "payload",
redirect: { mode: "follow", body: "resubmit" },
})
const { secondRequest } = await serverDone
// body: resubmit preserves the method too (POST + body)
assert.ok(secondRequest[0].startsWith("POST "), `expected POST, got: ${secondRequest[0]}`)
const cl = secondRequest.find((l) => l.toLowerCase().startsWith("content-length:"))
assert.ok(cl && +cl.split(":")[1].trim() > 0, "body should be resent with body: resubmit")
})
test("body: strip removes body payload even on 307 (Content-Length becomes 0)", async () => {
const { dialer, serverDone } = redirectServer(307, "http://other.example/")
await fetch(dialer, "http://localhost/", {
method: "POST",
body: "payload",
redirect: { mode: "follow", body: "strip" },
})
const { secondRequest } = await serverDone
// 307 preserves method (POST) but body is stripped → Content-Length: 0
assert.ok(secondRequest[0].startsWith("POST "), `expected POST, got: ${secondRequest[0]}`)
const cl = secondRequest.find((l) => l.toLowerCase().startsWith("content-length:"))
const clVal = cl ? +cl.split(":")[1].trim() : -1
assert.strictEqual(clVal, 0, "body: strip should result in Content-Length: 0 on 307")
})
})
suite("fetch() — redirect.collect", { skip: skipIfNotIntegration }, () => {
test("collects followed redirects in response.redirects", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", collect: true },
})
assert.strictEqual(res.status, 200)
assert.strictEqual(res.redirects.length, 1)
assert.strictEqual(res.redirects[0].status, 302)
assert.ok(res.redirects[0].location instanceof URL)
assert.strictEqual(res.redirects[0].location?.hostname, "other.example")
await serverDone
})
test("response.redirects is empty when no redirect was followed", async () => {
const { dialer, serverDone } = simpleServer(ok200)
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", collect: true },
})
assert.strictEqual(res.status, 200)
assert.deepStrictEqual(res.redirects, [])
await serverDone
})
test("response.redirects is empty when collect is not set", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } })
assert.strictEqual(res.status, 200)
assert.deepStrictEqual(res.redirects, [])
await serverDone
})
test("redirect.collect and interim: collect work simultaneously", async () => {
const [listener, dialer] = loopbackListener()
const serverDone = (async () => {
// First connection: plain 302 (no interim on this hop)
const t1 = await listener.accept()
const buf1 = new ReadBuffer(t1)
await collectRequest(buf1)
await t1.write(
enc.encode(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
),
)
t1.close()
// Second connection: 103 hint then 200 — interim is collected on the final response
const t2 = await listener.accept()
const buf2 = new ReadBuffer(t2)
await collectRequest(buf2)
await t2.write(
enc.encode(
"HTTP/1.1 103 Early Hints\r\nLink: </style.css>; rel=preload\r\n\r\n" +
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
),
)
t2.close()
listener.close()
})()
const res = await fetch(dialer, "http://localhost/", {
interim: "collect",
redirect: { mode: "follow", collect: true },
})
assert.strictEqual(res.status, 200)
// 1xx from the final hop are in informational
assert.strictEqual(res.informational.length, 1)
assert.strictEqual(res.informational[0].status, 103)
// 3xx hops followed are in redirects
assert.strictEqual(res.redirects.length, 1)
assert.strictEqual(res.redirects[0].status, 302)
await serverDone
})
test("Location is null in collected redirect when header is missing", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
// Mode follow without a Location → returns the 3xx as-is (can't follow)
// but collect is still populated for any hops that were collected before stopping.
// This tests that collect doesn't blow up on missing Location.
const res = await fetch(dialer, "http://localhost/", {
redirect: { mode: "follow", collect: true },
})
assert.strictEqual(res.status, 302)
assert.deepStrictEqual(res.redirects, [])
await serverDone
})
test("unparseable Location header causes the 3xx to be returned as-is", async () => {
// "http://[invalid" is rejected by the URL constructor → parseLocation returns null → no follow.
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://[invalid\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const res = await fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } })
assert.strictEqual(res.status, 302)
await serverDone
})
test("Location URL with embedded credentials sets Authorization on the redirect request", async () => {
const [listener, dialer] = loopbackListener()
let redirectedAuth = ""
const serverDone = (async () => {
const t1 = await listener.accept()
const buf1 = new ReadBuffer(t1)
await collectRequest(buf1)
await t1.write(
enc.encode(
"HTTP/1.1 302 Found\r\nLocation: http://user:pass@other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
),
)
t1.close()
const t2 = await listener.accept()
const buf2 = new ReadBuffer(t2)
const req2 = await collectRequest(buf2)
const authLine = req2.find((l) => l.toLowerCase().startsWith("authorization:"))
if (authLine) redirectedAuth = authLine
await t2.write(enc.encode(ok200))
t2.close()
listener.close()
})()
await fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } })
await serverDone
assert.ok(redirectedAuth.toLowerCase().includes("basic "), `expected basic auth, got: ${redirectedAuth}`)
})
})
// ─── streaming redirects ────────────────────────────────────────────────────────
suite("fetchStream() — redirects", { skip: skipIfNotIntegration }, () => {
test("3xx is yielded as-is without redirect.mode (default)", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const statuses: number[] = []
for await (const r of fetchStream(dialer, "http://localhost/")) {
statuses.push(r.status)
}
assert.deepStrictEqual(statuses, [302])
await serverDone
})
test("yields 3xx then 200 when redirect is followed", async () => {
const { dialer, serverDone } = redirectServer(302, "http://other.example/")
const statuses: number[] = []
for await (const r of fetchStream(dialer, "http://localhost/", {
redirect: { mode: "follow" },
})) {
statuses.push(r.status)
}
assert.deepStrictEqual(statuses, [302, 200])
await serverDone
})
test("yields 1xx, 3xx, then 200 in order", async () => {
const [listener, dialer] = loopbackListener()
const serverDone = (async () => {
const t1 = await listener.accept()
const buf1 = new ReadBuffer(t1)
await collectRequest(buf1)
await t1.write(
enc.encode(
"HTTP/1.1 103 Early Hints\r\nLink: </style.css>; rel=preload\r\n\r\n" +
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
),
)
t1.close()
const t2 = await listener.accept()
const buf2 = new ReadBuffer(t2)
await collectRequest(buf2)
await t2.write(enc.encode(ok200))
t2.close()
listener.close()
})()
const statuses: number[] = []
for await (const r of fetchStream(dialer, "http://localhost/", {
redirect: { mode: "follow" },
})) {
statuses.push(r.status)
}
assert.deepStrictEqual(statuses, [103, 302, 200])
await serverDone
})
test("yields 3xx only (no follow) when mode stops it", async () => {
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const statuses: number[] = []
for await (const r of fetchStream(dialer, "http://localhost/", {
redirect: { mode: "same-origin" },
})) {
statuses.push(r.status)
}
assert.deepStrictEqual(statuses, [302])
await serverDone
})
test("early break before redirect is handled cleanly", async () => {
// Use a single-connection server — the user breaks after the 302 so no second connection
// is ever made, and the server should still clean up without hanging.
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
for await (const r of fetchStream(dialer, "http://localhost/", {
redirect: { mode: "follow" },
})) {
assert.strictEqual(r.status, 302)
break
}
await serverDone
})
test("same-connection stops at Connection: close in streaming mode", async () => {
// Server sends a 302 to the same endpoint but closes the connection — mode:same-connection
// must not reconnect; the 302 should be the last yielded response.
const { dialer, serverDone } = simpleServer(
"HTTP/1.1 302 Found\r\nLocation: http://localhost/new\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
const statuses: number[] = []
for await (const r of fetchStream(dialer, "http://localhost/", {
redirect: { mode: "same-connection" },
})) {
statuses.push(r.status)
}
assert.deepStrictEqual(statuses, [302])
await serverDone
})
test("same-connection redirect in streaming mode (requires keepAlive)", async () => {
const { dialer, serverDone } = sameConnRedirectServer(302, "http://localhost/new", ok200ka)
const f = makeFetch(dialer)
const statuses: number[] = []
for await (const r of f.stream("http://localhost/", { redirect: { mode: "follow" } })) {
statuses.push(r.status)
}
assert.deepStrictEqual(statuses, [302, 200])
await f.pool.shutdown()
await serverDone
})
test("3xx body is accessible before following the redirect", async () => {
const [listener, dialer] = loopbackListener()
const serverDone = (async () => {
const t1 = await listener.accept()
const buf1 = new ReadBuffer(t1)
await collectRequest(buf1)
await t1.write(
enc.encode(
"HTTP/1.1 302 Found\r\nLocation: http://other.example/\r\nContent-Length: 4\r\nConnection: close\r\n\r\ngone",
),
)
t1.close()
const t2 = await listener.accept()
const buf2 = new ReadBuffer(t2)
await collectRequest(buf2)
await t2.write(enc.encode(ok200))
t2.close()
listener.close()
})()
const results: { status: number; body?: string }[] = []
for await (const r of fetchStream(dialer, "http://localhost/", {
redirect: { mode: "follow" },
})) {
if (r.hasBody) {
results.push({ status: r.status, body: await r.text() })
} else {
results.push({ status: r.status })
}
}
assert.strictEqual(results[0].status, 302)
assert.strictEqual(results[0].body, "gone")
assert.strictEqual(results[1].status, 200)
await serverDone
})
})
+374 -43
View File
@@ -1,7 +1,17 @@
import type { ClientConnection, ClientConnectionOptions } from "./connection.js"
import { shouldClose } from "../common/connection.js"
import { ClientRequestImpl } from "./objects.js"
import { ClientResponseImpl } from "./objects.js"
import { PooledDialer, UnpooledDialer, type ConnectionPool } from "./pool.js"
import type { ClientRequestInit, ClientResponse, RawDialer } from "./types.js"
import type {
ClientRedirectResponse,
ClientRequestInit,
ClientResponse,
RawDialer,
RedirectFilter,
RedirectFilterContext,
RedirectOptions,
} from "./types.js"
export interface Fetch {
(url: string | URL, options?: Omit<ClientRequestInit, "target">): Promise<ClientResponse>
@@ -26,6 +36,126 @@ type RawFetchArgs =
| URL
| (Omit<ClientRequestInit, "target"> & { url: string | URL })
// 3xx status codes that can be followed as redirects
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
function urlPort(url: URL): number {
if (url.port) return +url.port
return url.protocol === "https:" ? 443 : 80
}
function parseLocation(res: ClientResponse, base: URL): URL | null {
const loc = res.getHeader("location")
if (!loc || typeof loc !== "string") return null
try {
return new URL(loc, base)
} catch {
return null
}
}
function matchesFilter(filter: RedirectFilter, ctx: RedirectFilterContext): boolean {
if (typeof filter === "function") return filter(ctx)
const host = ctx.toUrl.host
if (filter instanceof Set) return filter.has(host)
return (filter as readonly string[]).includes(host)
}
function canFollowRedirect(
redirect: RedirectOptions,
ctx: RedirectFilterContext,
connHostname: string,
connPort: number,
connIsTls: boolean,
): boolean {
const { mode, filter } = redirect
if (!mode || mode === "manual") return false
const { toUrl, fromUrl } = ctx
const toPort = urlPort(toUrl)
const toIsTls = toUrl.protocol === "https:"
let allowed: boolean
switch (mode) {
case "same-connection":
allowed = toUrl.hostname === connHostname && toPort === connPort && toIsTls === connIsTls
break
case "same-origin":
allowed = toUrl.origin === fromUrl.origin
break
default:
allowed = true
}
if (!allowed) return false
if (filter && !matchesFilter(filter, ctx)) return false
return true
}
function buildRedirectReq(
prevReq: ClientRequestImpl,
fromUrl: URL,
toUrl: URL,
status: number,
redirect: RedirectOptions,
keepAlive: boolean,
): ClientRequestImpl {
const isResubmit = status === 307 || status === 308
const bodyPolicy = redirect.body ?? "strip-non-resubmit"
// 303 always strips body regardless of policy; 307/308 use the policy too.
const keepBody =
status !== 303 &&
(bodyPolicy === "resubmit" || (bodyPolicy !== "strip" && isResubmit))
const body = keepBody ? prevReq.body : null
let method = prevReq.method
if (status === 303) {
method = "GET"
} else if (!isResubmit && !keepBody && method === "POST") {
// 301/302/etc: POST→GET only when body is being stripped (browser convention).
// Preserve method when the user explicitly resubmits the body.
method = "GET"
}
const credPolicy = redirect.credentials ?? "strip-cross-origin"
const stripCreds = credPolicy === "strip" || (credPolicy === "strip-cross-origin" && toUrl.origin !== fromUrl.origin)
const headers: Record<string, string | readonly string[]> = {}
for (const [k, v] of Object.entries(prevReq.headers)) {
const lk = k.toLowerCase()
if (lk === "host" || lk === "connection") continue
if (stripCreds && (lk === "authorization" || lk === "cookie" || lk === "cookie2")) continue
if (body === null && (lk === "content-length" || lk === "content-type" || lk === "transfer-encoding")) continue
headers[k] = v
}
const req = new ClientRequestImpl({
method,
target: toUrl.pathname + toUrl.search,
body,
headers,
version: prevReq.version,
interim: prevReq.interim,
})
if (toUrl.username || toUrl.password) {
req.setHeader("Authorization", `Basic ${btoa([toUrl.username, toUrl.password].join(":"))}`)
}
req.setHeader("Host", toUrl.host)
if (keepAlive && !req.hasHeader("Connection")) req.setHeader("Connection", "keep-alive")
if (!keepAlive && req.getHeader("Connection") !== "upgrade") req.setHeader("Connection", "close")
return req
}
function makeCollectedRedirect(res: ClientResponse, location: URL): ClientRedirectResponse {
return {
status: res.status,
statusText: res.statusText,
version: res.version,
headers: res.headers,
location,
getHeader: (h) => res.getHeader(h),
hasHeader: (h) => res.hasHeader(h),
}
}
function prepareRequest(
keepAlive: boolean,
pool: ConnectionPool,
@@ -33,10 +163,12 @@ function prepareRequest(
maybeOptions: Omit<ClientRequestInit, "target"> | undefined,
): {
req: ClientRequestImpl
url: URL
hostname: string
port: number
isTls: boolean
connectionOptions: ClientConnectionOptions | undefined
redirect: RedirectOptions | undefined
} {
let url =
typeof urlOrOptions === "object" && "url" in urlOrOptions ? urlOrOptions.url : urlOrOptions
@@ -74,7 +206,15 @@ function prepareRequest(
if (!keepAlive && (req.getHeader("Connection") as string | undefined)?.toLowerCase() !== "upgrade")
req.setHeader("Connection", "close")
return { req, hostname: url.hostname, port, isTls, connectionOptions: options.connectionOptions }
return {
req,
url,
hostname: url.hostname,
port,
isTls,
connectionOptions: options.connectionOptions,
redirect: options.redirect,
}
}
function makeDone(
@@ -102,33 +242,124 @@ async function rawFetch(
urlOrOptions: RawFetchArgs,
maybeOptions?: Omit<ClientRequestInit, "target">,
): Promise<ClientResponse> {
const { req, hostname, port, isTls, connectionOptions } = prepareRequest(
keepAlive,
pool,
urlOrOptions,
maybeOptions,
)
const prepared = prepareRequest(keepAlive, pool, urlOrOptions, maybeOptions)
const { connectionOptions, redirect } = prepared
let { req, url, hostname, port, isTls } = prepared
const conn = await pool.getConnection(hostname, port, isTls, connectionOptions)
try {
const result = await conn.request(req)
const done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
// 101 Switching Protocols has no body, so onPrevBodyFinished() resolves as a
// microtask — before the caller can call connectWebSocket() / hijack(). With a
// PooledDialer that means done(false) fires first, putting the connection back in
// the idle pool (or closing it when keepAlive:false) before the hijack happens.
// Avoid the race by treating a 101 the same as a hijack from the pool's point of
// view: remove the connection immediately so no concurrent waiter can grab it.
if (result.status === 101) {
done(true)
} else {
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
let conn = await pool.getConnection(hostname, port, isTls, connectionOptions)
// No redirect following — original single-request behaviour
if (!redirect?.mode || redirect.mode === "manual") {
try {
const result = await conn.request(req)
const done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
// 101 Switching Protocols has no body, so onPrevBodyFinished() resolves as a
// microtask — before the caller can call connectWebSocket() / hijack(). With a
// PooledDialer that means done(false) fires first, putting the connection back in
// the idle pool (or closing it when keepAlive:false) before the hijack happens.
// Avoid the race by treating a 101 the same as a hijack from the pool's point of
// view: remove the connection immediately so no concurrent waiter can grab it.
if (result.status === 101) {
done(true)
} else {
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
}
return result
} catch (e) {
pool.rejectConnection(hostname, port, isTls, conn)
throw e
}
return result
} catch (e) {
pool.rejectConnection(hostname, port, isTls, conn)
throw e
}
// Redirect following loop
const collected: ClientRedirectResponse[] = []
let redirectCount = 0
const maxRedirects = redirect.max ?? 20
while (true) {
let res: ClientResponse
try {
res = await conn.request(req)
} catch (e) {
pool.rejectConnection(hostname, port, isTls, conn)
throw e
}
const isFollowable = REDIRECT_STATUSES.has(res.status) && redirectCount < maxRedirects
if (!isFollowable) {
if (redirect.collect && collected.length > 0) (res as ClientResponseImpl).setRedirects(collected)
const done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
// Apply the same 101 race-fix: a 101 inside a redirect chain (e.g. WebSocket upgrade
// after a redirect) must be immediately ejected from the pool, not released.
if (res.status === 101) {
done(true)
} else {
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
}
return res
}
const location = parseLocation(res, url)
const ctx: RedirectFilterContext = { fromUrl: url, toUrl: location ?? url, status: res.status }
if (!location || !canFollowRedirect(redirect, ctx, hostname, port, isTls)) {
if (redirect.collect && collected.length > 0) (res as ClientResponseImpl).setRedirects(collected)
const done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
return res
}
const nextPort = urlPort(location)
const nextIsTls = location.protocol === "https:"
// Reuse the current TCP connection only when the target is the same endpoint
// and the server hasn't signalled it wants to close.
const reuseConn =
location.hostname === hostname &&
nextPort === port &&
nextIsTls === isTls &&
!conn.ended &&
!shouldClose(req, res)
// same-connection requires the *exact* existing socket — stop if it can't be reused.
if (redirect.mode === "same-connection" && !reuseConn) {
if (redirect.collect && collected.length > 0) (res as ClientResponseImpl).setRedirects(collected)
const done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
return res
}
if (redirect.collect) collected.push(makeCollectedRedirect(res, location))
const nextReq = buildRedirectReq(req, url, location, res.status, redirect, keepAlive)
url = location
req = nextReq
redirectCount++
if (!reuseConn) {
// Drain the redirect body so the connection can be cleanly released.
let drainOk = true
// drain() exits via the while-condition rather than throwing: BasicBodyReader sets
// closed=true via buffer.ended before read() is re-entered, so the catch and the
// rejectConnection branch are unreachable with any standard transport.
/* c8 ignore start */
try {
await conn.drain()
} catch {
drainOk = false
}
if (!drainOk) pool.rejectConnection(hostname, port, isTls, conn)
/* c8 ignore stop */
if (drainOk) pool.releaseConnection(hostname, port, isTls, conn)
hostname = location.hostname
port = nextPort
isTls = nextIsTls
conn = await pool.getConnection(hostname, port, isTls, connectionOptions)
}
// else: same connection — conn.request() auto-drains #prevBody at the top of the next call
}
}
@@ -138,29 +369,130 @@ async function* rawFetchStream(
urlOrOptions: RawFetchArgs,
maybeOptions?: Omit<ClientRequestInit, "target">,
): AsyncGenerator<ClientResponse> {
const { req, hostname, port, isTls, connectionOptions } = prepareRequest(
keepAlive,
pool,
urlOrOptions,
maybeOptions,
)
const prepared = prepareRequest(keepAlive, pool, urlOrOptions, maybeOptions)
const { connectionOptions, redirect } = prepared
let { req, url, hostname, port, isTls } = prepared
const conn = await pool.getConnection(hostname, port, isTls, connectionOptions)
const done = makeDone(pool, hostname, port, isTls, conn)
let conn = await pool.getConnection(hostname, port, isTls, connectionOptions)
let done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
let finalSeen = false
// No redirect following — original behaviour
if (!redirect?.mode || redirect.mode === "manual") {
let finalStatus = 0
const innerGen = conn.requestStream(req)
try {
for await (const r of innerGen) {
if (r.status >= 200 || r.status === 101) { finalSeen = true; finalStatus = r.status }
yield r
}
} finally {
await innerGen.return(undefined)
if (!finalSeen) {
done(true)
} else if (!conn.hijacked) {
// Apply the same 101 race-fix as rawFetch.
if (finalStatus === 101) {
done(true)
} else {
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
}
}
}
return
}
// Redirect following: outer loop over redirect hops.
// We deliberately do NOT break from the inner for-await when we see a 3xx; instead we let
// requestStream run to natural completion (setting its internal `done = true`), which prevents
// it from closing the connection in its finally block.
let redirectCount = 0
const maxRedirects = redirect.max ?? 20
let finalStatus = 0
const innerGen = conn.requestStream(req)
try {
for await (const r of innerGen) {
if (r.status >= 200 || r.status === 101) { finalSeen = true; finalStatus = r.status }
yield r
while (true) {
let lastSeen: ClientResponse | undefined
const innerGen = conn.requestStream(req)
try {
for await (const r of innerGen) {
yield r
if (r.status >= 200 || r.status === 101) {
lastSeen = r
// No break: requestStream will complete naturally after resuming, setting done=true
// and skipping the connection-close in its finally block.
}
}
} finally {
// No-op when innerGen ran to completion; closes the connection when the user breaks early.
await innerGen.return(undefined)
}
if (!lastSeen) break
const r = lastSeen
const isFollowable = REDIRECT_STATUSES.has(r.status) && redirectCount < maxRedirects
if (!isFollowable) {
finalSeen = true
finalStatus = r.status
break
}
const location = parseLocation(r, url)
const ctx: RedirectFilterContext = { fromUrl: url, toUrl: location ?? url, status: r.status }
if (!location || !canFollowRedirect(redirect, ctx, hostname, port, isTls)) {
finalSeen = true
finalStatus = r.status
break
}
const nextPort = urlPort(location)
const nextIsTls = location.protocol === "https:"
const reuseConn =
location.hostname === hostname &&
nextPort === port &&
nextIsTls === isTls &&
!conn.ended &&
!shouldClose(req, r)
// same-connection requires the *exact* existing socket — stop if it can't be reused.
// The 3xx was already yielded; the stream ends here.
if (redirect.mode === "same-connection" && !reuseConn) {
finalSeen = true
break
}
const nextReq = buildRedirectReq(req, url, location, r.status, redirect, keepAlive)
url = location
req = nextReq
redirectCount++
if (!reuseConn) {
let drainOk = true
// Same reasoning as above: drain() cannot throw with standard transports.
/* c8 ignore start */
try {
await conn.drain()
} catch {
drainOk = false
}
if (!drainOk) done(true)
/* c8 ignore stop */
if (drainOk) done(false)
hostname = location.hostname
port = nextPort
isTls = nextIsTls
conn = await pool.getConnection(hostname, port, isTls, connectionOptions)
done = makeDone(pool, hostname, port, isTls, conn)
conn.setHijackListener(() => done(true))
}
// else: same connection — requestStream auto-drains at the top of its next call
}
} finally {
// Ensure the inner generator's cleanup runs (closes connection on early break).
// This is a no-op if the inner generator already ran to completion.
await innerGen.return(undefined)
if (!finalSeen) {
done(true)
} else if (!conn.hijacked) {
@@ -173,7 +505,6 @@ async function* rawFetchStream(
conn.onPrevBodyFinished().then(() => done(false), () => done(true))
}
}
// If hijacked: the hijack listener already called done(true)
}
}
+5
View File
@@ -8,6 +8,11 @@ export type {
ClientResponse,
ClientRequestInit,
ClientInformationalResponse,
ClientRedirectResponse,
RedirectMode,
RedirectFilter,
RedirectFilterContext,
RedirectOptions,
} from "./types.js"
export type {
RawTransport,
+11
View File
@@ -3,6 +3,7 @@ import type { BodyReaderOptions } from "../common/reader.js"
import type { RawTransport, Reader } from "../common/types.js"
import type {
ClientInformationalResponse,
ClientRedirectResponse,
ClientRequest,
ClientRequestInit,
ClientResponse,
@@ -30,6 +31,7 @@ export class ClientResponseImpl extends ReadableHttpImpl implements ClientRespon
#statusText: string
#hijackFn: (() => RawTransport) | null
#informational: readonly ClientInformationalResponse[]
#redirects: readonly ClientRedirectResponse[]
constructor({
version,
@@ -40,6 +42,7 @@ export class ClientResponseImpl extends ReadableHttpImpl implements ClientRespon
defaultBodyReaderOptions,
hijackFn,
informational = [],
redirects = [],
}: {
version: "1.0" | "1.1"
headers: Headers
@@ -49,6 +52,7 @@ export class ClientResponseImpl extends ReadableHttpImpl implements ClientRespon
defaultBodyReaderOptions?: BodyReaderOptions
hijackFn: (() => RawTransport) | null
informational?: readonly ClientInformationalResponse[]
redirects?: readonly ClientRedirectResponse[]
}) {
super({ headers, bodyStream, defaultBodyReaderOptions })
this.#version = version
@@ -56,6 +60,7 @@ export class ClientResponseImpl extends ReadableHttpImpl implements ClientRespon
this.#statusText = statusText
this.#hijackFn = hijackFn
this.#informational = informational
this.#redirects = redirects
}
get version(): "1.0" | "1.1" {
@@ -70,10 +75,16 @@ export class ClientResponseImpl extends ReadableHttpImpl implements ClientRespon
get informational(): readonly ClientInformationalResponse[] {
return this.#informational
}
get redirects(): readonly ClientRedirectResponse[] {
return this.#redirects
}
setInformational(informational: readonly ClientInformationalResponse[]): void {
this.#informational = informational
}
setRedirects(redirects: readonly ClientRedirectResponse[]): void {
this.#redirects = redirects
}
hijack(): RawTransport {
if (!this.#hijackFn) throw new Error("hijack not available on this response")
+63
View File
@@ -15,6 +15,67 @@ export interface ClientInformationalResponse {
hasHeader(header: string): boolean
}
export interface ClientRedirectResponse {
readonly status: number
readonly statusText: string
readonly version: "1.0" | "1.1"
readonly headers: Readonly<Record<string, string | readonly string[]>>
/** Parsed Location URL, or null if the header was missing or unparseable. */
readonly location: URL | null
getHeader(header: string): string | readonly string[] | undefined
hasHeader(header: string): boolean
}
/**
* "manual" — never follow redirects (default; 3xx is returned as-is)
* "same-connection" — follow only when the Location stays on the same TCP endpoint
* "same-origin" — follow when scheme + host + port are unchanged
* "follow" — follow any redirect
*/
export type RedirectMode = "manual" | "same-connection" | "same-origin" | "follow"
export interface RedirectFilterContext {
fromUrl: URL
toUrl: URL
status: number
}
/**
* Restricts which redirect targets are accepted.
* - string[] / Set<string>: matched against toUrl.host (hostname + port when non-standard)
* - function: called with the redirect context; return true to allow
*/
export type RedirectFilter =
| Set<string>
| readonly string[]
| ((ctx: RedirectFilterContext) => boolean)
export interface RedirectOptions {
mode?: RedirectMode
/** Maximum number of redirects to follow before returning the last 3xx. Default: 20. */
max?: number
filter?: RedirectFilter
/**
* What to do with Authorization / Cookie / Cookie2 headers on redirect.
* "keep" — always forward them
* "strip-cross-origin"— strip when the origin changes (default)
* "strip" — always strip
*/
credentials?: "keep" | "strip-cross-origin" | "strip"
/**
* What to do with the request body on redirect.
* "resubmit" — always resend the original body
* "strip-non-resubmit"— keep body only for 307/308 (default, matches browser behaviour)
* "strip" — always strip the body
*/
body?: "resubmit" | "strip-non-resubmit" | "strip"
/**
* In non-streaming mode: collect each followed 3xx into response.redirects[].
* Works alongside interim: "collect" — both arrays are populated independently.
*/
collect?: boolean
}
export interface ClientRequest extends WritableHttp {
method: string
target: string
@@ -27,10 +88,12 @@ export interface ClientResponse extends ReadableHttp {
readonly statusText: string
readonly version: "1.0" | "1.1"
readonly informational: readonly ClientInformationalResponse[]
readonly redirects: readonly ClientRedirectResponse[]
hijack(): RawTransport
}
export type ClientRequestInit = Pick<ClientRequest, "target"> &
Partial<Pick<ClientRequest, "method" | "headers" | "version" | "body" | "interim">> & {
connectionOptions?: ClientConnectionOptions
redirect?: RedirectOptions
}