Compare commits
5 Commits
4250c3d258
...
cb275ef62a
| Author | SHA1 | Date | |
|---|---|---|---|
| cb275ef62a | |||
| f720b8a53f | |||
| 380686595a | |||
| cbc95fc56c | |||
| 8a49b0a51d |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ export type {
|
||||
ClientResponse,
|
||||
ClientRequestInit,
|
||||
ClientInformationalResponse,
|
||||
ClientRedirectResponse,
|
||||
RedirectMode,
|
||||
RedirectFilter,
|
||||
RedirectFilterContext,
|
||||
RedirectOptions,
|
||||
} from "./types.js"
|
||||
export type {
|
||||
RawTransport,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user