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((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((resolve) => backendServer.close(() => resolve())); }); export default tap.start();