diff --git a/README.md b/README.md index 6fda15c..4f618cc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Regardless of whether you're actually interested in the Fetch API per se or not, By default, `fetch-h2` will accept `gzip` and `deflate` encodings, and decode transparently. If you also want to allow Brotli (`br`), use the [`fetch-h2-br`](https://www.npmjs.com/package/fetch-h2-br) package. -**NOTE;** HTTP/2 support was introduced in Node.js (version 8.4), and required `node` to be started with a flag `--expose-http2` up to version 8.7 (this module won't work without it). From Node.js 8.8, the `http2` module is available without any flag. The API has changed and not settled until 10.x, **and `fetch-h2` requires 10.x**. +**NOTE;** HTTP/2 support was introduced in Node.js (version 8.4), and required `node` to be started with a flag `--expose-http2` up to version 8.7 (this module won't work without it). From Node.js 8.8, the `http2` module is available without any flag. The API has changed and not settled until 10.x, **and `fetch-h2` requires 10.4+**. ## Releases diff --git a/certs/cert.pem b/certs/cert.pem index 050c514..5d31ca0 100644 --- a/certs/cert.pem +++ b/certs/cert.pem @@ -1,17 +1,20 @@ -----BEGIN CERTIFICATE----- -MIICpDCCAYwCCQCDxBRhc+faETANBgkqhkiG9w0BAQ0FADAUMRIwEAYDVQQDDAls -b2NhbGhvc3QwHhcNMTgwMTE2MTAwODExWhcNMTgwMjE1MTAwODExWjAUMRIwEAYD -VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD3 -2vxYIFETHAQ8NhXWMYg3OTzqT0Q4dbsdBU6UOblPz7T96KyLl4MKsvOGfgtlqBW6 -o3GEHDTUysLPwxG2oHpLKBT38QQtDYWMtPdPcHQcntiZD5AtZHDOEWeYCzQ4ef5e -vYishBHJV9xQjZTAg9faaK5cbfZJn7Csm3wqjXvh0CB0VQY6hCmrbZAff4paEkqL -ZnG/M7k8xUAM5Hi54rCyxO59J2zCVUs1R0pOmwHd5QFeBM5ih+pFAB84ECxfm4Yl -F2n++qrK7gxIsxlKZi0m87dXuAVTCOnuWysYlOsgHfuPbqfLjU6DZJP5Qt30z7rm -qrW74BXm0N5wl9FROOEpAgMBAAEwDQYJKoZIhvcNAQENBQADggEBAIq7lM7tGxhb -WEbEmiStplxce36P/L0jQk5x924aUYixY5S6G4zIJV+i/EOHE3lrFjufzsrAGByi -sGL/VWa64NahKTjnMjkhZ6iDb1hhIyv4+QE3cBd+srLnkcsvI5ToQLa7m1gF/nSv -qmEzNSfvPkNVFnqw+e15N2pG9r8OpVPOVaOSjRP4kE3H02aV7HQnQBiCYHATIeqy -7xy2NV2ZhtbVHIQKVX7m6Vx4Bi6D7kzvogD7IvkyC5OHrxWCLVp8MXLTS37bJaLE -ThTzV4Nh31Upz3A2K9RXPiPyD+KY4DKtcH/cn/c1d1AR0YtuoGFZ42gIKT/HU8G5 -6J89zv/AYgY= +MIIDUDCCAjgCCQDsXVZ67TzRPjANBgkqhkiG9w0BAQsFADBqMQswCQYDVQQGEwJz +ZTEMMAoGA1UECAwDZm9vMQwwCgYDVQQHDANiYXIxDzANBgNVBAoMBmZvb2JhcjES +MBAGA1UEAwwJZm9vYmFyLnNlMRowGAYJKoZIhvcNAQkBFgtmb29AYmFyLnRsZDAe +Fw0xOTAxMTIyMzIzNTBaFw0zOTAxMDcyMzIzNTBaMGoxCzAJBgNVBAYTAnNlMQww +CgYDVQQIDANmb28xDDAKBgNVBAcMA2JhcjEPMA0GA1UECgwGZm9vYmFyMRIwEAYD +VQQDDAlmb29iYXIuc2UxGjAYBgkqhkiG9w0BCQEWC2Zvb0BiYXIudGxkMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwd8VxBHL0Ovi0T1vbIhc52CIOdqP +lFnRtg/i8jNTrGCXjS2oERrHyvPHYwXSRis2zGbl+WQqZEHVDlVk/SY/z2DH1BTo +h+DIjd9fIlTXpaBTrU5QOKvJdIFjC7oSbxf1E8BtBrnuhwURHqPhEYKne8QdBGCT +HKRRprDa0GQQEJKVBDLmwMfVoLIh0k8ckjTOPx7126PfmsCTfae7psaplXLcJu9m +g/IcIPc8aRKvWLe8tM93p2rA0/1sO3Cj+ZCxWWaPoKmDa53TkFNLBaWMvO+sppXH +u57o5Wq2bF4fUpIvk6jNpqFvvGJhHiyMOpgzk1vtn+N/zraUTyeREkCHOQIDAQAB +MA0GCSqGSIb3DQEBCwUAA4IBAQCJQQA0YbqZEkQRWs0SWxF5NcZcXxyWrgagZ1Pb +LeuYpC3dczP2ugtUvzC5Gq1T6yOXyi2SI/wVu6AVOKx4WWtB61vGJUoVywcUR1ER +kshgQNcOMDPdVXEwZGCJZ162XhpWqGcYSbxZMPVvMmFB+qPkhmtimSSGOKUea29J +Zh6eyRIwgdrf7hfLqSB++Rr5kDGmT/jI7t/B9TySGfrO02+XDFoX19+ga5BV64pY +65fq9tkgpsbX1l6K+dGpTXSG+X/y4X4MJRjue3vOVcmMfXROO3G/MD99JSI+P+xU +jrgBhvpqcfC61nx62eNrXB/QpPUHdb2w+yXX0N2m5vnsX1nM -----END CERTIFICATE----- diff --git a/certs/key.pem b/certs/key.pem index 5501319..d67339a 100644 --- a/certs/key.pem +++ b/certs/key.pem @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD32vxYIFETHAQ8 -NhXWMYg3OTzqT0Q4dbsdBU6UOblPz7T96KyLl4MKsvOGfgtlqBW6o3GEHDTUysLP -wxG2oHpLKBT38QQtDYWMtPdPcHQcntiZD5AtZHDOEWeYCzQ4ef5evYishBHJV9xQ -jZTAg9faaK5cbfZJn7Csm3wqjXvh0CB0VQY6hCmrbZAff4paEkqLZnG/M7k8xUAM -5Hi54rCyxO59J2zCVUs1R0pOmwHd5QFeBM5ih+pFAB84ECxfm4YlF2n++qrK7gxI -sxlKZi0m87dXuAVTCOnuWysYlOsgHfuPbqfLjU6DZJP5Qt30z7rmqrW74BXm0N5w -l9FROOEpAgMBAAECggEAb2tE5wTYDWQZz0ts85XeqxyS8q3heBQMolYhZeaxFFzF -+yJedn4MzYF2ke4Vh4RRCE6zF/VqFoJzotwJGXT4pNKG4pK5EtuyPneXeWGPANKz -gdMKOC2fvDL8w8+9kOneXI6NYygXqtBRXPDYftaF8Uv/ndNc1OnxjRZ0cdiaaP65 -LhVGuEOa9LryKzG/Ix0Oq91Yz5UbXE3CvtFK6WUA979EZaNfiOhPslH/JwPSu0VQ -mjjXwYRDkUXRP5ywwnkAaGa2v6jycVdEzb+DnK0KA/Z5ZlWLm+GctqzUD2EscAQc -hLxpfUfBZkc9vzp3C6KqMn/R9OyNsnYxZPNfOIF7FQKBgQD9Xj3lrHFjScbbzRlZ -DYw05Zui5tE6OqqI4Z3GIHcnSkLy4YCpkDnPpNPbZJFj3IgR2vaBHzQBRztsiCPV -GBcZsapJr3BU5MTXFSseEMLogM8XPkp/+3Vh9E+lBzJQgro5qc9rilzxMq73rsgE -jRx9pc0D5jg65b/UUzBobpsn8wKBgQD6bhWgJ6gXtACCwYm8/eZf0BMvbdyIA/9j -AbSarhEjIHhaiobIpngbZICX+v/lWoZtyExad8S5fXTfRODb40Byc6jqw1azsMaF -OyPvwN/wy7ZGXXDOVG3FMzv6eFO85th1+EdkR+vOuJYVv3+4vZSefKhxFT/WWuzX -KtcBwoUVcwKBgQCI/DAJAh/X56aNZiljPXDllJJ+E79hdSCImzr7SMhDROJHgOZY -RvMKsfodLxVwYWZsCO+nxiAO5N1bA4wkBT9QE/+WkTTxoTJPe1FxkuxeWm4dCf+r -jF/dkwKQngB1CQj4bjgH06oGejmhDi10UHrr7/2VMx6JsXfyqvuMKujWQwKBgQDF -SOYWdkdQ7Qgd+jP1RBwxzOzgR28dY/DUYWqTFKABiTnnMgw+lA44njNEB4OCfo86 -eznTZ1j+O9xPa6as81k5EO64i0yJYLD0EoQcA1koDIO66S/OC+syGEue5R4qyb0r -Kn2rfZFCGF58IZGPyyICvPfBpljVGGpOk7wv8bsACwKBgCNKich65/gd6ftgHLVJ -4CpOrBzDO0dATAeYNXgG7ro6sFQDeWh2W5X4KtF/qSrf3AOc+R7uM1O1hUQdbCMJ -Jkgm56CsAxzFsQlm3JPpYg5n2VEL8NOzVHNt6X2JHKrgeQZS3dirjA6Dwyw+McNX -gxaXs3kc/V0IE93UVrlH3+Cf +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDB3xXEEcvQ6+LR +PW9siFznYIg52o+UWdG2D+LyM1OsYJeNLagRGsfK88djBdJGKzbMZuX5ZCpkQdUO +VWT9Jj/PYMfUFOiH4MiN318iVNeloFOtTlA4q8l0gWMLuhJvF/UTwG0Gue6HBREe +o+ERgqd7xB0EYJMcpFGmsNrQZBAQkpUEMubAx9WgsiHSTxySNM4/HvXbo9+awJN9 +p7umxqmVctwm72aD8hwg9zxpEq9Yt7y0z3enasDT/Ww7cKP5kLFZZo+gqYNrndOQ +U0sFpYy876ymlce7nujlarZsXh9Ski+TqM2moW+8YmEeLIw6mDOTW+2f43/OtpRP +J5ESQIc5AgMBAAECggEAC5fahloGFR01+AszcYsJ+zATlVoTgeyJFNkIWjFljIZO +KbwUM8mlLua7ApnjhByrbzesAujRfCNPqUbD/jteT3lbGbySVyXC+HDmEHiAWMAo +oNFxDKKBLn1aPeZHmesV1bOJEYDm2Z4c8vcby19DwqvsjEl2Ip1U4KHsw89oAoWW +u98dsEv5XX30HobngVCU4EPy5mblCYTcWQxE55FHknK3oZ4q1xmAURhGHwN0VwYT +InwzLA79fvBlnppKjuBv8mc2nKj3zgjmDprFsmx2iJ4N5VRmjt2yegRrSyzG+I4T +pclPrB0qQ43SUsyS7gMI2z7z0oH2m996RKLlQK+3XQKBgQDvAZv5F3aHcM3acjfZ +FndMTsFLCXIXWUzjyvpaceHEOFSg00e9rR7c+nP8vK1CXfsIhukRgSvUDMkMpNXN +yptliRvWVQuy/0TZ8om8TiePCE86GZeRjSJTfYKo1z1mWj0pz+75p5kaSR4U669p +rkqFc+tcMoxX+Fisi5ku6Iy4+wKBgQDPp+5snzbv5VZsURCp9eNh4BypCNFWTkMH +kCWC7IEjkZ5jzhU9wfcEMLrTBdnOzT7RA/DfgJOcwylc2EKTN3NQuSg7ffFELM7P +tin0R+kO0JygDj9YhiIW1DfsKe2yJ59pdMDaXr214pI8WTtfZABpc0LnKnbDpJXP ++pzTuKFyWwKBgD8608KwXGE0jKEv+mpqMSF07Fono5FdxKO2/UiUPEAnDuyFOMOL +W1DmyWyhlcyrBFCbMGm7HJc60q2PpiiNY1MXVM/9K90s/1ARhDLXEkwazKr4Pkr5 +ZY1k9P4qA0pisS+wnO5bUnvLwDOUrpFs1LY9lpSLoulbAEqVm+73AtOlAoGAMfR+ +QRdUSgXr8obV8W071FHr0yZR5edR7MHapFJtBreDWRM8vOyqlhF7AEUKDtwFXpcK +HVp7KF0y2CkWawAN979zVEyJ/BKjdgimsyORh4TcCQ0kZBFwpflLsr6rdg5eJSp3 +MpFUJitpbqcwx1PxXWzjDWWDyLERcUUi8TQbcr0CgYAXjId/k5Sm2EfeDsnGlR6L +HcPwJZ5iI+DrNg8sZn5u3EjhjcVT+mSe0CzMMqvKmwZ0LhmiB0ee0XiF0+iTsedd +Sru2OQgPkHgqxj71gBPk1NKozOb3pEDPqHMhuMjP/WBH2OPjB/bc84ayImlHGWaF +1lcTncGab2YneX4hdU15Qg== -----END PRIVATE KEY----- diff --git a/index.ts b/index.ts index 6cf9699..69c942f 100644 --- a/index.ts +++ b/index.ts @@ -1,11 +1,13 @@ import { Body, DataBody, JsonBody, StreamBody } from "./lib/body"; -import { Context, ContextOptions, PushHandler } from "./lib/context"; +import { Context, ContextOptions } from "./lib/context"; +import { PushHandler } from "./lib/context-http2"; import { CookieJar } from "./lib/cookie-jar"; import { AbortError, DecodeFunction, Decoder, FetchInit, + HttpProtocols, OnTrailers, TimeoutError, } from "./lib/core"; @@ -53,6 +55,7 @@ export { onPush, // Re-export + HttpProtocols, Body, JsonBody, StreamBody, diff --git a/lib/context-http1.ts b/lib/context-http1.ts new file mode 100644 index 0000000..65c79ff --- /dev/null +++ b/lib/context-http1.ts @@ -0,0 +1,345 @@ +import { request as requestHttp } from "http"; +import { request as requestHttps, RequestOptions } from "https"; +import { createConnection, Socket } from "net"; +import { URL } from "url"; + +import { defer, Deferred } from "already"; + +import { + Http1Options, +} from "./core"; +import { + Request +} from "./request"; +import { parseInput } from "./utils"; + + +export interface FreeSocketInfo +{ + socket?: Socket; + shouldCreateNew: boolean; +} + +export interface ConnectOptions +{ + rejectUnauthorized: boolean | undefined; + createConnection: ( ) => Socket; +} + +class OriginPool +{ + private usedSockets = new Set< Socket >( ); + private unusedSockets = new Set< Socket >( ); + private waiting: Array< Deferred< Socket > > = [ ]; + + private keepAlive: boolean; + private keepAliveMsecs: number; + private maxSockets: number; + private maxFreeSockets: number; + private connOpts: { timeout?: number; }; + + constructor( + keepAlive: boolean, + keepAliveMsecs: number, + maxSockets: number, + maxFreeSockets: number, + timeout: number | void + ) + { + this.keepAlive = keepAlive; + this.keepAliveMsecs = keepAliveMsecs; + this.maxSockets = maxSockets; + this.maxFreeSockets = maxFreeSockets; + this.connOpts = timeout == null ? { } : { timeout }; + } + + public connect( options: RequestOptions ) + { + const request = + options.protocol === "https:" + ? requestHttps + : requestHttp; + + const opts = { ...options }; + if ( opts.rejectUnauthorized == null || options.protocol === "https" ) + delete opts.rejectUnauthorized; + + const req = request( { ...this.connOpts, ...opts } ); + + return req; + } + + public addUsed( socket: Socket ) + { + if ( this.keepAlive ) + socket.setKeepAlive( true, this.keepAliveMsecs ); + + socket.once( "close", ( ) => + { + this.usedSockets.delete( socket ); + this.unusedSockets.delete( socket ); + } ); + + this.usedSockets.add( socket ); + } + + public getFreeSocket( ): FreeSocketInfo + { + const socket = this.getFirstUnused( ); + + if ( socket ) + return { socket, shouldCreateNew: false }; + + const shouldCreateNew = this.maxSockets >= this.usedSockets.size; + + return { shouldCreateNew }; + } + + public waitForSocket( ): Promise< Socket > + { + const deferred = defer< Socket >( ); + + this.waiting.push( deferred ); + + // Trigger due to potential race-condition + this.pumpWaiting( ); + + return deferred.promise; + } + + public async disconnectAll( ) + { + await Promise.all( + [ ...this.usedSockets, ...this.unusedSockets ] + .map( socket => + socket.destroyed ? void 0 : this.disconnectSocket( socket ) + ) + ); + + const waiting = this.waiting; + this.waiting.length = 0; + waiting.forEach( waiter => + // TODO: Better error class + message + waiter.reject( new Error( "Disconnected" ) ) + ); + } + + private getFirstUnused( ) + { + for ( const socket of this.unusedSockets.values( ) ) + // We obviously have a socket + return this.moveToUsed( socket ); + + return null; + } + + private tryReuse( socket: Socket ): boolean + { + if ( this.waiting.length === 0 ) + return false; + + const waiting = < Deferred< Socket > >this.waiting.shift( ); + waiting.resolve( socket ); + return true; + } + + private pumpWaiting( ) + { + while ( this.waiting.length > 0 && this.unusedSockets.size > 0 ) + { + const socket = < Socket >this.getFirstUnused( ); + const waiting = < Deferred< Socket > >this.waiting.shift( ); + waiting.resolve( socket ); + } + } + + private async disconnectSocket( socket: Socket ) + { + await new Promise< void >( ( resolve ) => + socket.end( Buffer.from( [ ] ), ( ) => resolve ) + ); + } + + // @ts-ignore + private async moveToUnused( socket: Socket ) + { + if ( this.tryReuse( socket ) ) + return; + + this.usedSockets.delete( socket ); + + if ( this.maxFreeSockets >= this.unusedSockets.size + 1 ) + { + await this.disconnectSocket( socket ); + return; + } + + this.unusedSockets.add( socket ); + socket.unref( ); + } + + private moveToUsed( socket: Socket ) + { + this.unusedSockets.delete( socket ); + this.usedSockets.add( socket ); + socket.ref( ); + return socket; + } +} + +class ContextPool +{ + private options: Http1Options; + private pools = new Map< string, OriginPool >( ); + + constructor( options: Http1Options ) + { + this.options = options; + } + + public hasOrigin( origin: string ) + { + return this.pools.has( origin ); + } + + public getOriginPool( origin: string ): OriginPool + { + const pool = this.pools.get( origin ); + + if ( !pool ) + { + const runIfFunction = + < T extends number | boolean | void > + ( value: T | ( ( origin: string ) => T ) ) => + typeof value === "function" ? value( origin ) : value; + + const keepAlive = runIfFunction( this.options.keepAlive ); + const keepAliveMsecs = runIfFunction( this.options.keepAliveMsecs ); + const maxSockets = runIfFunction( this.options.maxSockets ); + const maxFreeSockets = runIfFunction( this.options.maxFreeSockets ); + const timeout = runIfFunction( this.options.timeout ); + + const newPool = new OriginPool( + keepAlive, + keepAliveMsecs, + maxSockets, + maxFreeSockets, + timeout + ); + this.pools.set( origin, newPool ); + return newPool; + } + + return pool; + } + + public async disconnect( origin: string ) + { + const pool = this.pools.get( origin ); + if ( pool ) + await pool.disconnectAll( ); + } + + public async disconnectAll( ) + { + const pools = [ ...this.pools.values( ) ]; + await Promise.all( pools.map( pool => pool.disconnectAll( ) ) ); + } +} + +export class H1Context +{ + private contextPool: ContextPool; + + constructor( options: Http1Options ) + { + this.contextPool = new ContextPool( options ); + } + + public getFreeSocketForOrigin( origin: string ): FreeSocketInfo + { + return this.contextPool.hasOrigin( origin ) + ? this.contextPool.getOriginPool( origin ).getFreeSocket( ) + : { shouldCreateNew: true }; + } + + public addUsedSocket( origin: string, socket: Socket ) + { + return this.contextPool.getOriginPool( origin ).addUsed( socket ); + } + + public waitForSocket( origin: string ): Promise< Socket > + { + return this.contextPool.getOriginPool( origin ).waitForSocket( ); + } + + public connect( url: URL, extraOptions: ConnectOptions, request: Request ) + { + const { + origin, + protocol, + hostname, + password, + pathname, + search, + username, + } = url; + + const path = pathname + search; + + const port = parseInt( parseInput( url.href ).port, 10 ); + + const method = request.method; + + const auth = + ( username || password ) + ? { auth: `${username}:${password}` } + : { }; + + const options: RequestOptions = { + ...extraOptions, + agent: false, + hostname, + method, + path, + port, + protocol, + ...auth, + }; + + return this.contextPool.getOriginPool( origin ).connect( options ); + } + + public async makeNewConnection( url: string ) + { + return new Promise< Socket >( ( resolve, reject ) => + { + const { hostname, port } = parseInput( url ); + + const socket = createConnection( + parseInt( port, 10 ), + hostname, + ( ) => + { + resolve( socket ); + } + ); + + socket.once( "error", reject ); + + return socket; + } ); + } + + public disconnect( url: string ) + { + const { origin } = new URL( url ); + + this.contextPool.disconnect( origin ); + } + + public disconnectAll( ) + { + this.contextPool.disconnectAll( ); + } +} diff --git a/lib/context-http2.ts b/lib/context-http2.ts new file mode 100644 index 0000000..82bca40 --- /dev/null +++ b/lib/context-http2.ts @@ -0,0 +1,337 @@ +import { + ClientHttp2Session, + ClientHttp2Stream, + connect as http2Connect, + constants as h2constants, + IncomingHttpHeaders as IncomingHttp2Headers, + SecureClientSessionOptions, +} from "http2"; +import { URL } from "url"; + +import { asyncGuard, syncGuard } from "callguard"; + +import { + AbortError, + BaseContext, + TimeoutError, +} from "./core"; + +import { Request } from "./request"; +import { Response, StreamResponse } from "./response"; +import { makeOkError } from "./utils"; +import { setGotGoaway } from "./utils-http2"; + + +const { + HTTP2_HEADER_PATH, +} = h2constants; + +interface H2SessionItem +{ + session: ClientHttp2Session; + promise: Promise< ClientHttp2Session >; +} + +export type PushHandler = + ( + origin: string, + request: Request, + getResponse: ( ) => Promise< Response > + ) => void; + +export class H2Context +{ + public _pushHandler?: PushHandler; + + private _h2sessions: Map< string, H2SessionItem > = new Map( ); + private _h2staleSessions: Map< string, Set< ClientHttp2Session > > = + new Map( ); + private _context: BaseContext; + + constructor( context: BaseContext ) + { + this._context = context; + } + + public hasOrigin( origin: string ) + { + return this._h2sessions.has( origin ); + } + + public getOrCreateHttp2( + origin: string, + extraOptions?: SecureClientSessionOptions + ) + : { didCreate: boolean; session: Promise< ClientHttp2Session > } + { + const willCreate = !this._h2sessions.has( origin ); + + if ( willCreate ) + { + const sessionItem = this.connectHttp2( origin, extraOptions ); + + const { promise } = sessionItem; + + // Handle session closure (delete from store) + promise + .then( session => + { + session.once( + "close", + ( ) => this.disconnect( origin, session ) + ); + + session.once( + "goaway", + ( + _errorCode: number, + _lastStreamID: number, + _opaqueData: Buffer + ) => + { + setGotGoaway( session ); + this.releaseSession( origin ); + } + ); + } ) + .catch( ( ) => + { + if ( sessionItem.session ) + this.disconnect( origin, sessionItem.session ); + } ); + + this._h2sessions.set( origin, sessionItem ); + } + + const session = + ( < H2SessionItem >this._h2sessions.get( origin ) ).promise; + + return { didCreate: willCreate, session }; + } + + public disconnectSession( session: ClientHttp2Session ): Promise< void > + { + return new Promise< void >( resolve => + { + if ( session.destroyed ) + return resolve( ); + + session.once( "close", ( ) => resolve( ) ); + session.destroy( ); + } ); + } + + public releaseSession( origin: string ): void + { + const sessionItem = this.deleteActiveSession( origin ); + + if ( !sessionItem ) + return; + + if ( !this._h2staleSessions.has( origin ) ) + this._h2staleSessions.set( origin, new Set( ) ); + + ( < Set< ClientHttp2Session > >this._h2staleSessions.get( origin ) ) + .add( sessionItem.session ); + } + + public deleteActiveSession( origin: string ): H2SessionItem | void + { + if ( !this._h2sessions.has( origin ) ) + return; + + const sessionItem = this._h2sessions.get( origin ); + this._h2sessions.delete( origin ); + + return sessionItem; + } + + public async disconnectStaleSessions( origin: string ): Promise< void > + { + const promises: Array< Promise< void > > = [ ]; + + if ( !this._h2staleSessions.has( origin ) ) + return; + + const sessionSet = + < Set< ClientHttp2Session > >this._h2staleSessions.get( origin ); + this._h2staleSessions.delete( origin ); + + for ( const session of sessionSet ) + promises.push( this.disconnectSession( session ) ); + + return Promise.all( promises ).then( ( ) => { } ); + } + + public disconnectAll( ): Promise< void > + { + const promises: Array< Promise< void > > = [ ]; + + for ( const eventualH2session of this._h2sessions.values( ) ) + { + promises.push( this.handleDisconnect( eventualH2session ) ); + } + this._h2sessions.clear( ); + + for ( const origin of this._h2staleSessions.keys( ) ) + { + promises.push( this.disconnectStaleSessions( origin ) ); + } + + return Promise.all( promises ).then( ( ) => { } ); + } + + public disconnect( url: string, session?: ClientHttp2Session ): Promise< void > + { + const { origin } = new URL( url ); + const promises: Array< Promise< void > > = [ ]; + + const sessionItem = this.deleteActiveSession( origin ); + + if ( sessionItem && ( !session || sessionItem.session === session ) ) + promises.push( this.handleDisconnect( sessionItem ) ); + + if ( !session ) + { + promises.push( this.disconnectStaleSessions( origin ) ); + } + else if ( this._h2staleSessions.has( origin ) ) + { + const sessionSet = + < Set< ClientHttp2Session > > + this._h2staleSessions.get( origin ); + if ( sessionSet.has( session ) ) + { + sessionSet.delete( session ); + promises.push( this.disconnectSession( session ) ); + } + } + + return Promise.all( promises ).then( ( ) => { } ); + } + + private handleDisconnect( sessionItem: H2SessionItem ): Promise< void > + { + const { promise, session } = sessionItem; + + if ( session ) + session.destroy( ); + + return promise + .then( _h2session => { } ) + .catch( err => + { + const debugMode = false; + if ( debugMode ) + // tslint:disable-next-line + console.warn( "Disconnect error", err ); + } ); + } + + private handlePush( + origin: string, + pushedStream: ClientHttp2Stream, + requestHeaders: IncomingHttp2Headers + ) + { + if ( !this._pushHandler ) + return; // Drop push. TODO: Signal through error log: #8 + + const path = requestHeaders[ HTTP2_HEADER_PATH ] as string; + + // Remove pseudo-headers + Object.keys( requestHeaders ) + .filter( name => name.charAt( 0 ) === ":" ) + .forEach( name => { delete requestHeaders[ name ]; } ); + + const pushedRequest = new Request( path, { headers: requestHeaders } ); + + const futureResponse = new Promise< Response >( ( resolve, reject ) => + { + const guard = syncGuard( reject, { catchAsync: true } ); + + pushedStream.once( "aborted", ( ) => + reject( new AbortError( "Response aborted" ) ) + ); + pushedStream.once( "frameError", ( ) => + reject( new Error( "Push request failed" ) ) + ); + pushedStream.once( "error", reject ); + + pushedStream.once( "push", guard( + ( responseHeaders: IncomingHttp2Headers ) => + { + const response = new StreamResponse( + this._context._decoders, + path, + pushedStream, + responseHeaders, + false, + { }, + 2 + ); + + resolve( response ); + } + ) ); + } ); + + futureResponse + .catch( _err => { } ); // TODO: #8 + + const getResponse = ( ) => futureResponse; + + return this._pushHandler( origin, pushedRequest, getResponse ); + } + + private connectHttp2( + origin: string, + extraOptions: SecureClientSessionOptions = { } + ) + : H2SessionItem + { + const makeConnectionTimeout = ( ) => + new TimeoutError( `Connection timeout to ${origin}` ); + + const makeError = ( event?: string ) => + event + ? new Error( `Unknown connection error (${event}): ${origin}` ) + : new Error( `Connection closed` ); + + let session: ClientHttp2Session = < ClientHttp2Session >< any >void 0; + + // TODO: #8 + // tslint:disable-next-line + const aGuard = asyncGuard( console.error.bind( console ) ); + + const pushHandler = aGuard( + ( stream: ClientHttp2Stream, headers: IncomingHttp2Headers ) => + this.handlePush( origin, stream, headers ) + ); + + const options = { + ...this._context._sessionOptions, + ...extraOptions, + }; + + const promise = new Promise< ClientHttp2Session >( + ( resolve, reject ) => + { + session = + http2Connect( origin, options, ( ) => resolve( session ) ); + + session.on( "stream", pushHandler ); + + session.once( "close", ( ) => + reject( makeOkError( makeError( ) ) ) ); + + session.once( "timeout", ( ) => + reject( makeConnectionTimeout( ) ) ); + + session.once( "error", reject ); + } + ); + + return { promise, session }; + } +} diff --git a/lib/context-https.ts b/lib/context-https.ts new file mode 100644 index 0000000..77d5061 --- /dev/null +++ b/lib/context-https.ts @@ -0,0 +1,66 @@ +import { SecureClientSessionOptions } from "http2"; +import { connect, ConnectionOptions, TLSSocket } from "tls"; + +import { FetchError, HttpProtocols } from "./core"; + +const alpnProtocols = +{ + http1: Buffer.from( "\x08http/1.1" ), + http2: Buffer.from( "\x02h2" ), +}; + +export interface HttpsSocketResult +{ + socket: TLSSocket; + protocol: "http1" | "http2"; +} + +export function connectTLS( + host: string, + port: string, + protocols: ReadonlyArray< HttpProtocols >, + connOpts: SecureClientSessionOptions +): Promise< HttpsSocketResult > +{ + const usedProtocol = new Set< string >( ); + const _protocols = protocols.filter( protocol => + { + if ( protocol !== "http1" && protocol !== "http2" ) + return false; + if ( usedProtocol.has( protocol ) ) + return false; + usedProtocol.add( protocol ); + return true; + } ); + + const orderedProtocols = Buffer.concat( + _protocols.map( protocol => alpnProtocols[ protocol ] ) + ); + + const opts: ConnectionOptions = { + ...connOpts, + ALPNProtocols: orderedProtocols, + servername: host, + }; + + return new Promise< HttpsSocketResult >( ( resolve, reject ) => + { + const socket: TLSSocket = connect( parseInt( port, 10 ), host, opts, ( ) => + { + const { authorized, authorizationError, alpnProtocol = "" } = + socket; + + if ( !authorized && opts.rejectUnauthorized !== false ) + return reject( authorizationError ); + + if ( ![ "h2", "http/1.1", "http/1.0" ].includes( alpnProtocol ) ) + return reject( new FetchError( "Invalid ALPN response" ) ); + + const protocol = alpnProtocol === "h2" ? "http2" : "http1"; + + resolve( { socket, protocol } ); + } ); + + socket.once( "error", reject ); + } ); +} diff --git a/lib/context.ts b/lib/context.ts index f5915a8..93fa017 100644 --- a/lib/context.ts +++ b/lib/context.ts @@ -1,32 +1,33 @@ +import { ClientRequest } from "http"; import { ClientHttp2Session, - ClientHttp2Stream, - connect as http2Connect, - constants as h2constants, - IncomingHttpHeaders as IncomingHttp2Headers, SecureClientSessionOptions, } from "http2"; - -import { asyncGuard, syncGuard } from "callguard"; +import { Socket } from "net"; import { URL } from "url"; +import { H1Context } from "./context-http1"; +import { H2Context, PushHandler } from "./context-http2"; +import { connectTLS } from "./context-https"; import { CookieJar } from "./cookie-jar"; import { - AbortError, + BaseContext, Decoder, + FetchError, FetchInit, + Http1Options, + HttpProtocols, SimpleSession, - TimeoutError, + SimpleSessionHttp1, + SimpleSessionHttp2, } from "./core"; -import { fetch } from "./fetch"; +import { fetch as fetchHttp1 } from "./fetch-http1"; +import { fetch as fetchHttp2 } from "./fetch-http2"; import { version } from "./generated/version"; import { Request } from "./request"; -import { H2StreamResponse, Response } from "./response"; -import { setGotGoaway } from "./utils"; +import { Response } from "./response"; +import { parseInput } from "./utils"; -const { - HTTP2_HEADER_PATH, -} = h2constants; function makeDefaultUserAgent( ): string { @@ -49,51 +50,45 @@ export interface ContextOptions cookieJar: CookieJar; decoders: ReadonlyArray< Decoder >; session: SecureClientSessionOptions; + httpProtocol: HttpProtocols; + httpsProtocols: ReadonlyArray< HttpProtocols >; + http1: Partial< Http1Options >; } -interface SessionItem -{ - session: ClientHttp2Session; - promise: Promise< ClientHttp2Session >; -} - -function makeOkError( err: Error ): Error +export class Context implements BaseContext { - ( < any >err ).metaData = ( < any >err ).metaData || { }; - ( < any >err ).metaData.ok = true; - return err; -} - -export type PushHandler = - ( - origin: string, - request: Request, - getResponse: ( ) => Promise< Response > - ) => void; + public _decoders: ReadonlyArray< Decoder >; + public _sessionOptions: SecureClientSessionOptions; -export class Context -{ - private _h2sessions: Map< string, SessionItem >; - private _h2staleSessions: Map< string, Set< ClientHttp2Session > >; + private h1Context: H1Context; + private h2Context = new H2Context( this ); private _userAgent: string; private _accept: string; private _cookieJar: CookieJar; - private _decoders: ReadonlyArray< Decoder >; - private _sessionOptions: SecureClientSessionOptions; - private _pushHandler?: PushHandler; + private _httpProtocol: HttpProtocols; + private _httpsProtocols: Array< HttpProtocols >; + private _http1Options: Http1Options; constructor( opts?: Partial< ContextOptions > ) { - this._h2sessions = new Map( ); - this._h2staleSessions = new Map( ); - this._userAgent = ""; this._accept = ""; this._cookieJar = < CookieJar >< any >void 0; this._decoders = [ ]; this._sessionOptions = { }; + this._httpProtocol = "http1"; + this._httpsProtocols = [ "http2", "http1" ]; + this._http1Options = { + keepAlive: false, + keepAliveMsecs: 1000, + maxFreeSockets: 256, + maxSockets: Infinity, + timeout: void 0, + }; this.setup( opts ); + + this.h1Context = new H1Context( this._http1Options ); } public setup( opts?: Partial< ContextOptions > ) @@ -126,303 +121,242 @@ export class Context this._sessionOptions = "session" in opts ? opts.session || { } : { }; - } - public onPush( pushHandler?: PushHandler ) - { - this._pushHandler = pushHandler; - } + this._httpProtocol = "httpProtocol" in opts + ? opts.httpProtocol || "http1" + : "http1"; - public fetch( input: string | Request, init?: Partial< FetchInit > ) - : Promise< Response > - { - const sessionGetter: SimpleSession = { - accept: ( ) => this._accept, - contentDecoders: ( ) => this._decoders, - cookieJar: this._cookieJar, - get: ( url: string ) => this.get( url ), - userAgent: ( ) => this._userAgent, - }; - return fetch( sessionGetter, input, init ); + this._httpsProtocols = "httpsProtocols" in opts + ? [ ...( opts.httpsProtocols || [ ] ) ] + : [ "http2", "http1" ]; + + Object.assign( this._http1Options, opts.http1 || { } ); } - public releaseSession( origin: string ): void + public onPush( pushHandler?: PushHandler ) { - const sessionItem = this.deleteActiveSession( origin ); - - if ( !sessionItem ) - return; - - if ( !this._h2staleSessions.has( origin ) ) - this._h2staleSessions.set( origin, new Set( ) ); - - ( < Set< ClientHttp2Session > >this._h2staleSessions.get( origin ) ) - .add( sessionItem.session ); + this.h2Context._pushHandler = pushHandler; } - public deleteActiveSession( origin: string ): SessionItem | void + public async fetch( input: string | Request, init?: Partial< FetchInit > ) + : Promise< Response > { - if ( !this._h2sessions.has( origin ) ) - return; + const { hostname, origin, port, protocol, url } = + this.parseInput( input ); + + // Rewrite url to get rid of "http1://" and "http2://" + const request = + input instanceof Request + ? input.url !== url + ? input.clone( url ) + : input + : new Request( input, { ...( init || { } ), url } ); + + const { rejectUnauthorized } = this._sessionOptions; + + const makeSimpleSession = ( protocol: HttpProtocols ): SimpleSession => + ( { + accept: ( ) => this._accept, + contentDecoders: ( ) => this._decoders, + cookieJar: this._cookieJar, + protocol, + userAgent: ( ) => this._userAgent, + } ); - const sessionItem = this._h2sessions.get( origin ); - this._h2sessions.delete( origin ); + const doFetchHttp1 = ( socket: Socket ) => + { + const sessionGetterHttp1: SimpleSessionHttp1 = { + get: ( url: string ) => + this.getHttp1( url, socket, request, rejectUnauthorized ), + ...makeSimpleSession( "http1" ), + }; + return fetchHttp1( sessionGetterHttp1, request, init ); + }; - return sessionItem; - } + const doFetchHttp2 = ( ) => + { + const sessionGetterHttp2: SimpleSessionHttp2 = { + get: ( url: string ) => this.getHttp2( url ), + ...makeSimpleSession( "http2" ), + }; + return fetchHttp2( sessionGetterHttp2, request, init ); + }; - public disconnectSession( session: ClientHttp2Session ): Promise< void > - { - return new Promise< void >( resolve => + const tryWaitForHttp1 = async ( ) => { - if ( session.destroyed ) - return resolve( ); + const { socket: freeHttp1Socket, shouldCreateNew } = + this.h1Context.getFreeSocketForOrigin( origin ); - session.once( "close", ( ) => resolve( ) ); - session.destroy( ); - } ); - } + if ( freeHttp1Socket ) + return doFetchHttp1( freeHttp1Socket ); - public disconnectStaleSessions( origin: string ): Promise< void > - { - const promises: Array< Promise< void > > = [ ]; + if ( !shouldCreateNew ) + { + // We've maxed out HTTP/1 connections, wait for one to be + // freed. + const socket = await this.h1Context.waitForSocket( origin ); + return doFetchHttp1( socket ); + } + }; - if ( this._h2staleSessions.has( origin ) ) + if ( protocol === "http1" ) { - const sessionSet = - < Set< ClientHttp2Session > > - this._h2staleSessions.get( origin ); - this._h2staleSessions.delete( origin ); - - for ( const session of sessionSet ) - promises.push( this.disconnectSession( session ) ); + // Plain text HTTP/1(.1) + const resp = await tryWaitForHttp1( ); + if ( resp ) + return resp; + + const socket = await this.h1Context.makeNewConnection( url ); + this.h1Context.addUsedSocket( origin, socket ); + return doFetchHttp1( socket ); } - - return Promise.all( promises ).then( ( ) => { } ); - } - - public disconnect( url: string, session?: ClientHttp2Session ): Promise< void > - { - const { origin } = new URL( url ); - const promises: Array< Promise< void > > = [ ]; - - const sessionItem = this.deleteActiveSession( origin ); - - if ( sessionItem && ( !session || sessionItem.session === session ) ) - promises.push( this.handleDisconnect( sessionItem ) ); - - if ( !session ) + else if ( protocol === "http2" ) { - promises.push( this.disconnectStaleSessions( origin ) ); + // Plain text HTTP/2 + return doFetchHttp2( ); } - else if ( this._h2staleSessions.has( origin ) ) + else // protocol === "https" { - const sessionSet = - < Set< ClientHttp2Session > > - this._h2staleSessions.get( origin ); - if ( sessionSet.has( session ) ) + // If we already have a session/socket open to this origin, + // re-use it + + if ( this.h2Context.hasOrigin( origin ) ) + return doFetchHttp2( ); + + const resp = await tryWaitForHttp1( ); + if ( resp ) + return resp; + + // TODO: Make queue for subsequent fetch requests to the same + // origin, so they can re-use the http2 session, or http1 + // pool once we know what protocol will be used. + // This must apply to plain-text http1 too. + + // Use ALPN to figure out protocol lazily + const { protocol, socket } = await connectTLS( + hostname, + port, + this._httpsProtocols, + this._sessionOptions + ); + + if ( protocol === "http2" ) { - sessionSet.delete( session ); - promises.push( this.disconnectSession( session ) ); + // Convert socket into http2 session + await this.h2Context.getOrCreateHttp2( + origin, + { + createConnection: ( ) => socket, + } + ); + // Session now lingering, it will be re-used by the next get() + return doFetchHttp2( ); + } + else // protocol === "http1" + { + this.h1Context.addUsedSocket( origin, socket ); + return doFetchHttp1( socket ); } } - - return Promise.all( promises ).then( ( ) => { } ); } - public disconnectAll( ): Promise< void > + public async disconnect( url: string ) { - const promises: Array< Promise< void > > = [ ]; - - for ( const eventualH2session of this._h2sessions.values( ) ) - { - promises.push( this.handleDisconnect( eventualH2session ) ); - } - this._h2sessions.clear( ); - - for ( const origin of this._h2staleSessions.keys( ) ) - { - promises.push( this.disconnectStaleSessions( origin ) ); - } - - return Promise.all( promises ).then( ( ) => { } ); + await Promise.all( [ + this.h1Context.disconnect( url ), + this.h2Context.disconnect( url ), + ] ); } - private handlePush( - origin: string, - pushedStream: ClientHttp2Stream, - requestHeaders: IncomingHttp2Headers - ) + public async disconnectAll( ) { - if ( !this._pushHandler ) - return; // Drop push. TODO: Signal through error log: #8 - - const path = requestHeaders[ HTTP2_HEADER_PATH ] as string; - - // Remove pseudo-headers - Object.keys( requestHeaders ) - .filter( name => name.charAt( 0 ) === ":" ) - .forEach( name => { delete requestHeaders[ name ]; } ); - - const pushedRequest = new Request( path, { headers: requestHeaders } ); - - const futureResponse = new Promise< Response >( ( resolve, reject ) => - { - const guard = syncGuard( reject, { catchAsync: true } ); - - pushedStream.once( "aborted", ( ) => - reject( new AbortError( "Response aborted" ) ) - ); - pushedStream.once( "frameError", ( ) => - reject( new Error( "Push request failed" ) ) - ); - pushedStream.once( "error", reject ); - - pushedStream.once( "push", guard( - ( responseHeaders: IncomingHttp2Headers ) => - { - const response = new H2StreamResponse( - this._decoders, - path, - pushedStream, - responseHeaders, - false - ); - - resolve( response ); - } - ) ); - } ); - - futureResponse - .catch( _err => { } ); // TODO: #8 - - const getResponse = ( ) => futureResponse; - - return this._pushHandler( origin, pushedRequest, getResponse ); + await Promise.all([ + this.h1Context.disconnectAll( ), + this.h2Context.disconnectAll( ), + ]); } - private connect( origin: string ) - : SessionItem + private getHttp1( + url: string, + socket: Socket, + request: Request, + rejectUnauthorized?: boolean + ) + : ClientRequest { - const makeConnectionTimeout = ( ) => - new TimeoutError( `Connection timeout to ${origin}` ); - - const makeError = ( event?: string ) => - event - ? new Error( `Unknown connection error (${event}): ${origin}` ) - : new Error( `Connection closed` ); - - let session: ClientHttp2Session = < ClientHttp2Session >< any >void 0; - - // TODO: #8 - // tslint:disable-next-line - const aGuard = asyncGuard( console.error.bind( console ) ); - - const pushHandler = aGuard( - ( stream: ClientHttp2Stream, headers: IncomingHttp2Headers ) => - this.handlePush( origin, stream, headers ) - ); - - const options = this._sessionOptions; - - const promise = new Promise< ClientHttp2Session >( - ( resolve, reject ) => + return this.h1Context.connect( + new URL( url ), { - session = - http2Connect( origin, options, ( ) => resolve( session ) ); - - session.on( "stream", pushHandler ); - - session.once( "close", ( ) => - reject( makeOkError( makeError( ) ) ) ); - - session.once( "timeout", ( ) => - reject( makeConnectionTimeout( ) ) ); - - session.once( "error", reject ); - } + createConnection: ( ) => socket, + rejectUnauthorized, + }, + request ); - - return { promise, session }; } - private getOrCreate( origin: string, created = false ) + private getOrCreateHttp2( origin: string, created = false ) : Promise< ClientHttp2Session > { - const willCreate = !this._h2sessions.has( origin ); - - if ( willCreate ) - { - const sessionItem = this.connect( origin ); - - const { promise } = sessionItem; - - // Handle session closure (delete from store) - promise - .then( session => - { - session.once( - "close", - ( ) => this.disconnect( origin, session ) - ); + const { didCreate, session } = + this.h2Context.getOrCreateHttp2( origin ); - session.once( - "goaway", - ( - _errorCode: number, - _lastStreamID: number, - _opaqueData: Buffer - ) => - { - setGotGoaway( session ); - this.releaseSession( origin ); - } - ); - } ) - .catch( ( ) => - { - if ( sessionItem.session ) - this.disconnect( origin, sessionItem.session ); - } ); - - this._h2sessions.set( origin, sessionItem ); - } - - return ( < SessionItem >this._h2sessions.get( origin ) ).promise + return session .catch( err => { - if ( willCreate || created ) + if ( didCreate || created ) // Created in this request, forward error throw err; // Not created in this request, try again - return this.getOrCreate( origin, true ); + return this.getOrCreateHttp2( origin, true ); } ); } - private get( url: string ) + private getHttp2( url: string ) : Promise< ClientHttp2Session > { - const { origin } = new URL( url ); + const { origin } = typeof url === "string" ? new URL( url ) : url; - return this.getOrCreate( origin ); + return this.getOrCreateHttp2( origin ); } - private handleDisconnect( sessionItem: SessionItem ): Promise< void > + private parseInput( input: string | Request ) { - const { promise, session } = sessionItem; - - if ( session ) - session.destroy( ); - - return promise - .then( _h2session => { } ) - .catch( err => - { - const debugMode = false; - if ( debugMode ) - // tslint:disable-next-line - console.warn( "Disconnect error", err ); - } ); + const { hostname, origin, port, protocol, url } = + parseInput( typeof input !== "string" ? input.url : input ); + + const defaultHttp = this._httpProtocol; + + if ( + ( protocol === "http" && defaultHttp === "http1" ) + || protocol === "http1" + ) + return { + hostname, + origin, + port, + protocol: "http1", + url, + }; + else if ( + ( protocol === "http" && defaultHttp === "http2" ) + || protocol === "http2" + ) + return { + hostname, + origin, + port, + protocol: "http2", + url, + }; + else if ( protocol === "https" ) + return { + hostname, + origin, + port, + protocol: "https", + url, + }; + else + throw new FetchError( `Invalid protocol "${protocol}"` ); } } diff --git a/lib/core.ts b/lib/core.ts index e8cb057..a220320 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -1,14 +1,10 @@ -import { - ClientHttp2Session, - SecureClientSessionOptions, - SessionOptions, -} from "http2"; - -import { URL } from "url"; +import { ClientRequest } from "http"; +import { ClientHttp2Session, SecureClientSessionOptions } from "http2"; import { CookieJar } from "./cookie-jar"; import { Headers, RawHeaders } from "./headers"; + export type Method = "ACL" | "BIND" | @@ -93,6 +89,8 @@ export type ResponseTypes = "cors" | "error"; +export type HttpProtocols = "http1" | "http2"; + export interface IBody { readonly bodyUsed: boolean; @@ -128,6 +126,11 @@ export interface RequestInit extends RequestInitWithoutBody json: any; } +export interface RequestInitWithUrl extends RequestInit +{ + url: string; +} + export type OnTrailers = ( headers: Headers ) => void; export interface FetchInit extends RequestInit @@ -150,6 +153,15 @@ export interface ResponseInit headers: RawHeaders | Headers; } +export class FetchError extends Error +{ + constructor( message: string ) + { + super( message ); + Object.setPrototypeOf( this, FetchError.prototype ); + } +} + export class AbortError extends Error { constructor( message: string ) @@ -177,17 +189,41 @@ export interface Decoder decode: DecodeFunction; } +export type PerOriginOption< T > = ( origin: string ) => T; + +export interface Http1Options +{ + keepAlive: boolean | PerOriginOption< boolean >; + keepAliveMsecs: number | PerOriginOption< number >; + maxSockets: number | PerOriginOption< number >; + maxFreeSockets: number | PerOriginOption< number >; + timeout: void | number | PerOriginOption< void | number >; +} + +export interface BaseContext +{ + _decoders: ReadonlyArray< Decoder >; + _sessionOptions: SecureClientSessionOptions; +} + export interface SimpleSession { - cookieJar: CookieJar; + protocol: HttpProtocols; - get( - url: string | URL, - options?: SessionOptions | SecureClientSessionOptions - ): Promise< ClientHttp2Session >; + cookieJar: CookieJar; userAgent( ): string; accept( ): string; contentDecoders( ): ReadonlyArray< Decoder >; } + +export interface SimpleSessionHttp1 extends SimpleSession +{ + get( url: string ): ClientRequest; +} + +export interface SimpleSessionHttp2 extends SimpleSession +{ + get( url: string ): Promise< ClientHttp2Session >; +} diff --git a/lib/fetch-common.ts b/lib/fetch-common.ts new file mode 100644 index 0000000..6eb4b77 --- /dev/null +++ b/lib/fetch-common.ts @@ -0,0 +1,304 @@ +import { constants as h2constants } from "http2"; +import { URL } from "url"; + +import { Finally } from "already"; + +import { BodyInspector } from "./body"; +import { + AbortError, + FetchInit, + SimpleSession, + TimeoutError, +} from "./core"; +import { Headers, RawHeaders } from "./headers"; +import { Request } from "./request"; +import { Response } from "./response"; +import { arrayify } from "./utils"; + +const { + // Required for a request + HTTP2_HEADER_METHOD, + HTTP2_HEADER_SCHEME, + HTTP2_HEADER_PATH, + + // Methods + HTTP2_METHOD_GET, + HTTP2_METHOD_HEAD, + + // Requests + HTTP2_HEADER_USER_AGENT, + HTTP2_HEADER_ACCEPT, + HTTP2_HEADER_COOKIE, + HTTP2_HEADER_CONTENT_TYPE, + HTTP2_HEADER_CONTENT_LENGTH, + HTTP2_HEADER_ACCEPT_ENCODING, +} = h2constants; + + +function ensureNotCircularRedirection( redirections: ReadonlyArray< string > ) +: void +{ + const urls = [ ...redirections ]; + const last = urls.pop( ); + + for ( let i = 0; i < urls.length; ++i ) + if ( urls[ i ] === last ) + { + const err = new Error( "Redirection loop detected" ); + ( < any >err ).urls = urls.slice( i ); + throw err; + } +} + +export interface FetchExtra +{ + redirected: Array< string >; + timeoutAt?: number; +} + +export interface TimeoutInfo +{ + promise: Promise< Response >; + clear: ( ) => void; +} + +export async function setupFetch( + session: SimpleSession, + request: Request, + init: Partial< FetchInit > = { }, + extra: FetchExtra +) +{ + const { redirected } = extra; + + ensureNotCircularRedirection( redirected ); + + const { url, method, redirect, integrity } = request; + + const { signal, onTrailers } = init; + + const { + origin, + protocol, + pathname, search, hash, + } = new URL( url ); + const path = pathname + search + hash; + + const endStream = + method === HTTP2_METHOD_GET || method === HTTP2_METHOD_HEAD; + + const headers = new Headers( request.headers ); + + const cookies = ( await session.cookieJar.getCookies( url ) ) + .map( cookie => cookie.cookieString( ) ); + + const contentDecoders = session.contentDecoders( ); + + const acceptEncoding = + contentDecoders.length === 0 + ? "gzip;q=1.0, deflate;q=0.5" + : contentDecoders + .map( decoder => `${decoder.name};q=1.0` ) + .join( ", " ) + ", gzip;q=0.8, deflate;q=0.5"; + + if ( headers.has( HTTP2_HEADER_COOKIE ) ) + cookies.push( ...arrayify( headers.get( HTTP2_HEADER_COOKIE ) ) ); + + const headersToSend: RawHeaders = { + // Set required headers + ...( session.protocol === "http1" ? { } : { + [ HTTP2_HEADER_METHOD ]: method, + [ HTTP2_HEADER_SCHEME ]: protocol.replace( /:.*/, "" ), + [ HTTP2_HEADER_PATH ]: path, + } ), + + // Set default headers + [ HTTP2_HEADER_ACCEPT ]: session.accept( ), + [ HTTP2_HEADER_USER_AGENT ]: session.userAgent( ), + [ HTTP2_HEADER_ACCEPT_ENCODING ]: acceptEncoding, + }; + + if ( cookies.length > 0 ) + headersToSend[ HTTP2_HEADER_COOKIE ] = cookies.join( "; " ); + + for ( const [ key, val ] of headers.entries( ) ) + { + if ( key === "host" && session.protocol === "http2" ) + // Convert to :authority like curl does: + // https://github.com/grantila/fetch-h2/issues/9 + headersToSend[ ":authority" ] = val; + else if ( key !== HTTP2_HEADER_COOKIE ) + headersToSend[ key ] = val; + } + + const inspector = new BodyInspector( request ); + + if ( + !endStream && + inspector.length != null && + !request.headers.has( HTTP2_HEADER_CONTENT_LENGTH ) + ) + headersToSend[ HTTP2_HEADER_CONTENT_LENGTH ] = "" + inspector.length; + + if ( + !endStream && + !request.headers.has( "content-type" ) && + inspector.mime + ) + headersToSend[ HTTP2_HEADER_CONTENT_TYPE ] = inspector.mime; + + function timeoutError( ) + { + return new TimeoutError( + `${method} ${url} timed out after ${init.timeout} ms` ); + } + + const timeoutAt = extra.timeoutAt || ( + ( "timeout" in init && typeof init.timeout === "number" ) + // Setting the timeoutAt here at first time allows async cookie + // jar to not take part of timeout for at least the first request + // (in a potential redirect chain) + ? Date.now( ) + init.timeout + : void 0 + ); + + function setupTimeout( ): TimeoutInfo | null + { + if ( !timeoutAt ) + return null; + + const now = Date.now( ); + if ( now >= timeoutAt ) + throw timeoutError( ); + + let timerId: NodeJS.Timeout | null; + + return { + clear: ( ) => + { + if ( timerId ) + clearTimeout( timerId ); + }, + promise: new Promise( ( _resolve, reject ) => + { + timerId = setTimeout( ( ) => + { + timerId = null; + reject( timeoutError( ) ); + }, + timeoutAt - now + ); + } ), + }; + + } + + const timeoutInfo = setupTimeout( ); + + function abortError( ) + { + return new AbortError( `${method} ${url} aborted` ); + } + + if ( signal && signal.aborted ) + throw abortError( ); + + const signalPromise: Promise< Response > | null = + signal + ? + new Promise< Response >( ( _resolve, reject ) => + { + signal.onabort = ( ) => + { + reject( abortError( ) ); + }; + } ) + : null; + + function cleanup( ) + { + if ( timeoutInfo && timeoutInfo.clear ) + timeoutInfo.clear( ); + + if ( signal ) + delete signal.onabort; + } + + return { + cleanup, + contentDecoders, + endStream, + headersToSend, + integrity, + method, + onTrailers, + origin, + redirect, + redirected, + request, + signal, + signalPromise, + timeoutAt, + timeoutInfo, + url, + }; +} + +export function handleSignalAndTimeout( + signalPromise: Promise< Response > | null, + timeoutInfo: TimeoutInfo | null, + cleanup: ( ) => void, + fetcher: ( ) => Promise< Response > +) +{ + return Promise.race( + [ + < Promise< any > >signalPromise, + < Promise< any > >( timeoutInfo && timeoutInfo.promise ), + fetcher( ), + ] + .filter( promise => promise ) + ) + .then( ...Finally( cleanup ) ); +} + +export function make100Error( ) +{ + return new Error( + "Request failed with 100 continue. " + + "This can't happen unless a server failure" + ); +} + +export function makeAbortedError( ) +{ + return new AbortError( "Request aborted" ); +} + +export function makeTimeoutError( ) +{ + return new TimeoutError( "Request timed out" ); +} + +export function makeIllegalRedirectError( ) +{ + return new Error( + "Server responded illegally with a " + + "redirect code but missing 'location' header" + ); +} + +export function makeRedirectionError( location: string | null ) +{ + return new Error( `URL got redirected to ${location}` ); +} + +export function makeRedirectionMethodError( + location: string | null, method: string +) +{ + return new Error( + `URL got redirected to ${location}, which ` + + `'fetch-h2' doesn't support for ${method}` + ); +} diff --git a/lib/fetch-http1.ts b/lib/fetch-http1.ts new file mode 100644 index 0000000..82f97d0 --- /dev/null +++ b/lib/fetch-http1.ts @@ -0,0 +1,252 @@ +import { IncomingMessage } from "http"; +import { constants as h2constants } from "http2"; +import { Socket } from "net"; + +import { syncGuard } from "callguard"; + +import { + FetchInit, + SimpleSessionHttp1, +} from "./core"; +import { + FetchExtra, + handleSignalAndTimeout, + make100Error, + makeAbortedError, + makeIllegalRedirectError, + makeRedirectionError, + makeRedirectionMethodError, + makeTimeoutError, + setupFetch, +} from "./fetch-common"; +import { GuardedHeaders } from "./headers"; +import { Request } from "./request"; +import { Response, StreamResponse } from "./response"; +import { arrayify, isRedirectStatus, parseLocation } from "./utils"; + +const { + // Responses, these are the same in HTTP/1.1 and HTTP/2 + HTTP2_HEADER_LOCATION: HTTP1_HEADER_LOCATION, + HTTP2_HEADER_SET_COOKIE: HTTP1_HEADER_SET_COOKIE, +} = h2constants; + + +export async function fetchImpl( + session: SimpleSessionHttp1, + input: Request, + init: Partial< FetchInit > = { }, + extra: FetchExtra +) +: Promise< Response > +{ + const { + cleanup, + contentDecoders, + endStream, + headersToSend, + integrity, + method, + onTrailers, + redirect, + redirected, + request, + signal, + signalPromise, + timeoutAt, + timeoutInfo, + url, + } = await setupFetch( session, input, init, extra ); + + const doFetch = async ( ): Promise< Response > => + { + const req = session.get( url ); + + for ( const [ key, value ] of Object.entries( headersToSend ) ) + { + if ( value != null ) + req.setHeader( key, value ); + } + + const response = new Promise< Response >( ( resolve, reject ) => + { + const guard = syncGuard( reject, { catchAsync: true } ); + + req.once( "error", reject ); + + req.once( "aborted", guard( ( ) => + { + reject( makeAbortedError( ) ); + } ) ); + + req.once( "continue", guard( ( ) => + { + reject( make100Error( ) ); + } ) ); + + req.once( "information", guard( ( res: any ) => + { + resolve( new Response( + null, // No body + { status: res.statusCode } + ) ); + } ) ); + + req.once( "timeout", guard( ( ) => + { + reject( makeTimeoutError( ) ); + req.abort( ); + } ) ); + + req.once( "upgrade", guard( + ( + _res: IncomingMessage, + _socket: Socket, + _upgradeHead: Buffer + ) => + { + reject( new Error( "Upgrade not implemented!" ) ); + req.abort( ); + } ) + ); + + req.once( "response", guard( ( res: IncomingMessage ) => + { + if ( signal && signal.aborted ) + { + // No reason to continue, the request is aborted + req.abort( ); + return; + } + + const { headers, statusCode } = res; + + res.once( "end", guard( ( ) => + { + if ( !onTrailers ) + return; + + try + { + const { trailers } = res; + const headers = new GuardedHeaders( "response" ); + + Object.keys( trailers ).forEach( key => + { + if ( trailers[ key ] != null ) + headers.set( key, "" + trailers[ key ] ); + } ); + + onTrailers( headers ); + } + catch ( err ) + { + // TODO: Implement #8 + // tslint:disable-next-line + console.warn( "Trailer handling failed", err ); + } + } ) ); + + const location = parseLocation( + headers[ HTTP1_HEADER_LOCATION ], + url + ); + + const isRedirected = isRedirectStatus[ "" + statusCode ]; + + if ( headers[ HTTP1_HEADER_SET_COOKIE ] ) + { + const setCookies = + arrayify( headers[ HTTP1_HEADER_SET_COOKIE ] ); + + session.cookieJar.setCookies( setCookies, url ); + } + + delete headers[ "set-cookie" ]; + delete headers[ "set-cookie2" ]; + + if ( isRedirected && !location ) + return reject( makeIllegalRedirectError( ) ); + + if ( !isRedirected || redirect === "manual" ) + return resolve( + new StreamResponse( + contentDecoders, + url, + res, + headers, + redirect === "manual" + ? false + : extra.redirected.length > 0, + { + status: res.statusCode, + statusText: res.statusMessage, + }, + 1, + integrity + ) + ); + + if ( redirect === "error" ) + return reject( makeRedirectionError( location ) ); + + // redirect is 'follow' + + // We don't support re-sending a non-GET/HEAD request (as + // we don't want to [can't, if its' streamed] re-send the + // body). The concept is fundementally broken anyway... + if ( !endStream ) + return reject( + makeRedirectionMethodError( location, method ) + ); + + if ( !location ) + return reject( makeIllegalRedirectError( ) ); + + res.destroy( ); + resolve( + fetchImpl( + session, + request.clone( location ), + { signal, onTrailers }, + { + redirected: redirected.concat( url ), + timeoutAt, + } + ) + ); + } ) ); + } ); + + if ( endStream ) + req.end( ); + else + await request.readable( ) + .then( readable => + { + readable.pipe( req ); + } ); + + return response; + }; + + return handleSignalAndTimeout( + signalPromise, + timeoutInfo, + cleanup, + doFetch + ); +} + +export function fetch( + session: SimpleSessionHttp1, + input: Request, + init?: Partial< FetchInit > +) +: Promise< Response > +{ + const timeoutAt = void 0; + + const extra = { timeoutAt, redirected: [ ] }; + + return fetchImpl( session, input, init, extra ); +} diff --git a/lib/fetch-http2.ts b/lib/fetch-http2.ts new file mode 100644 index 0000000..60f7478 --- /dev/null +++ b/lib/fetch-http2.ts @@ -0,0 +1,317 @@ +import { + constants as h2constants, + IncomingHttpHeaders as IncomingHttp2Headers, +} from "http2"; + +import { syncGuard } from "callguard"; + +import { + AbortError, + FetchInit, + SimpleSessionHttp2, +} from "./core"; +import { + FetchExtra, + handleSignalAndTimeout, + make100Error, + makeAbortedError, + makeIllegalRedirectError, + makeRedirectionError, + makeRedirectionMethodError, + makeTimeoutError, + setupFetch, +} from "./fetch-common"; +import { GuardedHeaders } from "./headers"; +import { Request } from "./request"; +import { Response, StreamResponse } from "./response"; +import { arrayify, isRedirectStatus, parseLocation } from "./utils"; +import { hasGotGoaway } from "./utils-http2"; + +const { + // Responses + HTTP2_HEADER_STATUS, + HTTP2_HEADER_LOCATION, + HTTP2_HEADER_SET_COOKIE, + + // Error codes + NGHTTP2_NO_ERROR, +} = h2constants; + +// This is from nghttp2.h, but undocumented in Node.js +const NGHTTP2_ERR_START_STREAM_NOT_ALLOWED = -516; + +interface FetchExtraHttp2 extends FetchExtra +{ + raceConditionedGoaway: Set< string >; // per origin +} + +async function fetchImpl( + session: SimpleSessionHttp2, + input: Request, + init: Partial< FetchInit > = { }, + extra: FetchExtraHttp2 +) +: Promise< Response > +{ + const { + cleanup, + contentDecoders, + endStream, + headersToSend, + integrity, + method, + onTrailers, + origin, + redirect, + redirected, + request, + signal, + signalPromise, + timeoutAt, + timeoutInfo, + url, + } = await setupFetch( session, input, init, extra ); + + const { raceConditionedGoaway } = extra; + + function doFetch( ): Promise< Response > + { + return session.get( url ) + .then( async h2session => + { + const stream = h2session.request( headersToSend, { endStream } ); + + const response = new Promise< Response >( ( resolve, reject ) => + { + const guard = syncGuard( reject, { catchAsync: true } ); + + stream.on( "aborted", guard( ( ..._whatever ) => + { + reject( makeAbortedError( ) ); + } ) ); + + stream.on( "error", guard( ( err: Error ) => + { + reject( err ); + } ) ); + + stream.on( "frameError", guard( + ( _type: number, code: number, _streamId: number ) => + { + if ( + code === NGHTTP2_ERR_START_STREAM_NOT_ALLOWED && + endStream + ) + { + // This could be due to a race-condition in GOAWAY. + // As of current Node.js, the 'goaway' event is + // emitted on the session before this event + // is emitted, so we will know if we got it. + if ( + !raceConditionedGoaway.has( origin ) && + hasGotGoaway( h2session ) + ) + { + // Don't retry again due to potential GOAWAY + raceConditionedGoaway.add( origin ); + + // Since we've got the 'goaway' event, the + // context has already released the session, + // so a retry will create a new session. + resolve( + fetchImpl( + session, + request, + { signal, onTrailers }, + { + raceConditionedGoaway, + redirected, + timeoutAt, + } + ) + ); + + return; + } + } + + reject( new Error( "Request failed" ) ); + } ) + ); + + stream.on( "close", guard( ( ) => + { + // We'll get an 'error' event if there actually is an + // error, but not if we got NGHTTP2_NO_ERROR. + // In case of an error, the 'error' event will be awaited + // instead, to get (and propagate) the error object. + if ( stream.rstCode === NGHTTP2_NO_ERROR ) + reject( + new AbortError( "Stream prematurely closed" ) ); + } ) ); + + stream.on( "timeout", guard( ( ..._whatever ) => + { + reject( makeTimeoutError( ) ); + } ) ); + + stream.on( "trailers", guard( + ( _headers: IncomingHttp2Headers, _flags: any ) => + { + if ( !onTrailers ) + return; + try + { + const headers = new GuardedHeaders( "response" ); + + Object.keys( _headers ).forEach( key => + { + if ( Array.isArray( _headers[ key ] ) ) + ( < Array< string > >_headers[ key ] ) + .forEach( value => + headers.append( key, value ) ); + else + headers.set( key, "" + _headers[ key ] ); + } ); + + onTrailers( headers ); + } + catch ( err ) + { + // TODO: Implement #8 + // tslint:disable-next-line + console.warn( "Trailer handling failed", err ); + } + } ) ); + + // ClientHttp2Stream events + + stream.on( "continue", guard( ( ..._whatever ) => + { + reject( make100Error( ) ); + } ) ); + + stream.on( "headers", guard( + ( headers: IncomingHttp2Headers, _flags: any ) => + { + const code = headers[ HTTP2_HEADER_STATUS ]; + reject( new Error( + `Request failed with a ${code} status. ` + + "Any 1xx error is unexpected to fetch() and " + + "shouldn't happen." ) ); + } + ) ); + + stream.on( "response", guard( + ( headers: IncomingHttp2Headers ) => + { + if ( signal && signal.aborted ) + { + // No reason to continue, the request is aborted + stream.destroy( ); + return; + } + + const status = "" + headers[ HTTP2_HEADER_STATUS ]; + const location = parseLocation( + headers[ HTTP2_HEADER_LOCATION ], + url + ); + + const isRedirected = isRedirectStatus[ status ]; + + if ( headers[ HTTP2_HEADER_SET_COOKIE ] ) + { + const setCookies = + arrayify( headers[ HTTP2_HEADER_SET_COOKIE ] ); + + session.cookieJar.setCookies( setCookies, url ); + } + + delete headers[ "set-cookie" ]; + delete headers[ "set-cookie2" ]; + + if ( isRedirected && !location ) + return reject( makeIllegalRedirectError( ) ); + + if ( !isRedirected || redirect === "manual" ) + return resolve( + new StreamResponse( + contentDecoders, + url, + stream, + headers, + redirect === "manual" + ? false + : extra.redirected.length > 0, + { }, + 2, + integrity + ) + ); + + if ( redirect === "error" ) + return reject( makeRedirectionError( location ) ); + + // redirect is 'follow' + + // We don't support re-sending a non-GET/HEAD request (as + // we don't want to [can't, if its' streamed] re-send the + // body). The concept is fundementally broken anyway... + if ( !endStream ) + return reject( + makeRedirectionMethodError( location, method ) + ); + + if ( !location ) + return reject( makeIllegalRedirectError( ) ); + + stream.destroy( ); + resolve( + fetchImpl( + session, + request.clone( location ), + { signal, onTrailers }, + { + raceConditionedGoaway, + redirected: redirected.concat( url ), + timeoutAt, + } + ) + ); + } ) ); + } ); + + if ( !endStream ) + await request.readable( ) + .then( readable => + { + readable.pipe( stream ); + } ); + + return response; + } ); + } + + return handleSignalAndTimeout( + signalPromise, + timeoutInfo, + cleanup, + doFetch + ); +} + +export function fetch( + session: SimpleSessionHttp2, + input: Request, + init?: Partial< FetchInit > +) +: Promise< Response > +{ + const timeoutAt = void 0; + + const raceConditionedGoaway = new Set( ); + const extra = { timeoutAt, redirected: [ ], raceConditionedGoaway }; + + return fetchImpl( session, input, init, extra ); +} diff --git a/lib/fetch.ts b/lib/fetch.ts deleted file mode 100644 index 3f7d472..0000000 --- a/lib/fetch.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { - constants as h2constants, - IncomingHttpHeaders as IncomingHttp2Headers, -} from "http2"; -import { URL } from "url"; - -import { Finally } from "already"; -import { syncGuard } from "callguard"; - -import { BodyInspector } from "./body"; -import { - AbortError, - FetchInit, - SimpleSession, - TimeoutError, -} from "./core"; -import { GuardedHeaders, Headers, RawHeaders } from "./headers"; -import { Request } from "./request"; -import { H2StreamResponse, Response } from "./response"; -import { arrayify, hasGotGoaway, parseLocation } from "./utils"; - - -const { - // Required for a request - HTTP2_HEADER_METHOD, - HTTP2_HEADER_SCHEME, - HTTP2_HEADER_PATH, - - // Methods - HTTP2_METHOD_GET, - HTTP2_METHOD_HEAD, - - // Requests - HTTP2_HEADER_USER_AGENT, - HTTP2_HEADER_ACCEPT, - HTTP2_HEADER_COOKIE, - HTTP2_HEADER_CONTENT_TYPE, - HTTP2_HEADER_CONTENT_LENGTH, - HTTP2_HEADER_ACCEPT_ENCODING, - - // Responses - HTTP2_HEADER_STATUS, - HTTP2_HEADER_LOCATION, - HTTP2_HEADER_SET_COOKIE, - - // Error codes - NGHTTP2_NO_ERROR, -} = h2constants; - -// This is from nghttp2.h, but undocumented in Node.js -const NGHTTP2_ERR_START_STREAM_NOT_ALLOWED = -516; - -const isRedirectStatus: { [ status: string ]: boolean; } = { - 300: true, - 301: true, - 302: true, - 303: true, - 305: true, - 307: true, - 308: true, -}; - -function ensureNotCircularRedirection( redirections: ReadonlyArray< string > ) -: void -{ - const urls = [ ...redirections ]; - const last = urls.pop( ); - - for ( let i = 0; i < urls.length; ++i ) - if ( urls[ i ] === last ) - { - const err = new Error( "Redirection loop detected" ); - ( < any >err ).urls = urls.slice( i ); - throw err; - } -} - -interface FetchExtra -{ - redirected: Array< string >; - timeoutAt?: number; - raceConditionedGoaway: Set< string >; // per origin -} - -async function fetchImpl( - session: SimpleSession, - input: string | Request, - init: Partial< FetchInit > = { }, - extra: FetchExtra -) -: Promise< Response > -{ - const { redirected, raceConditionedGoaway } = extra; - ensureNotCircularRedirection( redirected ); - - const req = new Request( input, init ); - - const { url, method, redirect, integrity } = req; - - const { signal, onTrailers } = init; - - const { - origin, - protocol, - pathname, search, hash, - } = new URL( url ); - const path = pathname + search + hash; - - const endStream = - method === HTTP2_METHOD_GET || method === HTTP2_METHOD_HEAD; - - const headers = new Headers( req.headers ); - - const cookies = ( await session.cookieJar.getCookies( url ) ) - .map( cookie => cookie.cookieString( ) ); - - const contentDecoders = session.contentDecoders( ); - - const acceptEncoding = - contentDecoders.length === 0 - ? "gzip;q=1.0, deflate;q=0.5" - : contentDecoders - .map( decoder => `${decoder.name};q=1.0` ) - .join( ", " ) + ", gzip;q=0.8, deflate;q=0.5"; - - if ( headers.has( HTTP2_HEADER_COOKIE ) ) - cookies.push( ...arrayify( headers.get( HTTP2_HEADER_COOKIE ) ) ); - - const headersToSend: RawHeaders = { - // Set required headers - [ HTTP2_HEADER_METHOD ]: method, - [ HTTP2_HEADER_SCHEME ]: protocol.replace( /:.*/, "" ), - [ HTTP2_HEADER_PATH ]: path, - - // Set default headers - [ HTTP2_HEADER_ACCEPT ]: session.accept( ), - [ HTTP2_HEADER_USER_AGENT ]: session.userAgent( ), - [ HTTP2_HEADER_ACCEPT_ENCODING ]: acceptEncoding, - }; - - if ( cookies.length > 0 ) - headersToSend[ HTTP2_HEADER_COOKIE ] = cookies.join( "; " ); - - for ( const [ key, val ] of headers.entries( ) ) - { - if ( key === "host" ) - // Convert to :authority like curl does: - // https://github.com/grantila/fetch-h2/issues/9 - headersToSend[ ":authority" ] = val; - else if ( key !== HTTP2_HEADER_COOKIE ) - headersToSend[ key ] = val; - } - - const inspector = new BodyInspector( req ); - - if ( - !endStream && - inspector.length != null && - !req.headers.has( HTTP2_HEADER_CONTENT_LENGTH ) - ) - headersToSend[ HTTP2_HEADER_CONTENT_LENGTH ] = "" + inspector.length; - - if ( !endStream && !req.headers.has( "content-type" ) && inspector.mime ) - headersToSend[ HTTP2_HEADER_CONTENT_TYPE ] = inspector.mime; - - function timeoutError( ) - { - return new TimeoutError( - `${method} ${url} timed out after ${init.timeout} ms` ); - } - - const timeoutAt = extra.timeoutAt || ( - ( "timeout" in init && typeof init.timeout === "number" ) - // Setting the timeoutAt here at first time allows async cookie - // jar to not take part of timeout for at least the first request - // (in a potential redirect chain) - ? Date.now( ) + init.timeout - : void 0 - ); - - function setupTimeout( ) - : { promise: Promise< Response >; clear: ( ) => void; } | null - { - if ( !timeoutAt ) - return null; - - const now = Date.now( ); - if ( now >= timeoutAt ) - throw timeoutError( ); - - let timerId: NodeJS.Timeout | null; - - return { - clear: ( ) => - { - if ( timerId ) - clearTimeout( timerId ); - }, - promise: new Promise( ( _resolve, reject ) => - { - timerId = setTimeout( ( ) => - { - timerId = null; - reject( timeoutError( ) ); - }, - timeoutAt - now - ); - } ), - }; - - } - - const timeoutInfo = setupTimeout( ); - - function abortError( ) - { - return new AbortError( `${method} ${url} aborted` ); - } - - if ( signal && signal.aborted ) - throw abortError( ); - - const signalPromise: Promise< Response > | null = - signal - ? - new Promise< Response >( ( _resolve, reject ) => - { - signal.onabort = ( ) => - { - reject( abortError( ) ); - }; - } ) - : null; - - function cleanup( ) - { - if ( timeoutInfo && timeoutInfo.clear ) - timeoutInfo.clear( ); - - if ( signal ) - delete signal.onabort; - } - - function doFetch( ): Promise< Response > - { - return session.get( url ) - .then( async h2session => - { - const stream = h2session.request( headersToSend, { endStream } ); - - const response = new Promise< Response >( ( resolve, reject ) => - { - const guard = syncGuard( reject, { catchAsync: true } ); - - stream.on( "aborted", guard( ( ..._whatever ) => - { - reject( new AbortError( "Request aborted" ) ); - } ) ); - - stream.on( "error", guard( ( err: Error ) => - { - reject( err ); - } ) ); - - stream.on( "frameError", guard( - ( _type: number, code: number, _streamId: number ) => - { - if ( - code === NGHTTP2_ERR_START_STREAM_NOT_ALLOWED && - endStream - ) - { - // This could be due to a race-condition in GOAWAY. - // As of current Node.js, the 'goaway' event is - // emitted on the session before this event - // is emitted, so we will know if we got it. - if ( - !raceConditionedGoaway.has( origin ) && - hasGotGoaway( h2session ) - ) - { - // Don't retry again due to potential GOAWAY - raceConditionedGoaway.add( origin ); - - // Since we've got the 'goaway' event, the - // context has already released the session, - // so a retry will create a new session. - resolve( - fetchImpl( - session, - req, - { signal, onTrailers }, - { - raceConditionedGoaway, - redirected, - timeoutAt, - } - ) - ); - - return; - } - } - - reject( new Error( "Request failed" ) ); - } ) - ); - - stream.on( "close", guard( ( ) => - { - // We'll get an 'error' event if there actually is an - // error, but not if we got NGHTTP2_NO_ERROR. - // In case of an error, the 'error' event will be awaited - // instead, to get (and propagate) the error object. - if ( stream.rstCode === NGHTTP2_NO_ERROR ) - reject( - new AbortError( "Stream prematurely closed" ) ); - } ) ); - - stream.on( "timeout", guard( ( ..._whatever ) => - { - reject( new TimeoutError( "Request timed out" ) ); - } ) ); - - stream.on( "trailers", guard( - ( _headers: IncomingHttp2Headers, _flags: any ) => - { - if ( !onTrailers ) - return; - try - { - const headers = new GuardedHeaders( "response" ); - - Object.keys( _headers ).forEach( key => - { - if ( Array.isArray( _headers[ key ] ) ) - ( < Array< string > >_headers[ key ] ) - .forEach( value => - headers.append( key, value ) ); - else - headers.set( key, "" + _headers[ key ] ); - } ); - - onTrailers( headers ); - } - catch ( err ) - { - // TODO: Implement #8 - // tslint:disable-next-line - console.warn( "Trailer handling failed", err ); - } - } ) ); - - // ClientHttp2Stream events - - stream.on( "continue", guard( ( ..._whatever ) => - { - reject( new Error( - "Request failed with 100 continue. " + - "This can't happen unless a server failure" ) ); - } ) ); - - stream.on( "headers", guard( - ( headers: IncomingHttp2Headers, _flags: any ) => - { - const code = headers[ HTTP2_HEADER_STATUS ]; - reject( new Error( - `Request failed with a ${code} status. ` + - "Any 1xx error is unexpected to fetch() and " + - "shouldn't happen." ) ); - } - ) ); - - stream.on( "response", guard( - ( headers: IncomingHttp2Headers ) => - { - if ( signal && signal.aborted ) - { - // No reason to continue, the request is aborted - stream.destroy( ); - return; - } - - const status = "" + headers[ HTTP2_HEADER_STATUS ]; - const location = parseLocation( - headers[ HTTP2_HEADER_LOCATION ], - url - ); - - const isRedirected = isRedirectStatus[ status ]; - - if ( headers[ HTTP2_HEADER_SET_COOKIE ] ) - { - const setCookies = - arrayify( headers[ HTTP2_HEADER_SET_COOKIE ] ); - - session.cookieJar.setCookies( setCookies, url ); - } - - delete headers[ "set-cookie" ]; - delete headers[ "set-cookie2" ]; - - if ( isRedirected && !location ) - return reject( - new Error( "Server responded illegally with a " + - "redirect code but missing 'location' header" - ) - ); - - if ( !isRedirected || redirect === "manual" ) - return resolve( - new H2StreamResponse( - contentDecoders, - url, - stream, - headers, - redirect === "manual" - ? false - : extra.redirected.length > 0, - integrity - ) - ); - - if ( redirect === "error" ) - return reject( - new Error( `URL got redirected to ${location}` ) ); - - // redirect is 'follow' - - // We don't support re-sending a non-GET/HEAD request (as - // we don't want to [can't, if its' streamed] re-send the - // body). The concept is fundementally broken anyway... - if ( !endStream ) - return reject( new Error( - `URL got redirected to ${location}, which ` + - `'fetch-h2' doesn't support for ${method}` ) ); - - if ( !location ) - return reject( - new Error( - `URL got redirected without 'location' header` - ) - ); - - stream.destroy( ); - resolve( - fetchImpl( - session, - req.clone( location ), - { signal, onTrailers }, - { - raceConditionedGoaway, - redirected: redirected.concat( url ), - timeoutAt, - } - ) - ); - } ) ); - } ); - - if ( !endStream ) - await req.readable( ) - .then( readable => - { - readable.pipe( stream ); - } ); - - return response; - } ); - } - - return Promise.race( - [ - < Promise< any > >signalPromise, - < Promise< any > >( timeoutInfo && timeoutInfo.promise ), - doFetch( ), - ] - .filter( promise => promise ) - ) - .then( ...Finally( cleanup ) ); -} - -export function fetch( - session: SimpleSession, - input: string | Request, - init?: Partial< FetchInit > -) -: Promise< Response > -{ - const timeoutAt = void 0; - - const raceConditionedGoaway = new Set( ); - const extra = { timeoutAt, redirected: [ ], raceConditionedGoaway }; - - return fetchImpl( session, input, init, extra ); -} diff --git a/lib/request.ts b/lib/request.ts index f04d3ee..badbcd6 100644 --- a/lib/request.ts +++ b/lib/request.ts @@ -8,6 +8,7 @@ import { ReferrerTypes, RequestInit, RequestInitWithoutBody, + RequestInitWithUrl, } from "./core"; import { Body, JsonBody } from "./body"; @@ -49,15 +50,17 @@ export class Request extends Body implements RequestInitWithoutBody private _url: string; private _init: Partial< RequestInit >; - constructor( input: string | Request, init?: Partial< RequestInit > ) + constructor( input: string | Request, init?: Partial< RequestInitWithUrl > ) { super( ); + const { url: overwriteUrl } = init || ( { } as RequestInitWithUrl ); + // TODO: Consider throwing a TypeError if the URL has credentials this._url = input instanceof Request - ? input._url - : input; + ? ( overwriteUrl || input._url ) + : ( overwriteUrl || input ); if ( input instanceof Request ) { @@ -149,9 +152,6 @@ export class Request extends Body implements RequestInitWithoutBody public clone( newUrl?: string ): Request { - const ret = new Request( this ); - if ( newUrl ) - ret._url = newUrl; - return ret; + return new Request( this, { url: newUrl } ); } } diff --git a/lib/response.ts b/lib/response.ts index db3c494..bea24da 100644 --- a/lib/response.ts +++ b/lib/response.ts @@ -1,7 +1,6 @@ import { - ClientHttp2Stream, + // These are same as http1 for the usage here constants as h2constants, - IncomingHttpHeaders, } from "http2"; import { @@ -17,7 +16,6 @@ const { HTTP2_HEADER_CONTENT_LENGTH, } = h2constants; - import { BodyTypes, DecodeFunction, @@ -36,6 +34,11 @@ import { Body, } from "./body"; +import { + IncomingHttpHeaders, +} from "./types"; + + interface Extra { redirected: boolean; @@ -184,7 +187,17 @@ function makeHeadersFromH2Headers( headers: IncomingHttpHeaders ): Headers return out; } -function makeInit( inHeaders: IncomingHttpHeaders ): Partial< ResponseInit > +function makeInitHttp1( inHeaders: IncomingHttpHeaders ) +: Partial< ResponseInit > +{ + // Headers in HTTP/2 are compatible with HTTP/1 (colon illegal in HTTP/1) + const headers = makeHeadersFromH2Headers( inHeaders ); + + return { headers }; +} + +function makeInitHttp2( inHeaders: IncomingHttpHeaders ) +: Partial< ResponseInit > { const status = parseInt( "" + inHeaders[ HTTP2_HEADER_STATUS ], 10 ); const statusText = ""; // Not supported in H2 @@ -239,14 +252,16 @@ function handleEncoding( return decoder( stream ); } -export class H2StreamResponse extends Response +export class StreamResponse extends Response { constructor( contentDecoders: ReadonlyArray< Decoder >, url: string, - stream: ClientHttp2Stream, + stream: NodeJS.ReadableStream, headers: IncomingHttpHeaders, redirected: boolean, + init: Partial< ResponseInit >, + httpVersion: 1 | 2, integrity?: string ) { @@ -256,7 +271,14 @@ export class H2StreamResponse extends Response < NodeJS.ReadableStream >stream, headers ), - makeInit( headers ), + { + ...init, + ...( + httpVersion === 1 + ? makeInitHttp1( headers ) + : makeInitHttp2( headers ) + ), + }, makeExtra( url, redirected, integrity ) ); } diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..aea720d --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,12 @@ +import { + IncomingHttpHeaders as IncomingHttpHeadersH1, +} from "http"; + +import { + // ClientHttp2Stream, + // constants as h2constants, + IncomingHttpHeaders as IncomingHttpHeadersH2, +} from "http2"; + +export type IncomingHttpHeaders = + IncomingHttpHeadersH1 | IncomingHttpHeadersH2; diff --git a/lib/utils-http2.ts b/lib/utils-http2.ts new file mode 100644 index 0000000..68ea94f --- /dev/null +++ b/lib/utils-http2.ts @@ -0,0 +1,11 @@ +import { ClientHttp2Session } from "http2"; + +export function hasGotGoaway( session: ClientHttp2Session ) +{ + return !!( < any >session ).__fetch_h2_goaway; +} + +export function setGotGoaway( session: ClientHttp2Session ) +{ + ( < any >session ).__fetch_h2_goaway = true; +} diff --git a/lib/utils.ts b/lib/utils.ts index 2d79e60..f399902 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,3 @@ -import { ClientHttp2Session } from "http2"; import { URL } from "url"; export function arrayify< T >( @@ -28,12 +27,39 @@ export function parseLocation( return url.href; } -export function hasGotGoaway( session: ClientHttp2Session ) +export const isRedirectStatus: { [ status: string ]: boolean; } = { + 300: true, + 301: true, + 302: true, + 303: true, + 305: true, + 307: true, + 308: true, +}; + +export function makeOkError( err: Error ): Error { - return !!( < any >session ).__fetch_h2_goaway; + ( < any >err ).metaData = ( < any >err ).metaData || { }; + ( < any >err ).metaData.ok = true; + return err; } -export function setGotGoaway( session: ClientHttp2Session ) +export function parseInput( url: string ) { - ( < any >session ).__fetch_h2_goaway = true; + const explicitProtocol = + ( url.startsWith( "http2://" ) || url.startsWith( "http1://" ) ) + ? url.substr( 0, 5 ) + : null; + + url = url.replace( /^http[12]:\/\//, "http://" ); + + const { origin, hostname, port, protocol } = new URL( url ); + + return { + hostname, + origin, + port: port || ( protocol === "https:" ? "443" : "80" ), + protocol: explicitProtocol || protocol.replace( ":", "" ), + url, + }; } diff --git a/package.json b/package.json index 774b707..188ec34 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "types": "./dist/index.d.ts", "directories": {}, "engines": { - "node": ">=10.0" + "node": ">=10.4" }, "files": [ "dist" @@ -21,6 +21,7 @@ "build": "./node_modules/.bin/rimraf dist && ./node_modules/.bin/tsc -p .", "lint": "node_modules/.bin/tslint --project .", "mocha": "node_modules/.bin/mocha --bail --check-leaks dist/test", + "mocha:debug": "node_modules/.bin/mocha --inspect-brk dist/test", "test": "npm run lint && node_modules/.bin/nyc npm run mocha", "testfast": "node_modules/.bin/nyc node_modules/.bin/_mocha -- --bail --check-leaks -i --grep nghttp2.org dist/test", "test-nocov": "node_modules/.bin/mocha --bail --check-leaks dist/test", @@ -31,6 +32,7 @@ "coverage": "node_modules/.bin/nyc report --reporter=html", "version": "./node_modules/.bin/ts-node scripts/version-update.ts && npm run buildtest && scripts/version-git-add.sh", "prepack": "npm run build && npm run test", + "makecerts": "openssl req -x509 -nodes -days 7300 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem", "travis-deploy-once": "travis-deploy-once", "semantic-release": "semantic-release", "cz": "git-cz" @@ -44,6 +46,7 @@ "h2", "http2", "client", + "request", "api", "typesafe", "typescript" @@ -79,6 +82,9 @@ "to-arraybuffer": "1.x", "tough-cookie": "3.x" }, + "publishConfig": { + "tag": "beta" + }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" diff --git a/test/fetch-h2/context.ts b/test/fetch-h2/context.ts index e0d6fde..e217c6b 100644 --- a/test/fetch-h2/context.ts +++ b/test/fetch-h2/context.ts @@ -1,18 +1,15 @@ import { expect } from "chai"; -import { readFileSync } from "fs"; import "mocha"; -import { makeServer } from "../lib/server"; +import { TestData } from "../lib/server-common"; +import { makeMakeServer } from "../lib/server-helpers"; import { context, CookieJar, - disconnectAll, Response, } from "../../"; -afterEach( disconnectAll ); - function ensureStatusSuccess( response: Response ): Response { if ( response.status < 200 || response.status >= 300 ) @@ -20,12 +17,19 @@ function ensureStatusSuccess( response: Response ): Response return response; } -const key = readFileSync( __dirname + "/../../../certs/key.pem" ); -const cert = readFileSync( __dirname + "/../../../certs/cert.pem" ); - -describe( "context", function( ) +( [ + { proto: "http:", version: "http1" }, + { proto: "http:", version: "http2" }, + { proto: "https:", version: "http1" }, + { proto: "https:", version: "http2" }, +] as Array< TestData > ) +.forEach( ( { proto, version } ) => +{ +describe( `context (${version} over ${proto.replace( ":", "" )})`, function( ) { + const { cycleOpts, makeServer } = makeMakeServer( { proto, version } ); + this.timeout( 500 ); describe( "options", ( ) => @@ -35,12 +39,13 @@ describe( "context", function( ) const { server, port } = await makeServer( ); const { disconnectAll, fetch } = context( { + ...cycleOpts, overwriteUserAgent: true, userAgent: "foobar", } ); const response = ensureStatusSuccess( - await fetch( `http://localhost:${port}/headers` ) + await fetch( `${proto}//localhost:${port}/headers` ) ); const res = await response.json( ); @@ -56,11 +61,12 @@ describe( "context", function( ) const { server, port } = await makeServer( ); const { disconnectAll, fetch } = context( { + ...cycleOpts, userAgent: "foobar", } ); const response = ensureStatusSuccess( - await fetch( `http://localhost:${port}/headers` ) + await fetch( `${proto}//localhost:${port}/headers` ) ); const res = await response.json( ); @@ -78,10 +84,13 @@ describe( "context", function( ) const accept = "application/foobar, text/*;0.9"; - const { disconnectAll, fetch } = context( { accept } ); + const { disconnectAll, fetch } = context( { + ...cycleOpts, + accept, + } ); const response = ensureStatusSuccess( - await fetch( `http://localhost:${port}/headers` ) + await fetch( `${proto}//localhost:${port}/headers` ) ); const res = await response.json( ); @@ -93,16 +102,17 @@ describe( "context", function( ) } ); } ); + if ( proto === "https:" ) describe( "network settings", ( ) => { it( "should not be able to connect over unauthorized ssl", async ( ) => { - const { server, port } = await makeServer( { - serverOptions: { key, cert }, - } ); + const { server, port } = await makeServer( ); const { disconnectAll, fetch } = context( { + ...cycleOpts, overwriteUserAgent: true, + session: { rejectUnauthorized: true }, userAgent: "foobar", } ); @@ -129,11 +139,10 @@ describe( "context", function( ) it( "should be able to connect over unauthorized ssl", async ( ) => { - const { server, port } = await makeServer( { - serverOptions: { key, cert }, - } ); + const { server, port } = await makeServer( ); const { disconnectAll, fetch } = context( { + ...cycleOpts, overwriteUserAgent: true, session: { rejectUnauthorized: false }, userAgent: "foobar", @@ -161,22 +170,23 @@ describe( "context", function( ) const cookieJar = new CookieJar( ); expect( - await cookieJar.getCookies( `http://localhost:${port}/` ) + await cookieJar.getCookies( `${proto}//localhost:${port}/` ) ).to.be.empty; const { disconnectAll, fetch } = context( { + ...cycleOpts, cookieJar, overwriteUserAgent: true, userAgent: "foobar", } ); - await fetch( `http://localhost:${port}/set-cookie`, { + await fetch( `${proto}//localhost:${port}/set-cookie`, { json: [ "a=b" , "c=d" ], method: "POST", } ); const cookies = - await cookieJar.getCookies( `http://localhost:${port}/` ); + await cookieJar.getCookies( `${proto}//localhost:${port}/` ); expect( cookies ).to.not.be.empty; expect( cookies[ 0 ].key ).to.equal( "a" ); @@ -186,10 +196,10 @@ describe( "context", function( ) // Next request should maintain cookies - await fetch( `http://localhost:${port}/echo` ); + await fetch( `${proto}//localhost:${port}/echo` ); const cookies2 = - await cookieJar.getCookies( `http://localhost:${port}/` ); + await cookieJar.getCookies( `${proto}//localhost:${port}/` ); expect( cookies2 ).to.not.be.empty; @@ -198,10 +208,10 @@ describe( "context", function( ) cookieJar.reset( ); - await fetch( `http://localhost:${port}/echo` ); + await fetch( `${proto}//localhost:${port}/echo` ); const cookies3 = - await cookieJar.getCookies( `http://localhost:${port}/` ); + await cookieJar.getCookies( `${proto}//localhost:${port}/` ); expect( cookies3 ).to.be.empty; @@ -220,7 +230,7 @@ describe( "context", function( ) const { disconnectAll, fetch } = context( ); - const awaitFetch = fetch( "http://localhost:0" ); + const awaitFetch = fetch( "${proto}//localhost:0" ); disconnectAll( ); @@ -237,7 +247,10 @@ describe( "context", function( ) const { server } = await makeServer( ); const { disconnectAll, fetch } = - context( { session: { port: -1, host: < any >{ } } } ); + context( { + ...cycleOpts, + session: { port: -1, host: < any >{ } }, + } ); const awaitFetch = fetch( "ftp://localhost" ); @@ -251,3 +264,4 @@ describe( "context", function( ) } ); } ); } ); +} ); diff --git a/test/fetch-h2/nghttp2.org.ts b/test/fetch-h2/httpbin.ts similarity index 71% rename from test/fetch-h2/nghttp2.org.ts rename to test/fetch-h2/httpbin.ts index 45cf852..9db008e 100644 --- a/test/fetch-h2/nghttp2.org.ts +++ b/test/fetch-h2/httpbin.ts @@ -1,3 +1,5 @@ +import { URL } from "url"; + import { delay } from "already"; import { expect } from "chai"; import "mocha"; @@ -6,21 +8,44 @@ import * as through2 from "through2"; import { context, DataBody, - disconnectAll, - fetch, + HttpProtocols, JsonBody, StreamBody, } from "../../"; -afterEach( disconnectAll ); -describe( "nghttp2.org/httpbin", function( ) +interface TestData +{ + protocol: string; + site: string; + protos: Array< HttpProtocols >; +} + +( [ + { protocol: "https:", site: "nghttp2.org/httpbin", protos: [ "http2" ] }, + { protocol: "http:", site: "httpbin.org", protos: [ "http1" ] }, + { protocol: "https:", site: "httpbin.org", protos: [ "http1" ] }, +] as Array< TestData > ) +.forEach( ( { site, protocol, protos } ) => +{ +const host = `${protocol}//${site}`; +const baseHost = new URL( host ).origin; + +const name = `${site} (${protos[ 0 ]} over ${protocol.replace( ":", "" )})`; + +describe( name, function( ) { this.timeout( 5000 ); - it( "should be possible to GET HTTPS/2", async ( ) => + const { fetch, disconnectAll } = context( { + httpsProtocols: protos, + } ); + + afterEach( disconnectAll ); + + it( "should be possible to GET", async ( ) => { - const response = await fetch( "https://nghttp2.org/httpbin/user-agent" ); + const response = await fetch( `${host}/user-agent` ); const data = await response.json( ); expect( data[ "user-agent" ] ).to.include( "fetch-h2/" ); } ); @@ -30,7 +55,7 @@ describe( "nghttp2.org/httpbin", function( ) const testData = { foo: "bar" }; const response = await fetch( - "https://nghttp2.org/httpbin/post", + `${host}/post`, { body: new JsonBody( testData ), method: "POST", @@ -47,7 +72,7 @@ describe( "nghttp2.org/httpbin", function( ) const testData = '{"foo": "data"}'; const response = await fetch( - "https://nghttp2.org/httpbin/post", + `${host}/post`, { body: new DataBody( testData ), method: "POST", @@ -67,7 +92,7 @@ describe( "nghttp2.org/httpbin", function( ) stream.end( ); const response = await fetch( - "https://nghttp2.org/httpbin/post", + `${host}/post`, { body: new StreamBody( stream ), headers: { "content-length": "6" }, @@ -84,7 +109,7 @@ describe( "nghttp2.org/httpbin", function( ) const stream = through2( ); const eventualResponse = fetch( - "https://nghttp2.org/httpbin/post", + `${host}/post`, { body: new StreamBody( stream ), headers: { "content-length": "6" }, @@ -109,13 +134,13 @@ describe( "nghttp2.org/httpbin", function( ) const { fetch, disconnectAll } = context( ); const responseSet = await fetch( - "https://nghttp2.org/httpbin/cookies/set?foo=bar", + `${host}/cookies/set?foo=bar`, { redirect: "manual" } ); expect( responseSet.headers.has( "location" ) ).to.be.true; const redirectedTo = responseSet.headers.get( "location" ); - const response = await fetch( "https://nghttp2.org" + redirectedTo ); + const response = await fetch( baseHost + redirectedTo ); const data = await response.json( ); expect( data.cookies ).to.deep.equal( { foo: "bar" } ); @@ -128,10 +153,10 @@ describe( "nghttp2.org/httpbin", function( ) const { fetch, disconnectAll } = context( ); const response = await fetch( - "https://nghttp2.org/httpbin/relative-redirect/2", + `${host}/relative-redirect/2`, { redirect: "follow" } ); - expect( response.url ).to.equal( "https://nghttp2.org/httpbin/get" ); + expect( response.url ).to.equal( `${host}/get` ); await response.text( ); await disconnectAll( ); @@ -139,8 +164,9 @@ describe( "nghttp2.org/httpbin", function( ) it( "should be possible to GET gzip data", async ( ) => { - const response = await fetch( "https://nghttp2.org/httpbin/gzip" ); + const response = await fetch( `${host}/gzip` ); const data = await response.json( ); expect( data ).to.deep.include( { gzipped: true, method: "GET" } ); } ); } ); +} ); diff --git a/test/fetch-h2/index.ts b/test/fetch-h2/index.ts index d950a04..9f81868 100644 --- a/test/fetch-h2/index.ts +++ b/test/fetch-h2/index.ts @@ -6,20 +6,21 @@ import { buffer as getStreamAsBuffer } from "get-stream"; import "mocha"; import * as through2 from "through2"; -import { makeServer } from "../lib/server"; -import { createIntegrity } from "../lib/utils"; +import { TestData } from "../lib/server-common"; +import { makeMakeServer } from "../lib/server-helpers"; +import { cleanUrl, createIntegrity } from "../lib/utils"; import { + context, DataBody, - disconnectAll, - fetch, + disconnectAll as _disconnectAll, + fetch as _fetch, Headers, - onPush, + onPush as _onPush, Response, StreamBody, } from "../../"; -afterEach( disconnectAll ); async function getRejection< T >( promise: Promise< T > ): Promise< Error > { @@ -41,18 +42,44 @@ function ensureStatusSuccess( response: Response ): Response return response; } + +( [ + { proto: "http:", version: "http1" }, + { proto: "http:", version: "http2" }, + { proto: "https:", version: "http1" }, + { proto: "https:", version: "http2" }, +] as Array< TestData > ) +.forEach( ( { proto, version } ) => +{ +const { cycleOpts, makeServer } = makeMakeServer( { proto, version } ); + +const { disconnectAll, fetch, onPush } = + ( proto === "httpss:" && version === "http1" ) + ? { disconnectAll: _disconnectAll, fetch: _fetch, onPush: _onPush } + : context( { ...cycleOpts } ); + describe( "basic", ( ) => +{ +afterEach( disconnectAll ); + +describe( `(${version} over ${proto.replace( ":", "" )})`, ( ) => { it( "should be able to perform simple GET", async ( ) => { const { server, port } = await makeServer( ); + const headers = + version === "http1" ? { "http1-path": "/headers" } : { }; + const response = ensureStatusSuccess( - await fetch( `http://localhost:${port}/headers` ) + await fetch( `${proto}//localhost:${port}/headers`, { headers } ) ); const res = await response.json( ); - expect( res[ ":path" ] ).to.equal( "/headers" ); + if ( version === "http1" ) + expect( res[ "http1-path" ] ).to.equal( "/headers" ); + else + expect( res[ ":path" ] ).to.equal( "/headers" ); await server.shutdown( ); } ); @@ -68,7 +95,7 @@ describe( "basic", ( ) => const response = ensureStatusSuccess( await fetch( - `http://localhost:${port}/headers`, + `${proto}//localhost:${port}/headers`, { body: new DataBody( "foobar" ), headers, @@ -92,7 +119,7 @@ describe( "basic", ( ) => const json = { foo: "bar" }; const response = await fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { json, method: "POST", @@ -119,7 +146,7 @@ describe( "basic", ( ) => const response = ensureStatusSuccess( await fetch( - `http://localhost:${port}/headers`, + `${proto}//localhost:${port}/headers`, { body: new DataBody( "foobar" ), headers, @@ -145,7 +172,7 @@ describe( "basic", ( ) => stream.write( "foo" ); const eventualResponse = fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { body: new StreamBody( stream ), headers: { "content-length": "6" }, @@ -175,7 +202,7 @@ describe( "basic", ( ) => stream.write( "foo" ); const eventualResponse = fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { body: new StreamBody( stream ), method: "POST", @@ -200,7 +227,7 @@ describe( "basic", ( ) => const { server, port } = await makeServer( ); const eventualResponse = fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { body: "foo", json: { foo: "" }, @@ -222,7 +249,7 @@ describe( "basic", ( ) => const json = { foo: "bar" }; const response = await fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { json, method: "POST", @@ -245,7 +272,7 @@ describe( "basic", ( ) => const body = "foobar"; const response = await fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { body, method: "POST", @@ -266,7 +293,7 @@ describe( "basic", ( ) => const body = Buffer.from( "foobar" ); const response = await fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { body, method: "POST", @@ -291,7 +318,7 @@ describe( "basic", ( ) => stream.end( ); const response = await fetch( - `http://localhost:${port}/echo`, + `${proto}//localhost:${port}/echo`, { body: stream, method: "POST", @@ -315,7 +342,7 @@ describe( "basic", ( ) => const onTrailers = deferredTrailers.resolve; const response = await fetch( - `http://localhost:${port}/trailers`, + `${proto}//localhost:${port}/trailers`, { json: trailers, method: "POST", @@ -326,7 +353,7 @@ describe( "basic", ( ) => const data = await response.text( ); const receivedTrailers = await deferredTrailers.promise; - expect( data ).to.not.be.empty; + expect( data ).to.contain( "trailers will be sent" ); Object.keys( trailers ) .forEach( key => @@ -344,7 +371,7 @@ describe( "basic", ( ) => const { server, port } = await makeServer( ); const eventualResponse = fetch( - `http://localhost:${port}/wait/20`, + `${proto}//localhost:${port}/wait/20`, { method: "POST", timeout: 8, @@ -363,7 +390,7 @@ describe( "basic", ( ) => const { server, port } = await makeServer( ); const response = await fetch( - `http://localhost:${port}/wait/1`, + `${proto}//localhost:${port}/wait/1`, { method: "POST", timeout: 100, @@ -404,7 +431,7 @@ describe( "basic", ( ) => } ); const eventualResponse = fetch( - `http://localhost:${port}/sha256`, + `${proto}//localhost:${port}/sha256`, { body: new StreamBody( stream ), headers: { "content-length": "" + chunkSize * chunks }, @@ -451,7 +478,7 @@ describe( "basic", ( ) => } ); const eventualResponse = fetch( - `http://localhost:${port}/sha256`, + `${proto}//localhost:${port}/sha256`, { body: new StreamBody( stream ), method: "POST", @@ -468,6 +495,7 @@ describe( "basic", ( ) => await server.shutdown( ); } ); + if ( version === "http2" ) it( "should be able to receive pushed request", async ( ) => { const { server, port } = await makeServer( ); @@ -484,7 +512,7 @@ describe( "basic", ( ) => const response = ensureStatusSuccess( await fetch( - `http://localhost:${port}/push`, + `${proto}//localhost:${port}/push`, { json: [ { @@ -519,7 +547,7 @@ describe( "basic", ( ) => const response = ensureStatusSuccess( await fetch( - `http://localhost:${port}/headers`, + `${proto}//localhost:${port}/headers`, { headers: { host }, } @@ -528,7 +556,10 @@ describe( "basic", ( ) => const responseData = await response.json( ); - expect( responseData[ ":authority" ] ).to.equal( host ); + if ( version === "http2" ) + expect( responseData[ ":authority" ] ).to.equal( host ); + else + expect( responseData.host ).to.equal( host ); await server.shutdown( ); } ); @@ -538,7 +569,7 @@ describe( "basic", ( ) => const { server, port } = await makeServer( ); const response = ensureStatusSuccess( - await fetch( `http://localhost:${port}/headers` ) + await fetch( `${proto}//localhost:${port}/headers` ) ); const responseData = await response.json( ); @@ -556,7 +587,7 @@ describe( "basic", ( ) => const response = ensureStatusSuccess( await fetch( - `http://localhost:${port}/compressed/gzip`, + `${proto}//localhost:${port}/compressed/gzip`, { json: testData, method: "POST", @@ -581,7 +612,7 @@ describe( "basic", ( ) => const response = ensureStatusSuccess( await fetch( - `http://localhost:${port}/compressed/deflate`, + `${proto}//localhost:${port}/compressed/deflate`, { json: testData, method: "POST", @@ -599,37 +630,39 @@ describe( "basic", ( ) => } ); } ); -describe( "response", ( ) => +describe( `response (${proto})`, ( ) => { it( "should have a proper url", async ( ) => { const { server, port } = await makeServer( ); - const url = `http://localhost:${port}/headers`; + const url = `${proto}//localhost:${port}/headers`; const response = ensureStatusSuccess( await fetch( url ) ); - expect( response.url ).to.equal( url ); + expect( response.url ).to.equal( cleanUrl( url ) ); await disconnectAll( ); await server.shutdown( ); } ); } ); -describe( "goaway", ( ) => +if ( version === "http2" ) +describe( `goaway (${proto})`, ( ) => { + if ( proto === "http:" ) // This race is too fast for TLS it( "handle session failover (race conditioned)", async ( ) => { const { server, port } = await makeServer( ); - const url1 = `http://localhost:${port}/goaway`; - const url2 = `http://localhost:${port}/headers`; + const url1 = `${proto}//localhost:${port}/goaway`; + const url2 = `${proto}//localhost:${port}/headers`; const response1 = ensureStatusSuccess( await fetch( url1 ) ); - expect( response1.url ).to.equal( url1 ); + expect( response1.url ).to.equal( cleanUrl( url1 ) ); const response2 = ensureStatusSuccess( await fetch( url2 ) ); - expect( response2.url ).to.equal( url2 ); + expect( response2.url ).to.equal( cleanUrl( url2 ) ); await response1.text( ); await response2.text( ); @@ -642,16 +675,16 @@ describe( "goaway", ( ) => { const { server, port } = await makeServer( ); - const url1 = `http://localhost:${port}/goaway`; - const url2 = `http://localhost:${port}/headers`; + const url1 = `${proto}//localhost:${port}/goaway`; + const url2 = `${proto}//localhost:${port}/headers`; const response1 = ensureStatusSuccess( await fetch( url1 ) ); - expect( response1.url ).to.equal( url1 ); + expect( response1.url ).to.equal( cleanUrl( url1 ) ); await delay(20); const response2 = ensureStatusSuccess( await fetch( url2 ) ); - expect( response2.url ).to.equal( url2 ); + expect( response2.url ).to.equal( cleanUrl( url2 ) ); await response1.text( ); await response2.text( ); @@ -664,16 +697,16 @@ describe( "goaway", ( ) => { const { server, port } = await makeServer( ); - const url1 = `http://localhost:${port}/goaway/50`; - const url2 = `http://localhost:${port}/slow/50`; + const url1 = `${proto}//localhost:${port}/goaway/50`; + const url2 = `${proto}//localhost:${port}/slow/50`; const response1 = ensureStatusSuccess( await fetch( url1 ) ); - expect( response1.url ).to.equal( url1 ); + expect( response1.url ).to.equal( cleanUrl( url1 ) ); await delay( 10 ); const response2 = ensureStatusSuccess( await fetch( url2 ) ); - expect( response2.url ).to.equal( url2 ); + expect( response2.url ).to.equal( cleanUrl( url2 ) ); await delay( 10 ); @@ -688,19 +721,19 @@ describe( "goaway", ( ) => } ); } ); -describe( "integrity", ( ) => +describe( `integrity (${proto})`, ( ) => { it( "handle and succeed on valid integrity", async ( ) => { const { server, port } = await makeServer( ); - const url = `http://localhost:${port}/slow/0`; + const url = `${proto}//localhost:${port}/slow/0`; const data = "abcdefghij"; const integrity = createIntegrity( data ); const response = ensureStatusSuccess( await fetch( url, { integrity } ) ); - expect( response.url ).to.equal( url ); + expect( response.url ).to.equal( cleanUrl( url ) ); expect( await response.text( ) ).to.equal( data ); @@ -712,13 +745,13 @@ describe( "integrity", ( ) => { const { server, port } = await makeServer( ); - const url = `http://localhost:${port}/slow/0`; + const url = `${proto}//localhost:${port}/slow/0`; const data = "abcdefghij-x"; const integrity = createIntegrity( data ); const response = ensureStatusSuccess( await fetch( url, { integrity } ) ); - expect( response.url ).to.equal( url ); + expect( response.url ).to.equal( cleanUrl( url ) ); try { @@ -735,13 +768,13 @@ describe( "integrity", ( ) => } ); } ); -describe( "premature stream close", ( ) => +describe( `premature stream close (${proto})`, ( ) => { it( "handle and reject fetch operation", async ( ) => { const { server, port } = await makeServer( ); - const url = `http://localhost:${port}/prem-close`; + const url = `${proto}//localhost:${port}/prem-close`; try { @@ -750,10 +783,16 @@ describe( "premature stream close", ( ) => } catch ( err ) { - expect( err.message ).to.contain( "Stream prematurely closed" ); + const expected = + version === "http1" + ? "socket hang up" + : "Stream prematurely closed"; + expect( err.message ).to.contain( expected ); } await disconnectAll( ); await server.shutdown( ); } ); } ); +} ); +} ); diff --git a/test/lib/server-common.ts b/test/lib/server-common.ts new file mode 100644 index 0000000..869dfd2 --- /dev/null +++ b/test/lib/server-common.ts @@ -0,0 +1,85 @@ +import { + Server as HttpServer, +} from "http"; +import { + Http2Server, + IncomingHttpHeaders, + SecureServerOptions, + ServerHttp2Stream, +} from "http2"; +import { + Server as HttpsServer, +} from "https"; + +import { HttpProtocols } from "../../"; + + +export interface TestData +{ + proto: string; + version: HttpProtocols; +} + +export interface MatchData +{ + path: string; + stream: ServerHttp2Stream; + headers: IncomingHttpHeaders; +} + +export type Matcher = ( matchData: MatchData ) => boolean; + +export const ignoreError = ( cb: ( ) => any ) => { try { cb( ); } catch ( err ) { } }; + +export interface ServerOptions +{ + port?: number; + matchers?: ReadonlyArray< Matcher >; + serverOptions?: SecureServerOptions; +} + +export abstract class Server +{ + public port: number | null = null; + protected _opts: ServerOptions = { }; + protected _server: HttpServer | HttpsServer | Http2Server = < any >void 0; + + + public async listen( port: number | undefined = void 0 ): Promise< number > + { + return new Promise( ( resolve, _reject ) => + { + this._server.listen( port, "0.0.0.0", resolve ); + } ) + .then( ( ) => + { + const address = this._server.address( ); + if ( typeof address === "string" ) + return 0; + return address.port; + } ) + .then( port => + { + this.port = port; + return port; + } ); + } + + public async shutdown( ): Promise< void > + { + await this._shutdown( ); + return new Promise< void >( ( resolve, _reject ) => + { + this._server.close( resolve ); + } ); + } + + protected async _shutdown( ): Promise< void > { } +} + +export abstract class TypedServer +< ServerType extends HttpServer | HttpsServer | Http2Server > +extends Server +{ + protected _server: ServerType = < any >void 0; +} diff --git a/test/lib/server-helpers.ts b/test/lib/server-helpers.ts new file mode 100644 index 0000000..3d08244 --- /dev/null +++ b/test/lib/server-helpers.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "fs"; + +import { + ServerOptions, + TestData, +} from "./server-common"; +import { + makeServer as makeServer1, +} from "./server-http1"; +import { + makeServer as makeServer2, +} from "./server-http2"; + + +const key = readFileSync( __dirname + "/../../../certs/key.pem" ); +const cert = readFileSync( __dirname + "/../../../certs/cert.pem" ); + +export function makeMakeServer( { proto, version }: TestData ) +{ + const makeServer = ( opts?: ServerOptions ) => + { + const serverOptions = + ( opts && opts.serverOptions ) ? opts.serverOptions : { }; + + if ( proto === "https:" ) + { + opts = { + serverOptions: { + cert, + key, + ...serverOptions, + }, + ...( opts ? opts : { } ), + }; + } + + return version === "http1" + ? makeServer1( opts ) + : makeServer2( opts ); + }; + + const cycleOpts = { + httpProtocol: version, + httpsProtocols: [ version ], + session: { rejectUnauthorized: false }, + }; + + return { makeServer, cycleOpts }; +} diff --git a/test/lib/server-http1.ts b/test/lib/server-http1.ts new file mode 100644 index 0000000..b9e7108 --- /dev/null +++ b/test/lib/server-http1.ts @@ -0,0 +1,282 @@ +import { + createServer, + IncomingMessage, + Server as HttpServer, + ServerResponse, +} from "http"; +import { + constants as h2constants, +} from "http2"; +import { + createServer as createSecureServer, + Server as HttpsServer, +} from "https"; +import { Socket } from "net"; + +import { createHash } from "crypto"; +import { createDeflate, createGzip } from "zlib"; + +import { delay } from "already"; +import { buffer as getStreamAsBuffer } from "get-stream"; + +import { + ignoreError, + Server, + ServerOptions, + TypedServer, +} from "./server-common"; + +// These are the same in HTTP/1 and HTTP/2 +const { + HTTP2_HEADER_ACCEPT_ENCODING, + HTTP2_HEADER_CONTENT_LENGTH, + HTTP2_HEADER_CONTENT_TYPE, + HTTP2_HEADER_SET_COOKIE, +} = h2constants; + +interface RawHeaders +{ + [ name: string ]: number | string | Array< string >; +} + +export class ServerHttp1 extends TypedServer< HttpServer | HttpsServer > +{ + private _store = new Set< Socket >( ); + + constructor( opts: ServerOptions ) + { + super( ); + + this._opts = opts || { }; + if ( this._opts.serverOptions ) + this._server = createSecureServer( this._opts.serverOptions ); + else + this._server = createServer( ); + this.port = null; + + this._server.on( + "connection", + socket => { this._store.add( socket ); } + ); + + this._server.on( + "request", + ( request: IncomingMessage, response: ServerResponse ) => + { + this.onRequest( request, response ) + .catch( err => + { + console.error( "Unit test server failed", err ); + process.exit( 1 ); + } ); + } + ); + } + + public async _shutdown( ): Promise< void > + { + for ( const socket of this._store ) + { + socket.destroy( ); + } + this._store.clear( ); + } + + private async onRequest( + request: IncomingMessage, response: ServerResponse + ) + : Promise< void > + { + const { url: path, headers } = request; + let m; + + if ( path == null ) + throw new Error( "Internal test error" ); + + const sendHeaders = ( headers: RawHeaders ) => + { + const { ":status": status = 200, ...rest } = { ...headers }; + + response.statusCode = status; + + for ( const [ key, value ] of Object.entries( rest ) ) + response.setHeader( key, value ); + }; + + if ( path === "/headers" ) + { + sendHeaders( { + ":status": 200, + "content-type": "application/json", + } ); + + response.end( JSON.stringify( headers ) ); + } + else if ( path === "/echo" ) + { + const responseHeaders: RawHeaders = { + ":status": 200, + }; + [ HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_CONTENT_LENGTH ] + .forEach( name => + { + const value = headers[ name ]; + if ( value != null ) + responseHeaders[ name ] = value; + } ); + + sendHeaders( responseHeaders ); + request.pipe( response ); + } + else if ( path === "/set-cookie" ) + { + const responseHeaders: RawHeaders = { + ":status": 200, + [ HTTP2_HEADER_SET_COOKIE ]: [ ], + }; + + const data = await getStreamAsBuffer( request ); + const json = JSON.parse( data.toString( ) ); + json.forEach( ( cookie: any ) => + { + ( < any >responseHeaders[ HTTP2_HEADER_SET_COOKIE ] ) + .push( cookie ); + } ); + + sendHeaders( responseHeaders ); + response.end( ); + } + // tslint:disable-next-line + else if ( m = path.match( /\/wait\/(.+)/ ) ) + { + const timeout = parseInt( m[ 1 ], 10 ); + await delay( timeout ); + + const responseHeaders: RawHeaders = { + ":status": 200, + }; + [ HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_CONTENT_LENGTH ] + .forEach( name => + { + const value = headers[ name ]; + if ( value != null ) + responseHeaders[ name ] = value; + } ); + + try + { + sendHeaders( responseHeaders ); + request.pipe( response ); + } + catch ( err ) + // We ignore errors since this route is used to intentionally + // timeout, which causes us to try to write to a closed stream. + { } + } + else if ( path === "/trailers" ) + { + const responseHeaders = { + ":status": 200, + }; + + const data = await getStreamAsBuffer( request ); + const json = JSON.parse( data.toString( ) ); + + sendHeaders( responseHeaders ); + + response.write( "trailers will be sent" ); + + response.addTrailers( json ); + + response.end( ); + } + else if ( path === "/sha256" ) + { + const hash = createHash( "sha256" ); + + const responseHeaders = { + ":status": 200, + }; + sendHeaders( responseHeaders ); + + hash.on( "readable", ( ) => + { + const data = < Buffer >hash.read( ); + if ( data ) + { + response.write( data.toString( "hex" ) ); + response.end( ); + } + } ); + + request.pipe( hash ); + } + else if ( path.startsWith( "/compressed/" ) ) + { + const encoding = path.replace( "/compressed/", "" ); + + const accept = headers[ HTTP2_HEADER_ACCEPT_ENCODING ] as string; + + if ( !accept.includes( encoding ) ) + { + response.end( ); + return; + } + + const encoder = + encoding === "gzip" + ? createGzip( ) + : encoding === "deflate" + ? createDeflate( ) + : null; + + const responseHeaders = { + ":status": 200, + "content-encoding": encoding, + }; + + sendHeaders( responseHeaders ); + if ( encoder ) + request.pipe( encoder ).pipe( response ); + else + request.pipe( response ); + } + else if ( path.startsWith( "/slow/" ) ) + { + const waitMs = parseInt( path.replace( "/slow/", "" ), 10 ); + + const responseHeaders = { + ":status": 200, + [ HTTP2_HEADER_CONTENT_LENGTH ]: "10", + }; + + sendHeaders( responseHeaders ); + + response.write( "abcde" ); + + if ( waitMs > 0 ) + await delay( waitMs ); + + ignoreError( ( ) => response.write( "fghij" ) ); + ignoreError( ( ) => response.end( ) ); + } + else if ( path.startsWith( "/prem-close" ) ) + { + request.socket.destroy( ); + } + else + { + response.end( ); + } + } +} + +export async function makeServer( opts: ServerOptions = { } ) +: Promise< { server: Server; port: number | null; } > +{ + opts = opts || { }; + + const server = new ServerHttp1( opts ); + await server.listen( opts.port ); + return { server, port: server.port }; +} diff --git a/test/lib/server.ts b/test/lib/server-http2.ts similarity index 84% rename from test/lib/server.ts rename to test/lib/server-http2.ts index ddcaa81..420c431 100644 --- a/test/lib/server.ts +++ b/test/lib/server-http2.ts @@ -6,16 +6,21 @@ import { Http2Session, IncomingHttpHeaders, OutgoingHttpHeaders, - SecureServerOptions, ServerHttp2Stream, } from "http2"; import { createHash } from "crypto"; import { createDeflate, createGzip } from "zlib"; +import { delay } from "already"; import { buffer as getStreamAsBuffer } from "get-stream"; -import { delay } from "already"; +import { + ignoreError, + Server, + ServerOptions, + TypedServer, +} from "./server-common"; const { HTTP2_HEADER_PATH, @@ -25,33 +30,14 @@ const { HTTP2_HEADER_SET_COOKIE, } = constants; -export interface MatchData -{ - path: string; - stream: ServerHttp2Stream; - headers: IncomingHttpHeaders; -} - -export type Matcher = ( matchData: MatchData ) => boolean; - -export interface ServerOptions -{ - port?: number; - matchers?: ReadonlyArray< Matcher >; - serverOptions?: SecureServerOptions; -} - -const ignoreError = ( cb: ( ) => any ) => { try { cb( ); } catch ( err ) { } }; - -export class Server +export class ServerHttp2 extends TypedServer< Http2Server > { - public port: number | null; - private _opts: ServerOptions; - private _server: Http2Server; private _sessions: Set< Http2Session >; constructor( opts: ServerOptions ) { + super( ); + this._opts = opts || { }; if ( this._opts.serverOptions ) this._server = createSecureServer( this._opts.serverOptions ); @@ -71,36 +57,13 @@ export class Server } ); } - public async listen( port: number | undefined = void 0 ): Promise< number > + public async _shutdown( ): Promise< void > { - return new Promise( ( resolve, _reject ) => + for ( const session of this._sessions ) { - this._server.listen( port, "0.0.0.0", resolve ); - } ) - .then( ( ) => - { - const address = this._server.address( ); - if ( typeof address === "string" ) - return 0; - return address.port; - } ) - .then( port => - { - this.port = port; - return port; - } ); - } - - public async shutdown( ): Promise< void > - { - return new Promise< void >( ( resolve, _reject ) => - { - for ( const session of this._sessions ) - { - session.destroy( ); - } - this._server.close( resolve ); - } ); + session.destroy( ); + } + this._sessions.clear( ); } private async onStream( @@ -351,7 +314,7 @@ export async function makeServer( opts: ServerOptions = { } ) { opts = opts || { }; - const server = new Server( opts ); + const server = new ServerHttp2( opts ); await server.listen( opts.port ); return { server, port: server.port }; } diff --git a/test/lib/utils.ts b/test/lib/utils.ts index b376eaa..a34eeec 100644 --- a/test/lib/utils.ts +++ b/test/lib/utils.ts @@ -6,3 +6,6 @@ export function createIntegrity( data: string, hashType = "sha256" ) hash.update( data ); return hashType + "-" + hash.digest( "base64" ); } + +export const cleanUrl = ( url: string ) => + url.replace( /^http[12]:\/\//, "http://" );