fix(proxy-service): handle HTTP/3 backend forwarding failures with protocol fallback and pool cleanup
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '../ts/index.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as http2 from 'node:http2';
|
||||
import * as https from 'node:https';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { findFreePorts } from './helpers/port-allocator.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const CERT_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8');
|
||||
const KEY_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8');
|
||||
const TEST_DOMAIN = 'verdaccio.test';
|
||||
|
||||
let backendPort: number;
|
||||
let proxyPort: number;
|
||||
let unavailableH3Port: number;
|
||||
let backendServer: https.Server;
|
||||
let proxy: SmartProxy;
|
||||
|
||||
function httpsRequest(requestPath: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: 'localhost',
|
||||
port: proxyPort,
|
||||
path: requestPath,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Host: TEST_DOMAIN,
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
servername: TEST_DOMAIN,
|
||||
agent: new https.Agent({ keepAlive: false, rejectUnauthorized: false }),
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000, () => req.destroy(new Error('https request timeout')));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function http2Request(requestPath: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const session = http2.connect(`https://localhost:${proxyPort}`, {
|
||||
rejectUnauthorized: false,
|
||||
servername: TEST_DOMAIN,
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (!session.closed && !session.destroyed) {
|
||||
session.close();
|
||||
}
|
||||
};
|
||||
|
||||
session.once('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
session.once('connect', () => {
|
||||
const req = session.request({
|
||||
':method': 'GET',
|
||||
':path': requestPath,
|
||||
':authority': TEST_DOMAIN,
|
||||
});
|
||||
let status = 0;
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('response', (headers) => {
|
||||
status = Number(headers[':status'] ?? 0);
|
||||
});
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
cleanup();
|
||||
resolve({ status, body });
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('http2 request timeout'));
|
||||
}, 5000).unref();
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('setup - backend with Alt-Svc H3 hint and TLS proxy', async () => {
|
||||
[backendPort, proxyPort, unavailableH3Port] = await findFreePorts(3);
|
||||
|
||||
backendServer = https.createServer({ key: KEY_PEM, cert: CERT_PEM }, (req, res) => {
|
||||
const body = JSON.stringify({ ok: true, url: req.url, host: req.headers.host });
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'alt-svc': `h3=":${unavailableH3Port}"; ma=86400`,
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
backendServer.once('error', reject);
|
||||
backendServer.listen(backendPort, () => resolve());
|
||||
});
|
||||
|
||||
proxy = new SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
id: 'backend-protocol-fallback',
|
||||
name: 'backend-protocol-fallback',
|
||||
match: { ports: proxyPort, domains: TEST_DOMAIN },
|
||||
action: {
|
||||
type: 'forward',
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: {
|
||||
key: KEY_PEM,
|
||||
cert: CERT_PEM,
|
||||
},
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
host: 'localhost',
|
||||
port: backendPort,
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
],
|
||||
options: { backendProtocol: 'auto' },
|
||||
},
|
||||
},
|
||||
],
|
||||
connectionTimeout: 500,
|
||||
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 30 },
|
||||
});
|
||||
|
||||
await proxy.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
|
||||
tap.test('backend protocol auto: fresh HTTP/1.1 survives unavailable H3 hint', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const first = await httpsRequest('/@consent.software%2Fcatalog');
|
||||
expect(first.status).toEqual(200);
|
||||
expect(JSON.parse(first.body).ok).toEqual(true);
|
||||
|
||||
const second = await httpsRequest('/@consent.software%2Fcatalog?retry=1');
|
||||
expect(second.status).toEqual(200);
|
||||
expect(JSON.parse(second.body).url).toEqual('/@consent.software%2Fcatalog?retry=1');
|
||||
});
|
||||
|
||||
tap.test('backend protocol auto: fresh HTTP/2 survives suppressed H3 hint', async (tools) => {
|
||||
tools.timeout(10000);
|
||||
|
||||
const result = await http2Request('/@consent.software%2Fcatalog?frontend=h2');
|
||||
expect(result.status).toEqual(200);
|
||||
expect(JSON.parse(result.body).url).toEqual('/@consent.software%2Fcatalog?frontend=h2');
|
||||
});
|
||||
|
||||
tap.test('cleanup - backend protocol fallback', async () => {
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user