Skip to content

Commit 79f0e6f

Browse files
jrvb-rlclaudetode-rlReflex
authored
feat: add standalone HTTP/2 transport library (#799)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tony Deng <tony@runloop.ai> Co-authored-by: Reflex <agent@reflex.dev>
1 parent 72f8014 commit 79f0e6f

7 files changed

Lines changed: 1301 additions & 0 deletions

File tree

loadtest/sse-test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import http2 from 'node:http2';
2+
import { createH2Fetch } from '../src/lib/h2-transport/index.ts';
3+
import { execSync } from 'node:child_process';
4+
import fs from 'node:fs';
5+
import os from 'node:os';
6+
import path from 'node:path';
7+
8+
// Generate self-signed certs for the test server
9+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sse-test-'));
10+
const keyPath = path.join(tmpDir, 'key.pem');
11+
const certPath = path.join(tmpDir, 'cert.pem');
12+
13+
execSync(
14+
`openssl req -x509 -newkey rsa:2048 -keyout ${keyPath} -out ${certPath} ` +
15+
`-days 1 -nodes -subj "/CN=localhost" 2>/dev/null`,
16+
);
17+
18+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
19+
20+
async function startSSEServer(): Promise<{ port: number; close: () => void }> {
21+
return new Promise((resolve) => {
22+
const server = http2.createSecureServer({
23+
key: fs.readFileSync(keyPath),
24+
cert: fs.readFileSync(certPath),
25+
});
26+
27+
server.on('stream', (stream: http2.ServerHttp2Stream, headers) => {
28+
if (headers[':path'] === '/v1/test/sse') {
29+
stream.respond({
30+
':status': 200,
31+
'content-type': 'text/event-stream',
32+
'cache-control': 'no-cache',
33+
});
34+
35+
const events = [
36+
'data: {"id":1,"message":"hello"}\n\n',
37+
'data: {"id":2,"message":"world"}\n\n',
38+
'data: {"id":3,"message":"streaming done"}\n\n',
39+
];
40+
41+
let i = 0;
42+
const interval = setInterval(() => {
43+
if (i < events.length) {
44+
stream.write(events[i]);
45+
i++;
46+
} else {
47+
clearInterval(interval);
48+
stream.end();
49+
}
50+
}, 50);
51+
} else {
52+
stream.respond({ ':status': 200, 'content-type': 'application/json' });
53+
stream.end(JSON.stringify({ ok: true }));
54+
}
55+
});
56+
57+
server.listen(0, () => {
58+
const port = (server.address() as any).port;
59+
resolve({ port, close: () => server.close() });
60+
});
61+
});
62+
}
63+
64+
async function main() {
65+
const { port, close } = await startSSEServer();
66+
console.log('SSE test server on port', port);
67+
68+
const h2Fetch = createH2Fetch();
69+
70+
// Test 1: Normal JSON request
71+
console.log('\n--- Test 1: Normal JSON request ---');
72+
const jsonResp = (await h2Fetch(`https://localhost:${port}/v1/test/json`, {
73+
method: 'GET',
74+
headers: {},
75+
})) as any;
76+
console.log('Status:', jsonResp.status);
77+
const jsonBody = await jsonResp.json();
78+
console.log('Body:', jsonBody);
79+
console.log('PASS');
80+
81+
// Test 2: SSE streaming via getReader
82+
console.log('\n--- Test 2: SSE streaming (getReader) ---');
83+
const sseResp = (await h2Fetch(`https://localhost:${port}/v1/test/sse`, {
84+
method: 'GET',
85+
headers: { accept: 'text/event-stream' },
86+
})) as any;
87+
console.log('Status:', sseResp.status);
88+
console.log('Content-Type:', sseResp.headers.get('content-type'));
89+
90+
const reader = sseResp.body.getReader();
91+
const decoder = new TextDecoder();
92+
const events: string[] = [];
93+
94+
while (true) {
95+
const { done, value } = await reader.read();
96+
if (done) break;
97+
const text = decoder.decode(value, { stream: true });
98+
events.push(text);
99+
console.log(' Chunk:', JSON.stringify(text));
100+
}
101+
reader.releaseLock();
102+
console.log('Chunks received:', events.length);
103+
console.log('PASS');
104+
105+
// Test 3: SSE via async iteration (Node 18+ fast path)
106+
console.log('\n--- Test 3: SSE streaming (async iteration) ---');
107+
const sseResp2 = (await h2Fetch(`https://localhost:${port}/v1/test/sse`, {
108+
method: 'GET',
109+
headers: { accept: 'text/event-stream' },
110+
})) as any;
111+
112+
const iterEvents: string[] = [];
113+
for await (const chunk of sseResp2.body) {
114+
const text = decoder.decode(chunk, { stream: true });
115+
iterEvents.push(text);
116+
console.log(' Chunk:', JSON.stringify(text));
117+
}
118+
console.log('Chunks received:', iterEvents.length);
119+
console.log('PASS');
120+
121+
console.log('\n=== All SSE tests PASSED ===');
122+
await h2Fetch.close();
123+
close();
124+
125+
// Cleanup
126+
fs.rmSync(tmpDir, { recursive: true });
127+
process.exit(0);
128+
}
129+
130+
main().catch((err) => {
131+
console.error('FAIL:', err);
132+
process.exit(1);
133+
});

src/lib/h2-transport/headers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type http2 from 'node:http2';
2+
3+
/**
4+
* Minimal Headers implementation satisfying the SDK's usage:
5+
* response.headers.get('content-type')
6+
* response.headers.entries()
7+
*/
8+
export class H2Headers {
9+
private readonly map: Map<string, string>;
10+
11+
constructor(raw: http2.IncomingHttpHeaders) {
12+
this.map = new Map();
13+
for (const [key, value] of Object.entries(raw)) {
14+
if (key.startsWith(':')) continue;
15+
const normalized = Array.isArray(value) ? value.join(', ') : value;
16+
if (normalized !== undefined) {
17+
this.map.set(key.toLowerCase(), normalized);
18+
}
19+
}
20+
}
21+
22+
get(name: string): string | null {
23+
return this.map.get(name.toLowerCase()) ?? null;
24+
}
25+
26+
entries(): IterableIterator<[string, string]> {
27+
return this.map.entries();
28+
}
29+
}

src/lib/h2-transport/index.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* A high-performance HTTP/2 fetch adapter built on Node's native `node:http2`.
3+
*
4+
* Replaces undici for HTTP/2 transport. Manages a pool of persistent H2 sessions
5+
* per origin, multiplexing requests as concurrent streams. Each session handles up
6+
* to the server-advertised MAX_CONCURRENT_STREAMS; the pool auto-scales between
7+
* minConnections and maxConnections as load requires.
8+
*
9+
* Usage:
10+
* const fetch = createH2Fetch({ maxConnections: 20 });
11+
* const response = await fetch('https://api.example.com/v1/items', {
12+
* method: 'POST',
13+
* headers: { 'content-type': 'application/json' },
14+
* body: JSON.stringify({ name: 'test' }),
15+
* });
16+
*/
17+
18+
import { Readable } from 'node:stream';
19+
import { H2Pool, type H2PoolOptions } from './pool';
20+
import { MultipartBody } from '../../_shims/MultipartBody';
21+
import { type Fetch } from '../../core';
22+
23+
const MIN_NODE_MAJOR = 18;
24+
25+
function checkNodeVersion(): void {
26+
const match = process.versions?.node?.match(/^(\d+)/);
27+
if (!match?.[1]) return; // non-Node runtime, let it fail naturally
28+
const major = parseInt(match[1], 10);
29+
if (major < MIN_NODE_MAJOR) {
30+
throw new Error(
31+
`h2-transport requires Node.js ${MIN_NODE_MAJOR} or later (found ${process.versions.node}). ` +
32+
`The HTTP/2 transport depends on node:stream/web which is not available in older versions.`,
33+
);
34+
}
35+
}
36+
37+
export type { H2PoolOptions as H2FetchOptions };
38+
39+
export type H2Fetch = Fetch & {
40+
close: () => Promise<void>;
41+
};
42+
43+
async function bufferReadable(stream: Readable): Promise<Buffer> {
44+
const chunks: Buffer[] = [];
45+
for await (const chunk of stream) {
46+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
47+
}
48+
return Buffer.concat(chunks);
49+
}
50+
51+
async function normalizeBody(body: unknown): Promise<string | Buffer | null> {
52+
if (body == null) return null;
53+
if (typeof body === 'string') return body;
54+
if (Buffer.isBuffer(body)) return body;
55+
if (body instanceof MultipartBody) return normalizeBody((body as MultipartBody).body);
56+
if (body instanceof Readable) {
57+
// Multipart bodies arrive as a FormDataEncoder Readable with a known
58+
// content-length. session.request() takes string | Buffer, so buffer here.
59+
return bufferReadable(body);
60+
}
61+
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
62+
if (body instanceof ArrayBuffer) return Buffer.from(body);
63+
return String(body);
64+
}
65+
66+
/**
67+
* Build a fetch adapter backed by native HTTP/2 connection pools.
68+
*
69+
* Compatible with the SDK's `makeHttp2Fetch` interface: called with no arguments
70+
* for `http2: true`, or with options for `http2: <H2FetchOptions>`.
71+
*/
72+
export function createH2Fetch(options?: H2PoolOptions): H2Fetch {
73+
checkNodeVersion();
74+
const pools = new Map<string, H2Pool>();
75+
76+
function getPool(origin: string): H2Pool {
77+
let pool = pools.get(origin);
78+
if (!pool) {
79+
pool = new H2Pool(origin, options);
80+
pools.set(origin, pool);
81+
}
82+
return pool;
83+
}
84+
85+
const h2Fetch = (async (url, init) => {
86+
const {
87+
agent: _ignored, // node-fetch artifact injected by core.ts
88+
body: rawBody,
89+
signal,
90+
method = 'GET',
91+
headers: rawHeaders,
92+
} = (init ?? {}) as any;
93+
94+
const parsed = typeof url === 'string' ? new URL(url) : new URL(url.toString());
95+
const path = parsed.pathname + parsed.search;
96+
const body = await normalizeBody(rawBody);
97+
98+
const reqHeaders: Record<string, string> = {};
99+
if (rawHeaders) {
100+
if (typeof rawHeaders.entries === 'function') {
101+
for (const [k, v] of rawHeaders.entries()) {
102+
reqHeaders[k.toLowerCase()] = v;
103+
}
104+
} else {
105+
for (const [k, v] of Object.entries(rawHeaders as Record<string, string>)) {
106+
reqHeaders[k.toLowerCase()] = v;
107+
}
108+
}
109+
}
110+
111+
const pool = getPool(parsed.origin);
112+
return pool.request(path, method.toUpperCase(), reqHeaders, body, signal) as any;
113+
}) as H2Fetch;
114+
115+
h2Fetch.close = async () => {
116+
const closeTasks = [...pools.values()].map((pool) => pool.close());
117+
pools.clear();
118+
await Promise.all(closeTasks);
119+
};
120+
121+
return h2Fetch;
122+
}

0 commit comments

Comments
 (0)