Skip to content

Commit e4bbb25

Browse files
Reflexclaude
andcommitted
fix(h2-transport): return a real node-fetch Response and unref idle sessions
Making HTTP/2 the default surfaced two behaviors of the H2 transport that only mattered once non-opt-in clients used it: - `createH2Fetch` returned a bespoke `H2Response`, so `.asResponse()` no longer satisfied `instanceof Response` — breaking the generated resource tests that assert the raw response is a node-fetch `Response`. Adapt the internal `H2Response` to a real node-fetch `Response` at the fetch boundary (streaming preserved: the web `ReadableStream` body is consumed as a Node `Readable`). - Pooled H2 sessions kept the event loop alive, so a short script (and Jest) would hang on exit instead of terminating like the HTTP/1.1 transport. Unref idle sessions and ref them only while a request is in flight. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2d10853 commit e4bbb25

3 files changed

Lines changed: 40 additions & 2 deletions

File tree

src/lib/h2-transport/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717

1818
import { Readable } from 'node:stream';
1919
import { H2Pool, type H2PoolOptions } from './pool';
20+
import type { H2Response } from './response';
2021
import { MultipartBody } from '../../_shims/MultipartBody';
22+
import { Response } from 'node-fetch';
2123
import { type Fetch } from '../../core';
2224

25+
// Statuses that must not carry a body per the Fetch spec; node-fetch's Response
26+
// rejects a non-null body for these.
27+
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
28+
2329
const MIN_NODE_MAJOR = 18;
2430

2531
function checkNodeVersion(): void {
@@ -63,6 +69,25 @@ async function normalizeBody(body: unknown): Promise<string | Buffer | null> {
6369
return String(body);
6470
}
6571

72+
/**
73+
* Adapt an internal {@link H2Response} to a standard node-fetch `Response`, so
74+
* callers get the same type the default (HTTP/1.1) transport returns — including
75+
* `instanceof Response`. Body streaming is preserved: the H2 body is a web
76+
* `ReadableStream`, which node-fetch consumes as a Node `Readable`.
77+
*/
78+
function toFetchResponse(h2: H2Response): Response {
79+
const headers: Record<string, string> = {};
80+
for (const [key, value] of h2.headers.entries()) headers[key] = value;
81+
82+
const body = NULL_BODY_STATUSES.has(h2.status) ? null : Readable.fromWeb(h2.body as any);
83+
const response = new Response(body as any, { status: h2.status, headers });
84+
85+
// node-fetch derives `url` from the request internals it never saw; expose the
86+
// real request URL (`Response.url` is a prototype getter, shadowed here).
87+
Object.defineProperty(response, 'url', { value: h2.url, configurable: true });
88+
return response;
89+
}
90+
6691
/**
6792
* Build a fetch adapter backed by native HTTP/2 connection pools.
6893
*
@@ -109,7 +134,8 @@ export function createH2Fetch(options?: H2PoolOptions): H2Fetch {
109134
}
110135

111136
const pool = getPool(parsed.origin);
112-
return pool.request(path, method.toUpperCase(), reqHeaders, body, signal) as any;
137+
const h2resp = await pool.request(path, method.toUpperCase(), reqHeaders, body, signal);
138+
return toFetchResponse(h2resp) as any;
113139
}) as H2Fetch;
114140

115141
h2Fetch.close = async () => {

src/lib/h2-transport/session.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class H2Session {
7979
// Enlarge the session-level flow-control window so the server can push
8080
// large responses without stalling on WINDOW_UPDATE round trips.
8181
session.setLocalWindowSize(DEFAULT_INITIAL_WINDOW_SIZE);
82+
// An idle pooled session must not keep the process alive; each in-flight
83+
// request re-refs the socket (see `request`) and it is unref'd again once
84+
// the stream count returns to zero.
85+
session.unref();
8286
resolve();
8387
});
8488

@@ -140,6 +144,9 @@ export class H2Session {
140144

141145
const stream = this._session!.request(h2Headers);
142146
this._activeStreams++;
147+
// Keep the process alive while this request is outstanding; idle sessions
148+
// are unref'd (see `connect` and `cleanup`).
149+
this._session!.ref();
143150

144151
let settled = false;
145152
let cleaned = false;
@@ -155,6 +162,7 @@ export class H2Session {
155162
stream.on('error', () => {});
156163
stream.destroy();
157164
this._activeStreams--;
165+
if (this._activeStreams === 0) this._session?.unref();
158166
reject(createAbortError());
159167
return;
160168
}
@@ -165,6 +173,7 @@ export class H2Session {
165173
if (cleaned) return;
166174
cleaned = true;
167175
this._activeStreams--;
176+
if (this._activeStreams === 0) this._session?.unref();
168177
if (signal) signal.removeEventListener('abort', onAbort);
169178
if (this._state === SessionState.DRAINING && this._activeStreams === 0) {
170179
this._close();

tests/lib/h2-transport/integration.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Readable } from 'node:stream';
12
import { createH2Fetch } from '../../../src/lib/h2-transport/index';
23
import { cleanupCerts, testTls } from './helpers/certs';
34
import { defaultHandler, startTestServer, TestServer } from './helpers/testServer';
@@ -35,7 +36,9 @@ describe('integration through createH2Fetch', () => {
3536
} as any)) as any;
3637
expect(r.headers.get('content-type')).toBe('text/event-stream');
3738

38-
const reader = r.body.getReader();
39+
// `Response.body` is a node-fetch Node `Readable`; wrap it as a web stream
40+
// to exercise incremental `getReader()` consumption.
41+
const reader = Readable.toWeb(r.body).getReader();
3942
const decoder = new TextDecoder();
4043
let buf = '';
4144
while (true) {

0 commit comments

Comments
 (0)