diff --git a/package.json b/package.json index d88c59f..d2a44e9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "h3": "2.0.1-rc.26", "lint-staged": "^17.3.0", "pkg-pr-new": "^0.0.87", + "selfsigned": "^5.5.0", "sherif": "^1.13.0", "simple-git-hooks": "^2.13.1", "srvx": "^0.12.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83cb9eb..951d64b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: pkg-pr-new: specifier: ^0.0.87 version: 0.0.87 + selfsigned: + specifier: ^5.5.0 + version: 5.5.0 sherif: specifier: ^1.13.0 version: 1.13.0 @@ -892,6 +895,10 @@ packages: os: [linux] libc: [glibc] + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -1025,6 +1032,43 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@peculiar/asn1-cms@2.8.0': + resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} + + '@peculiar/asn1-csr@2.8.0': + resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} + + '@peculiar/asn1-ecc@2.8.0': + resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} + + '@peculiar/asn1-pfx@2.8.0': + resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} + + '@peculiar/asn1-rsa@2.8.0': + resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} + + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/asn1-x509-attr@2.8.0': + resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} + + '@peculiar/asn1-x509@2.8.0': + resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1514,6 +1558,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1594,6 +1642,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + c12@1.11.2: resolution: {integrity: sha512-oBs8a4uvSDO9dm8b7OCFW7+dgtVrwmwnrVXYzLm43ta7ep2jCn/0MhoUFygIWtxhyy6+/MG7/agvpY0U1Iemew==} peerDependencies: @@ -3070,6 +3122,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -3291,6 +3347,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -3341,6 +3404,9 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regexp-ast-analysis@0.7.1: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -3432,6 +3498,10 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + selfsigned@5.5.0: + resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==} + engines: {node: '>=18'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3683,11 +3753,21 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.12: resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4400,6 +4480,8 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@noble/hashes@1.4.0': {} + '@noble/hashes@1.8.0': {} '@ota-meshi/ast-token-store@0.3.0': {} @@ -4465,6 +4547,100 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pinojs/redact@0.4.0': {} '@pkgr/core@0.3.6': {} @@ -4953,6 +5129,12 @@ snapshots: asap@2.0.6: {} + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -5051,6 +5233,8 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + c12@1.11.2: dependencies: chokidar: 3.6.0 @@ -6851,6 +7035,15 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + pluralize@8.0.0: {} pnpm-workspace-yaml@1.7.0: @@ -7049,6 +7242,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -7095,6 +7294,8 @@ snapshots: dependencies: '@eslint-community/regexpp': 4.12.2 + reflect-metadata@0.2.2: {} + regexp-ast-analysis@0.7.1: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7202,6 +7403,11 @@ snapshots: secure-json-parse@4.1.0: {} + selfsigned@5.5.0: + dependencies: + '@peculiar/x509': 1.14.3 + pkijs: 3.4.0 + semver@7.8.5: {} send@1.2.1(supports-color@7.2.0): @@ -7450,12 +7656,20 @@ snapshots: dependencies: typescript: 6.0.3 + tslib@1.14.1: {} + + tslib@2.8.1: {} + tsx@4.23.12: dependencies: esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/tests/client-server.node-http2.ts b/tests/client-server.node-http2.ts new file mode 100644 index 0000000..37d6c24 --- /dev/null +++ b/tests/client-server.node-http2.ts @@ -0,0 +1,148 @@ +import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' +import type { AddressInfo } from 'node:net' +import type { ClientServerTest } from './client-server' +import * as http2 from 'node:http2' +import { Readable } from 'node:stream' +import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standardserver/fetch' +import { sendStandardResponse, toStandardLazyRequest } from '@standardserver/node' +import { generateTlsCert } from './tls' + +export interface NodeHttp2ClientServerTestOptions { + /** Serve over TLS (https) with a self-signed localhost certificate. */ + secure?: boolean +} + +export function createNodeHttp2ClientServerTest(options: NodeHttp2ClientServerTestOptions = {}): ClientServerTest { + const handler: ClientServerTest['handler'] = vi.fn(async () => { + return { status: 404, body: 'Not Found', headers: {} } + }) + + const requestListener = async (req: Http2ServerRequest, res: Http2ServerResponse) => { + // a client RST_STREAM surfaces as an 'error' on the raw stream (not on `res`), + // and crashes the process unless something listens to it + res.stream.on('error', () => {}) + + const standardRequest = toStandardLazyRequest(req, res) + const standardResponse = await handler(standardRequest) + + // rejects when the client aborts the stream mid-response + await sendStandardResponse(res, standardResponse).catch(() => {}) + } + + const serverReady = (async () => { + const tls = options.secure ? await generateTlsCert() : undefined + + const server = tls + ? http2.createSecureServer({ cert: tls.cert, key: tls.key }, requestListener) + : http2.createServer(requestListener) + + await new Promise(resolve => server.listen(0, resolve)) + + const port = (server.address() as AddressInfo).port + + // fetch cannot speak h2c, so requests go through a raw http2 client session + const session = tls + ? http2.connect(`https://localhost:${port}`, { ca: tls.cert }) + : http2.connect(`http://localhost:${port}`) + session.on('error', () => {}) + + return { server, session, port } + })() + + afterAll(async () => { + const { server, session } = await serverReady + + session.destroy() + server.close() + }) + + const request: ClientServerTest['request'] = vi.fn(async (standardRequest) => { + standardRequest.signal?.throwIfAborted() + + const { session, port } = await serverReady + + const [body, standardHeaders] = toFetchBody(standardRequest.body, standardRequest.headers) + + // Normalize the fetch body (Blob, FormData, ...) into a byte stream and let + // fetch fill in derived headers such as the multipart boundary. + const fetchRequest = new Request(`http://localhost:${port}${standardRequest.url}`, { + method: standardRequest.method, + headers: toFetchHeaders(standardHeaders), + body: body ?? null, + duplex: 'half', + }) + + const requestHeaders: http2.OutgoingHttpHeaders = { + ':method': standardRequest.method, + ':path': `${standardRequest.url}`, + } + fetchRequest.headers.forEach((value, key) => { + requestHeaders[key] = value + }) + + const stream = session.request(requestHeaders, { endStream: fetchRequest.body === null }) + + standardRequest.signal?.addEventListener('abort', () => { + stream.destroy(standardRequest.signal!.reason) + }) + + if (fetchRequest.body) { + void (async () => { + const reader = fetchRequest.body!.getReader() + + try { + while (true) { + const { done, value } = await reader.read() + + if (stream.destroyed || stream.writableEnded) { + return // abandon the body without cancelling it, like fetch does + } + + if (done) { + stream.end() + return + } + + if (!stream.write(value)) { + await new Promise((resolve) => { + stream.once('drain', resolve) + stream.once('close', resolve) + }) + } + } + } + catch (error) { + stream.destroy(error as Error) + } + })() + } + + const responseHeaders = await new Promise((resolve, reject) => { + stream.once('response', resolve) + stream.once('error', reject) + }) + + const headers = new Headers() + for (const [key, value] of Object.entries(responseHeaders)) { + if (key.startsWith(':') || value === undefined) { + continue + } + + for (const item of Array.isArray(value) ? value : [value]) { + headers.append(key, item) + } + } + + const response = new Response(Readable.toWeb(stream) as ReadableStream, { + status: Number(responseHeaders[':status']), + headers, + }) + + return toStandardLazyResponse(response) + }) + + return { + handler, + request, + } +} diff --git a/tests/client-server.node-https.ts b/tests/client-server.node-https.ts new file mode 100644 index 0000000..de476a6 --- /dev/null +++ b/tests/client-server.node-https.ts @@ -0,0 +1,143 @@ +import type { IncomingMessage } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { ClientServerTest } from './client-server' +import * as https from 'node:https' +import { Readable } from 'node:stream' +import { toFetchBody, toFetchHeaders, toStandardLazyResponse } from '@standardserver/fetch' +import { sendStandardResponse, toStandardLazyRequest } from '@standardserver/node' +import { generateTlsCert } from './tls' + +export function createNodeHttpsClientServerTest(): ClientServerTest { + const handler: ClientServerTest['handler'] = vi.fn(async () => { + return { status: 404, body: 'Not Found', headers: {} } + }) + + const serverReady = (async () => { + const { cert, key } = await generateTlsCert() + + const server = https.createServer({ cert, key }, async (req, res) => { + const standardRequest = toStandardLazyRequest(req, res) + const standardResponse = await handler(standardRequest) + + await sendStandardResponse(res, standardResponse) + }) + + await new Promise(resolve => server.listen(0, resolve)) + + // fetch cannot trust a custom CA per-request, so requests go through a raw https client + const agent = new https.Agent({ ca: cert }) + + return { server, agent, port: (server.address() as AddressInfo).port } + })() + + afterAll(async () => { + const { server, agent } = await serverReady + + agent.destroy() + server.close() + server.closeAllConnections() + }) + + const request: ClientServerTest['request'] = vi.fn(async (standardRequest) => { + standardRequest.signal?.throwIfAborted() + + const { agent, port } = await serverReady + + const [body, standardHeaders] = toFetchBody(standardRequest.body, standardRequest.headers) + + // Normalize the fetch body (Blob, FormData, ...) into a byte stream and let + // fetch fill in derived headers such as the multipart boundary. + const fetchRequest = new Request(`https://localhost:${port}${standardRequest.url}`, { + method: standardRequest.method, + headers: toFetchHeaders(standardHeaders), + body: body ?? null, + duplex: 'half', + }) + + const requestHeaders: Record = {} + fetchRequest.headers.forEach((value, key) => { + requestHeaders[key] = value + }) + + const req = https.request({ + host: 'localhost', + port, + path: `${standardRequest.url}`, + method: standardRequest.method, + headers: requestHeaders, + agent, + }) + + if (fetchRequest.body) { + // node's http client only defaults to chunked encoding for POST-like methods, + // for the rest (e.g. DELETE) it would send the body without any framing + ;(req as any).useChunkedEncodingByDefault = true + } + + standardRequest.signal?.addEventListener('abort', () => { + req.destroy(standardRequest.signal!.reason) + }) + + if (fetchRequest.body) { + void (async () => { + const reader = fetchRequest.body!.getReader() + + try { + while (true) { + const { done, value } = await reader.read() + + if (req.destroyed || req.writableEnded) { + return // abandon the body without cancelling it, like fetch does + } + + if (done) { + req.end() + return + } + + if (!req.write(value)) { + await new Promise((resolve) => { + req.once('drain', resolve) + req.once('close', resolve) + }) + } + } + } + catch (error) { + req.destroy(error as Error) + } + })() + } + else { + req.end() + } + + const res = await new Promise((resolve, reject) => { + req.once('response', resolve) + req.once('error', reject) + }) + + const headers = new Headers() + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined) { + continue + } + + for (const item of Array.isArray(value) ? value : [value]) { + headers.append(key, item) + } + } + + const response = new Response(Readable.toWeb(res) as ReadableStream, { + status: res.statusCode!, + headers, + }) + + return toStandardLazyResponse(response) + }) + + return { + handler, + request, + } +} diff --git a/tests/data-transfer.test.ts b/tests/data-transfer.test.ts index a5b367e..18f8e4a 100644 --- a/tests/data-transfer.test.ts +++ b/tests/data-transfer.test.ts @@ -9,6 +9,8 @@ import { createInprogressClientServerTest } from './client-server.inprogress' import { createInprogressFetchClientServerTest } from './client-server.inprogress-fetch' import { createMessagePortClientServerTest } from './client-server.message-port' import { createNodeHttpClientServerTest } from './client-server.node-http' +import { createNodeHttp2ClientServerTest } from './client-server.node-http2' +import { createNodeHttpsClientServerTest } from './client-server.node-https' import { createNodeSrvxClientServerTest } from './client-server.node-srvx' import { createNodeWsClientServerTest } from './client-server.node-ws' @@ -33,6 +35,9 @@ describe.each([ ['node-srvx', createNodeSrvxClientServerTest], // ['node-fetch-server', createNodeFetchServerClientServerTest], ['node-http', createNodeHttpClientServerTest], + ['node-https', createNodeHttpsClientServerTest], + ['node-http2', () => createNodeHttp2ClientServerTest()], + ['node-http2-secure', () => createNodeHttp2ClientServerTest({ secure: true })], ['message-port', () => createMessagePortClientServerTest()], ['message-port-fetch-streamed', () => createMessagePortClientServerTest({ fetchStreamed: true })], ['node-ws', () => createNodeWsClientServerTest()], diff --git a/tests/signal-and-cancel.test.ts b/tests/signal-and-cancel.test.ts index b3f548f..8a6a5b4 100644 --- a/tests/signal-and-cancel.test.ts +++ b/tests/signal-and-cancel.test.ts @@ -5,6 +5,8 @@ import { createFastifyClientServerTest } from './client-server.fastify' import { createHonoFetchClientServerTest } from './client-server.hono-fetch' import { createMessagePortClientServerTest } from './client-server.message-port' import { createNodeHttpClientServerTest } from './client-server.node-http' +import { createNodeHttp2ClientServerTest } from './client-server.node-http2' +import { createNodeHttpsClientServerTest } from './client-server.node-https' import { createNodeWsClientServerTest } from './client-server.node-ws' beforeEach(() => { @@ -41,6 +43,9 @@ describe.each([ // ['node-srvx', createNodeSrvxClientServerTest], // ['node-fetch-server', createNodeFetchServerClientServerTest], ['node-http', createNodeHttpClientServerTest], + ['node-https', createNodeHttpsClientServerTest], + ['node-http2', () => createNodeHttp2ClientServerTest()], + ['node-http2-secure', () => createNodeHttp2ClientServerTest({ secure: true })], ['message-port', () => createMessagePortClientServerTest()], ['message-port-fetch-streamed', () => createMessagePortClientServerTest({ fetchStreamed: true })], ['node-ws', () => createNodeWsClientServerTest()], diff --git a/tests/tls.ts b/tests/tls.ts new file mode 100644 index 0000000..d51c606 --- /dev/null +++ b/tests/tls.ts @@ -0,0 +1,28 @@ +import { generate } from 'selfsigned' + +let pems: ReturnType | undefined + +/** + * Self-signed localhost certificate generated once per test process, + * for the TLS (https) client-server adapters. + */ +export async function generateTlsCert(): Promise<{ cert: string, key: string }> { + pems ??= generate([{ name: 'commonName', value: 'localhost' }], { + keyType: 'ec', + algorithm: 'sha256', + extensions: [ + { name: 'basicConstraints', cA: true }, + { + name: 'subjectAltName', + altNames: [ + { type: 2, value: 'localhost' }, // DNS + { type: 7, ip: '127.0.0.1' }, // IP + ], + }, + ], + }) + + const { cert, private: key } = await pems + + return { cert, key } +}