├── externs └── index.d.ts ├── lib ├── generated │ └── version.ts ├── types.ts ├── abort.ts ├── utils-http2.ts ├── simple-session.ts ├── cookie-jar.ts ├── san.ts ├── utils.ts ├── context-https.ts ├── origin-cache.ts ├── request.ts ├── core.ts ├── headers.ts ├── fetch-http1.ts ├── response.ts ├── fetch-common.ts ├── body.ts ├── context-http1.ts ├── fetch-http2.ts ├── context-http2.ts └── context.ts ├── scripts ├── test-client ├── version-git-add.sh ├── version-update.ts ├── make-certs.sh └── create-exported-tests.ts ├── .gitignore ├── jest.config.unit.js ├── jest.config.exported.js ├── jest.config.js ├── test ├── tslint.json ├── docker-compose.yaml ├── lib │ ├── utils.ts │ ├── server-helpers.ts │ ├── server-common.ts │ ├── server-http1.ts │ └── server-http2.ts ├── fetch-h2 │ ├── headers.ts │ ├── http1.ts │ ├── origin-cache.ts │ ├── abort.ts │ ├── san.ts │ ├── context.ts │ └── body.ts └── integration │ ├── event-loop-reference.ts │ └── httpbin.ts ├── tsconfig.json ├── .github └── workflows │ ├── branches.yml │ └── master.yml ├── test-client └── index.ts ├── LICENSE ├── certs ├── cert.pem └── key.pem ├── index.ts ├── tslint.json └── package.json /externs/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'to-arraybuffer'; 2 | -------------------------------------------------------------------------------- /lib/generated/version.ts: -------------------------------------------------------------------------------- 1 | export const version = "1.0.0"; 2 | -------------------------------------------------------------------------------- /scripts/test-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require( '../dist/test-client' ); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | node_modules/ 4 | .node-version 5 | .nyc_output/ 6 | yarn.lock 7 | test-exported/ 8 | -------------------------------------------------------------------------------- /scripts/version-git-add.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git add dist/lib/generated/version.d.ts 4 | git add dist/lib/generated/version.js 5 | git add lib/generated/version.ts 6 | -------------------------------------------------------------------------------- /jest.config.unit.js: -------------------------------------------------------------------------------- 1 | const config = require( './jest.config.js' ); 2 | module.exports = { 3 | ...config, 4 | modulePathIgnorePatterns: [ 5 | ...config.modulePathIgnorePatterns, 6 | '/integration/' 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /jest.config.exported.js: -------------------------------------------------------------------------------- 1 | const config = require( './jest.config.js' ); 2 | module.exports = { 3 | ...config, 4 | testMatch: ['/test-exported/**/*.ts'], 5 | modulePathIgnorePatterns: ['/lib/', '/test-client/', '/integration/'], 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['/test/**/*.ts'], 5 | modulePathIgnorePatterns: ['/lib/', '/test-client/'], 6 | collectCoverageFrom: ['/lib/**', 'index.ts'], 7 | coverageReporters: ['lcov', 'text', 'html'], 8 | }; 9 | -------------------------------------------------------------------------------- /test/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "../tslint.json" 4 | ], 5 | "jsRules": {}, 6 | "rules": { 7 | "no-unused-expression": [ 8 | false, 9 | "allow-fast-null-checks" 10 | ], 11 | "no-console": false 12 | }, 13 | "rulesDirectory": [] 14 | } -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IncomingHttpHeaders as IncomingHttpHeadersH1, 3 | } from "http"; 4 | 5 | import { 6 | // ClientHttp2Stream, 7 | // constants as h2constants, 8 | IncomingHttpHeaders as IncomingHttpHeadersH2, 9 | } from "http2"; 10 | 11 | export type IncomingHttpHeaders = 12 | IncomingHttpHeadersH1 | IncomingHttpHeadersH2; 13 | -------------------------------------------------------------------------------- /scripts/version-update.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import { resolve } from "path"; 3 | 4 | // tslint:disable-next-line 5 | const { version } = require( "../package.json" ); 6 | 7 | const fileData = `export const version = "${version}";\n`; 8 | 9 | writeFileSync( resolve( __dirname, "../lib/generated/version.ts" ), fileData ); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": [ "ES2019" ], 5 | "outDir": "dist", 6 | "sourceMap": true, 7 | "module": "CommonJS", 8 | "target": "ES2019", 9 | "noUnusedParameters": true, 10 | "noUnusedLocals": true, 11 | "strict": true 12 | }, 13 | "include": [ 14 | "index.ts", 15 | "generated", 16 | "externs", 17 | "lib", 18 | "test", 19 | "test-client" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /scripts/make-certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Same as in the test and docker-compose (!) 6 | DIR=/tmp/fetch-h2-certs 7 | 8 | node_modules/.bin/rimraf ${DIR} 9 | mkdir -p ${DIR} 10 | node_modules/.bin/mkcert create-ca \ 11 | --key ${DIR}/ca-key.pem --cert ${DIR}/ca.pem 12 | node_modules/.bin/mkcert create-cert \ 13 | --ca-key ${DIR}/ca-key.pem --ca-cert ${DIR}/ca.pem \ 14 | --key ${DIR}/key.pem --cert ${DIR}/cert.pem \ 15 | --domains localhost,127.0.0.1 16 | -------------------------------------------------------------------------------- /test/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | http1bin: 4 | image: kennethreitz/httpbin 5 | ports: 6 | - "80" 7 | http2bin: 8 | image: skydoctor/httpbin-http2 9 | ports: 10 | - "8000" 11 | https1proxy: 12 | image: fsouza/docker-ssl-proxy 13 | environment: 14 | DOMAIN: localhost 15 | TARGET_HOST: http1bin 16 | TARGET_PORT: 80 17 | links: 18 | - http1bin 19 | ports: 20 | - 443 21 | volumes: 22 | - "/tmp/fetch-h2-certs:/etc/nginx/certs" 23 | -------------------------------------------------------------------------------- /test/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | import { 4 | Response, 5 | } from "../../index"; 6 | 7 | 8 | export function createIntegrity( data: string, hashType = "sha256" ) 9 | { 10 | const hash = createHash( hashType ); 11 | hash.update( data ); 12 | return hashType + "-" + hash.digest( "base64" ); 13 | } 14 | 15 | export const cleanUrl = ( url: string ) => 16 | url.replace( /^http[12]:\/\//, "http://" ); 17 | 18 | export function ensureStatusSuccess( response: Response ): Response 19 | { 20 | if ( response.status < 200 || response.status >= 300 ) 21 | throw new Error( "Status not 2xx" ); 22 | return response; 23 | } 24 | -------------------------------------------------------------------------------- /lib/abort.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | 4 | export const signalEvent = "internal-abort"; 5 | 6 | export interface AbortSignal extends EventEmitter 7 | { 8 | readonly aborted: boolean; 9 | onabort: ( ) => void; 10 | } 11 | 12 | class AbortSignalImpl extends EventEmitter implements AbortSignal 13 | { 14 | public aborted = false; 15 | 16 | constructor( ) 17 | { 18 | super( ); 19 | 20 | this.once( signalEvent, ( ) => 21 | { 22 | this.aborted = true; 23 | this.emit( "abort" ); 24 | this.onabort && this.onabort( ); 25 | } ); 26 | } 27 | 28 | public onabort = ( ) => { }; 29 | } 30 | 31 | export class AbortController 32 | { 33 | public readonly signal: AbortSignal = new AbortSignalImpl( ); 34 | 35 | public abort = ( ) => 36 | { 37 | this.signal.emit( signalEvent ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/utils-http2.ts: -------------------------------------------------------------------------------- 1 | import { ClientHttp2Session } from "http2"; 2 | 3 | 4 | export interface MonkeyH2Session extends ClientHttp2Session 5 | { 6 | __fetch_h2_destroyed?: boolean; 7 | __fetch_h2_goaway?: boolean; 8 | __fetch_h2_refcount: number; 9 | } 10 | 11 | export function hasGotGoaway( session: ClientHttp2Session ) 12 | { 13 | return !!( < MonkeyH2Session >session ).__fetch_h2_goaway; 14 | } 15 | 16 | export function setGotGoaway( session: ClientHttp2Session ) 17 | { 18 | ( < MonkeyH2Session >session ).__fetch_h2_goaway = true; 19 | } 20 | 21 | export function isDestroyed( session: ClientHttp2Session ) 22 | { 23 | const monkeySession = < MonkeyH2Session >session; 24 | return monkeySession.destroyed || monkeySession.__fetch_h2_destroyed; 25 | } 26 | 27 | export function setDestroyed( session: ClientHttp2Session ) 28 | { 29 | ( < MonkeyH2Session >session ).__fetch_h2_destroyed = true; 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/branches.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Branches 5 | 6 | on: 7 | push: 8 | branches-ignore: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: 19 | - 12.x 20 | - 14.x 21 | - 16.x 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm i 29 | - run: npm run build 30 | - run: npm run test 31 | env: 32 | CI: true 33 | -------------------------------------------------------------------------------- /test-client/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line 2 | import { fetch, setup, HttpProtocols } from ".."; 3 | import { pipeline } from "stream"; 4 | 5 | // tslint:disable no-console 6 | 7 | async function work( ) 8 | { 9 | const args = process.argv.slice( 2 ); 10 | 11 | const [ method, url, version, insecure ] = args; 12 | 13 | const rejectUnauthorized = insecure !== "insecure"; 14 | 15 | setup( { 16 | http1: { 17 | keepAlive: false, 18 | }, 19 | ...( 20 | !version ? { } : { 21 | httpProtocol: version as HttpProtocols, 22 | httpsProtocols: [ version as HttpProtocols ], 23 | } 24 | ), 25 | session: { rejectUnauthorized }, 26 | } ); 27 | 28 | const response = await fetch( 29 | url, 30 | { 31 | method: < any >method, 32 | redirect: 'follow', 33 | } 34 | ); 35 | 36 | pipeline( await response.readable( ), process.stdout, err => 37 | { 38 | if ( !err ) 39 | return; 40 | 41 | console.error( "Failed to fetch", err.stack ); 42 | process.exit( 1 ); 43 | } ) 44 | } 45 | 46 | work( ) 47 | .catch( err => { console.error( err, err.stack ); } ); 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gustaf Räntilä 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/create-exported-tests.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path" 2 | import { 3 | readFile as fsReadFile, 4 | writeFile as fsWriteFile, 5 | } from "fs" 6 | import { promisify } from "util" 7 | 8 | import * as readdir from "recursive-readdir" 9 | import * as execa from "execa" 10 | import * as libRimraf from "rimraf" 11 | 12 | 13 | const readFile = promisify( fsReadFile ); 14 | const writeFile = promisify( fsWriteFile ); 15 | const rimraf = promisify( libRimraf ); 16 | 17 | async function createExportedTests( ) 18 | { 19 | const root = path.join( __dirname, ".." ); 20 | const source = path.join( root, "test" ); 21 | const target = path.join( root, "test-exported" ); 22 | 23 | await rimraf( target ); 24 | 25 | await execa( "cp", [ "-r", source, target ] ); 26 | 27 | const files = await readdir( target ); 28 | 29 | for ( const filename of files ) 30 | { 31 | const data = await readFile( filename, 'utf8' ); 32 | await writeFile( 33 | filename, 34 | data 35 | .replace( "../../index", "../../dist" ) 36 | .replace( "../../lib", "../../dist/lib" ) 37 | ); 38 | } 39 | } 40 | 41 | createExportedTests( ) 42 | .catch( err => 43 | { 44 | console.error( err.stack ); 45 | process.exit( 1 ); 46 | } ); 47 | -------------------------------------------------------------------------------- /lib/simple-session.ts: -------------------------------------------------------------------------------- 1 | import { ClientRequest } from "http"; 2 | import { ClientHttp2Session } from "http2"; 3 | 4 | import { CookieJar } from "./cookie-jar"; 5 | import { HttpProtocols, Decoder, FetchInit } from "./core"; 6 | import { FetchExtra } from "./fetch-common"; 7 | import { Request } from "./request"; 8 | import { Response } from "./response"; 9 | 10 | 11 | export interface SimpleSession 12 | { 13 | protocol: HttpProtocols; 14 | 15 | cookieJar: CookieJar; 16 | 17 | userAgent( ): string; 18 | accept( ): string; 19 | 20 | contentDecoders( ): ReadonlyArray< Decoder >; 21 | 22 | newFetch( 23 | input: string | Request, 24 | init?: Partial< FetchInit >, 25 | extra?: FetchExtra 26 | ) 27 | : Promise< Response >; 28 | } 29 | 30 | export interface SimpleSessionHttp1Request 31 | { 32 | req: ClientRequest; 33 | cleanup: ( ) => void; 34 | } 35 | 36 | export interface SimpleSessionHttp2Session 37 | { 38 | session: Promise< ClientHttp2Session >; 39 | cleanup: ( ) => void; 40 | } 41 | 42 | export interface SimpleSessionHttp1 extends SimpleSession 43 | { 44 | get( url: string ): SimpleSessionHttp1Request; 45 | } 46 | 47 | export interface SimpleSessionHttp2 extends SimpleSession 48 | { 49 | get( ): SimpleSessionHttp2Session; 50 | } 51 | -------------------------------------------------------------------------------- /test/lib/server-helpers.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import * as path from "path"; 3 | 4 | import { 5 | ServerOptions, 6 | TestData, 7 | } from "./server-common"; 8 | import { 9 | makeServer as makeServer1, 10 | } from "./server-http1"; 11 | import { 12 | makeServer as makeServer2, 13 | } from "./server-http2"; 14 | 15 | 16 | const key = readFileSync( path.join( process.cwd(), "certs", "key.pem" ) ); 17 | const cert = readFileSync( path.join( process.cwd(), "certs", "cert.pem" ) ); 18 | 19 | export function makeMakeServer( { proto, version }: TestData ) 20 | { 21 | const makeServer = ( opts?: ServerOptions ) => 22 | { 23 | const serverOptions = 24 | ( opts && opts.serverOptions ) ? opts.serverOptions : { }; 25 | 26 | if ( proto === "https:" ) 27 | { 28 | opts = { 29 | serverOptions: { 30 | cert, 31 | key, 32 | ...serverOptions, 33 | }, 34 | ...( opts ? opts : { } ), 35 | }; 36 | } 37 | 38 | return version === "http1" 39 | ? makeServer1( opts ) 40 | : makeServer2( opts ); 41 | }; 42 | 43 | const cycleOpts = { 44 | httpProtocol: version, 45 | httpsProtocols: [ version ], 46 | session: { rejectUnauthorized: false }, 47 | }; 48 | 49 | return { makeServer, cycleOpts }; 50 | } 51 | -------------------------------------------------------------------------------- /certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDUDCCAjgCCQDsXVZ67TzRPjANBgkqhkiG9w0BAQsFADBqMQswCQYDVQQGEwJz 3 | ZTEMMAoGA1UECAwDZm9vMQwwCgYDVQQHDANiYXIxDzANBgNVBAoMBmZvb2JhcjES 4 | MBAGA1UEAwwJZm9vYmFyLnNlMRowGAYJKoZIhvcNAQkBFgtmb29AYmFyLnRsZDAe 5 | Fw0xOTAxMTIyMzIzNTBaFw0zOTAxMDcyMzIzNTBaMGoxCzAJBgNVBAYTAnNlMQww 6 | CgYDVQQIDANmb28xDDAKBgNVBAcMA2JhcjEPMA0GA1UECgwGZm9vYmFyMRIwEAYD 7 | VQQDDAlmb29iYXIuc2UxGjAYBgkqhkiG9w0BCQEWC2Zvb0BiYXIudGxkMIIBIjAN 8 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwd8VxBHL0Ovi0T1vbIhc52CIOdqP 9 | lFnRtg/i8jNTrGCXjS2oERrHyvPHYwXSRis2zGbl+WQqZEHVDlVk/SY/z2DH1BTo 10 | h+DIjd9fIlTXpaBTrU5QOKvJdIFjC7oSbxf1E8BtBrnuhwURHqPhEYKne8QdBGCT 11 | HKRRprDa0GQQEJKVBDLmwMfVoLIh0k8ckjTOPx7126PfmsCTfae7psaplXLcJu9m 12 | g/IcIPc8aRKvWLe8tM93p2rA0/1sO3Cj+ZCxWWaPoKmDa53TkFNLBaWMvO+sppXH 13 | u57o5Wq2bF4fUpIvk6jNpqFvvGJhHiyMOpgzk1vtn+N/zraUTyeREkCHOQIDAQAB 14 | MA0GCSqGSIb3DQEBCwUAA4IBAQCJQQA0YbqZEkQRWs0SWxF5NcZcXxyWrgagZ1Pb 15 | LeuYpC3dczP2ugtUvzC5Gq1T6yOXyi2SI/wVu6AVOKx4WWtB61vGJUoVywcUR1ER 16 | kshgQNcOMDPdVXEwZGCJZ162XhpWqGcYSbxZMPVvMmFB+qPkhmtimSSGOKUea29J 17 | Zh6eyRIwgdrf7hfLqSB++Rr5kDGmT/jI7t/B9TySGfrO02+XDFoX19+ga5BV64pY 18 | 65fq9tkgpsbX1l6K+dGpTXSG+X/y4X4MJRjue3vOVcmMfXROO3G/MD99JSI+P+xU 19 | jrgBhvpqcfC61nx62eNrXB/QpPUHdb2w+yXX0N2m5vnsX1nM 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /lib/cookie-jar.ts: -------------------------------------------------------------------------------- 1 | import { Cookie, CookieJar as ToughCookieJar } from "tough-cookie"; 2 | 3 | 4 | export class CookieJar 5 | { 6 | private _jar: ToughCookieJar; 7 | 8 | constructor( jar = new ToughCookieJar( ) ) 9 | { 10 | this._jar = jar; 11 | } 12 | 13 | public reset( jar = new ToughCookieJar( ) ) 14 | { 15 | this._jar = jar; 16 | } 17 | 18 | public setCookie( cookie: string | Cookie, url: string ): Promise< Cookie > 19 | { 20 | return new Promise< Cookie >( ( resolve, reject ) => 21 | { 22 | this._jar.setCookie( cookie, url, ( err, cookie ) => 23 | { 24 | if ( err ) 25 | return reject( err ); 26 | resolve( cookie ); 27 | } ); 28 | } ); 29 | } 30 | 31 | public setCookies( cookies: ReadonlyArray< string | Cookie >, url: string ) 32 | : Promise< ReadonlyArray< Cookie > > 33 | { 34 | return Promise.all( 35 | cookies.map( cookie => this.setCookie( cookie, url ) ) 36 | ); 37 | } 38 | 39 | public getCookies( url: string ): Promise< ReadonlyArray< Cookie > > 40 | { 41 | return new Promise< ReadonlyArray< Cookie > >( ( resolve, reject ) => 42 | { 43 | this._jar.getCookies( url, ( err, cookies ) => 44 | { 45 | if ( err ) 46 | return reject( err ); 47 | resolve( cookies ); 48 | } ); 49 | } ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Master 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: 18 | - 12.x 19 | - 14.x 20 | - 16.x 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm i 28 | - run: npm run build 29 | - run: npm run test 30 | env: 31 | CI: true 32 | 33 | release: 34 | name: Release 35 | runs-on: ubuntu-latest 36 | needs: build 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v1 40 | - name: Setup Node.js 41 | uses: actions/setup-node@v1 42 | with: 43 | node-version: 14 44 | - run: npm i 45 | - run: npm run build 46 | - run: npm run test --coverage 47 | env: 48 | CI: true 49 | - name: Coveralls 50 | uses: coverallsapp/github-action@master 51 | with: 52 | github-token: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Release 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | run: npx semantic-release 58 | -------------------------------------------------------------------------------- /lib/san.ts: -------------------------------------------------------------------------------- 1 | import { PeerCertificate } from "tls" 2 | 3 | 4 | export type AltNameMatcher = ( name: string ) => boolean; 5 | 6 | export interface AltNameMatch 7 | { 8 | names: Array< string >; 9 | dynamic?: AltNameMatcher; 10 | } 11 | 12 | 13 | function getNames( cert: PeerCertificate ) 14 | { 15 | const CN = cert.subject?.CN; 16 | const sans = ( cert.subjectaltname ?? '' ) 17 | .split( ',' ) 18 | .map( name => name.trim( ) ) 19 | .filter( name => name.startsWith( 'DNS:' ) ) 20 | .map( name => name.substr( 4 ) ); 21 | 22 | if ( cert.subjectaltname ) 23 | // Ignore CN if SAN:s are present; https://stackoverflow.com/a/29600674 24 | return [ ...new Set( sans ) ]; 25 | else 26 | return [ CN ]; 27 | } 28 | 29 | export function makeRegex( name: string ) 30 | { 31 | return "^" + name 32 | .split( '*' ) 33 | .map( part => part.replace( /[^a-zA-Z0-9]/g, val => `\\${val}` ) ) 34 | .join( '[^.]+' ) + "$"; 35 | } 36 | 37 | function makeMatcher( regexes: ReadonlyArray< RegExp > ): AltNameMatcher 38 | { 39 | return ( name: string ) => regexes.some( regex => name.match( regex ) ); 40 | } 41 | 42 | export function parseOrigin( cert?: PeerCertificate ): AltNameMatch 43 | { 44 | const names: Array< string > = [ ]; 45 | const regexes: Array< RegExp > = [ ]; 46 | 47 | if ( cert ) 48 | { 49 | getNames( cert ).forEach( name => 50 | { 51 | if ( name.match( /.*\*.*\*.*/ ) ) 52 | throw new Error( `Invalid CN/subjectAltNames: ${name}` ); 53 | 54 | if ( name.includes( "*" ) ) 55 | regexes.push( new RegExp( makeRegex( name ) ) ); 56 | else 57 | names.push( name ); 58 | } ); 59 | } 60 | 61 | const ret: AltNameMatch = { 62 | names, 63 | ...( !regexes.length ? { } : { dynamic: makeMatcher( regexes ) } ), 64 | }; 65 | 66 | return ret; 67 | } 68 | -------------------------------------------------------------------------------- /test/fetch-h2/headers.ts: -------------------------------------------------------------------------------- 1 | import { Headers } from "../../index"; 2 | import { GuardedHeaders } from "../../lib/headers"; 3 | 4 | 5 | const toObject = ( keyvals: IterableIterator< [ string, string ] > ) => 6 | [ ...keyvals ].reduce( 7 | ( prev, cur ) => 8 | Object.assign( prev, { [ cur[ 0 ] ]: cur[ 1 ] } ) 9 | , 10 | { } 11 | ); 12 | 13 | describe( "headers", ( ) => 14 | { 15 | describe( "regular", ( ) => 16 | { 17 | it( "empty", async ( ) => 18 | { 19 | const headers = new Headers( ); 20 | 21 | expect( toObject( headers.entries( ) ) ).toMatchObject( { } ); 22 | } ); 23 | 24 | it( "value", async ( ) => 25 | { 26 | const headers = new Headers( { a: "b" } ); 27 | 28 | expect( toObject( headers.entries( ) ) ) 29 | .toMatchObject( { a: "b" } ); 30 | } ); 31 | } ); 32 | 33 | describe( "guarded", ( ) => 34 | { 35 | it( "empty", async ( ) => 36 | { 37 | const headers = new GuardedHeaders( "response" ); 38 | 39 | expect( toObject( headers.entries( ) ) ).toMatchObject( { } ); 40 | } ); 41 | 42 | it( "value", async ( ) => 43 | { 44 | const headers = new GuardedHeaders( "response", { a: "b" } ); 45 | 46 | expect( toObject( headers.entries( ) ) ) 47 | .toMatchObject( { a: "b" } ); 48 | } ); 49 | } ); 50 | 51 | describe( "iterable", ( ) => 52 | { 53 | it( "for-of iterable", async ( ) => 54 | { 55 | const headers = new GuardedHeaders( "response" ); 56 | headers.append( "foo", "bar" ); 57 | headers.append( "foo", "baz" ); 58 | headers.append( "a", "b" ); 59 | 60 | const test: any = { }; 61 | for ( const [ key, value ] of headers ) 62 | { 63 | test[ key ] = value; 64 | } 65 | 66 | expect( test ).toMatchObject( { 67 | a: "b", 68 | foo: "bar,baz", 69 | } ); 70 | } ); 71 | } ); 72 | } ); 73 | -------------------------------------------------------------------------------- /certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDB3xXEEcvQ6+LR 3 | PW9siFznYIg52o+UWdG2D+LyM1OsYJeNLagRGsfK88djBdJGKzbMZuX5ZCpkQdUO 4 | VWT9Jj/PYMfUFOiH4MiN318iVNeloFOtTlA4q8l0gWMLuhJvF/UTwG0Gue6HBREe 5 | o+ERgqd7xB0EYJMcpFGmsNrQZBAQkpUEMubAx9WgsiHSTxySNM4/HvXbo9+awJN9 6 | p7umxqmVctwm72aD8hwg9zxpEq9Yt7y0z3enasDT/Ww7cKP5kLFZZo+gqYNrndOQ 7 | U0sFpYy876ymlce7nujlarZsXh9Ski+TqM2moW+8YmEeLIw6mDOTW+2f43/OtpRP 8 | J5ESQIc5AgMBAAECggEAC5fahloGFR01+AszcYsJ+zATlVoTgeyJFNkIWjFljIZO 9 | KbwUM8mlLua7ApnjhByrbzesAujRfCNPqUbD/jteT3lbGbySVyXC+HDmEHiAWMAo 10 | oNFxDKKBLn1aPeZHmesV1bOJEYDm2Z4c8vcby19DwqvsjEl2Ip1U4KHsw89oAoWW 11 | u98dsEv5XX30HobngVCU4EPy5mblCYTcWQxE55FHknK3oZ4q1xmAURhGHwN0VwYT 12 | InwzLA79fvBlnppKjuBv8mc2nKj3zgjmDprFsmx2iJ4N5VRmjt2yegRrSyzG+I4T 13 | pclPrB0qQ43SUsyS7gMI2z7z0oH2m996RKLlQK+3XQKBgQDvAZv5F3aHcM3acjfZ 14 | FndMTsFLCXIXWUzjyvpaceHEOFSg00e9rR7c+nP8vK1CXfsIhukRgSvUDMkMpNXN 15 | yptliRvWVQuy/0TZ8om8TiePCE86GZeRjSJTfYKo1z1mWj0pz+75p5kaSR4U669p 16 | rkqFc+tcMoxX+Fisi5ku6Iy4+wKBgQDPp+5snzbv5VZsURCp9eNh4BypCNFWTkMH 17 | kCWC7IEjkZ5jzhU9wfcEMLrTBdnOzT7RA/DfgJOcwylc2EKTN3NQuSg7ffFELM7P 18 | tin0R+kO0JygDj9YhiIW1DfsKe2yJ59pdMDaXr214pI8WTtfZABpc0LnKnbDpJXP 19 | +pzTuKFyWwKBgD8608KwXGE0jKEv+mpqMSF07Fono5FdxKO2/UiUPEAnDuyFOMOL 20 | W1DmyWyhlcyrBFCbMGm7HJc60q2PpiiNY1MXVM/9K90s/1ARhDLXEkwazKr4Pkr5 21 | ZY1k9P4qA0pisS+wnO5bUnvLwDOUrpFs1LY9lpSLoulbAEqVm+73AtOlAoGAMfR+ 22 | QRdUSgXr8obV8W071FHr0yZR5edR7MHapFJtBreDWRM8vOyqlhF7AEUKDtwFXpcK 23 | HVp7KF0y2CkWawAN979zVEyJ/BKjdgimsyORh4TcCQ0kZBFwpflLsr6rdg5eJSp3 24 | MpFUJitpbqcwx1PxXWzjDWWDyLERcUUi8TQbcr0CgYAXjId/k5Sm2EfeDsnGlR6L 25 | HcPwJZ5iI+DrNg8sZn5u3EjhjcVT+mSe0CzMMqvKmwZ0LhmiB0ee0XiF0+iTsedd 26 | Sru2OQgPkHgqxj71gBPk1NKozOb3pEDPqHMhuMjP/WBH2OPjB/bc84ayImlHGWaF 27 | 1lcTncGab2YneX4hdU15Qg== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { AbortController, AbortSignal } from "./lib/abort"; 2 | import { Body, DataBody, JsonBody, StreamBody } from "./lib/body"; 3 | import { Context, ContextOptions } from "./lib/context"; 4 | import { PushHandler } from "./lib/context-http2"; 5 | import { CookieJar } from "./lib/cookie-jar"; 6 | import { 7 | AbortError, 8 | DecodeFunction, 9 | Decoder, 10 | RequestInit, 11 | FetchInit, 12 | HttpProtocols, 13 | Method, 14 | OnTrailers, 15 | TimeoutError, 16 | HttpVersion, 17 | } from "./lib/core"; 18 | import { Headers } from "./lib/headers"; 19 | import { Request } from "./lib/request"; 20 | import { Response } from "./lib/response"; 21 | 22 | 23 | const defaultContext = new Context( ); 24 | 25 | const setup = 26 | ( opts: Partial< ContextOptions > ) => 27 | defaultContext.setup( opts ); 28 | const fetch = 29 | ( input: string | Request, init?: Partial< FetchInit > ) => 30 | defaultContext.fetch( input, init ); 31 | const disconnect = 32 | ( url: string ) => 33 | defaultContext.disconnect( url ); 34 | const disconnectAll = 35 | ( ) => 36 | defaultContext.disconnectAll( ); 37 | const onPush = 38 | ( handler?: PushHandler ) => 39 | defaultContext.onPush( handler ); 40 | 41 | function context( opts?: Partial< ContextOptions > ) 42 | { 43 | const ctx = new Context( opts ); 44 | return { 45 | disconnect: ctx.disconnect.bind( ctx ) as typeof disconnect, 46 | disconnectAll: ctx.disconnectAll.bind( ctx ) as typeof disconnectAll, 47 | fetch: ctx.fetch.bind( ctx ) as typeof fetch, 48 | onPush: ctx.onPush.bind( ctx ) as typeof onPush, 49 | setup: ctx.setup.bind( ctx ) as typeof setup, 50 | }; 51 | } 52 | 53 | export { 54 | setup, 55 | context, 56 | fetch, 57 | disconnect, 58 | disconnectAll, 59 | onPush, 60 | 61 | // Re-export 62 | AbortController, 63 | AbortSignal, 64 | RequestInit, 65 | FetchInit, 66 | HttpProtocols, 67 | Body, 68 | JsonBody, 69 | StreamBody, 70 | DataBody, 71 | Headers, 72 | Request, 73 | Response, 74 | AbortError, 75 | TimeoutError, 76 | HttpVersion, 77 | OnTrailers, 78 | ContextOptions, 79 | DecodeFunction, 80 | Decoder, 81 | CookieJar, 82 | Method, 83 | }; 84 | -------------------------------------------------------------------------------- /test/lib/server-common.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Server as HttpServer, 3 | } from "http"; 4 | import { 5 | Http2Server, 6 | IncomingHttpHeaders, 7 | SecureServerOptions, 8 | ServerHttp2Stream, 9 | } from "http2"; 10 | import { 11 | Server as HttpsServer, 12 | } from "https"; 13 | 14 | import { HttpProtocols } from "../../index"; 15 | 16 | 17 | export interface TestData 18 | { 19 | proto: "http:" | "https:"; 20 | version: HttpProtocols; 21 | } 22 | 23 | export interface MatchData 24 | { 25 | path: string; 26 | stream: ServerHttp2Stream; 27 | headers: IncomingHttpHeaders; 28 | } 29 | 30 | export type Matcher = ( matchData: MatchData ) => boolean; 31 | 32 | export const ignoreError = ( cb: ( ) => any ) => { try { cb( ); } catch ( err ) { } }; 33 | 34 | export interface ServerOptions 35 | { 36 | port?: number; 37 | matchers?: ReadonlyArray< Matcher >; 38 | serverOptions?: SecureServerOptions; 39 | } 40 | 41 | export abstract class Server 42 | { 43 | public port: number | null = null; 44 | protected _opts: ServerOptions = { }; 45 | protected _server: HttpServer | HttpsServer | Http2Server = < any >void 0; 46 | 47 | 48 | public async listen( port: number | undefined = void 0 ): Promise< number > 49 | { 50 | return new Promise< void >( ( resolve, _reject ) => 51 | { 52 | this._server.listen( port, "0.0.0.0", resolve ); 53 | } ) 54 | .then( ( ) => 55 | { 56 | const address = this._server.address( ); 57 | if ( !address || typeof address === "string" ) 58 | return 0; 59 | return address.port; 60 | } ) 61 | .then( port => 62 | { 63 | this.port = port; 64 | return port; 65 | } ); 66 | } 67 | 68 | public async shutdown( ): Promise< void > 69 | { 70 | await this._shutdown( ); 71 | return new Promise< void >( ( resolve, reject ) => 72 | { 73 | this._server.close( ( err?: Error ) => 74 | { 75 | if ( err ) 76 | return reject( err ); 77 | resolve( ); 78 | } ); 79 | } ); 80 | } 81 | 82 | protected async _shutdown( ): Promise< void > { } 83 | } 84 | 85 | export abstract class TypedServer 86 | < ServerType extends HttpServer | HttpsServer | Http2Server > 87 | extends Server 88 | { 89 | protected _server: ServerType = < any >void 0; 90 | } 91 | -------------------------------------------------------------------------------- /test/integration/event-loop-reference.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import * as execa from "execa"; 4 | 5 | import { TestData } from "../lib/server-common"; 6 | import { makeMakeServer } from "../lib/server-helpers"; 7 | 8 | 9 | const script = 10 | path.resolve( path.join( process.cwd( ), "scripts", "test-client" ) ); 11 | 12 | describe( "event-loop", ( ) => 13 | { 14 | jest.setTimeout( 20000 ); 15 | 16 | const runs: Array< TestData > = [ 17 | { proto: "http:", version: "http1" }, 18 | { proto: "https:", version: "http1" }, 19 | { proto: "http:", version: "http2" }, 20 | { proto: "https:", version: "http2" }, 21 | ]; 22 | 23 | runs.forEach( ( { proto, version } ) => 24 | { 25 | const { makeServer } = makeMakeServer( { proto, version } ); 26 | 27 | it( `should unref ${proto} ${version}`, async ( ) => 28 | { 29 | const { port, server } = await makeServer( ); 30 | 31 | const url = `${proto}//localhost:${port}/headers`; 32 | 33 | const body = { foo: "bar" }; 34 | 35 | const { stdout } = await execa( 36 | script, 37 | [ "GET", url, version, "insecure" ], 38 | { input: JSON.stringify( body ), stderr: 'inherit' } 39 | ); 40 | 41 | const responseBody = JSON.parse( stdout ); 42 | expect( responseBody[ "user-agent" ] ).toContain( "fetch-h2/" ); 43 | 44 | await server.shutdown( ); 45 | } ); 46 | 47 | it( `should handle redirect ${proto} ${version}`, async ( ) => 48 | { 49 | const { port, server } = await makeServer( ); 50 | 51 | const url = `${proto}//localhost:${port}/redirect/delay/50`; 52 | 53 | const body = { foo: "bar" }; 54 | 55 | const { stdout } = await execa( 56 | script, 57 | [ "GET", url, version, "insecure" ], 58 | { input: JSON.stringify( body ), stderr: 'inherit' } 59 | ); 60 | 61 | expect( stdout ).toBe( "abcdefghij" ); 62 | 63 | await server.shutdown( ); 64 | } ); 65 | 66 | it( `should handle absolute redirect ${proto} ${version}`, async ( ) => 67 | { 68 | const { port, server } = await makeServer( ); 69 | 70 | const redirectTo = `${proto}//localhost:${port}/delay/50`; 71 | const url = `${proto}//localhost:${port}/redirect/${redirectTo}`; 72 | 73 | const body = { foo: "bar" }; 74 | 75 | const { stdout } = await execa( 76 | script, 77 | [ "GET", url, version, "insecure" ], 78 | { input: JSON.stringify( body ), stderr: 'inherit' } 79 | ); 80 | 81 | expect( stdout ).toBe( "abcdefghij" ); 82 | 83 | await server.shutdown( ); 84 | } ); 85 | } ); 86 | } ); 87 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url"; 2 | import { createBrotliCompress } from "zlib"; 3 | import { promisify } from "util"; 4 | import * as stream from "stream"; 5 | 6 | 7 | export const pipeline = promisify( stream.pipeline ); 8 | 9 | export function arrayify< T >( 10 | value: 11 | T | Array< T > | Readonly< T > | ReadonlyArray< T > | undefined | null 12 | ) 13 | : Array< T > 14 | { 15 | if ( value != null && Array.isArray( value ) ) 16 | return value; 17 | 18 | return value == null 19 | ? [ ] 20 | : Array.isArray( value ) 21 | ? [ ...value ] 22 | : [ value ]; 23 | } 24 | 25 | export interface ParsedLocation 26 | { 27 | url: string; 28 | isRelative: boolean; 29 | } 30 | 31 | export function parseLocation( 32 | location: string | Array< string > | undefined, origin: string 33 | ) 34 | : null | ParsedLocation 35 | { 36 | if ( "string" !== typeof location ) 37 | return null; 38 | 39 | const originUrl = new URL( origin ); 40 | const url = new URL( location, origin ); 41 | 42 | return { 43 | url: url.href, 44 | isRelative: originUrl.origin === url.origin, 45 | }; 46 | } 47 | 48 | export const isRedirectStatus: { [ status: string ]: boolean; } = { 49 | 300: true, 50 | 301: true, 51 | 302: true, 52 | 303: true, 53 | 305: true, 54 | 307: true, 55 | 308: true, 56 | }; 57 | 58 | export function makeOkError( err: Error ): Error 59 | { 60 | ( < any >err ).metaData = ( < any >err ).metaData || { }; 61 | ( < any >err ).metaData.ok = true; 62 | return err; 63 | } 64 | 65 | export function parseInput( url: string ) 66 | { 67 | const explicitProtocol = 68 | ( url.startsWith( "http2://" ) || url.startsWith( "http1://" ) ) 69 | ? url.substr( 0, 5 ) 70 | : null; 71 | 72 | url = url.replace( /^http[12]:\/\//, "http://" ); 73 | 74 | const { origin, hostname, port, protocol } = new URL( url ); 75 | 76 | return { 77 | hostname, 78 | origin, 79 | port: port || ( protocol === "https:" ? "443" : "80" ), 80 | protocol: explicitProtocol || protocol.replace( ":", "" ), 81 | url, 82 | }; 83 | } 84 | 85 | export const identity = < T >( t: T ) => t; 86 | 87 | export function uniq< T >( arr: ReadonlyArray< T > ): Array< T >; 88 | export function uniq< T, U >( arr: ReadonlyArray< T >, pred: ( t: T ) => U ) 89 | : Array< T >; 90 | export function uniq< T, U >( arr: ReadonlyArray< T >, pred?: ( t: T ) => U ) 91 | : Array< T > 92 | { 93 | if ( !pred ) 94 | return Array.from( new Set< T >( arr ) ); 95 | 96 | const known = new Set< U >( ); 97 | return arr.filter( value => 98 | { 99 | const u = pred( value ); 100 | const first = !known.has( u ); 101 | 102 | known.add( u ); 103 | 104 | return first; 105 | } ); 106 | } 107 | 108 | export function hasBuiltinBrotli( ) 109 | { 110 | return typeof createBrotliCompress === "function"; 111 | } 112 | -------------------------------------------------------------------------------- /lib/context-https.ts: -------------------------------------------------------------------------------- 1 | import { SecureClientSessionOptions } from "http2"; 2 | import { connect, ConnectionOptions, TLSSocket } from "tls"; 3 | 4 | import { HttpProtocols } from "./core"; 5 | import { AltNameMatch, parseOrigin } from "./san"; 6 | 7 | 8 | const needsSocketHack = [ "12", "13" ] 9 | .includes( process.versions.node.split( '.' )[ 0 ] ); 10 | 11 | const alpnProtocols = 12 | { 13 | http1: Buffer.from( "\x08http/1.1" ), 14 | http2: Buffer.from( "\x02h2" ), 15 | }; 16 | 17 | export interface HttpsSocketResult 18 | { 19 | socket: TLSSocket; 20 | protocol: "http1" | "http2"; 21 | altNameMatch: AltNameMatch; 22 | } 23 | 24 | const defaultMethod: Array< HttpProtocols > = [ "http2", "http1" ]; 25 | 26 | export function connectTLS( 27 | host: string, 28 | port: string, 29 | protocols: ReadonlyArray< HttpProtocols >, 30 | connOpts: SecureClientSessionOptions 31 | ): Promise< HttpsSocketResult > 32 | { 33 | const usedProtocol = new Set< string >( ); 34 | const _protocols = protocols.filter( protocol => 35 | { 36 | if ( protocol !== "http1" && protocol !== "http2" ) 37 | return false; 38 | if ( usedProtocol.has( protocol ) ) 39 | return false; 40 | usedProtocol.add( protocol ); 41 | return true; 42 | } ); 43 | 44 | const orderedProtocols = Buffer.concat( 45 | ( _protocols.length !== 0 ? _protocols : defaultMethod ) 46 | .map( protocol => alpnProtocols[ protocol ] ) 47 | ); 48 | 49 | const opts: ConnectionOptions = { 50 | ...connOpts, 51 | ALPNProtocols: orderedProtocols, 52 | servername: host, 53 | }; 54 | 55 | return new Promise< HttpsSocketResult >( ( resolve, reject ) => 56 | { 57 | const socket: TLSSocket = connect( parseInt( port, 10 ), host, opts, 58 | ( ) => 59 | { 60 | const { authorized, authorizationError, alpnProtocol = "" } = 61 | socket; 62 | const cert = socket.getPeerCertificate( ); 63 | const altNameMatch = parseOrigin( cert ); 64 | 65 | if ( !authorized && opts.rejectUnauthorized !== false ) 66 | return reject( authorizationError ); 67 | 68 | if ( 69 | !alpnProtocol || 70 | ![ "h2", "http/1.1", "http/1.0" ].includes( alpnProtocol ) 71 | ) 72 | { 73 | // Maybe the server doesn't understand ALPN, enforce 74 | // user-provided protocol, or fallback to HTTP/1 75 | if ( _protocols.length === 1 ) 76 | return resolve( { 77 | altNameMatch, 78 | protocol: _protocols[ 0 ], 79 | socket, 80 | } ); 81 | else 82 | return resolve( { 83 | altNameMatch, 84 | protocol: "http1", 85 | socket, 86 | } ); 87 | } 88 | 89 | const protocol = alpnProtocol === "h2" ? "http2" : "http1"; 90 | 91 | resolve( { socket, protocol, altNameMatch } ); 92 | } ); 93 | 94 | if ( needsSocketHack ) 95 | socket.once( 'secureConnect', ( ) => 96 | { 97 | ( socket as any ).secureConnecting = false; 98 | } ); 99 | 100 | socket.once( "error", reject ); 101 | } ); 102 | } 103 | -------------------------------------------------------------------------------- /test/fetch-h2/http1.ts: -------------------------------------------------------------------------------- 1 | import { lsof } from "list-open-files"; 2 | 3 | import { makeMakeServer } from "../lib/server-helpers"; 4 | 5 | import { 6 | context, 7 | } from "../../index"; 8 | import { ensureStatusSuccess } from "../lib/utils"; 9 | 10 | 11 | const itSkipCi = process.env.CI ? it.skip : it; 12 | 13 | describe( `http1`, ( ) => 14 | { 15 | const { cycleOpts, makeServer } = 16 | makeMakeServer( { proto: "http:", version: "http1" } ); 17 | 18 | describe( "keep-alive", ( ) => 19 | { 20 | describe( "http1.keelAlive === true (default)", ( ) => 21 | { 22 | it( "should not send 'connection: close'", async ( ) => 23 | { 24 | const { server, port } = await makeServer( ); 25 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 26 | 27 | const response1 = ensureStatusSuccess( 28 | await fetch( `http://localhost:${port}/headers` ) 29 | ); 30 | 31 | const headers = await response1.json( ); 32 | 33 | expect( headers.connection ).not.toBe( "close" ); 34 | 35 | disconnectAll( ); 36 | 37 | await server.shutdown( ); 38 | } ); 39 | 40 | itSkipCi( "should re-use socket", async ( ) => 41 | { 42 | const { server, port } = await makeServer( ); 43 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 44 | 45 | const [ { files: openFilesA } ] = await lsof( { } ); 46 | 47 | const response1 = ensureStatusSuccess( 48 | await fetch( `http://localhost:${port}/headers` ) 49 | ); 50 | await response1.json( ); 51 | 52 | const [ { files: openFilesB } ] = await lsof( { } ); 53 | 54 | const response2 = ensureStatusSuccess( 55 | await fetch( `http://localhost:${port}/headers` ) 56 | ); 57 | await response2.json( ); 58 | 59 | const [ { files: openFilesC } ] = await lsof( { } ); 60 | 61 | const ipA = openFilesA.filter( fd => fd.type === 'IP' ); 62 | const ipB = openFilesB.filter( fd => fd.type === 'IP' ); 63 | const ipC = openFilesC.filter( fd => fd.type === 'IP' ); 64 | 65 | // 2 less because client+server 66 | expect( ipA.length ).toEqual( ipB.length - 2 ); 67 | expect( ipB.length ).toEqual( ipC.length ); 68 | expect( ipB ).toEqual( ipC ); 69 | 70 | disconnectAll( ); 71 | 72 | await server.shutdown( ); 73 | } ); 74 | } ); 75 | 76 | describe( "http1.keelAlive === false", ( ) => 77 | { 78 | it( "should send 'connection: close'", 79 | async ( ) => 80 | { 81 | const { server, port } = await makeServer( ); 82 | const { disconnectAll, fetch } = context( { 83 | ...cycleOpts, 84 | http1: { 85 | keepAlive: false, 86 | }, 87 | } ); 88 | 89 | const response1 = ensureStatusSuccess( 90 | await fetch( `http://localhost:${port}/headers` ) 91 | ); 92 | 93 | const headers = await response1.json( ); 94 | 95 | expect( headers.connection ).toBe( "close" ); 96 | 97 | disconnectAll( ); 98 | 99 | await server.shutdown( ); 100 | } ); 101 | } ); 102 | } ); 103 | } ); 104 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended" 4 | ], 5 | "jsRules": {}, 6 | "rules": { 7 | "no-consecutive-blank-lines": [ 8 | true, 9 | 2 10 | ], 11 | "indent": [ 12 | true, 13 | "tabs", 14 | 4 15 | ], 16 | "one-line": [ 17 | false, 18 | "check-catch", 19 | "check-finally", 20 | "check-else", 21 | "check-open-brace", 22 | "check-whitespace" 23 | ], 24 | "no-unused-variable": [ 25 | false 26 | ], 27 | "no-unused-expression": [ 28 | true, 29 | "allow-fast-null-checks" 30 | ], 31 | "no-angle-bracket-type-assertion": false, 32 | "whitespace": [ 33 | true, 34 | "check-branch", 35 | "check-decl", 36 | "check-operator", 37 | "check-module", 38 | "check-separator", 39 | "check-rest-spread", 40 | "check-type", 41 | "check-type-operator", 42 | "check-preblock", 43 | "check-postbrace" 44 | ], 45 | "no-trailing-whitespace": [ 46 | true, 47 | "ignore-template-strings" 48 | ], 49 | "curly": false, 50 | "no-shadowed-variable": false, 51 | "trailing-comma": [ 52 | true, 53 | { 54 | "multiline": { 55 | "objects": "always", 56 | "arrays": "always", 57 | "functions": "never", 58 | "typeLiterals": "ignore" 59 | }, 60 | "singleline": "never", 61 | "esSpecCompliant": true 62 | } 63 | ], 64 | "no-empty": [ 65 | true, 66 | "allow-empty-catch", 67 | "allow-empty-functions" 68 | ], 69 | "interface-name": [ 70 | false 71 | ], 72 | "variable-name": [ 73 | true, 74 | "ban-keywords", 75 | "check-format", 76 | "allow-leading-underscore", 77 | "allow-trailing-underscore" 78 | ], 79 | "member-ordering": [ 80 | true, 81 | { 82 | "order": [ 83 | "public-static-field", 84 | "protected-static-field", 85 | "private-static-field", 86 | "public-instance-field", 87 | "protected-instance-field", 88 | "private-instance-field", 89 | "public-constructor", 90 | "protected-constructor", 91 | "private-constructor", 92 | "public-static-method", 93 | "protected-static-method", 94 | "private-static-method", 95 | "public-instance-method", 96 | "protected-instance-method", 97 | "private-instance-method" 98 | ] 99 | } 100 | ], 101 | "arrow-parens": false, 102 | "max-classes-per-file": false, 103 | "array-type": [ 104 | true, 105 | "generic" 106 | ] 107 | }, 108 | "rulesDirectory": [] 109 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-h2", 3 | "version": "0.0.0-development", 4 | "description": "HTTP/1+2 Fetch API client for Node.js", 5 | "author": "Gustaf Räntilä", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/grantila/fetch-h2/issues" 9 | }, 10 | "homepage": "https://github.com/grantila/fetch-h2#readme", 11 | "main": "./dist/index.js", 12 | "types": "./dist/index.d.ts", 13 | "directories": {}, 14 | "engines": { 15 | "node": ">=12" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build:ts": "./node_modules/.bin/rimraf dist && ./node_modules/.bin/tsc -p .", 22 | "build:cert": "scripts/make-certs.sh", 23 | "build": "concurrently 'yarn build:ts' 'yarn build:cert'", 24 | "lint": "node_modules/.bin/tslint --project .", 25 | "jest:core": "node_modules/.bin/jest --detectOpenHandles --coverage", 26 | "jest:fast": "yarn jest:core --config jest.config.unit.js $@", 27 | "jest:exported": "node_modules/.bin/jest --config jest.config.exported.js $@", 28 | "jest:integration": "node_modules/.bin/compd -f test/docker-compose.yaml yarn jest:core", 29 | "jest:debug": "node --inspect-brk node_modules/.bin/jest", 30 | "test": "yarn lint && yarn jest:integration", 31 | "test:exported": "./node_modules/.bin/ts-node scripts/create-exported-tests.ts && yarn jest:exported", 32 | "buildtest": "npm run build && npm run jest", 33 | "buildtestcov": "npm run build && npm run test", 34 | "coveralls": "cat coverage/lcov.info | node_modules/.bin/coveralls", 35 | "version": "./node_modules/.bin/ts-node scripts/version-update.ts && npm run build && npm run test && scripts/version-git-add.sh", 36 | "clean:pack": "node_modules/.bin/rimraf dist/test* && find dist/ -name '*.map' -delete", 37 | "prepack": "npm run build && npm run test && npm run clean:pack && npm run test:exported", 38 | "makecerts": "openssl req -x509 -nodes -days 7300 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem", 39 | "cz": "git-cz" 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/grantila/fetch-h2" 44 | }, 45 | "keywords": [ 46 | "fetch", 47 | "h2", 48 | "http2", 49 | "client", 50 | "request", 51 | "api", 52 | "typesafe", 53 | "typescript" 54 | ], 55 | "devDependencies": { 56 | "@types/execa": "^2.0.0", 57 | "@types/from2": "^2.3.1", 58 | "@types/jest": "^27.0.2", 59 | "@types/node": "^16.10.2", 60 | "@types/recursive-readdir": "^2.2.0", 61 | "@types/rimraf": "^3.0.2", 62 | "@types/through2": "^2.0.36", 63 | "commitizen": "^4.2.4", 64 | "compd": "^3.0.0", 65 | "concurrently": "^6.3.0", 66 | "cz-conventional-changelog": "^3.3.0", 67 | "execa": "^5.1.1", 68 | "from2": "^2.3.0", 69 | "jest": "^27.2.4", 70 | "list-open-files": "^1.1.0", 71 | "mkcert": "^1.4.0", 72 | "recursive-readdir": "^2.2.2", 73 | "rimraf": "^3.0.2", 74 | "ts-jest": "^27.0.5", 75 | "ts-node": "^10.2.1", 76 | "tslint": "^6.1.3", 77 | "typescript": "^4.4.3" 78 | }, 79 | "dependencies": { 80 | "@types/tough-cookie": "^4.0.0", 81 | "already": "^2.2.1", 82 | "callguard": "^2.0.0", 83 | "get-stream": "^6.0.1", 84 | "through2": "^4.0.2", 85 | "to-arraybuffer": "^1.0.1", 86 | "tough-cookie": "^4.0.0" 87 | }, 88 | "config": { 89 | "commitizen": { 90 | "path": "./node_modules/cz-conventional-changelog" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/fetch-h2/origin-cache.ts: -------------------------------------------------------------------------------- 1 | import OriginCache from "../../lib/origin-cache" 2 | import { makeRegex } from "../../lib/san" 3 | 4 | 5 | describe( "Origin cache", ( ) => 6 | { 7 | it( "should handle not-found origins", async ( ) => 8 | { 9 | const oc = new OriginCache( ); 10 | 11 | expect( oc.get( "http1", "foo.com" ) ).toBeUndefined( ); 12 | } ); 13 | 14 | it( "should handle static and dynamic (wildcard) alt-names", async ( ) => 15 | { 16 | const oc = new OriginCache( ); 17 | 18 | const firstOrigin = "example.com"; 19 | const protocol = "http1"; 20 | const session = { }; 21 | 22 | oc.set( 23 | firstOrigin, 24 | protocol, 25 | session, 26 | { 27 | names: [ firstOrigin, "example.org" ], 28 | dynamic: ( origin: string ) => 29 | !!origin.match( makeRegex( "*.example.com" ) ), 30 | } 31 | ); 32 | 33 | const result = { 34 | protocol, 35 | session, 36 | firstOrigin, 37 | }; 38 | 39 | expect( oc.get( protocol, "foo.com" ) ).toBeUndefined( ); 40 | expect( oc.get( protocol, "example.com" ) ).toEqual( result ); 41 | expect( oc.get( protocol, "example.org" ) ).toEqual( result ); 42 | expect( oc.get( protocol, "foo.example.com" ) ).toEqual( result ); 43 | expect( oc.get( "http2", "example.com" ) ).toBeUndefined( ); 44 | expect( oc.get( "http2", "example.org" ) ).toBeUndefined( ); 45 | expect( oc.get( "http2", "foo.example.com" ) ).toBeUndefined( ); 46 | expect( oc.get( protocol, "sub.foo.example.com" ) ).toBeUndefined( ); 47 | } ); 48 | 49 | it( "should handle origin without alt-names (non-TLS)", async ( ) => 50 | { 51 | const oc = new OriginCache( ); 52 | 53 | const firstOrigin = "example.com"; 54 | const protocol = "http1"; 55 | const session = { }; 56 | 57 | oc.set( 58 | firstOrigin, 59 | protocol, 60 | session 61 | ); 62 | 63 | const result = { 64 | protocol, 65 | session, 66 | firstOrigin, 67 | }; 68 | 69 | expect( oc.get( protocol, "foo.com" ) ).toBeUndefined( ); 70 | expect( oc.get( protocol, "example.com" ) ).toEqual( result ); 71 | expect( oc.get( protocol, "foo.example.com" ) ).toBeUndefined( ); 72 | expect( oc.get( "http2", "example.com" ) ).toBeUndefined( ); 73 | expect( oc.get( "http2", "example.org" ) ).toBeUndefined( ); 74 | expect( oc.get( "http2", "foo.example.com" ) ).toBeUndefined( ); 75 | expect( oc.get( protocol, "sub.foo.example.com" ) ).toBeUndefined( ); 76 | } ); 77 | 78 | it( "should cleanup properly", async ( ) => 79 | { 80 | const oc = new OriginCache( ); 81 | 82 | const firstOrigin = "example.com"; 83 | const protocol = "http1"; 84 | const session = { }; 85 | 86 | oc.set( 87 | firstOrigin, 88 | protocol, 89 | session, 90 | { 91 | names: [ firstOrigin, "example.org" ], 92 | dynamic: ( origin: string ) => 93 | !!origin.match( makeRegex( "*.example.com" ) ), 94 | } 95 | ); 96 | 97 | oc.get( protocol, "foo.com" ); 98 | oc.get( protocol, "example.com" ); 99 | oc.get( protocol, "example.org" ); 100 | oc.get( protocol, "foo.example.com" ); 101 | oc.get( protocol, "sub.foo.example.com" ); 102 | 103 | expect( oc.delete( session ) ).toBe( true ); 104 | 105 | expect( ( oc as any ).sessionMap.size ).toBe( 0 ); 106 | expect( ( oc as any ).staticMap.size ).toBe( 0 ); 107 | 108 | expect( oc.delete( session ) ).toBe( false ); 109 | expect( oc.delete( "foo" ) ).toBe( false ); 110 | } ); 111 | } ); 112 | -------------------------------------------------------------------------------- /lib/origin-cache.ts: -------------------------------------------------------------------------------- 1 | import { AltNameMatch } from "./san" 2 | 3 | 4 | export type Protocol = 'https1' | 'https2' | 'http1' | 'http2'; 5 | 6 | interface State< Session > 7 | { 8 | protocol: Protocol; 9 | firstOrigin: string; 10 | session: Session; 11 | match?: AltNameMatch; 12 | resolved: Array< string >; 13 | cleanup?: ( ) => void; 14 | } 15 | 16 | function makeKey( protocol: Protocol, origin: string ) 17 | { 18 | return protocol + ":" + origin; 19 | } 20 | 21 | type AnySessionMap = { [ key in Protocol ]: unknown; }; 22 | 23 | export interface OriginCacheEntry< P, Session > 24 | { 25 | protocol: P; 26 | session: Session; 27 | firstOrigin: string; 28 | } 29 | 30 | export default class OriginCache< SessionMap extends AnySessionMap > 31 | { 32 | private sessionMap: Map< unknown, State< unknown > > = new Map( ); 33 | private staticMap: Map< string, State< unknown > > = new Map( ); 34 | 35 | public get< P extends Protocol >( protocol: P, origin: string ) 36 | : OriginCacheEntry< typeof protocol, SessionMap[ P ] > | undefined 37 | { 38 | const key = makeKey( protocol, origin ); 39 | 40 | const stateByStatic = this.staticMap.get( key ); 41 | if ( stateByStatic ) 42 | return { 43 | protocol: stateByStatic.protocol as P, 44 | session: stateByStatic.session, 45 | firstOrigin: stateByStatic.firstOrigin, 46 | }; 47 | 48 | const stateByDynamic = [ ...this.sessionMap.values( ) ].find( state => 49 | state.protocol === protocol && 50 | state.match && 51 | state.match.dynamic && 52 | state.match.dynamic( origin ) 53 | ); 54 | 55 | if ( stateByDynamic ) 56 | { 57 | // An origin matching a dynamic (wildcard) alt-name was found. 58 | // Cache this to find it statically in the future. 59 | stateByDynamic.resolved.push( origin ); 60 | this.staticMap.set( key, stateByDynamic ); 61 | return { 62 | protocol: stateByDynamic.protocol as P, 63 | session: stateByDynamic.session, 64 | firstOrigin: stateByDynamic.firstOrigin, 65 | }; 66 | } 67 | } 68 | 69 | public set( 70 | origin: string, 71 | protocol: Protocol, 72 | session: SessionMap[ typeof protocol ], 73 | altNameMatch?: AltNameMatch, 74 | cleanup?: ( ) => void 75 | ) 76 | { 77 | const state: State< typeof session > = { 78 | protocol, 79 | firstOrigin: origin, 80 | session, 81 | match: altNameMatch, 82 | resolved: [ ], 83 | cleanup, 84 | }; 85 | 86 | this.sessionMap.set( session, state ); 87 | 88 | if ( altNameMatch ) 89 | altNameMatch.names.forEach( origin => 90 | { 91 | this.staticMap.set( makeKey( protocol, origin ), state ); 92 | } ); 93 | 94 | this.staticMap.set( makeKey( protocol, origin ), state ); 95 | } 96 | 97 | // Returns true if a session was deleted, false otherwise 98 | public delete( session: SessionMap[ keyof SessionMap ] ) 99 | { 100 | const state = this.sessionMap.get( session ); 101 | 102 | if ( !state ) 103 | return false; 104 | 105 | [ 106 | state.firstOrigin, 107 | ...state.resolved, 108 | ...( state.match?.names ?? [ ] ), 109 | ] 110 | .forEach( origin => 111 | { 112 | this.staticMap.delete( makeKey( state.protocol, origin ) ); 113 | } ); 114 | this.sessionMap.delete( session ); 115 | 116 | return true; 117 | } 118 | 119 | public disconnectAll( ) 120 | { 121 | [ ...this.sessionMap ].forEach( ( [ _, session ] ) => 122 | { 123 | session.cleanup?.( ); 124 | } ); 125 | 126 | this.sessionMap.clear( ); 127 | this.staticMap.clear( ); 128 | } 129 | 130 | public disconnect( origin: string ) 131 | { 132 | [ 133 | this.get( 'https1', origin ), 134 | this.get( 'https2', origin ), 135 | this.get( 'http1', origin ), 136 | this.get( 'http2', origin ), 137 | ] 138 | .filter( < T >( t: T ): t is NonNullable< T > => !!t ) 139 | .forEach( ( { session } ) => 140 | { 141 | this.sessionMap.get( session )?.cleanup?.( ); 142 | this.delete( session ); 143 | } ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /test/fetch-h2/abort.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbortController, 3 | AbortError, 4 | fetch, 5 | } from "../../index"; 6 | 7 | import { Server } from "../lib/server-common"; 8 | import { makeServer as makeServerHttp1 } from "../lib/server-http1"; 9 | import { makeServer as makeServerHttp2 } from "../lib/server-http2"; 10 | import { ensureStatusSuccess } from "../lib/utils"; 11 | 12 | type Protocols = "http1" | "http2"; 13 | const protos: Array< Protocols > = [ "http1", "http2" ]; 14 | 15 | async function makeServer( proto: Protocols ) 16 | : Promise< { server: Server; port: number | null; } > 17 | { 18 | if ( proto === "http1" ) 19 | return makeServerHttp1( ); 20 | else if ( proto === "http2" ) 21 | return makeServerHttp2( ); 22 | return < any >void 0; 23 | } 24 | 25 | const testProtos = protos.map( proto => ( { 26 | makeServer: ( ) => makeServer( proto ), 27 | proto: proto === "http1" ? "http" : "http2", 28 | version: proto, 29 | } ) ); 30 | 31 | describe( "abort", ( ) => 32 | { 33 | describe( "AbortController", ( ) => 34 | { 35 | it( "should create proper signal and trigger abort once", async ( ) => 36 | { 37 | const controller = new AbortController( ); 38 | 39 | const signal = controller.signal; 40 | 41 | const spy = jest.fn( ); 42 | 43 | signal.on( "abort", spy ); 44 | 45 | expect( signal.aborted ).toBe( false ); 46 | controller.abort( ); 47 | expect( signal.aborted ).toBe( true ); 48 | controller.abort( ); 49 | expect( signal.aborted ).toBe( true ); 50 | 51 | expect( spy.mock.calls.length ).toBe( 1 ); 52 | } ); 53 | 54 | it( "should be destructable", async ( ) => 55 | { 56 | const { signal, abort } = new AbortController( ); 57 | 58 | const spy = jest.fn( ); 59 | 60 | signal.on( "abort", spy ); 61 | 62 | expect( signal.aborted ).toBe( false ); 63 | abort( ); 64 | expect( signal.aborted ).toBe( true ); 65 | abort( ); 66 | expect( signal.aborted ).toBe( true ); 67 | 68 | expect( spy.mock.calls.length ).toBe( 1 ); 69 | } ); 70 | 71 | it( "signal.onaborted should trigger once", async ( ) => 72 | { 73 | const { signal, abort } = new AbortController( ); 74 | 75 | const spy = jest.fn( ); 76 | 77 | signal.onabort = spy; 78 | 79 | expect( signal.aborted ).toBe( false ); 80 | abort( ); 81 | expect( signal.aborted ).toBe( true ); 82 | abort( ); 83 | expect( signal.aborted ).toBe( true ); 84 | 85 | expect( spy.mock.calls.length ).toBe( 1 ); 86 | } ); 87 | } ); 88 | 89 | testProtos.forEach( ( { proto, makeServer, version } ) => 90 | describe( `fetch (${version})`, ( ) => 91 | { 92 | it( "should handle pre-aborted", async ( ) => 93 | { 94 | const { signal, abort } = new AbortController( ); 95 | 96 | const { server, port } = await makeServer( ); 97 | 98 | abort( ); 99 | 100 | const awaitFetch = 101 | fetch( `${proto}://localhost:${port}/delay/100`, { signal } ); 102 | 103 | await expect( awaitFetch ).rejects.toThrowError( AbortError ); 104 | 105 | await server.shutdown( ); 106 | } ); 107 | 108 | it( "should handle abort on request", async ( ) => 109 | { 110 | const { signal, abort } = new AbortController( ); 111 | 112 | const { server, port } = await makeServer( ); 113 | 114 | setTimeout( abort, 20 ); 115 | 116 | const awaitFetch = 117 | fetch( `${proto}://localhost:${port}/delay/100`, { signal } ); 118 | 119 | await expect( awaitFetch ).rejects.toThrowError( AbortError ); 120 | 121 | await server.shutdown( ); 122 | } ); 123 | 124 | it( "should handle abort on body", async ( ) => 125 | { 126 | const { signal, abort } = new AbortController( ); 127 | 128 | const { server, port } = await makeServer( ); 129 | 130 | setTimeout( abort, 100 ); 131 | 132 | const response = ensureStatusSuccess( 133 | await fetch( `${proto}://localhost:${port}/slow/200`, { signal } ) 134 | ); 135 | 136 | const awaitBody = response.arrayBuffer( ); 137 | 138 | await expect( awaitBody ).rejects.toThrowError( AbortError ); 139 | 140 | await server.shutdown( ); 141 | } ); 142 | } ) ); 143 | } ); 144 | -------------------------------------------------------------------------------- /test/fetch-h2/san.ts: -------------------------------------------------------------------------------- 1 | import { parseOrigin, makeRegex } from "../../lib/san" 2 | import { PeerCertificate } from "tls" 3 | 4 | 5 | describe( "SAN", ( ) => 6 | { 7 | describe( "makeRegex", ( ) => 8 | { 9 | it( "should handle non alpha-numeric characters right", async ( ) => 10 | { 11 | const regex = makeRegex( "*.example-domain.com" ); 12 | 13 | expect( regex ).toBe( "^[^.]+\\.example\\-domain\\.com$" ); 14 | 15 | const re = new RegExp( regex ); 16 | const testOrigin = "foo.example-domain.com"; 17 | const m = testOrigin.match( re ) as RegExpMatchArray; 18 | 19 | expect( m[ 0 ] ).toBe( testOrigin ); 20 | } ); 21 | 22 | it( "should not allow sub-domains", async ( ) => 23 | { 24 | const regex = makeRegex( "*.example-domain.com" ); 25 | 26 | const re = new RegExp( regex ); 27 | const testOrigin = "sub.foo.example-domain.com"; 28 | 29 | expect( testOrigin.match( re ) ).toBeNull( ); 30 | } ); 31 | } ); 32 | 33 | it( "Should match on CN when no SAN is provided (plain)", ( ) => 34 | { 35 | const cert = { subject: { CN: "foo.com" } } as PeerCertificate; 36 | const { names, dynamic } = parseOrigin( cert ); 37 | expect( names ).toStrictEqual( [ "foo.com" ] ); 38 | expect( dynamic ).toBe( undefined ); 39 | } ); 40 | 41 | it( "Should match on CN when no SAN is provided (dynamic)", ( ) => 42 | { 43 | const cert = { subject: { CN: "*.foo.com" } } as PeerCertificate; 44 | const { names, dynamic } = parseOrigin( cert ); 45 | expect( names.length ).toBe( 0 ); 46 | expect( dynamic?.( "test.foo.com" ) ).toBe( true ); 47 | } ); 48 | 49 | describe( "Multi wildcard domains", ( ) => 50 | { 51 | it( "Should throw on double-wildcards", ( ) => 52 | { 53 | const cert = { subject: { CN: "*.*.foo.com" } } as PeerCertificate; 54 | const test = ( ) => parseOrigin( cert ); 55 | expect( test ).toThrow( /invalid/i ); 56 | } ); 57 | 58 | const subjectaltname = [ 59 | "DNS:foo.com", 60 | "DNS:bar.com", 61 | "DNS:example1.com", 62 | "DNS:*.example1.com", 63 | "DNS:*.example2.com", 64 | ].join( ", " ); 65 | 66 | const certs = [ 67 | { 68 | name: "CN is wildcard", 69 | cert: { 70 | subject: { CN: "*.example1.com" }, 71 | subjectaltname, 72 | } as PeerCertificate, 73 | }, 74 | { 75 | name: "CN is plain", 76 | cert: { 77 | subject: { CN: "example1.com" }, 78 | subjectaltname, 79 | } as PeerCertificate, 80 | }, 81 | { 82 | name: "CN is wildcard but not in SAN", 83 | cert: { 84 | subject: { CN: "*.invalid.com" }, 85 | subjectaltname, 86 | } as PeerCertificate, 87 | }, 88 | { 89 | name: "CN is plain but not in SAN", 90 | cert: { 91 | subject: { CN: "invalid.com" }, 92 | subjectaltname, 93 | } as PeerCertificate, 94 | }, 95 | ]; 96 | 97 | certs.forEach( ( { name, cert } ) => describe( name, ( ) => 98 | { 99 | it( `Should not match other domains`, ( ) => 100 | { 101 | const { names, dynamic } = parseOrigin( cert ); 102 | 103 | expect( names.includes( "invalid.com" ) ).toBe( false ); 104 | expect( dynamic?.( "invalid.com" ) ).toBe( false ); 105 | expect( dynamic?.( "test.invalid.com" ) ).toBe( false ); 106 | expect( dynamic?.( "sub.foo.com" ) ).toBe( false ); 107 | expect( dynamic?.( "sub.bar.com" ) ).toBe( false ); 108 | } ); 109 | 110 | it( `Should handle plain names`, ( ) => 111 | { 112 | const match = parseOrigin( cert ); 113 | 114 | expect( match.dynamic?.( "foo.com" ) ).toBe( false ); 115 | expect( match.dynamic?.( "bar.com" ) ).toBe( false ); 116 | expect( match.names.includes( "foo.com" ) ).toBe( true ); 117 | expect( match.names.includes( "bar.com" ) ).toBe( true ); 118 | expect( match.names.includes( "example1.com" ) ).toBe( true ); 119 | } ); 120 | 121 | it( `Should not wildcard plain names`, ( ) => 122 | { 123 | const match = parseOrigin( cert ); 124 | 125 | expect( match.dynamic?.( "sub.example1.com" ) ).toBe( true ); 126 | expect( match.dynamic?.( "sub.example2.com" ) ).toBe( true ); 127 | } ); 128 | } ) ); 129 | } ); 130 | } ); 131 | -------------------------------------------------------------------------------- /lib/request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CacheTypes, 3 | CredentialsTypes, 4 | Method, 5 | ModeTypes, 6 | RedirectTypes, 7 | ReferrerPolicyTypes, 8 | ReferrerTypes, 9 | RequestInit, 10 | RequestInitWithoutBody, 11 | RequestInitWithUrl, 12 | } from "./core"; 13 | 14 | import { Body, JsonBody } from "./body"; 15 | import { GuardedHeaders, Headers } from "./headers"; 16 | 17 | 18 | const defaultInit: Partial< RequestInit > = { 19 | allowForbiddenHeaders: false, 20 | cache: "default", 21 | credentials: "omit", 22 | method: "GET", 23 | mode: "same-origin", 24 | redirect: "manual", 25 | referrer: "client", 26 | }; 27 | 28 | export class Request extends Body implements RequestInitWithoutBody 29 | { 30 | // @ts-ignore 31 | public readonly method: Method; 32 | // @ts-ignore 33 | public readonly url: string; 34 | // @ts-ignore 35 | public readonly headers: Headers; 36 | // @ts-ignore 37 | public readonly referrer: ReferrerTypes; 38 | // @ts-ignore 39 | public readonly referrerPolicy: ReferrerPolicyTypes; 40 | // @ts-ignore 41 | public readonly mode: ModeTypes; 42 | // @ts-ignore 43 | public readonly credentials: CredentialsTypes; 44 | // @ts-ignore 45 | public readonly redirect: RedirectTypes; 46 | // @ts-ignore 47 | public readonly integrity: string; 48 | // @ts-ignore 49 | public readonly cache: CacheTypes; 50 | // @ts-ignore 51 | public readonly allowForbiddenHeaders: boolean; 52 | 53 | private _url: string; 54 | private _init: Partial< RequestInit >; 55 | 56 | constructor( input: string | Request, init?: Partial< RequestInitWithUrl > ) 57 | { 58 | super( ); 59 | 60 | const { url: overwriteUrl } = init || ( { } as RequestInitWithUrl ); 61 | 62 | // TODO: Consider throwing a TypeError if the URL has credentials 63 | this._url = 64 | input instanceof Request 65 | ? ( overwriteUrl || input._url ) 66 | : ( overwriteUrl || input ); 67 | 68 | if ( input instanceof Request ) 69 | { 70 | if ( input.hasBody( ) ) 71 | // Move body to this request 72 | this.setBody( input ); 73 | 74 | const newInit: Partial< RequestInit > = Object.assign( 75 | { }, 76 | input, 77 | init 78 | ); 79 | init = newInit; 80 | 81 | // TODO: Follow MDN: 82 | // If this object exists on another origin to the 83 | // constructor call, the Request.referrer is stripped out. 84 | // If this object has a Request.mode of navigate, the mode 85 | // value is converted to same-origin. 86 | } 87 | 88 | this._init = Object.assign( { }, defaultInit, init ); 89 | const allowForbiddenHeaders = 90 | < boolean >this._init.allowForbiddenHeaders; 91 | 92 | const headers = new GuardedHeaders( 93 | allowForbiddenHeaders 94 | ? "none" 95 | : this._init.mode === "no-cors" 96 | ? "request-no-cors" 97 | : "request", 98 | this._init.headers 99 | ); 100 | 101 | if ( this._init.body && this._init.json ) 102 | throw new Error( "Cannot specify both 'body' and 'json'" ); 103 | 104 | if ( !this.hasBody( ) && this._init.body ) 105 | { 106 | if ( headers.has( "content-type" ) ) 107 | this.setBody( this._init.body, headers.get( "content-type" ) ); 108 | else 109 | this.setBody( this._init.body ); 110 | } 111 | else if ( !this.hasBody( ) && this._init.json ) 112 | { 113 | this.setBody( new JsonBody( this._init.json ) ); 114 | } 115 | 116 | Object.defineProperties( this, { 117 | allowForbiddenHeaders: { 118 | enumerable: true, 119 | value: allowForbiddenHeaders, 120 | }, 121 | cache: { 122 | enumerable: true, 123 | value: this._init.cache, 124 | }, 125 | credentials: { 126 | enumerable: true, 127 | value: this._init.credentials, 128 | }, 129 | headers: { 130 | enumerable: true, 131 | value: headers, 132 | }, 133 | integrity: { 134 | enumerable: true, 135 | value: this._init.integrity, 136 | }, 137 | method: { 138 | enumerable: true, 139 | value: this._init.method, 140 | }, 141 | mode: { 142 | enumerable: true, 143 | value: this._init.mode, 144 | }, 145 | redirect: { 146 | enumerable: true, 147 | value: this._init.redirect, 148 | }, 149 | referrer: { 150 | enumerable: true, 151 | value: this._init.referrer, 152 | }, 153 | referrerPolicy: { 154 | enumerable: true, 155 | value: this._init.referrerPolicy, 156 | }, 157 | url: { 158 | enumerable: true, 159 | value: this._url, 160 | }, 161 | } ); 162 | } 163 | 164 | public clone( newUrl?: string ): Request 165 | { 166 | return new Request( this, { url: newUrl } ); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /lib/core.ts: -------------------------------------------------------------------------------- 1 | import { AbortSignal } from "./abort"; 2 | import { Headers, RawHeaders } from "./headers"; 3 | 4 | 5 | export type Method = 6 | "ACL" | 7 | "BIND" | 8 | "CHECKOUT" | 9 | "CONNECT" | 10 | "COPY" | 11 | "DELETE" | 12 | "GET" | 13 | "HEAD" | 14 | "LINK" | 15 | "LOCK" | 16 | "M-SEARCH" | 17 | "MERGE" | 18 | "MKACTIVITY" | 19 | "MKCALENDAR" | 20 | "MKCOL" | 21 | "MOVE" | 22 | "NOTIFY" | 23 | "OPTIONS" | 24 | "PATCH" | 25 | "POST" | 26 | "PROPFIND" | 27 | "PROPPATCH" | 28 | "PURGE" | 29 | "PUT" | 30 | "REBIND" | 31 | "REPORT" | 32 | "SEARCH" | 33 | "SUBSCRIBE" | 34 | "TRACE" | 35 | "UNBIND" | 36 | "UNLINK" | 37 | "UNLOCK" | 38 | "UNSUBSCRIBE"; 39 | 40 | export type StorageBodyTypes = 41 | Buffer | NodeJS.ReadableStream; 42 | 43 | export type BodyTypes = 44 | StorageBodyTypes | string; 45 | 46 | export type ModeTypes = 47 | "cors" | 48 | "no-cors" | 49 | "same-origin"; 50 | 51 | export type CredentialsTypes = 52 | "omit" | 53 | "same-origin" | 54 | "include"; 55 | 56 | export type CacheTypes = 57 | "default" | 58 | "no-store" | 59 | "reload" | 60 | "no-cache" | 61 | "force-cache" | 62 | "only-if-cached"; 63 | 64 | export type RedirectTypes = 65 | "follow" | 66 | "error" | 67 | "manual"; 68 | 69 | export type SpecialReferrerTypes = 70 | "no-referrer" | 71 | "client"; 72 | 73 | export type ReferrerTypes = 74 | SpecialReferrerTypes | 75 | string; 76 | 77 | export type ReferrerPolicyTypes = 78 | "no-referrer" | 79 | "no-referrer-when-downgrade" | 80 | "origin" | 81 | "origin-when-cross-origin" | 82 | "unsafe-url"; 83 | 84 | export type ResponseTypes = 85 | "basic" | 86 | "cors" | 87 | "error"; 88 | 89 | export type HttpProtocols = "http1" | "http2"; 90 | 91 | export type HttpVersion = 1 | 2; 92 | 93 | export interface IBody 94 | { 95 | readonly bodyUsed: boolean; 96 | arrayBuffer( ): Promise< ArrayBuffer >; 97 | formData( ): Promise< any /* FormData */ >; 98 | json( ): Promise< any >; 99 | text( ): Promise< string >; 100 | readable( ): Promise< NodeJS.ReadableStream >; 101 | } 102 | 103 | export interface RequestInitWithoutBody 104 | { 105 | method: Method; 106 | headers: RawHeaders | Headers; 107 | mode: ModeTypes; 108 | credentials: CredentialsTypes; 109 | cache: CacheTypes; 110 | redirect: RedirectTypes; 111 | referrer: ReferrerTypes; 112 | referrerPolicy: ReferrerPolicyTypes; 113 | integrity: string; 114 | allowForbiddenHeaders: boolean; 115 | } 116 | 117 | export interface RequestInit extends RequestInitWithoutBody 118 | { 119 | body: BodyTypes | IBody; 120 | json: any; 121 | } 122 | 123 | export interface RequestInitWithUrl extends RequestInit 124 | { 125 | url: string; 126 | } 127 | 128 | export type OnTrailers = ( headers: Headers ) => void; 129 | 130 | export interface FetchInit extends RequestInit 131 | { 132 | signal: AbortSignal; 133 | 134 | // This is a helper (just like node-fetch), not part of the Fetch API. 135 | // Must not be used if signal is used. 136 | // In milliseconds. 137 | timeout: number; 138 | 139 | // Callback for trailing headers 140 | onTrailers: OnTrailers; 141 | } 142 | 143 | export interface ResponseInit 144 | { 145 | status: number; 146 | statusText: string; 147 | headers: RawHeaders | Headers; 148 | allowForbiddenHeaders: boolean; 149 | } 150 | 151 | export class FetchError extends Error 152 | { 153 | constructor( message: string ) 154 | { 155 | super( message ); 156 | Object.setPrototypeOf( this, FetchError.prototype ); 157 | } 158 | } 159 | 160 | export class AbortError extends Error 161 | { 162 | constructor( message: string ) 163 | { 164 | super( message ); 165 | Object.setPrototypeOf( this, AbortError.prototype ); 166 | } 167 | } 168 | 169 | export class TimeoutError extends Error 170 | { 171 | constructor( message: string ) 172 | { 173 | super( message ); 174 | Object.setPrototypeOf( this, TimeoutError.prototype ); 175 | } 176 | } 177 | 178 | export class RetryError extends Error 179 | { 180 | constructor( message: string ) 181 | { 182 | super( message ); 183 | Object.setPrototypeOf( this, RetryError.prototype ); 184 | } 185 | } 186 | 187 | export type DecodeFunction = 188 | ( stream: NodeJS.ReadableStream ) => NodeJS.ReadableStream; 189 | 190 | export interface Decoder 191 | { 192 | name: string; 193 | decode: DecodeFunction; 194 | } 195 | 196 | export type PerOrigin< T > = ( origin: string ) => T; 197 | 198 | export function getByOrigin< T >( 199 | val: T | PerOrigin< T >, 200 | origin: string 201 | ) 202 | : T 203 | { 204 | return typeof val === "function" 205 | ? ( < PerOrigin< T > >val )( origin ) 206 | : val; 207 | } 208 | 209 | export function parsePerOrigin< T >( 210 | val: T | PerOrigin< T > | undefined, 211 | _default: T 212 | ) 213 | : T | PerOrigin< T > 214 | { 215 | if ( val == null ) 216 | { 217 | return _default; 218 | } 219 | 220 | if ( typeof val === "function" ) 221 | return ( origin: string ) => 222 | { 223 | const ret = ( < PerOrigin< T > >val )( origin ); 224 | if ( ret == null ) 225 | return _default; 226 | return ret; 227 | }; 228 | 229 | return val; 230 | } 231 | 232 | export interface Http1Options 233 | { 234 | keepAlive: boolean | PerOrigin< boolean >; 235 | keepAliveMsecs: number | PerOrigin< number >; 236 | maxSockets: number | PerOrigin< number >; 237 | maxFreeSockets: number | PerOrigin< number >; 238 | timeout: void | number | PerOrigin< void | number >; 239 | } 240 | -------------------------------------------------------------------------------- /test/integration/httpbin.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url"; 2 | import * as fs from "fs"; 3 | 4 | import { delay } from "already"; 5 | import * as through2 from "through2"; 6 | 7 | import { 8 | context, 9 | DataBody, 10 | fetch as fetchType, 11 | disconnectAll as disconnectAllType, 12 | HttpProtocols, 13 | JsonBody, 14 | StreamBody, 15 | } from "../../index"; 16 | 17 | 18 | interface TestData 19 | { 20 | scheme: "http:" | "https:"; 21 | site: string; 22 | protos: Array< HttpProtocols >; 23 | certs?: boolean; 24 | } 25 | 26 | type TestFunction = 27 | ( fetch: typeof fetchType, disconnectAll: typeof disconnectAllType ) => 28 | Promise< void >; 29 | 30 | const ca = fs.readFileSync( "/tmp/fetch-h2-certs/ca.pem" ); 31 | const cert = fs.readFileSync( "/tmp/fetch-h2-certs/cert.pem" ); 32 | 33 | const http1bin = `localhost:${process.env.HTTP1BIN_PORT}`; 34 | const http2bin = `localhost:${process.env.HTTP2BIN_PORT}`; 35 | const https1bin = `localhost:${process.env.HTTPS1PROXY_PORT}`; 36 | 37 | ( [ 38 | { scheme: "http:", site: http2bin, protos: [ "http2" ] }, 39 | { scheme: "http:", site: http1bin, protos: [ "http1" ] }, 40 | { scheme: "https:", site: https1bin, protos: [ "http1" ], certs: false }, 41 | { scheme: "https:", site: https1bin, protos: [ "http1" ], certs: true }, 42 | ] as Array< TestData > ) 43 | .forEach( ( { site, scheme, protos, certs } ) => 44 | { 45 | const host = `${scheme}//${site}`; 46 | const baseHost = new URL( host ).origin; 47 | 48 | const name = `${site} (${protos[ 0 ]} over ${scheme.replace( ":", "" )})` + 49 | ( certs ? ' (using explicit certificates)' : '' ); 50 | 51 | describe( name, ( ) => 52 | { 53 | function wrapContext( fn: TestFunction ) 54 | { 55 | return async ( ) => 56 | { 57 | const { fetch, disconnectAll } = context( { 58 | httpsProtocols: protos, 59 | session: certs 60 | ? { ca, cert, rejectUnauthorized: false } 61 | : { rejectUnauthorized: false }, 62 | } ); 63 | 64 | // Disconnection shouldn't be necessary, fetch-h2 should unref 65 | // the sockets correctly. 66 | await fn( fetch, disconnectAll ); 67 | }; 68 | } 69 | 70 | it( "should be possible to GET", wrapContext( async ( fetch ) => 71 | { 72 | const response = await fetch( `${host}/user-agent` ); 73 | const data = await response.json( ); 74 | expect( data[ "user-agent" ] ).toContain( "fetch-h2/" ); 75 | } ) ); 76 | 77 | it( "should be possible to POST JSON", wrapContext( async ( fetch ) => 78 | { 79 | const testData = { foo: "bar" }; 80 | 81 | const response = await fetch( 82 | `${host}/post`, 83 | { 84 | body: new JsonBody( testData ), 85 | method: "POST", 86 | } 87 | ); 88 | const data = await response.json( ); 89 | expect( testData ).toEqual( data.json ); 90 | // fetch-h2 should set content type for JsonBody 91 | expect( data.headers[ "Content-Type" ] ).toBe( "application/json" ); 92 | } ) ); 93 | 94 | it( "should be possible to POST buffer-data", wrapContext( 95 | async ( fetch ) => 96 | { 97 | const testData = '{"foo": "data"}'; 98 | 99 | const response = await fetch( 100 | `${host}/post`, 101 | { 102 | body: new DataBody( testData ), 103 | method: "POST", 104 | } 105 | ); 106 | const data = await response.json( ); 107 | expect( data.data ).toBe( testData ); 108 | expect( data.headers ).not.toHaveProperty( "Content-Type" ); 109 | } ) ); 110 | 111 | it( "should be possible to POST already ended stream-data", 112 | wrapContext( async ( fetch ) => 113 | { 114 | const stream = through2( ); 115 | 116 | stream.write( "foo" ); 117 | stream.write( "bar" ); 118 | stream.end( ); 119 | 120 | const response = await fetch( 121 | `${host}/post`, 122 | { 123 | allowForbiddenHeaders: true, 124 | body: new StreamBody( stream ), 125 | headers: { "content-length": "6" }, 126 | method: "POST", 127 | } 128 | ); 129 | 130 | const data = await response.json( ); 131 | expect( data.data ).toBe( "foobar" ); 132 | } ) ); 133 | 134 | it( "should be possible to POST not yet ended stream-data", 135 | wrapContext( async ( fetch ) => 136 | { 137 | const stream = through2( ); 138 | 139 | const eventualResponse = fetch( 140 | `${host}/post`, 141 | { 142 | allowForbiddenHeaders: true, 143 | body: new StreamBody( stream ), 144 | headers: { "content-length": "6" }, 145 | method: "POST", 146 | } 147 | ); 148 | 149 | await delay( 1 ); 150 | 151 | stream.write( "foo" ); 152 | stream.write( "bar" ); 153 | stream.end( ); 154 | 155 | const response = await eventualResponse; 156 | 157 | const data = await response.json( ); 158 | expect( data.data ).toBe( "foobar" ); 159 | } ) ); 160 | 161 | it( "should save and forward cookies", 162 | wrapContext( async ( fetch, disconnectAll ) => 163 | { 164 | const responseSet = await fetch( 165 | `${host}/cookies/set?foo=bar`, 166 | { redirect: "manual" } ); 167 | 168 | expect( responseSet.headers.has( "location" ) ).toBe( true ); 169 | const redirectedTo = responseSet.headers.get( "location" ); 170 | if ( scheme === "https:" ) 171 | // Over TLS, we need to read the payload, or the socket will not 172 | // deref. 173 | await responseSet.text( ); 174 | 175 | const response = await fetch( baseHost + redirectedTo ); 176 | 177 | const data = await response.json( ); 178 | expect( data.cookies ).toEqual( { foo: "bar" } ); 179 | 180 | await disconnectAll( ); 181 | } ) ); 182 | 183 | it( "should handle (and follow) relative paths", 184 | wrapContext( async ( fetch ) => 185 | { 186 | 187 | const response = await fetch( 188 | `${host}/relative-redirect/2`, 189 | { redirect: "follow" } ); 190 | 191 | expect( response.url ).toBe( `${host}/get` ); 192 | await response.text( ); 193 | } ) ); 194 | 195 | it( "should be possible to GET gzip data", wrapContext( 196 | async ( fetch ) => 197 | { 198 | const response = await fetch( `${host}/gzip` ); 199 | const data = await response.json( ); 200 | expect( data ).toMatchObject( { gzipped: true, method: "GET" } ); 201 | } ) ); 202 | } ); 203 | } ); 204 | -------------------------------------------------------------------------------- /lib/headers.ts: -------------------------------------------------------------------------------- 1 | import { arrayify } from "./utils"; 2 | 3 | 4 | export type GuardTypes = 5 | "immutable" | "request" | "request-no-cors" | "response" | "none"; 6 | 7 | export interface RawHeaders 8 | { 9 | [ key: string ]: string | Array< string > | undefined; 10 | } 11 | 12 | type HeaderMap = Map< string, Array< string > >; 13 | 14 | 15 | const forbiddenHeaders = [ 16 | "accept-charset", 17 | "accept-encoding", 18 | "access-control-request-headers", 19 | "access-control-request-method", 20 | "connection", 21 | "content-length", 22 | "cookie", 23 | "cookie2", 24 | "date", 25 | "dnt", 26 | "expect", 27 | "host", 28 | "keep-alive", 29 | "origin", 30 | "referer", 31 | "te", 32 | "trailer", 33 | "transfer-encoding", 34 | "upgrade", 35 | "via", 36 | ]; 37 | 38 | function isForbiddenHeader( name: string ): boolean 39 | { 40 | if ( name.startsWith( "proxy-" ) || name.startsWith( "sec-" ) ) 41 | // Safe headers 42 | return false; 43 | 44 | return forbiddenHeaders.includes( name ); 45 | } 46 | 47 | function isForbiddenResponseHeader( name: string ) 48 | { 49 | return [ "set-cookie", "set-cookie2" ].includes( name ); 50 | } 51 | 52 | function isSimpleHeader( name: string, value?: string ): boolean 53 | { 54 | const simpleHeaders = [ 55 | "accept", 56 | "accept-language", 57 | "content-language", 58 | 59 | "dpr", 60 | "downlink", 61 | "save-data", 62 | "viewport-width", 63 | "width", 64 | ]; 65 | 66 | if ( simpleHeaders.includes( name ) ) 67 | return true; 68 | 69 | if ( name !== "content-type" ) 70 | return false; 71 | 72 | if ( value == null ) 73 | return false; 74 | 75 | const mimeType = value.replace( /;.*/, "" ).toLowerCase( ); 76 | 77 | return [ 78 | "application/x-www-form-urlencoded", 79 | "multipart/form-data", 80 | "text/plain", 81 | ].includes( mimeType ); 82 | } 83 | 84 | function filterName( name: string ): string 85 | { 86 | if ( /[^A-Za-z0-9\-#$%&'*+.\^_`|~]/.test( name ) ) 87 | throw new TypeError( 88 | "Invalid character in header field name: " + name ); 89 | 90 | return name.toLowerCase( ); 91 | } 92 | 93 | function _ensureGuard( 94 | guard: GuardTypes, 95 | name?: string, 96 | value?: string 97 | ) 98 | : void 99 | { 100 | if ( guard === "immutable" ) 101 | throw new TypeError( 102 | "Header guard error: Cannot change immutable header" ); 103 | 104 | if ( !name ) 105 | return; 106 | 107 | if ( guard === "request" && isForbiddenHeader( name ) ) 108 | throw new TypeError( 109 | "Header guard error: " + 110 | "Cannot set forbidden header for requests" + 111 | ` (${name})` ); 112 | 113 | if ( guard === "request-no-cors" && !isSimpleHeader( name, value ) ) 114 | throw new TypeError( 115 | "Header guard error: " + 116 | "Cannot set non-simple header for no-cors requests" + 117 | ` (${name})` ); 118 | 119 | if ( guard === "response" && isForbiddenResponseHeader( name ) ) 120 | throw new TypeError( 121 | "Header guard error: " + 122 | "Cannot set forbidden response header for response" + 123 | ` (${name})` ); 124 | } 125 | 126 | let _guard: GuardTypes | null = null; 127 | 128 | export class Headers 129 | { 130 | protected _guard: GuardTypes; 131 | private _data: HeaderMap; 132 | 133 | constructor( init?: RawHeaders | Headers ) 134 | { 135 | this._guard = < GuardTypes >_guard || "none"; 136 | _guard = null; 137 | this._data = new Map( ); 138 | 139 | const set = ( name: string, values: ReadonlyArray< string > ) => 140 | { 141 | if ( values.length === 1 ) 142 | this.set( name, values[ 0 ] ); 143 | else 144 | for ( const value of values ) 145 | this.append( name, value ); 146 | }; 147 | 148 | if ( !init ) 149 | return; 150 | 151 | else if ( init instanceof Headers ) 152 | { 153 | for ( const [ name, values ] of init._data.entries( ) ) 154 | set( name, values ); 155 | } 156 | 157 | else 158 | { 159 | for ( const _name of Object.keys( init ) ) 160 | { 161 | const name = filterName( _name ); 162 | const value = arrayify( init[ _name ] ) 163 | .map( val => `${val}` ); 164 | set( name, [ ...value ] ); 165 | } 166 | } 167 | } 168 | 169 | get [ Symbol.toStringTag ]( ) 170 | { 171 | return "Map"; // This causes unit test libraries to treat this as a Map 172 | } 173 | 174 | public [ Symbol.iterator ]( ) 175 | { 176 | return this.entries( ); 177 | } 178 | 179 | public append( name: string, value: string ): void 180 | { 181 | const _name = filterName( name ); 182 | 183 | _ensureGuard( this._guard, _name, value ); 184 | 185 | if ( !this._data.has( _name ) ) 186 | this._data.set( _name, [ value ] ); 187 | 188 | else 189 | ( < Array< string > >this._data.get( _name ) ).push( value ); 190 | } 191 | 192 | public delete( name: string ): void 193 | { 194 | const _name = filterName( name ); 195 | 196 | _ensureGuard( this._guard ); 197 | 198 | this._data.delete( _name ); 199 | } 200 | 201 | public *entries( ): IterableIterator< [ string, string ] > 202 | { 203 | for ( const [ name, value ] of this._data.entries( ) ) 204 | yield [ name, value.join( "," ) ]; 205 | } 206 | 207 | public get( name: string ): string | null 208 | { 209 | const _name = filterName( name ); 210 | 211 | return this._data.has( _name ) 212 | ? ( < Array< string > >this._data.get( _name ) ).join( "," ) 213 | : null; 214 | } 215 | 216 | public has( name: string ): boolean 217 | { 218 | return this._data.has( filterName( name ) ); 219 | } 220 | 221 | public keys( ): IterableIterator< string > 222 | { 223 | return this._data.keys( ); 224 | } 225 | 226 | public set( name: string, value: string ): void 227 | { 228 | const _name = filterName( name ); 229 | 230 | _ensureGuard( this._guard, _name, value ); 231 | 232 | this._data.set( _name, [ value ] ); 233 | } 234 | 235 | public *values( ): IterableIterator< string > 236 | { 237 | for ( const value of this._data.values( ) ) 238 | yield value.join( "," ); 239 | } 240 | 241 | // This is non-standard, but useful 242 | public toJSON( ) 243 | { 244 | return [ ...this.entries( ) ] 245 | .reduce( ( prev, [ key, val ] ) => 246 | Object.assign( prev, { [ key ]: val } ), 247 | { } 248 | ); 249 | } 250 | } 251 | 252 | export class GuardedHeaders extends Headers 253 | { 254 | constructor( guard: GuardTypes, init?: RawHeaders | Headers ) 255 | { 256 | super( ( _guard = guard, init ) ); 257 | _guard = null; 258 | } 259 | } 260 | 261 | export function ensureHeaders( headers: RawHeaders | Headers | undefined ) 262 | { 263 | return headers instanceof Headers ? headers : new Headers( headers ); 264 | } 265 | -------------------------------------------------------------------------------- /lib/fetch-http1.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from "http"; 2 | import { constants as h2constants } from "http2"; 3 | import { Socket } from "net"; 4 | 5 | import { syncGuard } from "callguard"; 6 | 7 | import { AbortController } from "./abort"; 8 | import { FetchInit } from "./core"; 9 | import { SimpleSessionHttp1 } from "./simple-session"; 10 | import { 11 | FetchExtra, 12 | handleSignalAndTimeout, 13 | make100Error, 14 | makeAbortedError, 15 | makeIllegalRedirectError, 16 | makeRedirectionError, 17 | makeRedirectionMethodError, 18 | makeTimeoutError, 19 | setupFetch, 20 | } from "./fetch-common"; 21 | import { GuardedHeaders } from "./headers"; 22 | import { Request } from "./request"; 23 | import { Response, StreamResponse } from "./response"; 24 | import { 25 | arrayify, 26 | isRedirectStatus, 27 | parseLocation, 28 | pipeline, 29 | ParsedLocation, 30 | } from "./utils"; 31 | 32 | const { 33 | // Responses, these are the same in HTTP/1.1 and HTTP/2 34 | HTTP2_HEADER_LOCATION: HTTP1_HEADER_LOCATION, 35 | HTTP2_HEADER_SET_COOKIE: HTTP1_HEADER_SET_COOKIE, 36 | } = h2constants; 37 | 38 | 39 | export async function fetchImpl( 40 | session: SimpleSessionHttp1, 41 | input: Request, 42 | init: Partial< FetchInit > = { }, 43 | extra: FetchExtra 44 | ) 45 | : Promise< Response > 46 | { 47 | const { 48 | cleanup, 49 | contentDecoders, 50 | endStream, 51 | headersToSend, 52 | integrity, 53 | method, 54 | onTrailers, 55 | redirect, 56 | redirected, 57 | request, 58 | signal, 59 | signalPromise, 60 | timeoutAt, 61 | timeoutInfo, 62 | url, 63 | } = await setupFetch( session, input, init, extra ); 64 | 65 | const { req, cleanup: socketCleanup } = session.get( url ); 66 | 67 | const doFetch = async ( ): Promise< Response > => 68 | { 69 | for ( const [ key, value ] of Object.entries( headersToSend ) ) 70 | { 71 | if ( value != null ) 72 | req.setHeader( key, value ); 73 | } 74 | 75 | const response = new Promise< Response >( ( resolve, reject ) => 76 | { 77 | const guard = syncGuard( reject, { catchAsync: true } ); 78 | 79 | req.once( "error", reject ); 80 | 81 | req.once( "aborted", guard( ( ) => 82 | { 83 | reject( makeAbortedError( ) ); 84 | } ) ); 85 | 86 | req.once( "continue", guard( ( ) => 87 | { 88 | reject( make100Error( ) ); 89 | } ) ); 90 | 91 | req.once( "information", guard( ( res: any ) => 92 | { 93 | resolve( new Response( 94 | null, // No body 95 | { status: res.statusCode } 96 | ) ); 97 | } ) ); 98 | 99 | req.once( "timeout", guard( ( ) => 100 | { 101 | reject( makeTimeoutError( ) ); 102 | req.abort( ); 103 | } ) ); 104 | 105 | req.once( "upgrade", guard( 106 | ( 107 | _res: IncomingMessage, 108 | _socket: Socket, 109 | _upgradeHead: Buffer 110 | ) => 111 | { 112 | reject( new Error( "Upgrade not implemented!" ) ); 113 | req.abort( ); 114 | } ) 115 | ); 116 | 117 | req.once( "response", guard( ( res: IncomingMessage ) => 118 | { 119 | res.once( "end", socketCleanup ); 120 | 121 | const { 122 | signal: bodySignal = void 0, 123 | abort: bodyAbort = void 0, 124 | } = signal ? new AbortController( ) : { }; 125 | 126 | if ( signal ) 127 | { 128 | const abortHandler = ( ) => 129 | { 130 | ( < ( ) => void >bodyAbort )( ); 131 | req.abort( ); 132 | res.destroy( ); 133 | }; 134 | 135 | if ( signal.aborted ) 136 | { 137 | // No reason to continue, the request is aborted 138 | abortHandler( ); 139 | return; 140 | } 141 | 142 | signal.once( "abort", abortHandler ); 143 | res.once( "end", ( ) => 144 | { 145 | signal.removeListener( "abort", abortHandler ); 146 | } ); 147 | } 148 | 149 | const { headers, statusCode } = res; 150 | 151 | res.once( "end", guard( ( ) => 152 | { 153 | if ( !onTrailers ) 154 | return; 155 | 156 | try 157 | { 158 | const { trailers } = res; 159 | const headers = new GuardedHeaders( "response" ); 160 | 161 | Object.keys( trailers ).forEach( key => 162 | { 163 | if ( trailers[ key ] != null ) 164 | headers.set( key, "" + trailers[ key ] ); 165 | } ); 166 | 167 | onTrailers( headers ); 168 | } 169 | catch ( err ) 170 | { 171 | // TODO: Implement #8 172 | // tslint:disable-next-line 173 | console.warn( "Trailer handling failed", err ); 174 | } 175 | } ) ); 176 | 177 | const location = parseLocation( 178 | headers[ HTTP1_HEADER_LOCATION ], 179 | url 180 | ); 181 | 182 | const isRedirected = isRedirectStatus[ "" + statusCode ]; 183 | 184 | if ( headers[ HTTP1_HEADER_SET_COOKIE ] ) 185 | { 186 | const setCookies = 187 | arrayify( headers[ HTTP1_HEADER_SET_COOKIE ] ); 188 | 189 | session.cookieJar.setCookies( setCookies, url ); 190 | } 191 | 192 | if ( !input.allowForbiddenHeaders ) 193 | { 194 | delete headers[ "set-cookie" ]; 195 | delete headers[ "set-cookie2" ]; 196 | } 197 | 198 | if ( isRedirected && !location ) 199 | return reject( makeIllegalRedirectError( ) ); 200 | 201 | if ( !isRedirected || redirect === "manual" ) 202 | return resolve( 203 | new StreamResponse( 204 | contentDecoders, 205 | url, 206 | res, 207 | headers, 208 | redirect === "manual" 209 | ? false 210 | : extra.redirected.length > 0, 211 | { 212 | status: res.statusCode, 213 | statusText: res.statusMessage, 214 | }, 215 | bodySignal, 216 | 1, 217 | input.allowForbiddenHeaders, 218 | integrity 219 | ) 220 | ); 221 | 222 | const { url: locationUrl, isRelative } = 223 | location as ParsedLocation; 224 | 225 | if ( redirect === "error" ) 226 | return reject( makeRedirectionError( locationUrl ) ); 227 | 228 | // redirect is 'follow' 229 | 230 | // We don't support re-sending a non-GET/HEAD request (as 231 | // we don't want to [can't, if its' streamed] re-send the 232 | // body). The concept is fundementally broken anyway... 233 | if ( !endStream ) 234 | return reject( 235 | makeRedirectionMethodError( locationUrl, method ) 236 | ); 237 | 238 | res.destroy( ); 239 | 240 | if ( isRelative ) 241 | { 242 | resolve( 243 | fetchImpl( 244 | session, 245 | request.clone( locationUrl ), 246 | { signal, onTrailers }, 247 | { 248 | redirected: redirected.concat( url ), 249 | timeoutAt, 250 | } 251 | ) 252 | ); 253 | } 254 | else 255 | { 256 | resolve( session.newFetch( 257 | request.clone( locationUrl ), 258 | init, 259 | { 260 | timeoutAt, 261 | redirected: redirected.concat( url ), 262 | } 263 | ) ); 264 | } 265 | } ) ); 266 | } ); 267 | 268 | if ( endStream ) 269 | req.end( ); 270 | else 271 | await request.readable( ) 272 | .then( readable => 273 | { 274 | pipeline( readable, req ) 275 | .catch ( _err => 276 | { 277 | // TODO: Implement error handling 278 | } ); 279 | } ); 280 | 281 | return response; 282 | }; 283 | 284 | return handleSignalAndTimeout( 285 | signalPromise, 286 | timeoutInfo, 287 | cleanup, 288 | doFetch, 289 | socketCleanup 290 | ); 291 | } 292 | 293 | export function fetch( 294 | session: SimpleSessionHttp1, 295 | input: Request, 296 | init?: Partial< FetchInit >, 297 | extra?: FetchExtra 298 | ) 299 | : Promise< Response > 300 | { 301 | extra = { 302 | timeoutAt: extra?.timeoutAt, 303 | redirected: extra?.redirected ?? [ ], 304 | }; 305 | 306 | return fetchImpl( session, input, init, extra ); 307 | } 308 | -------------------------------------------------------------------------------- /test/lib/server-http1.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createServer, 3 | IncomingMessage, 4 | Server as HttpServer, 5 | ServerResponse, 6 | } from "http"; 7 | import { 8 | constants as h2constants, 9 | } from "http2"; 10 | import { 11 | createServer as createSecureServer, 12 | Server as HttpsServer, 13 | } from "https"; 14 | import { Duplex } from "stream"; 15 | import { pipeline } from "../../lib/utils"; 16 | 17 | import { createHash } from "crypto"; 18 | import { createBrotliCompress, createDeflate, createGzip } from "zlib"; 19 | 20 | import { delay } from "already"; 21 | import { buffer as getStreamBuffer } from "get-stream"; 22 | 23 | import { 24 | ignoreError, 25 | Server, 26 | ServerOptions, 27 | TypedServer, 28 | } from "./server-common"; 29 | 30 | // These are the same in HTTP/1 and HTTP/2 31 | const { 32 | HTTP2_HEADER_ACCEPT_ENCODING, 33 | HTTP2_HEADER_CONTENT_LENGTH, 34 | HTTP2_HEADER_CONTENT_TYPE, 35 | HTTP2_HEADER_SET_COOKIE, 36 | HTTP2_HEADER_LOCATION, 37 | } = h2constants; 38 | 39 | interface RawHeaders 40 | { 41 | [ name: string ]: number | string | Array< string >; 42 | } 43 | 44 | export class ServerHttp1 extends TypedServer< HttpServer | HttpsServer > 45 | { 46 | private _store = new Set< Duplex >( ); 47 | 48 | constructor( opts: ServerOptions ) 49 | { 50 | super( ); 51 | 52 | this._opts = opts || { }; 53 | if ( this._opts.serverOptions ) 54 | this._server = createSecureServer( this._opts.serverOptions ); 55 | else 56 | this._server = createServer( ); 57 | this.port = null; 58 | 59 | this._server.on( 60 | "connection", 61 | socket => { this._store.add( socket ); } 62 | ); 63 | 64 | this._server.on( 65 | "request", 66 | ( request: IncomingMessage, response: ServerResponse ) => 67 | { 68 | this.onRequest( request, response ) 69 | .catch( err => 70 | { 71 | console.error( "Unit test server failed", err ); 72 | process.exit( 1 ); 73 | } ); 74 | } 75 | ); 76 | } 77 | 78 | public async _shutdown( ): Promise< void > 79 | { 80 | for ( const socket of this._store ) 81 | { 82 | socket.destroy( ); 83 | } 84 | this._store.clear( ); 85 | } 86 | 87 | private async onRequest( 88 | request: IncomingMessage, response: ServerResponse 89 | ) 90 | : Promise< void > 91 | { 92 | const { url: path, headers } = request; 93 | let m; 94 | 95 | if ( path == null ) 96 | throw new Error( "Internal test error" ); 97 | 98 | const sendHeaders = ( headers: RawHeaders ) => 99 | { 100 | const { ":status": status = 200, ...rest } = { ...headers }; 101 | 102 | response.statusCode = status; 103 | 104 | for ( const [ key, value ] of Object.entries( rest ) ) 105 | response.setHeader( key, value ); 106 | }; 107 | 108 | if ( path === "/headers" ) 109 | { 110 | sendHeaders( { 111 | ":status": 200, 112 | "content-type": "application/json", 113 | } ); 114 | 115 | response.end( JSON.stringify( headers ) ); 116 | } 117 | else if ( path === "/echo" ) 118 | { 119 | const responseHeaders: RawHeaders = { 120 | ":status": 200, 121 | }; 122 | [ HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_CONTENT_LENGTH ] 123 | .forEach( name => 124 | { 125 | const value = headers[ name ]; 126 | if ( value != null ) 127 | responseHeaders[ name ] = value; 128 | } ); 129 | 130 | sendHeaders( responseHeaders ); 131 | pipeline( request, response ); 132 | } 133 | else if ( path === "/set-cookie" ) 134 | { 135 | const responseHeaders: RawHeaders = { 136 | ":status": 200, 137 | [ HTTP2_HEADER_SET_COOKIE ]: [ ], 138 | }; 139 | 140 | const data = await getStreamBuffer( request ); 141 | const json = JSON.parse( data.toString( ) ); 142 | json.forEach( ( cookie: any ) => 143 | { 144 | ( < any >responseHeaders[ HTTP2_HEADER_SET_COOKIE ] ) 145 | .push( cookie ); 146 | } ); 147 | 148 | sendHeaders( responseHeaders ); 149 | response.end( ); 150 | } 151 | // tslint:disable-next-line 152 | else if ( m = path.match( /\/wait\/(.+)/ ) ) 153 | { 154 | const timeout = parseInt( m[ 1 ], 10 ); 155 | await delay( timeout ); 156 | 157 | const responseHeaders: RawHeaders = { 158 | ":status": 200, 159 | }; 160 | [ HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_CONTENT_LENGTH ] 161 | .forEach( name => 162 | { 163 | const value = headers[ name ]; 164 | if ( value != null ) 165 | responseHeaders[ name ] = value; 166 | } ); 167 | 168 | try 169 | { 170 | sendHeaders( responseHeaders ); 171 | pipeline( request, response ); 172 | } 173 | catch ( err ) 174 | // We ignore errors since this route is used to intentionally 175 | // timeout, which causes us to try to write to a closed stream. 176 | { } 177 | } 178 | else if ( path === "/trailers" ) 179 | { 180 | const responseHeaders = { 181 | ":status": 200, 182 | }; 183 | 184 | const data = await getStreamBuffer( request ); 185 | const json = JSON.parse( data.toString( ) ); 186 | 187 | sendHeaders( responseHeaders ); 188 | 189 | response.write( "trailers will be sent" ); 190 | 191 | response.addTrailers( json ); 192 | 193 | response.end( ); 194 | } 195 | else if ( path === "/sha256" ) 196 | { 197 | const hash = createHash( "sha256" ); 198 | 199 | const responseHeaders = { 200 | ":status": 200, 201 | }; 202 | sendHeaders( responseHeaders ); 203 | 204 | hash.on( "readable", ( ) => 205 | { 206 | const data = < Buffer >hash.read( ); 207 | if ( data ) 208 | { 209 | response.write( data.toString( "hex" ) ); 210 | response.end( ); 211 | } 212 | } ); 213 | 214 | pipeline( request, hash ); 215 | } 216 | else if ( path.startsWith( "/compressed/" ) ) 217 | { 218 | const encoding = path.replace( "/compressed/", "" ); 219 | 220 | const accept = headers[ HTTP2_HEADER_ACCEPT_ENCODING ] as string; 221 | 222 | if ( !accept.includes( encoding ) ) 223 | { 224 | response.end( ); 225 | return; 226 | } 227 | 228 | const encoder = 229 | encoding === "gzip" 230 | ? createGzip( ) 231 | : encoding === "deflate" 232 | ? createDeflate( ) 233 | : encoding === "br" 234 | ? createBrotliCompress( ) 235 | : null; 236 | 237 | const responseHeaders = { 238 | ":status": 200, 239 | "content-encoding": encoding, 240 | }; 241 | 242 | sendHeaders( responseHeaders ); 243 | if ( encoder ) 244 | pipeline( request, encoder, response ); 245 | else 246 | pipeline( request, response ); 247 | } 248 | else if ( path.startsWith( "/delay/" ) ) 249 | { 250 | const waitMs = parseInt( path.replace( "/delay/", "" ), 10 ); 251 | 252 | if ( waitMs > 0 ) 253 | await delay( waitMs ); 254 | 255 | const responseHeaders = { 256 | ":status": 200, 257 | [ HTTP2_HEADER_CONTENT_LENGTH ]: "10", 258 | }; 259 | 260 | sendHeaders( responseHeaders ); 261 | 262 | response.write( "abcde" ); 263 | 264 | ignoreError( ( ) => response.write( "fghij" ) ); 265 | ignoreError( ( ) => response.end( ) ); 266 | } 267 | else if ( path.startsWith( "/slow/" ) ) 268 | { 269 | const waitMs = parseInt( path.replace( "/slow/", "" ), 10 ); 270 | 271 | const responseHeaders = { 272 | ":status": 200, 273 | [ HTTP2_HEADER_CONTENT_LENGTH ]: "10", 274 | }; 275 | 276 | sendHeaders( responseHeaders ); 277 | 278 | response.write( "abcde" ); 279 | 280 | if ( waitMs > 0 ) 281 | await delay( waitMs ); 282 | 283 | ignoreError( ( ) => response.write( "fghij" ) ); 284 | ignoreError( ( ) => response.end( ) ); 285 | } 286 | else if ( path.startsWith( "/prem-close" ) ) 287 | { 288 | request.socket.destroy( ); 289 | } 290 | else if ( path.startsWith( "/redirect/" ) ) 291 | { 292 | const redirectTo = 293 | path.slice( 10 ).startsWith( "http" ) 294 | ? path.slice( 10 ) 295 | : path.slice( 9 ); 296 | 297 | const responseHeaders = { 298 | ":status": 302, 299 | [ HTTP2_HEADER_LOCATION ]: redirectTo, 300 | }; 301 | 302 | sendHeaders( responseHeaders ); 303 | response.end( ); 304 | } 305 | else 306 | { 307 | response.end( ); 308 | } 309 | } 310 | } 311 | 312 | export async function makeServer( opts: ServerOptions = { } ) 313 | : Promise< { server: Server; port: number | null; } > 314 | { 315 | opts = opts || { }; 316 | 317 | const server = new ServerHttp1( opts ); 318 | await server.listen( opts.port ); 319 | return { server, port: server.port }; 320 | } 321 | -------------------------------------------------------------------------------- /lib/response.ts: -------------------------------------------------------------------------------- 1 | import { 2 | // These are same as http1 for the usage here 3 | constants as h2constants, 4 | } from "http2"; 5 | 6 | import { pipeline } from "stream"; 7 | 8 | import { 9 | constants as zlibConstants, 10 | createBrotliDecompress, 11 | createGunzip, 12 | createInflate, 13 | ZlibOptions, 14 | } from "zlib"; 15 | 16 | const { 17 | HTTP2_HEADER_LOCATION, 18 | HTTP2_HEADER_STATUS, 19 | HTTP2_HEADER_CONTENT_TYPE, 20 | HTTP2_HEADER_CONTENT_ENCODING, 21 | HTTP2_HEADER_CONTENT_LENGTH, 22 | } = h2constants; 23 | 24 | import { 25 | BodyTypes, 26 | DecodeFunction, 27 | Decoder, 28 | HttpVersion, 29 | ResponseInit, 30 | ResponseTypes, 31 | } from "./core"; 32 | 33 | import { 34 | AbortSignal, 35 | } from "./abort"; 36 | 37 | import { 38 | hasBuiltinBrotli, 39 | } from "./utils"; 40 | 41 | import { 42 | ensureHeaders, 43 | GuardedHeaders, 44 | Headers, 45 | } from "./headers"; 46 | 47 | import { 48 | Body, 49 | } from "./body"; 50 | 51 | import { 52 | IncomingHttpHeaders, 53 | } from "./types"; 54 | 55 | 56 | interface Extra 57 | { 58 | httpVersion: HttpVersion; 59 | redirected: boolean; 60 | integrity: string; 61 | signal: AbortSignal; 62 | type: ResponseTypes; 63 | url: string; 64 | } 65 | 66 | export class Response extends Body 67 | { 68 | // @ts-ignore 69 | public readonly headers: Headers; 70 | // @ts-ignore 71 | public readonly ok: boolean; 72 | // @ts-ignore 73 | public readonly redirected: boolean; 74 | // @ts-ignore 75 | public readonly status: number; 76 | // @ts-ignore 77 | public readonly statusText: string; 78 | // @ts-ignore 79 | public readonly type: ResponseTypes; 80 | // @ts-ignore 81 | public readonly url: string; 82 | // @ts-ignore 83 | public readonly useFinalURL: boolean; 84 | // @ts-ignore 85 | public readonly httpVersion: HttpVersion; 86 | 87 | constructor( 88 | body: BodyTypes | Body | null = null, 89 | init: Partial< ResponseInit > = { }, 90 | extra?: Partial< Extra > 91 | ) 92 | { 93 | super( ); 94 | 95 | const headers = ensureHeaders( 96 | init.allowForbiddenHeaders 97 | ? new GuardedHeaders( "none", init.headers ) 98 | : init.headers 99 | ); 100 | 101 | const _extra = < Partial< Extra > >( extra || { } ); 102 | 103 | const type = _extra.type || "basic"; 104 | const redirected = !!_extra.redirected || false; 105 | const url = _extra.url || ""; 106 | const integrity = _extra.integrity || null; 107 | 108 | this.setSignal( _extra.signal ); 109 | 110 | if ( body ) 111 | { 112 | const contentType = headers.get( HTTP2_HEADER_CONTENT_TYPE ); 113 | const contentLength = headers.get( HTTP2_HEADER_CONTENT_LENGTH ); 114 | const contentEncoding = 115 | headers.get( HTTP2_HEADER_CONTENT_ENCODING ); 116 | 117 | const length = 118 | ( contentLength == null || contentEncoding != null ) 119 | ? null 120 | : parseInt( contentLength, 10 ); 121 | 122 | if ( contentType ) 123 | this.setBody( body, contentType, integrity, length ); 124 | else 125 | this.setBody( body, null, integrity, length ); 126 | } 127 | 128 | Object.defineProperties( this, { 129 | headers: { 130 | enumerable: true, 131 | value: headers, 132 | }, 133 | httpVersion: { 134 | enumerable: true, 135 | value: _extra.httpVersion, 136 | }, 137 | ok: { 138 | enumerable: true, 139 | get: ( ) => this.status >= 200 && this.status < 300, 140 | }, 141 | redirected: { 142 | enumerable: true, 143 | value: redirected, 144 | }, 145 | status: { 146 | enumerable: true, 147 | value: init.status || 200, 148 | }, 149 | statusText: { 150 | enumerable: true, 151 | value: init.statusText || "", 152 | }, 153 | type: { 154 | enumerable: true, 155 | value: type, 156 | }, 157 | url: { 158 | enumerable: true, 159 | value: url, 160 | }, 161 | useFinalURL: { 162 | enumerable: true, 163 | value: undefined, 164 | }, 165 | } ); 166 | } 167 | 168 | // Returns a new Response object associated with a network error. 169 | public static error( ): Response 170 | { 171 | const headers = new GuardedHeaders( "immutable" ); 172 | const status = 521; 173 | const statusText = "Web Server Is Down"; 174 | return new Response( 175 | null, { headers, status, statusText }, { type: "error" } ); 176 | } 177 | 178 | // Creates a new response with a different URL. 179 | public static redirect( url: string, status?: number ) 180 | { 181 | status = status || 302; 182 | 183 | const headers = { 184 | [ HTTP2_HEADER_LOCATION ]: url, 185 | }; 186 | 187 | return new Response( null, { headers, status } ); 188 | } 189 | 190 | // Creates a clone of a Response object. 191 | public clone( ): Response 192 | { 193 | const { headers, status, statusText } = this; 194 | return new Response( this, { headers, status, statusText } ); 195 | } 196 | } 197 | 198 | function makeHeadersFromH2Headers( 199 | headers: IncomingHttpHeaders, 200 | allowForbiddenHeaders: boolean 201 | ) 202 | : Headers 203 | { 204 | const out = new GuardedHeaders( 205 | allowForbiddenHeaders ? "none" : "response" ); 206 | 207 | for ( const key of Object.keys( headers ) ) 208 | { 209 | if ( key.startsWith( ":" ) ) 210 | // We ignore pseudo-headers 211 | continue; 212 | 213 | const value = headers[ key ]; 214 | if ( Array.isArray( value ) ) 215 | value.forEach( val => out.append( key, val ) ); 216 | else if ( value != null ) 217 | out.set( key, value ); 218 | } 219 | 220 | return out; 221 | } 222 | 223 | function makeInitHttp1( 224 | inHeaders: IncomingHttpHeaders, 225 | allowForbiddenHeaders: boolean 226 | ) 227 | : Partial< ResponseInit > 228 | { 229 | // Headers in HTTP/2 are compatible with HTTP/1 (colon illegal in HTTP/1) 230 | const headers = 231 | makeHeadersFromH2Headers( inHeaders, allowForbiddenHeaders ); 232 | 233 | return { headers }; 234 | } 235 | 236 | function makeInitHttp2( 237 | inHeaders: IncomingHttpHeaders, 238 | allowForbiddenHeaders: boolean 239 | ) 240 | : Partial< ResponseInit > 241 | { 242 | const status = parseInt( "" + inHeaders[ HTTP2_HEADER_STATUS ], 10 ); 243 | const statusText = ""; // Not supported in H2 244 | const headers = 245 | makeHeadersFromH2Headers( inHeaders, allowForbiddenHeaders ); 246 | 247 | return { status, statusText, headers }; 248 | } 249 | 250 | function makeExtra( 251 | httpVersion: HttpVersion, 252 | url: string, 253 | redirected: boolean, 254 | signal?: AbortSignal, 255 | integrity?: string 256 | ) 257 | : Partial< Extra > 258 | { 259 | const type = "basic"; // TODO: Implement CORS 260 | 261 | return { httpVersion, redirected, integrity, signal, type, url }; 262 | } 263 | 264 | function handleEncoding( 265 | contentDecoders: ReadonlyArray< Decoder >, 266 | stream: NodeJS.ReadableStream, 267 | headers: IncomingHttpHeaders 268 | ) 269 | : NodeJS.ReadableStream 270 | { 271 | const contentEncoding = headers[ HTTP2_HEADER_CONTENT_ENCODING ] as string; 272 | 273 | if ( !contentEncoding ) 274 | return stream; 275 | 276 | const handleStreamResult = ( _err: NodeJS.ErrnoException | null ) => 277 | { 278 | // TODO: Add error handling 279 | }; 280 | 281 | const zlibOpts: ZlibOptions = { 282 | flush: zlibConstants.Z_SYNC_FLUSH, 283 | finishFlush: zlibConstants.Z_SYNC_FLUSH, 284 | }; 285 | 286 | const decoders: { [ name: string ]: DecodeFunction; } = { 287 | deflate: ( stream: NodeJS.ReadableStream ) => 288 | pipeline( stream, createInflate( ), handleStreamResult ), 289 | gzip: ( stream: NodeJS.ReadableStream ) => 290 | pipeline( stream, createGunzip( zlibOpts ), handleStreamResult ), 291 | }; 292 | 293 | if ( hasBuiltinBrotli( ) ) 294 | { 295 | decoders.br = ( stream: NodeJS.ReadableStream ) => 296 | pipeline( stream, createBrotliDecompress( ), handleStreamResult ); 297 | } 298 | 299 | contentDecoders.forEach( decoder => 300 | { 301 | decoders[ decoder.name ] = decoder.decode; 302 | } ); 303 | 304 | const decoder = decoders[ contentEncoding ]; 305 | 306 | if ( !decoder ) 307 | // We haven't asked for this encoding, and we can't handle it. 308 | // Pushing raw encoded stream through... 309 | return stream; 310 | 311 | return decoder( stream ); 312 | } 313 | 314 | export class StreamResponse extends Response 315 | { 316 | constructor( 317 | contentDecoders: ReadonlyArray< Decoder >, 318 | url: string, 319 | stream: NodeJS.ReadableStream, 320 | headers: IncomingHttpHeaders, 321 | redirected: boolean, 322 | init: Partial< ResponseInit >, 323 | signal: AbortSignal | undefined, 324 | httpVersion: HttpVersion, 325 | allowForbiddenHeaders: boolean, 326 | integrity?: string 327 | ) 328 | { 329 | super( 330 | handleEncoding( 331 | contentDecoders, 332 | < NodeJS.ReadableStream >stream, 333 | headers 334 | ), 335 | { 336 | ...init, 337 | allowForbiddenHeaders, 338 | ...( 339 | httpVersion === 1 340 | ? makeInitHttp1( headers, allowForbiddenHeaders ) 341 | : makeInitHttp2( headers, allowForbiddenHeaders ) 342 | ), 343 | }, 344 | makeExtra( httpVersion, url, redirected, signal, integrity ) 345 | ); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /lib/fetch-common.ts: -------------------------------------------------------------------------------- 1 | import { constants as h2constants } from "http2"; 2 | import { URL } from "url"; 3 | 4 | import { rethrow } from "already"; 5 | 6 | import { BodyInspector } from "./body"; 7 | import { AbortError, Decoder, FetchInit, TimeoutError } from "./core"; 8 | import { SimpleSession } from "./simple-session"; 9 | import { Headers, RawHeaders } from "./headers"; 10 | import { Request } from "./request"; 11 | import { Response } from "./response"; 12 | import { arrayify, hasBuiltinBrotli } from "./utils"; 13 | 14 | const { 15 | // Required for a request 16 | HTTP2_HEADER_METHOD, 17 | HTTP2_HEADER_SCHEME, 18 | HTTP2_HEADER_PATH, 19 | HTTP2_HEADER_AUTHORITY, 20 | 21 | // Methods 22 | HTTP2_METHOD_GET, 23 | HTTP2_METHOD_HEAD, 24 | 25 | // Requests 26 | HTTP2_HEADER_USER_AGENT, 27 | HTTP2_HEADER_ACCEPT, 28 | HTTP2_HEADER_COOKIE, 29 | HTTP2_HEADER_CONTENT_TYPE, 30 | HTTP2_HEADER_CONTENT_LENGTH, 31 | HTTP2_HEADER_ACCEPT_ENCODING, 32 | } = h2constants; 33 | 34 | 35 | function ensureNotCircularRedirection( redirections: ReadonlyArray< string > ) 36 | : void 37 | { 38 | const urls = [ ...redirections ]; 39 | const last = urls.pop( ); 40 | 41 | for ( let i = 0; i < urls.length; ++i ) 42 | if ( urls[ i ] === last ) 43 | { 44 | const err = new Error( "Redirection loop detected" ); 45 | ( < any >err ).urls = urls.slice( i ); 46 | throw err; 47 | } 48 | } 49 | 50 | export interface FetchExtra 51 | { 52 | redirected: Array< string >; 53 | timeoutAt?: number; 54 | } 55 | 56 | export interface TimeoutInfo 57 | { 58 | promise: Promise< Response >; 59 | clear: ( ) => void; 60 | } 61 | 62 | 63 | interface AcceptEncodings 64 | { 65 | name: string; 66 | score: number; 67 | } 68 | 69 | const makeDefaultEncodings = ( mul = 1 ) => 70 | hasBuiltinBrotli( ) 71 | ? [ 72 | { name: "br", score: 1.0 * mul }, 73 | { name: "gzip", score: 0.8 * mul }, 74 | { name: "deflate", score: 0.5 * mul }, 75 | ] 76 | : [ 77 | { name: "gzip", score: 1.0 * mul }, 78 | { name: "deflate", score: 0.5 * mul }, 79 | ]; 80 | 81 | const defaultEncodings = makeDefaultEncodings( ); 82 | const fallbackEncodings = makeDefaultEncodings( 0.8 ); 83 | 84 | const stringifyEncoding = ( acceptEncoding: AcceptEncodings ) => 85 | `${acceptEncoding.name};q=${acceptEncoding.score}`; 86 | 87 | const stringifyEncodings = ( accepts: ReadonlyArray< AcceptEncodings > ) => 88 | accepts 89 | .map( acceptEncoding => stringifyEncoding( acceptEncoding ) ) 90 | .join( ", " ); 91 | 92 | function getEncodings( contentDecoders: ReadonlyArray< Decoder > ): string 93 | { 94 | if ( contentDecoders.length === 0 ) 95 | return stringifyEncodings( defaultEncodings ); 96 | 97 | const makeScore = ( index: number ) => 98 | 1 - ( index / ( contentDecoders.length ) ) * 0.2; 99 | 100 | return stringifyEncodings( 101 | [ 102 | ...contentDecoders.map( ( { name }, index ) => 103 | ( { name, score: makeScore( index ) } ) 104 | ), 105 | ...fallbackEncodings, 106 | ] 107 | ); 108 | } 109 | 110 | export async function setupFetch( 111 | session: SimpleSession, 112 | request: Request, 113 | init: Partial< FetchInit > = { }, 114 | extra: FetchExtra 115 | ) 116 | { 117 | const { redirected } = extra; 118 | 119 | ensureNotCircularRedirection( redirected ); 120 | 121 | const { url, method, redirect, integrity } = request; 122 | 123 | const { signal, onTrailers } = init; 124 | 125 | const { 126 | origin, 127 | protocol, 128 | host, 129 | pathname, search, hash, 130 | } = new URL( url ); 131 | const path = pathname + search + hash; 132 | 133 | const endStream = 134 | method === HTTP2_METHOD_GET || method === HTTP2_METHOD_HEAD; 135 | 136 | const headers = new Headers( request.headers ); 137 | 138 | const cookies = ( await session.cookieJar.getCookies( url ) ) 139 | .map( cookie => cookie.cookieString( ) ); 140 | 141 | const contentDecoders = session.contentDecoders( ); 142 | 143 | const acceptEncoding = getEncodings( contentDecoders ); 144 | 145 | if ( headers.has( HTTP2_HEADER_COOKIE ) ) 146 | cookies.push( ...arrayify( headers.get( HTTP2_HEADER_COOKIE ) ) ); 147 | 148 | if ( !headers.has( "host" ) ) 149 | headers.set( "host", host ); 150 | 151 | const headersToSend: RawHeaders = { 152 | // Set required headers 153 | ...( session.protocol === "http1" ? { } : { 154 | [ HTTP2_HEADER_METHOD ]: method, 155 | [ HTTP2_HEADER_SCHEME ]: protocol.replace( /:.*/, "" ), 156 | [ HTTP2_HEADER_PATH ]: path, 157 | } ), 158 | 159 | // Set default headers 160 | [ HTTP2_HEADER_ACCEPT ]: session.accept( ), 161 | [ HTTP2_HEADER_USER_AGENT ]: session.userAgent( ), 162 | [ HTTP2_HEADER_ACCEPT_ENCODING ]: acceptEncoding, 163 | }; 164 | 165 | if ( cookies.length > 0 ) 166 | headersToSend[ HTTP2_HEADER_COOKIE ] = cookies.join( "; " ); 167 | 168 | for ( const [ key, val ] of headers.entries( ) ) 169 | { 170 | if ( key === "host" && session.protocol === "http2" ) 171 | // Convert to :authority like curl does: 172 | // https://github.com/grantila/fetch-h2/issues/9 173 | headersToSend[ HTTP2_HEADER_AUTHORITY ] = val; 174 | else if ( key !== HTTP2_HEADER_COOKIE ) 175 | headersToSend[ key ] = val; 176 | } 177 | 178 | const inspector = new BodyInspector( request ); 179 | 180 | if ( 181 | !endStream && 182 | inspector.length != null && 183 | !request.headers.has( HTTP2_HEADER_CONTENT_LENGTH ) 184 | ) 185 | headersToSend[ HTTP2_HEADER_CONTENT_LENGTH ] = "" + inspector.length; 186 | 187 | if ( 188 | !endStream && 189 | !request.headers.has( "content-type" ) && 190 | inspector.mime 191 | ) 192 | headersToSend[ HTTP2_HEADER_CONTENT_TYPE ] = inspector.mime; 193 | 194 | function timeoutError( ) 195 | { 196 | return new TimeoutError( 197 | `${method} ${url} timed out after ${init.timeout} ms` ); 198 | } 199 | 200 | const timeoutAt = extra.timeoutAt || ( 201 | ( "timeout" in init && typeof init.timeout === "number" ) 202 | // Setting the timeoutAt here at first time allows async cookie 203 | // jar to not take part of timeout for at least the first request 204 | // (in a potential redirect chain) 205 | ? Date.now( ) + init.timeout 206 | : void 0 207 | ); 208 | 209 | function setupTimeout( ): TimeoutInfo | null 210 | { 211 | if ( !timeoutAt ) 212 | return null; 213 | 214 | const now = Date.now( ); 215 | if ( now >= timeoutAt ) 216 | throw timeoutError( ); 217 | 218 | let timerId: NodeJS.Timeout | null; 219 | 220 | return { 221 | clear: ( ) => 222 | { 223 | if ( timerId ) 224 | clearTimeout( timerId ); 225 | }, 226 | promise: new Promise( ( _resolve, reject ) => 227 | { 228 | timerId = setTimeout( ( ) => 229 | { 230 | timerId = null; 231 | reject( timeoutError( ) ); 232 | }, 233 | timeoutAt - now 234 | ); 235 | } ), 236 | }; 237 | 238 | } 239 | 240 | const timeoutInfo = setupTimeout( ); 241 | 242 | function abortError( ) 243 | { 244 | return new AbortError( `${method} ${url} aborted` ); 245 | } 246 | 247 | if ( signal && signal.aborted ) 248 | throw abortError( ); 249 | 250 | let abortHandler: ( ( ) => void ) | undefined; 251 | 252 | const signalPromise: Promise< Response > | null = 253 | signal 254 | ? 255 | new Promise< Response >( ( _resolve, reject ) => 256 | { 257 | signal.once( "abort", abortHandler = ( ) => 258 | { 259 | reject( abortError( ) ); 260 | } ); 261 | } ) 262 | : null; 263 | 264 | function cleanup( ) 265 | { 266 | timeoutInfo?.clear?.( ); 267 | timeoutInfo?.promise?.catch( _err => { } ); 268 | 269 | if ( signal && abortHandler ) 270 | signal.removeListener( "abort", abortHandler ); 271 | } 272 | 273 | return { 274 | cleanup, 275 | contentDecoders, 276 | endStream, 277 | headersToSend, 278 | integrity, 279 | method, 280 | onTrailers, 281 | origin, 282 | redirect, 283 | redirected, 284 | request, 285 | signal, 286 | signalPromise, 287 | timeoutAt, 288 | timeoutInfo, 289 | url, 290 | }; 291 | } 292 | 293 | export function handleSignalAndTimeout( 294 | signalPromise: Promise< Response > | null, 295 | timeoutInfo: TimeoutInfo | null, 296 | cleanup: ( ) => void, 297 | fetcher: ( ) => Promise< Response >, 298 | onError: ( ) => void 299 | ) 300 | { 301 | return Promise.race( 302 | [ 303 | < Promise< any > >signalPromise, 304 | < Promise< any > >( timeoutInfo && timeoutInfo.promise ), 305 | fetcher( ).catch( rethrow( onError ) ), 306 | ] 307 | .filter( promise => promise ) 308 | ) 309 | .finally( cleanup ); 310 | } 311 | 312 | export function make100Error( ) 313 | { 314 | return new Error( 315 | "Request failed with 100 continue. " + 316 | "This can't happen unless a server failure" 317 | ); 318 | } 319 | 320 | export function makeAbortedError( ) 321 | { 322 | return new AbortError( "Request aborted" ); 323 | } 324 | 325 | export function makeTimeoutError( ) 326 | { 327 | return new TimeoutError( "Request timed out" ); 328 | } 329 | 330 | export function makeIllegalRedirectError( ) 331 | { 332 | return new Error( 333 | "Server responded illegally with a " + 334 | "redirect code but missing 'location' header" 335 | ); 336 | } 337 | 338 | export function makeRedirectionError( location: string | null ) 339 | { 340 | return new Error( `URL got redirected to ${location}` ); 341 | } 342 | 343 | export function makeRedirectionMethodError( 344 | location: string | null, method: string 345 | ) 346 | { 347 | return new Error( 348 | `URL got redirected to ${location}, which ` + 349 | `'fetch-h2' doesn't support for ${method}` 350 | ); 351 | } 352 | -------------------------------------------------------------------------------- /test/lib/server-http2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | constants, 3 | createSecureServer, 4 | createServer, 5 | Http2Server, 6 | Http2Session, 7 | IncomingHttpHeaders, 8 | OutgoingHttpHeaders, 9 | ServerHttp2Stream, 10 | } from "http2"; 11 | import { pipeline } from "../../lib/utils"; 12 | 13 | import { createHash } from "crypto"; 14 | import { createBrotliCompress, createDeflate, createGzip } from "zlib"; 15 | 16 | import { delay } from "already"; 17 | import { buffer as getStreamBuffer } from "get-stream"; 18 | 19 | import { 20 | ignoreError, 21 | Server, 22 | ServerOptions, 23 | TypedServer, 24 | } from "./server-common"; 25 | 26 | const { 27 | HTTP2_HEADER_PATH, 28 | HTTP2_HEADER_CONTENT_TYPE, 29 | HTTP2_HEADER_CONTENT_LENGTH, 30 | HTTP2_HEADER_ACCEPT_ENCODING, 31 | HTTP2_HEADER_SET_COOKIE, 32 | HTTP2_HEADER_LOCATION, 33 | } = constants; 34 | 35 | export class ServerHttp2 extends TypedServer< Http2Server > 36 | { 37 | private _sessions: Set< Http2Session >; 38 | private _awaits: Array< Promise< any > > = [ ]; 39 | 40 | constructor( opts: ServerOptions ) 41 | { 42 | super( ); 43 | 44 | this._opts = opts || { }; 45 | if ( this._opts.serverOptions ) 46 | this._server = createSecureServer( this._opts.serverOptions ); 47 | else 48 | this._server = createServer( ); 49 | this._sessions = new Set( ); 50 | this.port = null; 51 | 52 | this._server.on( "stream", ( stream, headers ) => 53 | { 54 | const awaitStream = this.onStream( stream, headers ) 55 | .catch( err => 56 | { 57 | console.error( "Unit test server failed", err.stack ); 58 | process.exit( 1 ); 59 | } ) 60 | .then( ( ) => 61 | { 62 | const index = this._awaits.findIndex( promise => 63 | promise === awaitStream ); 64 | if ( index !== -1 ) 65 | this._awaits.splice( index, 1 ); 66 | } ); 67 | 68 | this._awaits.push( awaitStream ); 69 | } ); 70 | } 71 | 72 | public async _shutdown( ): Promise< void > 73 | { 74 | for ( const session of this._sessions ) 75 | { 76 | session.destroy( ); 77 | } 78 | await Promise.all( this._awaits ); 79 | this._sessions.clear( ); 80 | } 81 | 82 | private async onStream( 83 | stream: ServerHttp2Stream, 84 | headers: IncomingHttpHeaders 85 | ) 86 | : Promise< void > 87 | { 88 | this._sessions.add( stream.session ); 89 | stream.session.once( "close", ( ) => 90 | this._sessions.delete( stream.session ) ); 91 | 92 | const path = headers[ HTTP2_HEADER_PATH ] as string; 93 | let m; 94 | 95 | if ( path === "/headers" ) 96 | { 97 | stream.respond( { 98 | ":status": 200, 99 | "content-type": "application/json", 100 | } ); 101 | 102 | stream.end( JSON.stringify( headers ) ); 103 | } 104 | else if ( path === "/echo" ) 105 | { 106 | const responseHeaders: OutgoingHttpHeaders = { 107 | ":status": 200, 108 | }; 109 | [ HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_CONTENT_LENGTH ] 110 | .forEach( name => 111 | { 112 | responseHeaders[ name ] = headers[ name ]; 113 | } ); 114 | 115 | stream.respond( responseHeaders ); 116 | pipeline( stream, stream ); 117 | } 118 | else if ( path === "/set-cookie" ) 119 | { 120 | const responseHeaders: OutgoingHttpHeaders = { 121 | ":status": 200, 122 | [ HTTP2_HEADER_SET_COOKIE ]: [ ], 123 | }; 124 | 125 | const data = await getStreamBuffer( stream ); 126 | const json = JSON.parse( data.toString( ) ); 127 | json.forEach( ( cookie: any ) => 128 | { 129 | ( < any >responseHeaders[ HTTP2_HEADER_SET_COOKIE ] ) 130 | .push( cookie ); 131 | } ); 132 | 133 | stream.respond( responseHeaders ); 134 | stream.end( ); 135 | } 136 | // tslint:disable-next-line 137 | else if ( m = path.match( /\/wait\/(.+)/ ) ) 138 | { 139 | const timeout = parseInt( m[ 1 ], 10 ); 140 | await delay( timeout ); 141 | 142 | const responseHeaders: OutgoingHttpHeaders = { 143 | ":status": 200, 144 | }; 145 | [ HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_CONTENT_LENGTH ] 146 | .forEach( name => 147 | { 148 | responseHeaders[ name ] = headers[ name ]; 149 | } ); 150 | 151 | try 152 | { 153 | stream.respond( responseHeaders ); 154 | pipeline( stream, stream ); 155 | } 156 | catch ( err ) 157 | // We ignore errors since this route is used to intentionally 158 | // timeout, which causes us to try to write to a closed stream. 159 | { } 160 | } 161 | else if ( path === "/trailers" ) 162 | { 163 | const responseHeaders = { 164 | ":status": 200, 165 | }; 166 | 167 | const data = await getStreamBuffer( stream ); 168 | const json = JSON.parse( data.toString( ) ); 169 | 170 | stream.once( "wantTrailers", ( ) => 171 | { 172 | // TODO: Fix when @types/node is fixed 173 | (stream).sendTrailers( json ); 174 | } ); 175 | 176 | stream.respond( 177 | responseHeaders, 178 | // TODO: Fix when @types/node is fixed 179 | { 180 | waitForTrailers: true, 181 | } 182 | ); 183 | 184 | stream.write( "trailers will be sent" ); 185 | 186 | stream.end( ); 187 | } 188 | else if ( path === "/sha256" ) 189 | { 190 | const hash = createHash( "sha256" ); 191 | 192 | const responseHeaders = { 193 | ":status": 200, 194 | }; 195 | stream.respond( responseHeaders ); 196 | 197 | hash.on( "readable", ( ) => 198 | { 199 | const data = < Buffer >hash.read( ); 200 | if ( data ) 201 | { 202 | stream.write( data.toString( "hex" ) ); 203 | stream.end( ); 204 | } 205 | } ); 206 | 207 | pipeline( stream, hash ); 208 | } 209 | else if ( path === "/push" ) 210 | { 211 | const responseHeaders = { 212 | ":status": 200, 213 | }; 214 | 215 | const data = await getStreamBuffer( stream ); 216 | const json = JSON.parse( data.toString( ) ); 217 | 218 | json.forEach( ( pushable: any ) => 219 | { 220 | function cb( err: Error | null, pushStream: ServerHttp2Stream ) 221 | { 222 | if ( err ) 223 | return; 224 | if ( pushable.data ) 225 | pushStream.write( pushable.data ); 226 | pushStream.end( ); 227 | } 228 | stream.pushStream( pushable.headers || { }, cb ); 229 | } ); 230 | 231 | stream.respond( responseHeaders ); 232 | stream.write( "push-route" ); 233 | stream.end( ); 234 | } 235 | else if ( path.startsWith( "/compressed/" ) ) 236 | { 237 | const encoding = path.replace( "/compressed/", "" ); 238 | 239 | const accept = headers[ HTTP2_HEADER_ACCEPT_ENCODING ] as string; 240 | 241 | if ( !accept.includes( encoding ) ) 242 | { 243 | stream.destroy( ); 244 | return; 245 | } 246 | 247 | const encoder = 248 | encoding === "gzip" 249 | ? createGzip( ) 250 | : encoding === "deflate" 251 | ? createDeflate( ) 252 | : encoding === "br" 253 | ? createBrotliCompress( ) 254 | : null; 255 | 256 | const responseHeaders = { 257 | ":status": 200, 258 | "content-encoding": encoding, 259 | }; 260 | 261 | stream.respond( responseHeaders ); 262 | if ( encoder ) 263 | pipeline( stream, encoder, stream ); 264 | else 265 | pipeline( stream, stream ); 266 | } 267 | else if ( path.startsWith( "/goaway" ) ) 268 | { 269 | const waitMs = path.startsWith( "/goaway/" ) 270 | ? parseInt( path.replace( "/goaway/", "" ), 10 ) 271 | : 0; 272 | 273 | const responseHeaders = { 274 | ":status": 200, 275 | [ HTTP2_HEADER_CONTENT_LENGTH ]: "10", 276 | }; 277 | 278 | stream.respond( responseHeaders ); 279 | 280 | stream.write( "abcde" ); 281 | 282 | stream.session.goaway( ); 283 | 284 | if ( waitMs > 0 ) 285 | await delay( waitMs ); 286 | 287 | ignoreError( ( ) => stream.write( "fghij" ) ); 288 | ignoreError( ( ) => stream.end( ) ); 289 | } 290 | else if ( path.startsWith( "/delay/" ) ) 291 | { 292 | const waitMs = parseInt( path.replace( "/delay/", "" ), 10 ); 293 | 294 | if ( waitMs > 0 ) 295 | await delay( waitMs ); 296 | 297 | const responseHeaders = { 298 | ":status": 200, 299 | [ HTTP2_HEADER_CONTENT_LENGTH ]: "10", 300 | }; 301 | 302 | ignoreError( ( ) => stream.respond( responseHeaders ) ); 303 | ignoreError( ( ) => stream.write( "abcde" ) ); 304 | ignoreError( ( ) => stream.write( "fghij" ) ); 305 | ignoreError( ( ) => stream.end( ) ); 306 | } 307 | else if ( path.startsWith( "/slow/" ) ) 308 | { 309 | const waitMs = parseInt( path.replace( "/slow/", "" ), 10 ); 310 | 311 | const responseHeaders = { 312 | ":status": 200, 313 | [ HTTP2_HEADER_CONTENT_LENGTH ]: "10", 314 | }; 315 | 316 | stream.respond( responseHeaders ); 317 | 318 | stream.write( "abcde" ); 319 | 320 | if ( waitMs > 0 ) 321 | await delay( waitMs ); 322 | 323 | ignoreError( ( ) => stream.write( "fghij" ) ); 324 | ignoreError( ( ) => stream.end( ) ); 325 | } 326 | else if ( path.startsWith( "/prem-close" ) ) 327 | { 328 | stream.close( ); 329 | } 330 | else if ( path.startsWith( "/redirect/" ) ) 331 | { 332 | const redirectTo = 333 | path.slice( 10 ).startsWith( "http" ) 334 | ? path.slice( 10 ) 335 | : path.slice( 9 ); 336 | 337 | const responseHeaders = { 338 | ":status": 302, 339 | [ HTTP2_HEADER_LOCATION ]: redirectTo, 340 | }; 341 | 342 | stream.respond( responseHeaders ); 343 | stream.end( ); 344 | } 345 | else 346 | { 347 | const matched = ( this._opts.matchers || [ ] ) 348 | .some( matcher => matcher( { path, stream, headers } ) ); 349 | 350 | if ( !matched ) 351 | { 352 | stream.respond( { ":status": 400 } ); 353 | stream.end( ); 354 | } 355 | } 356 | 357 | if ( !stream.closed ) 358 | return new Promise( resolve => stream.once( "close", resolve ) ); 359 | } 360 | } 361 | 362 | export async function makeServer( opts: ServerOptions = { } ) 363 | : Promise< { server: Server; port: number | null; } > 364 | { 365 | opts = opts || { }; 366 | 367 | const server = new ServerHttp2( opts ); 368 | await server.listen( opts.port ); 369 | return { server, port: server.port }; 370 | } 371 | -------------------------------------------------------------------------------- /lib/body.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | import { tap } from "already"; 4 | import { buffer as getStreamBuffer } from "get-stream"; 5 | import * as through2 from "through2"; 6 | import * as toArrayBuffer from "to-arraybuffer"; 7 | 8 | import { AbortSignal } from "./abort"; 9 | import { AbortError, BodyTypes, IBody, StorageBodyTypes } from "./core"; 10 | 11 | 12 | const abortError = new AbortError( "Response aborted" ); 13 | 14 | function makeUnknownDataError( ) 15 | { 16 | return new Error( "Unknown body data" ); 17 | } 18 | 19 | function throwIntegrityMismatch( ): never 20 | { 21 | throw new Error( "Resource integrity mismatch" ); 22 | } 23 | 24 | function throwLengthMismatch( ): never 25 | { 26 | throw new RangeError( 27 | "Resource length mismatch (possibly incomplete body)" ); 28 | } 29 | 30 | function parseIntegrity( integrity: string ) 31 | { 32 | const [ algorithm, ...expectedHash ] = integrity.split( "-" ); 33 | return { algorithm, hash: expectedHash.join( "-" ) }; 34 | } 35 | 36 | function isStream( body: StorageBodyTypes ): body is NodeJS.ReadableStream 37 | { 38 | return body && 39 | ( "readable" in ( < NodeJS.ReadableStream >Object( body ) ) ); 40 | } 41 | 42 | const emptyBuffer = new ArrayBuffer( 0 ); 43 | 44 | export class Body implements IBody 45 | { 46 | // @ts-ignore 47 | public readonly bodyUsed: boolean; 48 | protected _length: number | null; 49 | protected _mime?: string; 50 | protected _body?: StorageBodyTypes | null; 51 | private _used: boolean; 52 | private _integrity?: string; 53 | private _signal?: AbortSignal; 54 | 55 | constructor( ) 56 | { 57 | this._length = null; 58 | this._used = false; 59 | 60 | Object.defineProperties( this, { 61 | bodyUsed: { 62 | enumerable: true, 63 | get: ( ) => this._used, 64 | }, 65 | } ); 66 | } 67 | 68 | public async arrayBuffer( allowIncomplete = false ): Promise< ArrayBuffer > 69 | { 70 | this._ensureUnused( ); 71 | this._ensureNotAborted( ); 72 | 73 | if ( this._body == null ) 74 | return this.validateIntegrity( emptyBuffer, allowIncomplete ); 75 | 76 | else if ( isStream( this._body ) ) 77 | return this.awaitBuffer( < NodeJS.ReadableStream >this._body ) 78 | .then( buffer => 79 | this.validateIntegrity( buffer, allowIncomplete ) 80 | ) 81 | .then( buffer => toArrayBuffer( buffer ) ); 82 | 83 | else if ( Buffer.isBuffer( this._body ) ) 84 | return this.validateIntegrity( 85 | toArrayBuffer( < Buffer >this._body ), 86 | allowIncomplete 87 | ); 88 | 89 | else 90 | throw makeUnknownDataError( ); 91 | } 92 | 93 | public async formData( ): Promise< never /* FormData */ > 94 | { 95 | throw new Error( "Body.formData() is not yet implemented" ); 96 | } 97 | 98 | public async json( ): Promise< any > 99 | { 100 | this._ensureUnused( ); 101 | this._ensureNotAborted( ); 102 | 103 | if ( this._body == null ) 104 | return Promise.resolve( 105 | this.validateIntegrity( emptyBuffer, false ) 106 | ) 107 | .then( ( ) => this._body ); 108 | else if ( isStream( this._body ) ) 109 | return this.awaitBuffer( < NodeJS.ReadableStream >this._body ) 110 | .then( tap( buffer => 111 | < any >this.validateIntegrity( buffer, false ) 112 | ) ) 113 | .then( buffer => JSON.parse( buffer.toString( ) ) ); 114 | else if ( Buffer.isBuffer( this._body ) ) 115 | return Promise.resolve( < Buffer >this._body ) 116 | .then( tap( buffer => 117 | < any >this.validateIntegrity( buffer, false ) 118 | ) ) 119 | .then( buffer => JSON.parse( buffer.toString( ) ) ); 120 | else 121 | throw makeUnknownDataError( ); 122 | } 123 | 124 | public async text( allowIncomplete = false ): Promise< string > 125 | { 126 | this._ensureUnused( ); 127 | this._ensureNotAborted( ); 128 | 129 | if ( this._body == null ) 130 | return Promise.resolve( 131 | this.validateIntegrity( emptyBuffer, allowIncomplete ) 132 | ) 133 | .then( ( ) => < string >< BodyTypes >this._body ); 134 | else if ( isStream( this._body ) ) 135 | return this.awaitBuffer( < NodeJS.ReadableStream >this._body ) 136 | .then( tap( buffer => 137 | < any >this.validateIntegrity( buffer, allowIncomplete ) 138 | ) ) 139 | .then( buffer => buffer.toString( ) ); 140 | else if ( Buffer.isBuffer( this._body ) ) 141 | return Promise.resolve( < Buffer >this._body ) 142 | .then( tap( buffer => 143 | < any >this.validateIntegrity( buffer, allowIncomplete ) 144 | ) ) 145 | .then( buffer => buffer.toString( ) ); 146 | else 147 | throw makeUnknownDataError( ); 148 | } 149 | 150 | public async readable( ): Promise< NodeJS.ReadableStream > 151 | { 152 | this._ensureUnused( ); 153 | this._ensureNotAborted( ); 154 | 155 | if ( this._body == null ) 156 | { 157 | const stream = through2( ); 158 | stream.end( ); 159 | return Promise.resolve( stream ); 160 | } 161 | else if ( isStream( this._body ) ) 162 | return Promise.resolve( < NodeJS.ReadableStream >this._body ); 163 | else if ( Buffer.isBuffer( this._body ) ) 164 | return Promise.resolve( through2( ) ) 165 | .then( stream => 166 | { 167 | stream.write( this._body ); 168 | stream.end( ); 169 | return stream; 170 | } ); 171 | else 172 | throw makeUnknownDataError( ); 173 | } 174 | 175 | protected setSignal( signal: AbortSignal | undefined ) 176 | { 177 | this._signal = signal; 178 | } 179 | 180 | protected hasBody( ): boolean 181 | { 182 | return "_body" in this; 183 | } 184 | 185 | protected setBody( 186 | body: BodyTypes | IBody | null, 187 | mime?: string | null, 188 | integrity?: string | null, 189 | length: number | null = null 190 | ) 191 | : void 192 | { 193 | this._ensureUnused( ); 194 | this._length = length; 195 | this._used = false; 196 | 197 | if ( body instanceof Body ) 198 | { 199 | body._ensureUnused( ); 200 | this._body = body._body; 201 | this._mime = body._mime; 202 | } 203 | else if ( typeof body === "string" ) 204 | this._body = Buffer.from( body ); 205 | else if ( body != null ) 206 | this._body = < StorageBodyTypes >body; 207 | else 208 | this._body = body; 209 | 210 | if ( Buffer.isBuffer( this._body ) ) 211 | this._length = ( < Buffer >this._body ).length; 212 | 213 | if ( mime ) 214 | this._mime = mime; 215 | 216 | if ( integrity ) 217 | this._integrity = integrity; 218 | } 219 | 220 | private async awaitBuffer( readable: NodeJS.ReadableStream ) 221 | : Promise< Buffer > 222 | { 223 | if ( !this._signal ) 224 | return getStreamBuffer( readable ); 225 | 226 | // Race the readable against the abort signal 227 | let callback: ( ) => void = ( ) => { }; 228 | const onAborted = new Promise< Buffer >( ( _, reject ) => 229 | { 230 | callback = ( ) => { reject( abortError ); }; 231 | this._signal?.addListener( 'abort', callback ); 232 | } ); 233 | 234 | try 235 | { 236 | this._ensureNotAborted( ); 237 | 238 | return await Promise.race( [ 239 | getStreamBuffer( readable ), 240 | onAborted, 241 | ] ); 242 | } 243 | finally 244 | { 245 | this._signal.removeListener( 'abort', callback ); 246 | // Could happen if abort and other error happen practically 247 | // simultaneously. Ensure Node.js won't get mad about this. 248 | onAborted.catch( ( ) => { } ); 249 | } 250 | } 251 | 252 | private validateIntegrity< T extends Buffer | ArrayBuffer >( 253 | data: T, 254 | allowIncomplete: boolean 255 | ) 256 | : T 257 | { 258 | this._ensureNotAborted( ); 259 | 260 | if ( 261 | !allowIncomplete && 262 | this._length != null && 263 | data.byteLength !== this._length 264 | ) 265 | throwLengthMismatch( ); 266 | 267 | if ( !this._integrity ) 268 | // This is valid 269 | return data; 270 | 271 | const { algorithm, hash: expectedHash } = 272 | parseIntegrity( this._integrity ); 273 | 274 | // jest (I presume) modifies ArrayBuffer, breaking instanceof 275 | const instanceOfArrayBuffer = ( val: any ) => 276 | val && val.constructor && val.constructor.name === "ArrayBuffer"; 277 | 278 | const hash = createHash( algorithm ) 279 | .update( 280 | instanceOfArrayBuffer( data ) 281 | ? new DataView( data ) 282 | : < Buffer >data 283 | ) 284 | .digest( "base64" ); 285 | 286 | if ( expectedHash.toLowerCase( ) !== hash.toLowerCase( ) ) 287 | throwIntegrityMismatch( ); 288 | 289 | return data; 290 | } 291 | 292 | private _ensureNotAborted( ) 293 | { 294 | if ( this._signal && this._signal.aborted ) 295 | throw abortError; 296 | } 297 | 298 | private _ensureUnused( ) 299 | { 300 | if ( this._used ) 301 | throw new ReferenceError( "Body already used" ); 302 | this._used = true; 303 | } 304 | 305 | // @ts-ignore 306 | private async blob( ): Promise< never > 307 | { 308 | throw new Error( 309 | "Body.blob() is not implemented (makes no sense in Node.js), " + 310 | "use another getter." ); 311 | } 312 | } 313 | 314 | export class JsonBody extends Body 315 | { 316 | constructor( obj: any ) 317 | { 318 | super( ); 319 | 320 | const body = Buffer.from( JSON.stringify( obj ) ); 321 | this.setBody( body, "application/json" ); 322 | } 323 | } 324 | 325 | export class StreamBody extends Body 326 | { 327 | constructor( readable: NodeJS.ReadableStream ) 328 | { 329 | super( ); 330 | 331 | this.setBody( readable ); 332 | } 333 | } 334 | 335 | export class DataBody extends Body 336 | { 337 | constructor( data: Buffer | string | null ) 338 | { 339 | super( ); 340 | 341 | this.setBody( data ); 342 | } 343 | } 344 | 345 | export class BodyInspector extends Body 346 | { 347 | private _ref: Body; 348 | 349 | constructor( body: Body ) 350 | { 351 | super( ); 352 | 353 | this._ref = body; 354 | } 355 | 356 | private _getMime( ) 357 | { 358 | return this._mime; 359 | } 360 | 361 | private _getLength( ) 362 | { 363 | return this._length; 364 | } 365 | 366 | private _getBody( ) 367 | { 368 | return this._body; 369 | } 370 | 371 | get mime( ) 372 | { 373 | return this._getMime.call( this._ref ); 374 | } 375 | 376 | get length( ) 377 | { 378 | return this._getLength.call( this._ref ); 379 | } 380 | 381 | get stream( ) 382 | { 383 | const rawBody = this._getBody.call( this._ref ); 384 | return rawBody && isStream( rawBody ) ? rawBody : undefined; 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /lib/context-http1.ts: -------------------------------------------------------------------------------- 1 | import { request as requestHttp } from "http"; 2 | import { request as requestHttps, RequestOptions } from "https"; 3 | import { createConnection, Socket } from "net"; 4 | import { URL } from "url"; 5 | 6 | import { defer, Deferred } from "already"; 7 | 8 | import { 9 | getByOrigin, 10 | Http1Options, 11 | parsePerOrigin, 12 | PerOrigin, 13 | } from "./core"; 14 | import { 15 | Request 16 | } from "./request"; 17 | import { parseInput } from "./utils"; 18 | 19 | 20 | export interface ConnectOptions 21 | { 22 | rejectUnauthorized: boolean | undefined; 23 | createConnection: ( ) => Socket; 24 | } 25 | 26 | export interface SocketAndCleanup 27 | { 28 | socket: Socket; 29 | cleanup: ( ) => void; 30 | } 31 | 32 | export interface FreeSocketInfoWithSocket extends SocketAndCleanup 33 | { 34 | shouldCreateNew: boolean; 35 | } 36 | export interface FreeSocketInfoWithoutSocket 37 | { 38 | socket: never; 39 | cleanup: never; 40 | shouldCreateNew: boolean; 41 | } 42 | export type FreeSocketInfo = 43 | FreeSocketInfoWithSocket | FreeSocketInfoWithoutSocket; 44 | 45 | export class OriginPool 46 | { 47 | private usedSockets = new Set< Socket >( ); 48 | private unusedSockets = new Set< Socket >( ); 49 | private waiting: Array< Deferred< SocketAndCleanup > > = [ ]; 50 | 51 | private keepAlive: boolean; 52 | private keepAliveMsecs: number; 53 | private maxSockets: number; 54 | private maxFreeSockets: number; 55 | private connOpts: { timeout?: number; }; 56 | 57 | constructor( 58 | keepAlive: boolean, 59 | keepAliveMsecs: number, 60 | maxSockets: number, 61 | maxFreeSockets: number, 62 | timeout: number | void 63 | ) 64 | { 65 | this.keepAlive = keepAlive; 66 | this.keepAliveMsecs = keepAliveMsecs; 67 | this.maxSockets = maxSockets; 68 | this.maxFreeSockets = maxFreeSockets; 69 | this.connOpts = timeout == null ? { } : { timeout }; 70 | } 71 | 72 | public connect( options: RequestOptions ) 73 | { 74 | const request = 75 | options.protocol === "https:" 76 | ? requestHttps 77 | : requestHttp; 78 | 79 | const opts = { ...options }; 80 | if ( opts.rejectUnauthorized == null || options.protocol === "https" ) 81 | delete opts.rejectUnauthorized; 82 | 83 | const req = request( { ...this.connOpts, ...opts } ); 84 | 85 | return req; 86 | } 87 | 88 | public addUsed( socket: Socket ) 89 | { 90 | if ( this.keepAlive ) 91 | socket.setKeepAlive( true, this.keepAliveMsecs ); 92 | 93 | socket.once( "close", ( ) => 94 | { 95 | this.usedSockets.delete( socket ); 96 | this.unusedSockets.delete( socket ); 97 | } ); 98 | 99 | this.usedSockets.add( socket ); 100 | 101 | return this.makeCleaner( socket ); 102 | } 103 | 104 | public getFreeSocket( ): FreeSocketInfo 105 | { 106 | const socketAndCleanup = this.getFirstUnused( ); 107 | 108 | if ( socketAndCleanup ) 109 | return { ...socketAndCleanup, shouldCreateNew: false }; 110 | 111 | const shouldCreateNew = this.maxSockets >= this.usedSockets.size; 112 | 113 | return { shouldCreateNew } as FreeSocketInfoWithoutSocket; 114 | } 115 | 116 | public waitForSocket( ): Promise< SocketAndCleanup > 117 | { 118 | const deferred = defer< SocketAndCleanup >( ); 119 | 120 | this.waiting.push( deferred ); 121 | 122 | // Trigger due to potential race-condition 123 | this.pumpWaiting( ); 124 | 125 | return deferred.promise; 126 | } 127 | 128 | public async disconnectAll( ) 129 | { 130 | await Promise.all( 131 | [ ...this.usedSockets, ...this.unusedSockets ] 132 | .map( socket => 133 | socket.destroyed ? void 0 : this.disconnectSocket( socket ) 134 | ) 135 | ); 136 | 137 | const waiting = this.waiting; 138 | this.waiting.length = 0; 139 | waiting.forEach( waiter => 140 | // TODO: Better error class + message 141 | waiter.reject( new Error( "Disconnected" ) ) 142 | ); 143 | } 144 | 145 | private getFirstUnused( ): SocketAndCleanup | null 146 | { 147 | for ( const socket of this.unusedSockets.values( ) ) 148 | { 149 | // We obviously have a socket 150 | this.moveToUsed( socket ); 151 | return { socket, cleanup: this.makeCleaner( socket ) }; 152 | } 153 | 154 | return null; 155 | } 156 | 157 | private tryReuse( socket: Socket ): boolean 158 | { 159 | if ( this.waiting.length === 0 ) 160 | return false; 161 | 162 | const waiting = < Deferred< SocketAndCleanup > >this.waiting.shift( ); 163 | waiting.resolve( { socket, cleanup: this.makeCleaner( socket ) } ); 164 | return true; 165 | } 166 | 167 | private pumpWaiting( ) 168 | { 169 | while ( this.waiting.length > 0 && this.unusedSockets.size > 0 ) 170 | { 171 | const socketAndCleanup = 172 | < SocketAndCleanup >this.getFirstUnused( ); 173 | const waiting = 174 | < Deferred< SocketAndCleanup > >this.waiting.shift( ); 175 | waiting.resolve( socketAndCleanup ); 176 | } 177 | } 178 | 179 | private async disconnectSocket( socket: Socket ) 180 | { 181 | socket.destroy( ); 182 | } 183 | 184 | private makeCleaner( socket: Socket ) 185 | { 186 | let hasCleaned = false; 187 | return ( ) => 188 | { 189 | if ( hasCleaned ) 190 | return; 191 | hasCleaned = true; 192 | 193 | if ( !socket.destroyed ) 194 | this.moveToUnused( socket ); 195 | }; 196 | } 197 | 198 | private async moveToUnused( socket: Socket ) 199 | { 200 | if ( this.tryReuse( socket ) ) 201 | return; 202 | 203 | this.usedSockets.delete( socket ); 204 | 205 | if ( this.maxFreeSockets < this.unusedSockets.size + 1 ) 206 | { 207 | await this.disconnectSocket( socket ); 208 | return; 209 | } 210 | 211 | this.unusedSockets.add( socket ); 212 | socket.unref( ); 213 | } 214 | 215 | private moveToUsed( socket: Socket ) 216 | { 217 | this.unusedSockets.delete( socket ); 218 | this.usedSockets.add( socket ); 219 | socket.ref( ); 220 | return socket; 221 | } 222 | } 223 | 224 | class ContextPool 225 | { 226 | public readonly keepAlive: boolean | PerOrigin< boolean >; 227 | 228 | private pools = new Map< string, OriginPool >( ); 229 | 230 | private keepAliveMsecs: number | PerOrigin< number >; 231 | private maxSockets: number | PerOrigin< number >; 232 | private maxFreeSockets: number | PerOrigin< number >; 233 | private timeout: void | number | PerOrigin< void | number >; 234 | 235 | constructor( options: Partial< Http1Options > ) 236 | { 237 | this.keepAlive = parsePerOrigin( options.keepAlive, true ); 238 | this.keepAliveMsecs = parsePerOrigin( options.keepAliveMsecs, 1000 ); 239 | this.maxSockets = parsePerOrigin( options.maxSockets, 256 ); 240 | this.maxFreeSockets = parsePerOrigin( options.maxFreeSockets, Infinity ); 241 | this.timeout = parsePerOrigin( options.timeout, void 0 ); 242 | } 243 | 244 | public hasOrigin( origin: string ) 245 | { 246 | return this.pools.has( origin ); 247 | } 248 | 249 | public getOriginPool( origin: string ): OriginPool 250 | { 251 | const pool = this.pools.get( origin ); 252 | 253 | if ( !pool ) 254 | { 255 | const keepAlive = getByOrigin( this.keepAlive, origin ); 256 | const keepAliveMsecs = getByOrigin( this.keepAliveMsecs, origin ); 257 | const maxSockets = getByOrigin( this.maxSockets, origin ); 258 | const maxFreeSockets = getByOrigin( this.maxFreeSockets, origin ); 259 | const timeout = getByOrigin( this.timeout, origin ); 260 | 261 | const newPool = new OriginPool( 262 | keepAlive, 263 | keepAliveMsecs, 264 | maxSockets, 265 | maxFreeSockets, 266 | timeout 267 | ); 268 | this.pools.set( origin, newPool ); 269 | return newPool; 270 | } 271 | 272 | return pool; 273 | } 274 | 275 | public async disconnect( origin: string ) 276 | { 277 | const pool = this.pools.get( origin ); 278 | if ( pool ) 279 | await pool.disconnectAll( ); 280 | } 281 | 282 | public async disconnectAll( ) 283 | { 284 | const pools = [ ...this.pools.values( ) ]; 285 | await Promise.all( pools.map( pool => pool.disconnectAll( ) ) ); 286 | } 287 | } 288 | 289 | function sessionToPool( session: unknown ) 290 | { 291 | return session as OriginPool; 292 | } 293 | 294 | export class H1Context 295 | { 296 | private contextPool: ContextPool; 297 | 298 | constructor( options: Partial< Http1Options > ) 299 | { 300 | this.contextPool = new ContextPool( options ); 301 | } 302 | 303 | public getSessionForOrigin( origin: string ) 304 | { 305 | return this.contextPool.getOriginPool( origin ); 306 | } 307 | 308 | public getFreeSocketForSession( session: OriginPool ): FreeSocketInfo 309 | { 310 | const pool = sessionToPool( session ); 311 | return pool.getFreeSocket( ); 312 | } 313 | 314 | public addUsedSocket( session: OriginPool, socket: Socket ) 315 | { 316 | const pool = sessionToPool( session ); 317 | return pool.addUsed( socket ); 318 | } 319 | 320 | public waitForSocketBySession( session: OriginPool ): Promise< SocketAndCleanup > 321 | { 322 | return sessionToPool( session ).waitForSocket( ); 323 | } 324 | 325 | public connect( url: URL, extraOptions: ConnectOptions, request: Request ) 326 | { 327 | const { 328 | origin, 329 | protocol, 330 | hostname, 331 | password, 332 | pathname, 333 | search, 334 | username, 335 | } = url; 336 | 337 | const path = pathname + search; 338 | 339 | const port = parseInt( parseInput( url.href ).port, 10 ); 340 | 341 | const method = request.method; 342 | 343 | const auth = 344 | ( username || password ) 345 | ? { auth: `${username}:${password}` } 346 | : { }; 347 | 348 | const options: RequestOptions = { 349 | ...extraOptions, 350 | agent: false, 351 | hostname, 352 | method, 353 | path, 354 | port, 355 | protocol, 356 | ...auth, 357 | }; 358 | 359 | if ( !options.headers ) 360 | options.headers = { }; 361 | 362 | options.headers.connection = this.contextPool.keepAlive 363 | ? "keep-alive" 364 | : "close"; 365 | 366 | return this.contextPool.getOriginPool( origin ).connect( options ); 367 | } 368 | 369 | public async makeNewConnection( url: string ) 370 | { 371 | return new Promise< Socket >( ( resolve, reject ) => 372 | { 373 | const { hostname, port } = parseInput( url ); 374 | 375 | const socket = createConnection( 376 | parseInt( port, 10 ), 377 | hostname, 378 | ( ) => 379 | { 380 | resolve( socket ); 381 | } 382 | ); 383 | 384 | socket.once( "error", reject ); 385 | 386 | return socket; 387 | } ); 388 | } 389 | 390 | public disconnect( url: string ) 391 | { 392 | const { origin } = new URL( url ); 393 | 394 | this.contextPool.disconnect( origin ); 395 | } 396 | 397 | public disconnectAll( ) 398 | { 399 | this.contextPool.disconnectAll( ); 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /lib/fetch-http2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | constants as h2constants, 3 | IncomingHttpHeaders as IncomingHttp2Headers, 4 | ClientHttp2Stream, 5 | } from "http2"; 6 | 7 | import { syncGuard } from "callguard"; 8 | 9 | import { AbortController } from "./abort"; 10 | import { 11 | AbortError, 12 | RetryError, 13 | FetchInit, 14 | } from "./core"; 15 | import { SimpleSessionHttp2 } from "./simple-session"; 16 | import { 17 | FetchExtra, 18 | handleSignalAndTimeout, 19 | make100Error, 20 | makeAbortedError, 21 | makeIllegalRedirectError, 22 | makeRedirectionError, 23 | makeRedirectionMethodError, 24 | makeTimeoutError, 25 | setupFetch, 26 | } from "./fetch-common"; 27 | import { GuardedHeaders } from "./headers"; 28 | import { Request } from "./request"; 29 | import { Response, StreamResponse } from "./response"; 30 | import { 31 | arrayify, 32 | isRedirectStatus, 33 | parseLocation, 34 | pipeline, 35 | ParsedLocation, 36 | } from "./utils"; 37 | import { hasGotGoaway } from "./utils-http2"; 38 | 39 | const { 40 | // Responses 41 | HTTP2_HEADER_STATUS, 42 | HTTP2_HEADER_LOCATION, 43 | HTTP2_HEADER_SET_COOKIE, 44 | 45 | // Error codes 46 | NGHTTP2_NO_ERROR, 47 | } = h2constants; 48 | 49 | // This is from nghttp2.h, but undocumented in Node.js 50 | const NGHTTP2_ERR_START_STREAM_NOT_ALLOWED = -516; 51 | 52 | interface FetchExtraHttp2 extends FetchExtra 53 | { 54 | raceConditionedGoaway: Set< string >; // per origin 55 | } 56 | 57 | async function fetchImpl( 58 | session: SimpleSessionHttp2, 59 | input: Request, 60 | init: Partial< FetchInit > = { }, 61 | extra: FetchExtraHttp2 62 | ) 63 | : Promise< Response > 64 | { 65 | const { 66 | cleanup, 67 | contentDecoders, 68 | endStream, 69 | headersToSend, 70 | integrity, 71 | method, 72 | onTrailers, 73 | origin, 74 | redirect, 75 | redirected, 76 | request, 77 | signal, 78 | signalPromise, 79 | timeoutAt, 80 | timeoutInfo, 81 | url, 82 | } = await setupFetch( session, input, init, extra ); 83 | 84 | const { raceConditionedGoaway } = extra; 85 | 86 | const streamPromise = session.get( ); 87 | 88 | async function doFetch( ): Promise< Response > 89 | { 90 | const { session: ph2session, cleanup: socketCleanup } = streamPromise; 91 | const h2session = await ph2session; 92 | 93 | const tryRetryOnGoaway = 94 | ( resolve: ( value: Promise< Response > ) => void ) => 95 | { 96 | // This could be due to a race-condition in GOAWAY. 97 | // As of current Node.js, the 'goaway' event is emitted on the 98 | // session before this event (at least frameError, probably 99 | // 'error' too) is emitted, so we will know if we got it. 100 | if ( 101 | !raceConditionedGoaway.has( origin ) && 102 | hasGotGoaway( h2session ) 103 | ) 104 | { 105 | // Don't retry again due to potential GOAWAY 106 | raceConditionedGoaway.add( origin ); 107 | 108 | // Since we've got the 'goaway' event, the 109 | // context has already released the session, 110 | // so a retry will create a new session. 111 | resolve( 112 | fetchImpl( 113 | session, 114 | request, 115 | { signal, onTrailers }, 116 | { 117 | raceConditionedGoaway, 118 | redirected, 119 | timeoutAt, 120 | } 121 | ) 122 | ); 123 | 124 | return true; 125 | } 126 | return false; 127 | }; 128 | 129 | let stream: ClientHttp2Stream; 130 | let shouldCleanupSocket = true; 131 | try 132 | { 133 | stream = h2session.request( headersToSend, { endStream } ); 134 | } 135 | catch ( err: any ) 136 | { 137 | if ( err.code === "ERR_HTTP2_GOAWAY_SESSION" ) 138 | { 139 | // Retry with new session 140 | throw new RetryError( err.code ); 141 | } 142 | throw err; 143 | } 144 | 145 | const response = new Promise< Response >( ( resolve, reject ) => 146 | { 147 | const guard = syncGuard( reject, { catchAsync: true } ); 148 | 149 | stream.on( "aborted", guard( ( ..._whatever ) => 150 | { 151 | reject( makeAbortedError( ) ); 152 | } ) ); 153 | 154 | stream.on( "error", guard( ( err: Error ) => 155 | { 156 | if ( 157 | err && 158 | ( < any >err ).code === "ERR_HTTP2_STREAM_ERROR" && 159 | err.message && 160 | err.message.includes( "NGHTTP2_REFUSED_STREAM" ) 161 | ) 162 | { 163 | if ( tryRetryOnGoaway( resolve ) ) 164 | return; 165 | } 166 | reject( err ); 167 | } ) ); 168 | 169 | stream.on( "frameError", guard( 170 | ( _type: number, code: number, _streamId: number ) => 171 | { 172 | if ( 173 | code === NGHTTP2_ERR_START_STREAM_NOT_ALLOWED && 174 | endStream 175 | ) 176 | { 177 | if ( tryRetryOnGoaway( resolve ) ) 178 | return; 179 | } 180 | 181 | reject( new Error( "Request failed" ) ); 182 | } ) 183 | ); 184 | 185 | stream.on( "close", guard( ( ) => 186 | { 187 | if ( shouldCleanupSocket ) 188 | socketCleanup( ); 189 | 190 | // We'll get an 'error' event if there actually is an 191 | // error, but not if we got NGHTTP2_NO_ERROR. 192 | // In case of an error, the 'error' event will be awaited 193 | // instead, to get (and propagate) the error object. 194 | if ( stream.rstCode === NGHTTP2_NO_ERROR ) 195 | reject( 196 | new AbortError( "Stream prematurely closed" ) ); 197 | } ) ); 198 | 199 | stream.on( "timeout", guard( ( ..._whatever ) => 200 | { 201 | reject( makeTimeoutError( ) ); 202 | } ) ); 203 | 204 | stream.on( "trailers", guard( 205 | ( _headers: IncomingHttp2Headers, _flags: any ) => 206 | { 207 | if ( !onTrailers ) 208 | return; 209 | try 210 | { 211 | const headers = new GuardedHeaders( "response" ); 212 | 213 | Object.keys( _headers ).forEach( key => 214 | { 215 | if ( Array.isArray( _headers[ key ] ) ) 216 | ( < Array< string > >_headers[ key ] ) 217 | .forEach( value => 218 | headers.append( key, value ) ); 219 | else 220 | headers.set( key, "" + _headers[ key ] ); 221 | } ); 222 | 223 | onTrailers( headers ); 224 | } 225 | catch ( err ) 226 | { 227 | // TODO: Implement #8 228 | // tslint:disable-next-line 229 | console.warn( "Trailer handling failed", err ); 230 | } 231 | } ) ); 232 | 233 | // ClientHttp2Stream events 234 | 235 | stream.on( "continue", guard( ( ..._whatever ) => 236 | { 237 | reject( make100Error( ) ); 238 | } ) ); 239 | 240 | stream.on( "response", guard( ( headers: IncomingHttp2Headers ) => 241 | { 242 | const { 243 | signal: bodySignal = void 0, 244 | abort: bodyAbort = void 0, 245 | } = signal ? new AbortController( ) : { }; 246 | 247 | if ( signal ) 248 | { 249 | const abortHandler = ( ) => 250 | { 251 | ( < ( ) => void >bodyAbort )( ); 252 | stream.destroy( ); 253 | }; 254 | 255 | if ( signal.aborted ) 256 | { 257 | // No reason to continue, the request is aborted 258 | abortHandler( ); 259 | return; 260 | } 261 | 262 | signal.once( "abort", abortHandler ); 263 | stream.once( "close", ( ) => 264 | { 265 | signal.removeListener( "abort", abortHandler ); 266 | } ); 267 | } 268 | 269 | const status = "" + headers[ HTTP2_HEADER_STATUS ]; 270 | const location = parseLocation( 271 | headers[ HTTP2_HEADER_LOCATION ], 272 | url 273 | ); 274 | 275 | const isRedirected = isRedirectStatus[ status ]; 276 | 277 | if ( headers[ HTTP2_HEADER_SET_COOKIE ] ) 278 | { 279 | const setCookies = 280 | arrayify( headers[ HTTP2_HEADER_SET_COOKIE ] ); 281 | 282 | session.cookieJar.setCookies( setCookies, url ); 283 | } 284 | 285 | if ( !input.allowForbiddenHeaders ) 286 | { 287 | delete headers[ "set-cookie" ]; 288 | delete headers[ "set-cookie2" ]; 289 | } 290 | 291 | if ( isRedirected && !location ) 292 | return reject( makeIllegalRedirectError( ) ); 293 | 294 | if ( !isRedirected || redirect === "manual" ) 295 | return resolve( 296 | new StreamResponse( 297 | contentDecoders, 298 | url, 299 | stream, 300 | headers, 301 | redirect === "manual" 302 | ? false 303 | : extra.redirected.length > 0, 304 | { }, 305 | bodySignal, 306 | 2, 307 | input.allowForbiddenHeaders, 308 | integrity 309 | ) 310 | ); 311 | 312 | const { url: locationUrl, isRelative } = 313 | location as ParsedLocation; 314 | 315 | if ( redirect === "error" ) 316 | return reject( makeRedirectionError( locationUrl ) ); 317 | 318 | // redirect is 'follow' 319 | 320 | // We don't support re-sending a non-GET/HEAD request (as 321 | // we don't want to [can't, if its' streamed] re-send the 322 | // body). The concept is fundementally broken anyway... 323 | if ( !endStream ) 324 | return reject( 325 | makeRedirectionMethodError( locationUrl, method ) 326 | ); 327 | 328 | if ( !location ) 329 | return reject( makeIllegalRedirectError( ) ); 330 | 331 | if ( isRelative ) 332 | { 333 | shouldCleanupSocket = false; 334 | stream.destroy( ); 335 | resolve( fetchImpl( 336 | session, 337 | request.clone( locationUrl ), 338 | init, 339 | { 340 | raceConditionedGoaway, 341 | redirected: redirected.concat( url ), 342 | timeoutAt, 343 | } 344 | ) ); 345 | } 346 | else 347 | { 348 | resolve( session.newFetch( 349 | request.clone( locationUrl ), 350 | init, 351 | { 352 | timeoutAt, 353 | redirected: redirected.concat( url ), 354 | } 355 | ) ); 356 | } 357 | } ) ); 358 | } ); 359 | 360 | if ( !endStream ) 361 | await request.readable( ) 362 | .then( readable => 363 | { 364 | pipeline( readable, stream ) 365 | .catch ( _err => 366 | { 367 | // TODO: Implement error handling 368 | } ); 369 | } ); 370 | 371 | return response; 372 | } 373 | 374 | return handleSignalAndTimeout( 375 | signalPromise, 376 | timeoutInfo, 377 | cleanup, 378 | doFetch, 379 | streamPromise.cleanup 380 | ); 381 | } 382 | 383 | export function fetch( 384 | session: SimpleSessionHttp2, 385 | input: Request, 386 | init?: Partial< FetchInit >, 387 | extra?: FetchExtra 388 | ) 389 | : Promise< Response > 390 | { 391 | const http2Extra: FetchExtraHttp2 = { 392 | timeoutAt: extra?.timeoutAt, 393 | redirected: extra?.redirected ?? [ ], 394 | raceConditionedGoaway: new Set< string>( ), 395 | }; 396 | 397 | return fetchImpl( session, input, init, http2Extra ); 398 | } 399 | -------------------------------------------------------------------------------- /test/fetch-h2/context.ts: -------------------------------------------------------------------------------- 1 | import { map } from "already"; 2 | import { lsof } from "list-open-files"; 3 | 4 | import { TestData } from "../lib/server-common"; 5 | import { makeMakeServer } from "../lib/server-helpers"; 6 | 7 | import { 8 | context, 9 | CookieJar, 10 | Response, 11 | } from "../../index"; 12 | 13 | 14 | function ensureStatusSuccess( response: Response ): Response 15 | { 16 | if ( response.status < 200 || response.status >= 300 ) 17 | throw new Error( "Status not 2xx" ); 18 | return response; 19 | } 20 | 21 | 22 | ( [ 23 | { proto: "http:", version: "http1" }, 24 | { proto: "http:", version: "http2" }, 25 | { proto: "https:", version: "http1" }, 26 | { proto: "https:", version: "http2" }, 27 | ] as Array< TestData > ) 28 | .forEach( ( { proto, version } ) => 29 | { 30 | describe( `context (${version} over ${proto.replace( ":", "" )})`, ( ) => 31 | { 32 | const { cycleOpts, makeServer } = makeMakeServer( { proto, version } ); 33 | 34 | jest.setTimeout( 500 ); 35 | 36 | describe( "options", ( ) => 37 | { 38 | it( "should be able to overwrite default user agent", async ( ) => 39 | { 40 | const { server, port } = await makeServer( ); 41 | 42 | const { disconnectAll, fetch } = context( { 43 | ...cycleOpts, 44 | overwriteUserAgent: true, 45 | userAgent: "foobar", 46 | } ); 47 | 48 | const response = ensureStatusSuccess( 49 | await fetch( `${proto}//localhost:${port}/headers` ) 50 | ); 51 | 52 | const res = await response.json( ); 53 | expect( res[ "user-agent" ] ).toBe( "foobar" ); 54 | 55 | disconnectAll( ); 56 | 57 | await server.shutdown( ); 58 | } ); 59 | 60 | it( "should be able to set (combined) user agent", async ( ) => 61 | { 62 | const { server, port } = await makeServer( ); 63 | 64 | const { disconnectAll, fetch } = context( { 65 | ...cycleOpts, 66 | userAgent: "foobar", 67 | } ); 68 | 69 | const response = ensureStatusSuccess( 70 | await fetch( `${proto}//localhost:${port}/headers` ) 71 | ); 72 | 73 | const res = await response.json( ); 74 | expect( res[ "user-agent" ] ).toContain( "foobar" ); 75 | expect( res[ "user-agent" ] ).toContain( "fetch-h2" ); 76 | 77 | disconnectAll( ); 78 | 79 | await server.shutdown( ); 80 | } ); 81 | 82 | it( "should be able to set default accept header", async ( ) => 83 | { 84 | const { server, port } = await makeServer( ); 85 | 86 | const accept = "application/foobar, text/*;0.9"; 87 | 88 | const { disconnectAll, fetch } = context( { 89 | ...cycleOpts, 90 | accept, 91 | } ); 92 | 93 | const response = ensureStatusSuccess( 94 | await fetch( `${proto}//localhost:${port}/headers` ) 95 | ); 96 | 97 | const res = await response.json( ); 98 | expect( res.accept ).toBe( accept ); 99 | 100 | disconnectAll( ); 101 | 102 | await server.shutdown( ); 103 | } ); 104 | } ); 105 | 106 | if ( proto === "https:" ) 107 | describe( "network settings", ( ) => 108 | { 109 | it( "should not be able to connect over unauthorized ssl", async ( ) => 110 | { 111 | const { server, port } = await makeServer( ); 112 | 113 | const { disconnectAll, fetch } = context( { 114 | ...cycleOpts, 115 | overwriteUserAgent: true, 116 | session: { rejectUnauthorized: true }, 117 | userAgent: "foobar", 118 | } ); 119 | 120 | try 121 | { 122 | await fetch( `https://localhost:${port}/headers` ); 123 | expect( true ).toEqual( false ); 124 | } 125 | catch ( err: any ) 126 | { 127 | expect( 128 | err.message.includes( "closed" ) // < Node 9.4 129 | || 130 | err.message.includes( "self signed" ) // >= Node 9.4 131 | || 132 | err.message.includes( "expired" ) 133 | ).toBeTruthy( ); 134 | } 135 | 136 | await disconnectAll( ); 137 | 138 | await server.shutdown( ); 139 | } ); 140 | 141 | it( "should be able to connect over unauthorized ssl", async ( ) => 142 | { 143 | const { server, port } = await makeServer( ); 144 | 145 | const { disconnectAll, fetch } = context( { 146 | ...cycleOpts, 147 | overwriteUserAgent: true, 148 | session: { rejectUnauthorized: false }, 149 | userAgent: "foobar", 150 | } ); 151 | 152 | const response = ensureStatusSuccess( 153 | await fetch( `https://localhost:${port}/headers` ) 154 | ); 155 | 156 | const res = await response.json( ); 157 | expect( res[ "user-agent" ] ).toBe( "foobar" ); 158 | 159 | await disconnectAll( ); 160 | 161 | await server.shutdown( ); 162 | } ); 163 | } ); 164 | 165 | describe( "cookies", ( ) => 166 | { 167 | it( "should be able to specify custom cookie jar", async ( ) => 168 | { 169 | const { server, port } = await makeServer( ); 170 | 171 | const cookieJar = new CookieJar( ); 172 | 173 | expect( 174 | await cookieJar.getCookies( `${proto}//localhost:${port}/` ) 175 | ).toEqual( [ ] ); 176 | 177 | const { disconnectAll, fetch } = context( { 178 | ...cycleOpts, 179 | cookieJar, 180 | overwriteUserAgent: true, 181 | userAgent: "foobar", 182 | } ); 183 | 184 | await fetch( `${proto}//localhost:${port}/set-cookie`, { 185 | json: [ "a=b" , "c=d" ], 186 | method: "POST", 187 | } ); 188 | 189 | const cookies = 190 | await cookieJar.getCookies( `${proto}//localhost:${port}/` ); 191 | 192 | expect( cookies.length ).toBeGreaterThan( 1 ); 193 | expect( cookies[ 0 ].key ).toBe( "a" ); 194 | expect( cookies[ 0 ].value ).toBe( "b" ); 195 | expect( cookies[ 1 ].key ).toBe( "c" ); 196 | expect( cookies[ 1 ].value ).toBe( "d" ); 197 | 198 | // Next request should maintain cookies 199 | 200 | await fetch( `${proto}//localhost:${port}/echo` ); 201 | 202 | const cookies2 = 203 | await cookieJar.getCookies( `${proto}//localhost:${port}/` ); 204 | 205 | expect( cookies2.length ).toBeGreaterThan( 0 ); 206 | 207 | // If we manually clear the cookie jar, subsequent requests 208 | // shouldn't have any cookies 209 | 210 | cookieJar.reset( ); 211 | 212 | await fetch( `${proto}//localhost:${port}/echo` ); 213 | 214 | const cookies3 = 215 | await cookieJar.getCookies( `${proto}//localhost:${port}/` ); 216 | 217 | expect( cookies3 ).toEqual( [ ] ); 218 | 219 | disconnectAll( ); 220 | 221 | await server.shutdown( ); 222 | } ); 223 | 224 | it( "shouldn't be able to read cookie headers be default", async ( ) => 225 | { 226 | const { server, port } = await makeServer( ); 227 | 228 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 229 | 230 | const response = await fetch( 231 | `${proto}//localhost:${port}/set-cookie`, 232 | { 233 | json: [ "a=b" , "c=d" ], 234 | method: "POST", 235 | } 236 | ); 237 | 238 | expect( response.headers.get( "set-cookie" ) ).toBe( null ); 239 | expect( response.headers.get( "set-cookie2" ) ).toBe( null ); 240 | 241 | disconnectAll( ); 242 | 243 | await server.shutdown( ); 244 | } ); 245 | 246 | it( "should be able to read cookie headers if allowed", async ( ) => 247 | { 248 | const { server, port } = await makeServer( ); 249 | 250 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 251 | 252 | const response = await fetch( 253 | `${proto}//localhost:${port}/set-cookie`, 254 | { 255 | allowForbiddenHeaders: true, 256 | json: [ "a=b" , "c=d" ], 257 | method: "POST", 258 | } 259 | ); 260 | 261 | expect( response.headers.get( "set-cookie" ) ).toBe( "a=b,c=d" ); 262 | 263 | disconnectAll( ); 264 | 265 | await server.shutdown( ); 266 | } ); 267 | } ); 268 | 269 | describe( "disconnection", ( ) => 270 | { 271 | it( "should be able to disconnect non-connection", 272 | async ( ) => 273 | { 274 | const { server } = await makeServer( ); 275 | 276 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 277 | 278 | const awaitFetch = fetch( "${proto}//localhost:0" ); 279 | 280 | disconnectAll( ); 281 | 282 | await awaitFetch.catch( ( ) => { } ); 283 | 284 | disconnectAll( ); 285 | 286 | await server.shutdown( ); 287 | } ); 288 | 289 | it( "should be able to disconnect invalid url", 290 | async ( ) => 291 | { 292 | const { server } = await makeServer( ); 293 | 294 | const { disconnectAll, fetch } = 295 | context( { 296 | ...cycleOpts, 297 | session: { port: -1, host: < any >{ } }, 298 | } ); 299 | 300 | const awaitFetch = fetch( "ftp://localhost" ); 301 | 302 | disconnectAll( ); 303 | 304 | await awaitFetch.catch( ( ) => { } ); 305 | 306 | disconnectAll( ); 307 | 308 | await server.shutdown( ); 309 | } ); 310 | } ); 311 | 312 | describe( "session sharing", ( ) => 313 | { 314 | jest.setTimeout( 2500 ); 315 | 316 | it( "should re-use session for same host", async ( ) => 317 | { 318 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 319 | 320 | const urls = [ 321 | [ "https://en.wikipedia.org/wiki/33", "33" ], 322 | [ "https://en.wikipedia.org/wiki/44", "44" ], 323 | [ "https://en.wikipedia.org/wiki/42", "42" ], 324 | ]; 325 | 326 | const [ { files: openFilesBefore } ] = await lsof( ); 327 | 328 | const resps = await map( 329 | urls, 330 | { concurrency: Infinity }, 331 | async ( [ url, title ] ) => 332 | { 333 | const resp = await fetch( url ); 334 | const text = await resp.text( ); 335 | const m = text.match( /]*>(.*)<\/h1>/ ); 336 | return { expected: title, got: m?.[ 1 ] }; 337 | } 338 | ); 339 | 340 | const [ { files: openFilesAfter } ] = await lsof( { } ); 341 | 342 | const numAfter = 343 | openFilesAfter.filter( fd => fd.type === 'IP' ).length; 344 | const numBefore = 345 | openFilesBefore.filter( fd => fd.type === 'IP' ).length; 346 | 347 | // HTTP/1.1 will most likely spawn new sockets, but timing *may* 348 | // affect this. For HTTP/2, it should always just use 1 socket per 349 | // origin / SAN cluster. 350 | if ( version === 'http2' ) 351 | expect( numBefore ).toEqual( numAfter - 1 ); 352 | 353 | resps.forEach( ( { expected, got } ) => 354 | { 355 | expect( expected ).toBe( got ); 356 | } ); 357 | 358 | await disconnectAll( ); 359 | } ); 360 | 361 | it( "should re-use session for same SAN but different host", 362 | async ( ) => 363 | { 364 | const { disconnectAll, fetch } = context( { ...cycleOpts } ); 365 | 366 | const urls = [ 367 | { lang: "en", title: "33" }, 368 | { lang: "en", title: "44" }, 369 | { lang: "sv", title: "33" }, 370 | { lang: "sv", title: "44" }, 371 | ] as const; 372 | 373 | const [ { files: openFilesBefore } ] = await lsof( ); 374 | 375 | const resps = await map( 376 | urls, 377 | { concurrency: Infinity }, 378 | async ( { lang, title } ) => 379 | { 380 | const url = `https://${lang}.wikipedia.org/wiki/${title}`; 381 | const resp = await fetch( url ); 382 | const text = await resp.text( ); 383 | const mLang = text.match( /]* lang="([^"]+)"/ ); 384 | const mTitle = text.match( /]*>([^<]+)<\/h1>/ ); 385 | return { 386 | expectedLang: lang, 387 | gotLang: mLang?.[ 1 ], 388 | expectedTitle: title, 389 | gotTitle: mTitle?.[ 1 ], 390 | }; 391 | } 392 | ); 393 | 394 | const [ { files: openFilesAfter } ] = await lsof( { } ); 395 | 396 | const numAfter = 397 | openFilesAfter.filter( fd => fd.type === 'IP' ).length; 398 | const numBefore = 399 | openFilesBefore.filter( fd => fd.type === 'IP' ).length; 400 | 401 | // HTTP/1.1 will most likely spawn new sockets, but timing *may* 402 | // affect this. For HTTP/2, it should always just use 1 socket per 403 | // origin / SAN cluster. 404 | if ( version === 'http2' ) 405 | expect( numBefore ).toEqual( numAfter - 1 ); 406 | 407 | resps.forEach( 408 | ( { expectedLang, gotLang, expectedTitle, gotTitle } ) => 409 | { 410 | expect( expectedLang ).toBe( gotLang ); 411 | expect( expectedTitle ).toBe( gotTitle ); 412 | } 413 | ); 414 | 415 | await disconnectAll( ); 416 | } ); 417 | } ); 418 | } ); 419 | } ); 420 | -------------------------------------------------------------------------------- /lib/context-http2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientHttp2Session, 3 | ClientHttp2Stream, 4 | connect as http2Connect, 5 | constants as h2constants, 6 | IncomingHttpHeaders as IncomingHttp2Headers, 7 | SecureClientSessionOptions, 8 | } from "http2"; 9 | import { URL } from "url"; 10 | 11 | import { asyncGuard, syncGuard } from "callguard"; 12 | 13 | import { 14 | AbortError, 15 | Decoder, 16 | TimeoutError, 17 | } from "./core"; 18 | 19 | import { Request } from "./request"; 20 | import { Response, StreamResponse } from "./response"; 21 | import { makeOkError } from "./utils"; 22 | import { 23 | isDestroyed, 24 | MonkeyH2Session, 25 | setDestroyed, 26 | setGotGoaway, 27 | } from "./utils-http2"; 28 | 29 | 30 | const { 31 | HTTP2_HEADER_PATH, 32 | } = h2constants; 33 | 34 | interface H2SessionItem 35 | { 36 | firstOrigin: string; 37 | session: ClientHttp2Session; 38 | promise: Promise< ClientHttp2Session >; 39 | 40 | ref: ( ) => void; 41 | unref: ( ) => void; 42 | } 43 | 44 | export interface CacheableH2Session 45 | { 46 | ref: ( ) => void; 47 | session: Promise< ClientHttp2Session >; 48 | unref: ( ) => void; 49 | } 50 | 51 | export type PushHandler = 52 | ( 53 | origin: string, 54 | request: Request, 55 | getResponse: ( ) => Promise< Response > 56 | ) => void; 57 | 58 | export type GetDecoders = ( origin: string ) => ReadonlyArray< Decoder >; 59 | export type GetSessionOptions = 60 | ( origin: string ) => SecureClientSessionOptions; 61 | 62 | export class H2Context 63 | { 64 | public _pushHandler?: PushHandler; 65 | 66 | // TODO: Remove in favor of protocol-agnostic origin cache 67 | private _h2sessions = new Map< string, H2SessionItem >( ); 68 | private _h2staleSessions = new Map< string, Set< ClientHttp2Session > >( ); 69 | private _getDecoders: GetDecoders; 70 | private _getSessionOptions: GetSessionOptions; 71 | 72 | constructor( 73 | getDecoders: GetDecoders, 74 | getSessionOptions: GetSessionOptions 75 | ) 76 | { 77 | this._getDecoders = getDecoders; 78 | this._getSessionOptions = getSessionOptions; 79 | 80 | /* istanbul ignore next */ 81 | if ( process.env.DEBUG_FETCH_H2 ) 82 | { 83 | const debug = ( line: string, ...args: Array< any > ) => 84 | { 85 | // tslint:disable-next-line 86 | console.error( line, ...args ); 87 | }; 88 | 89 | const printSession = ( origin: string, session: MonkeyH2Session ) => 90 | { 91 | debug( " First origin:", origin ); 92 | debug( " Ref-counter:", session.__fetch_h2_refcount ); 93 | debug( " Destroyed:", session.destroyed ); 94 | debug( " Destroyed mark:", session.__fetch_h2_destroyed ); 95 | }; 96 | 97 | process.on( "SIGUSR2", ( ) => 98 | { 99 | debug( "[Debug fetch-h2]: H2 sessions" ); 100 | 101 | debug( " Active sessions" ); 102 | [ ...this._h2sessions.entries( ) ] 103 | .forEach( ( [ origin, { session } ] ) => 104 | { 105 | printSession( origin, < MonkeyH2Session >session ); 106 | } ); 107 | 108 | debug( " Stale sessions" ); 109 | [ ...this._h2staleSessions.entries( ) ] 110 | .forEach( ( [ origin, set ] ) => 111 | { 112 | [ ...set ] 113 | .forEach( ( session ) => 114 | { 115 | printSession( origin, < MonkeyH2Session >session ); 116 | } ); 117 | } ); 118 | } ); 119 | } 120 | } 121 | 122 | public createHttp2( 123 | origin: string, 124 | onGotGoaway: ( ) => void, 125 | extraOptions?: SecureClientSessionOptions 126 | ) 127 | : CacheableH2Session 128 | { 129 | const sessionItem = this.connectHttp2( origin, extraOptions ); 130 | 131 | const { promise } = sessionItem; 132 | 133 | // Handle session closure (delete from store) 134 | promise 135 | .then( session => 136 | { 137 | session.once( 138 | "close", 139 | ( ) => this.disconnect( origin, session ) 140 | ); 141 | 142 | session.once( 143 | "goaway", 144 | ( 145 | _errorCode: number, 146 | _lastStreamID: number, 147 | _opaqueData: Buffer 148 | ) => 149 | { 150 | setGotGoaway( session ); 151 | onGotGoaway( ); 152 | this.releaseSession( origin ); 153 | } 154 | ); 155 | } ) 156 | .catch( ( ) => 157 | { 158 | if ( sessionItem.session ) 159 | this.disconnect( origin, sessionItem.session ); 160 | } ); 161 | 162 | this._h2sessions.set( origin, sessionItem ); 163 | 164 | const { promise: session, ref, unref } = sessionItem; 165 | 166 | return { 167 | ref, 168 | unref, 169 | session, 170 | }; 171 | } 172 | 173 | public disconnectSession( session: ClientHttp2Session ): Promise< void > 174 | { 175 | return new Promise< void >( resolve => 176 | { 177 | if ( session.destroyed ) 178 | return resolve( ); 179 | 180 | session.once( "close", ( ) => resolve( ) ); 181 | session.destroy( ); 182 | } ); 183 | } 184 | 185 | public releaseSession( origin: string ): void 186 | { 187 | const sessionItem = this.deleteActiveSession( origin ); 188 | 189 | if ( !sessionItem ) 190 | return; 191 | 192 | if ( !this._h2staleSessions.has( origin ) ) 193 | this._h2staleSessions.set( origin, new Set( ) ); 194 | 195 | ( < Set< ClientHttp2Session > >this._h2staleSessions.get( origin ) ) 196 | .add( sessionItem.session ); 197 | } 198 | 199 | public deleteActiveSession( origin: string ): H2SessionItem | void 200 | { 201 | const sessionItem = this._h2sessions.get( origin ); 202 | 203 | if ( !sessionItem ) 204 | return; 205 | 206 | this._h2sessions.delete( origin ); 207 | 208 | sessionItem.session.unref( ); 209 | // Never re-ref, this session is over 210 | setDestroyed( sessionItem.session ); 211 | 212 | return sessionItem; 213 | } 214 | 215 | public async disconnectStaleSessions( origin: string ): Promise< void > 216 | { 217 | const promises: Array< Promise< void > > = [ ]; 218 | 219 | const sessionSet = this._h2staleSessions.get( origin ); 220 | 221 | if ( !sessionSet ) 222 | return; 223 | 224 | this._h2staleSessions.delete( origin ); 225 | 226 | for ( const session of sessionSet ) 227 | promises.push( this.disconnectSession( session ) ); 228 | 229 | return Promise.all( promises ).then( ( ) => { } ); 230 | } 231 | 232 | public disconnectAll( ): Promise< void > 233 | { 234 | const promises: Array< Promise< void > > = [ ]; 235 | 236 | for ( const eventualH2session of this._h2sessions.values( ) ) 237 | { 238 | promises.push( this.handleDisconnect( eventualH2session ) ); 239 | } 240 | this._h2sessions.clear( ); 241 | 242 | for ( const origin of this._h2staleSessions.keys( ) ) 243 | { 244 | promises.push( this.disconnectStaleSessions( origin ) ); 245 | } 246 | 247 | return Promise.all( promises ).then( ( ) => { } ); 248 | } 249 | 250 | public disconnect( url: string, session?: ClientHttp2Session ) 251 | : Promise< void > 252 | { 253 | const { origin } = new URL( url ); 254 | const promises: Array< Promise< void > > = [ ]; 255 | 256 | const sessionItem = this.deleteActiveSession( origin ); 257 | 258 | if ( sessionItem && ( !session || sessionItem.session === session ) ) 259 | promises.push( this.handleDisconnect( sessionItem ) ); 260 | 261 | if ( !session ) 262 | { 263 | promises.push( this.disconnectStaleSessions( origin ) ); 264 | } 265 | else if ( this._h2staleSessions.has( origin ) ) 266 | { 267 | const sessionSet = 268 | < Set< ClientHttp2Session > > 269 | this._h2staleSessions.get( origin ); 270 | if ( sessionSet.has( session ) ) 271 | { 272 | sessionSet.delete( session ); 273 | promises.push( this.disconnectSession( session ) ); 274 | } 275 | } 276 | 277 | return Promise.all( promises ).then( ( ) => { } ); 278 | } 279 | 280 | private handleDisconnect( sessionItem: H2SessionItem ): Promise< void > 281 | { 282 | const { promise, session } = sessionItem; 283 | 284 | if ( session ) 285 | session.destroy( ); 286 | 287 | return promise 288 | .then( _h2session => { } ) 289 | .catch( err => 290 | { 291 | const debugMode = false; 292 | if ( debugMode ) 293 | // tslint:disable-next-line 294 | console.warn( "Disconnect error", err ); 295 | } ); 296 | } 297 | 298 | private handlePush( 299 | origin: string, 300 | pushedStream: ClientHttp2Stream, 301 | requestHeaders: IncomingHttp2Headers, 302 | ref: ( ) => void, 303 | unref: ( ) => void 304 | ) 305 | { 306 | if ( !this._pushHandler ) 307 | return; // Drop push. TODO: Signal through error log: #8 308 | 309 | const path = requestHeaders[ HTTP2_HEADER_PATH ] as string; 310 | 311 | // Remove pseudo-headers 312 | Object.keys( requestHeaders ) 313 | .filter( name => name.charAt( 0 ) === ":" ) 314 | .forEach( name => { delete requestHeaders[ name ]; } ); 315 | 316 | const pushedRequest = new Request( 317 | path, 318 | { headers: requestHeaders, allowForbiddenHeaders: true } 319 | ); 320 | 321 | ref( ); 322 | 323 | const futureResponse = new Promise< Response >( ( resolve, reject ) => 324 | { 325 | const guard = syncGuard( reject, { catchAsync: true } ); 326 | 327 | pushedStream.once( "close", unref ); 328 | 329 | pushedStream.once( "aborted", ( ) => 330 | reject( new AbortError( "Response aborted" ) ) 331 | ); 332 | pushedStream.once( "frameError", ( ) => 333 | reject( new Error( "Push request failed" ) ) 334 | ); 335 | pushedStream.once( "error", reject ); 336 | 337 | pushedStream.once( "push", guard( 338 | ( responseHeaders: IncomingHttp2Headers ) => 339 | { 340 | const response = new StreamResponse( 341 | this._getDecoders( origin ), 342 | path, 343 | pushedStream, 344 | responseHeaders, 345 | false, 346 | { }, 347 | void 0, 348 | 2, 349 | false 350 | ); 351 | 352 | resolve( response ); 353 | } 354 | ) ); 355 | } ); 356 | 357 | futureResponse 358 | .catch( _err => { } ); // TODO: #8 359 | 360 | const getResponse = ( ) => futureResponse; 361 | 362 | return this._pushHandler( origin, pushedRequest, getResponse ); 363 | } 364 | 365 | private connectHttp2( 366 | origin: string, 367 | extraOptions: SecureClientSessionOptions = { } 368 | ) 369 | : H2SessionItem 370 | { 371 | const makeConnectionTimeout = ( ) => 372 | new TimeoutError( `Connection timeout to ${origin}` ); 373 | 374 | const makeError = ( event?: string ) => 375 | event 376 | ? new Error( `Unknown connection error (${event}): ${origin}` ) 377 | : new Error( `Connection closed` ); 378 | 379 | let session: ClientHttp2Session = < ClientHttp2Session >< any >void 0; 380 | 381 | // TODO: #8 382 | // tslint:disable-next-line 383 | const aGuard = asyncGuard( console.error.bind( console ) ); 384 | 385 | const sessionRefs = { } as Pick< H2SessionItem, 'ref' | 'unref' >; 386 | 387 | const makeRefs = ( session: ClientHttp2Session ) => 388 | { 389 | const monkeySession = < MonkeyH2Session >session; 390 | monkeySession.__fetch_h2_refcount = 1; // Begins ref'd 391 | sessionRefs.ref = ( ) => 392 | { 393 | if ( isDestroyed( session ) ) 394 | return; 395 | 396 | if ( monkeySession.__fetch_h2_refcount === 0 ) 397 | // Go from unref'd to ref'd 398 | session.ref( ); 399 | ++monkeySession.__fetch_h2_refcount; 400 | }; 401 | sessionRefs.unref = ( ) => 402 | { 403 | if ( isDestroyed( session ) ) 404 | return; 405 | 406 | --monkeySession.__fetch_h2_refcount; 407 | if ( monkeySession.__fetch_h2_refcount === 0 ) 408 | // Go from ref'd to unref'd 409 | session.unref( ); 410 | }; 411 | }; 412 | 413 | const options = { 414 | ...this._getSessionOptions( origin ), 415 | ...extraOptions, 416 | }; 417 | 418 | const promise = new Promise< ClientHttp2Session >( 419 | ( resolve, reject ) => 420 | { 421 | session = 422 | http2Connect( origin, options, ( ) => resolve( session ) ); 423 | 424 | makeRefs( session ); 425 | 426 | session.on( "stream", aGuard( 427 | ( 428 | stream: ClientHttp2Stream, 429 | headers: IncomingHttp2Headers 430 | ) => 431 | this.handlePush( 432 | origin, 433 | stream, 434 | headers, 435 | ( ) => sessionRefs.ref( ), 436 | ( ) => sessionRefs.unref( ) 437 | ) 438 | ) ); 439 | 440 | session.once( "close", ( ) => 441 | reject( makeOkError( makeError( ) ) ) ); 442 | 443 | session.once( "timeout", ( ) => 444 | reject( makeConnectionTimeout( ) ) ); 445 | 446 | session.once( "error", reject ); 447 | } 448 | ); 449 | 450 | return { 451 | firstOrigin: origin, 452 | promise, 453 | ref: ( ) => sessionRefs.ref( ), 454 | session, 455 | unref: ( ) => sessionRefs.unref( ), 456 | }; 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /lib/context.ts: -------------------------------------------------------------------------------- 1 | import { ClientRequest } from "http"; 2 | import { 3 | SecureClientSessionOptions, 4 | } from "http2"; 5 | import { Socket } from "net"; 6 | import { URL } from "url"; 7 | import { funnel, once, specific } from "already"; 8 | 9 | import { H1Context, OriginPool } from "./context-http1"; 10 | import { CacheableH2Session, H2Context, PushHandler } from "./context-http2"; 11 | import { connectTLS } from "./context-https"; 12 | import { CookieJar } from "./cookie-jar"; 13 | import { 14 | Decoder, 15 | FetchError, 16 | FetchInit, 17 | getByOrigin, 18 | Http1Options, 19 | HttpProtocols, 20 | parsePerOrigin, 21 | PerOrigin, 22 | RetryError, 23 | } from "./core"; 24 | import { 25 | SimpleSession, 26 | SimpleSessionHttp1, 27 | SimpleSessionHttp2, 28 | } from "./simple-session"; 29 | import { fetch as fetchHttp1 } from "./fetch-http1"; 30 | import { fetch as fetchHttp2 } from "./fetch-http2"; 31 | import { version } from "./generated/version"; 32 | import { Request } from "./request"; 33 | import { Response } from "./response"; 34 | import { parseInput } from "./utils"; 35 | import OriginCache from "./origin-cache"; 36 | import { FetchExtra } from "./fetch-common"; 37 | 38 | 39 | function makeDefaultUserAgent( ): string 40 | { 41 | const name = `fetch-h2/${version} (+https://github.com/grantila/fetch-h2)`; 42 | const node = `nodejs/${process.versions.node}`; 43 | const nghttp2 = `nghttp2/${( < any >process.versions ).nghttp2}`; 44 | const uv = `uv/${process.versions.uv}`; 45 | 46 | return `${name} ${node} ${nghttp2} ${uv}`; 47 | } 48 | 49 | const defaultUserAgent = makeDefaultUserAgent( ); 50 | const defaultAccept = "application/json,text/*;q=0.9,*/*;q=0.8"; 51 | 52 | export interface ContextOptions 53 | { 54 | userAgent: string | PerOrigin< string >; 55 | overwriteUserAgent: boolean | PerOrigin< boolean >; 56 | accept: string | PerOrigin< string >; 57 | cookieJar: CookieJar; 58 | decoders: 59 | ReadonlyArray< Decoder > | PerOrigin< ReadonlyArray< Decoder > >; 60 | session: 61 | SecureClientSessionOptions | PerOrigin< SecureClientSessionOptions >; 62 | httpProtocol: HttpProtocols | PerOrigin< HttpProtocols >; 63 | httpsProtocols: 64 | ReadonlyArray< HttpProtocols > | 65 | PerOrigin< ReadonlyArray< HttpProtocols > >; 66 | http1: Partial< Http1Options > | PerOrigin< Partial< Http1Options > >; 67 | } 68 | 69 | interface SessionMap 70 | { 71 | http1: OriginPool; 72 | https1: OriginPool; 73 | http2: CacheableH2Session; 74 | https2: CacheableH2Session; 75 | } 76 | 77 | export class Context 78 | { 79 | private h1Context: H1Context; 80 | private h2Context: H2Context; 81 | 82 | private _userAgent: string | PerOrigin< string >; 83 | private _overwriteUserAgent: boolean | PerOrigin< boolean >; 84 | private _accept: string | PerOrigin< string >; 85 | private _cookieJar: CookieJar; 86 | private _decoders: 87 | ReadonlyArray< Decoder > | PerOrigin< ReadonlyArray< Decoder > >; 88 | private _sessionOptions: 89 | SecureClientSessionOptions | PerOrigin< SecureClientSessionOptions >; 90 | private _httpProtocol: HttpProtocols | PerOrigin< HttpProtocols >; 91 | private _httpsProtocols: 92 | ReadonlyArray< HttpProtocols > | 93 | PerOrigin< ReadonlyArray< HttpProtocols > >; 94 | private _http1Options: Partial< Http1Options | PerOrigin< Http1Options > >; 95 | private _httpsFunnel = funnel< Response >( ); 96 | private _http1Funnel = funnel< Response >( ); 97 | private _http2Funnel = funnel< Response >( ); 98 | private _originCache = new OriginCache< SessionMap >( ); 99 | 100 | constructor( opts?: Partial< ContextOptions > ) 101 | { 102 | this._userAgent = ""; 103 | this._overwriteUserAgent = false; 104 | this._accept = ""; 105 | this._cookieJar = < CookieJar >< any >void 0; 106 | this._decoders = [ ]; 107 | this._sessionOptions = { }; 108 | this._httpProtocol = "http1"; 109 | this._httpsProtocols = [ "http2", "http1" ]; 110 | this._http1Options = { }; 111 | 112 | this.setup( opts ); 113 | 114 | this.h1Context = new H1Context( this._http1Options ); 115 | this.h2Context = new H2Context( 116 | this.decoders.bind( this ), 117 | this.sessionOptions.bind( this ) 118 | ); 119 | } 120 | 121 | public setup( opts?: Partial< ContextOptions > ) 122 | { 123 | opts = opts || { }; 124 | 125 | this._cookieJar = "cookieJar" in opts 126 | ? ( opts.cookieJar || new CookieJar( ) ) 127 | : new CookieJar( ); 128 | 129 | this._userAgent = parsePerOrigin( opts.userAgent, "" ); 130 | this._overwriteUserAgent = 131 | parsePerOrigin( opts.overwriteUserAgent, false ); 132 | this._accept = parsePerOrigin( opts.accept, defaultAccept ); 133 | this._decoders = parsePerOrigin( opts.decoders, [ ] ); 134 | this._sessionOptions = parsePerOrigin( opts.session, { } ); 135 | this._httpProtocol = parsePerOrigin( opts.httpProtocol, "http1" ); 136 | 137 | this._httpsProtocols = parsePerOrigin( 138 | opts.httpsProtocols, 139 | [ "http2", "http1" ] 140 | ); 141 | 142 | Object.assign( this._http1Options, opts.http1 || { } ); 143 | } 144 | 145 | public userAgent( origin: string ) 146 | { 147 | const combine = ( userAgent: string, overwriteUserAgent: boolean ) => 148 | { 149 | const defaultUA = overwriteUserAgent ? "" : defaultUserAgent; 150 | 151 | return userAgent 152 | ? defaultUA 153 | ? userAgent + " " + defaultUA 154 | : userAgent 155 | : defaultUA; 156 | }; 157 | 158 | return combine( 159 | getByOrigin( this._userAgent, origin ), 160 | getByOrigin( this._overwriteUserAgent, origin ) 161 | ); 162 | } 163 | 164 | public decoders( origin: string ) 165 | { 166 | return getByOrigin( this._decoders, origin ); 167 | } 168 | public sessionOptions( origin: string ) 169 | { 170 | return getByOrigin( this._sessionOptions, origin ); 171 | } 172 | 173 | public onPush( pushHandler?: PushHandler ) 174 | { 175 | this.h2Context._pushHandler = pushHandler; 176 | } 177 | 178 | public async fetch( input: string | Request, init?: Partial< FetchInit > ) 179 | { 180 | return this.retryFetch( input, init ); 181 | } 182 | 183 | public async disconnect( url: string ) 184 | { 185 | const { origin } = this.parseInput( url ); 186 | this._originCache.disconnect( origin ); 187 | 188 | await Promise.all( [ 189 | this.h1Context.disconnect( url ), 190 | this.h2Context.disconnect( url ), 191 | ] ); 192 | } 193 | 194 | public async disconnectAll( ) 195 | { 196 | this._originCache.disconnectAll( ); 197 | 198 | await Promise.all( [ 199 | this.h1Context.disconnectAll( ), 200 | this.h2Context.disconnectAll( ), 201 | ] ); 202 | } 203 | 204 | private async retryFetch( 205 | input: string | Request, 206 | init: Partial< FetchInit > | undefined, 207 | extra?: FetchExtra, 208 | count: number = 0 209 | ) 210 | : Promise< Response > 211 | { 212 | ++count; 213 | 214 | return this.retryableFetch( input, init, extra ) 215 | .catch( specific( RetryError, err => 216 | { 217 | // TODO: Implement a more robust retry logic 218 | if ( count > 10 ) 219 | throw err; 220 | return this.retryFetch( input, init, extra, count ); 221 | } ) ); 222 | } 223 | 224 | private async retryableFetch( 225 | input: string | Request, 226 | init?: Partial< FetchInit >, 227 | extra?: FetchExtra 228 | ) 229 | : Promise< Response > 230 | { 231 | const { hostname, origin, port, protocol, url } = 232 | this.parseInput( input ); 233 | 234 | // Rewrite url to get rid of "http1://" and "http2://" 235 | const request = 236 | input instanceof Request 237 | ? input.url !== url 238 | ? input.clone( url ) 239 | : input 240 | : new Request( input, { ...( init || { } ), url } ); 241 | 242 | const { rejectUnauthorized } = this.sessionOptions( origin ); 243 | 244 | const makeSimpleSession = ( protocol: HttpProtocols ): SimpleSession => 245 | ( { 246 | accept: ( ) => getByOrigin( this._accept, origin ), 247 | contentDecoders: ( ) => getByOrigin( this._decoders, origin ), 248 | cookieJar: this._cookieJar, 249 | protocol, 250 | userAgent: ( ) => this.userAgent( origin ), 251 | newFetch: this.retryFetch.bind( this ), 252 | } ); 253 | 254 | const doFetchHttp1 = ( socket: Socket, cleanup: ( ) => void ) => 255 | { 256 | const sessionGetterHttp1: SimpleSessionHttp1 = { 257 | get: ( url: string ) => 258 | ( { 259 | cleanup, 260 | req: this.getHttp1( 261 | url, 262 | socket, 263 | request, 264 | rejectUnauthorized ), 265 | } ), 266 | ...makeSimpleSession( "http1" ), 267 | }; 268 | return fetchHttp1( sessionGetterHttp1, request, init, extra ); 269 | }; 270 | 271 | const doFetchHttp2 = async ( cacheableSession: CacheableH2Session ) => 272 | { 273 | const { session, unref } = cacheableSession; 274 | const cleanup = once( unref ); 275 | 276 | try 277 | { 278 | const sessionGetterHttp2: SimpleSessionHttp2 = { 279 | get: ( ) => ( { session, cleanup } ), 280 | ...makeSimpleSession( "http2" ), 281 | }; 282 | return await fetchHttp2( 283 | sessionGetterHttp2, request, init, extra 284 | ); 285 | } 286 | catch ( err ) 287 | { 288 | cleanup( ); 289 | throw err; 290 | } 291 | }; 292 | 293 | const tryWaitForHttp1 = async ( session: OriginPool ) => 294 | { 295 | const { socket: freeHttp1Socket, cleanup, shouldCreateNew } = 296 | this.h1Context.getFreeSocketForSession( session ); 297 | 298 | if ( freeHttp1Socket ) 299 | return doFetchHttp1( freeHttp1Socket, cleanup ); 300 | 301 | if ( !shouldCreateNew ) 302 | { 303 | // We've maxed out HTTP/1 connections, wait for one to be 304 | // freed. 305 | const { socket, cleanup } = 306 | await this.h1Context.waitForSocketBySession( session ); 307 | return doFetchHttp1( socket, cleanup ); 308 | } 309 | }; 310 | 311 | if ( protocol === "http1" ) 312 | { 313 | return this._http1Funnel( async ( shouldRetry, retry, shortcut ) => 314 | { 315 | if ( shouldRetry( ) ) 316 | return retry( ); 317 | 318 | // Plain text HTTP/1(.1) 319 | const cacheItem = this._originCache.get( "http1", origin ); 320 | 321 | const session = 322 | cacheItem?.session ?? 323 | this.h1Context.getSessionForOrigin( origin ); 324 | 325 | const resp = await tryWaitForHttp1( session ); 326 | if ( resp ) 327 | return resp; 328 | 329 | const socket = await this.h1Context.makeNewConnection( url ); 330 | 331 | this._originCache.set( origin, "http1", session ); 332 | 333 | shortcut( ); 334 | 335 | const cleanup = 336 | this.h1Context.addUsedSocket( session, socket ); 337 | return doFetchHttp1( socket, cleanup ); 338 | } ); 339 | } 340 | else if ( protocol === "http2" ) 341 | { 342 | return this._http2Funnel( async ( _, __, shortcut ) => 343 | { 344 | // Plain text HTTP/2 345 | const cacheItem = this._originCache.get( "http2", origin ); 346 | 347 | if ( cacheItem ) 348 | { 349 | cacheItem.session.ref( ); 350 | shortcut( ); 351 | return doFetchHttp2( cacheItem.session ); 352 | } 353 | 354 | // Convert socket into http2 session, this will ref (*) 355 | const cacheableSession = this.h2Context.createHttp2( 356 | origin, 357 | ( ) => { this._originCache.delete( cacheableSession ); } 358 | ); 359 | 360 | this._originCache.set( origin, "http2", cacheableSession ); 361 | 362 | shortcut( ); 363 | 364 | // Session now lingering, it will be re-used by the next get() 365 | return doFetchHttp2( cacheableSession ); 366 | } ); 367 | } 368 | else // protocol === "https" 369 | { 370 | return this._httpsFunnel( ( shouldRetry, retry, shortcut ) => 371 | shouldRetry( ) 372 | ? retry( ) 373 | : this.connectSequenciallyTLS( 374 | shortcut, 375 | hostname, 376 | port, 377 | origin, 378 | tryWaitForHttp1, 379 | doFetchHttp1, 380 | doFetchHttp2 381 | ) 382 | ); 383 | } 384 | } 385 | 386 | private async connectSequenciallyTLS( 387 | shortcut: ( ) => void, 388 | hostname: string, 389 | port: string, 390 | origin: string, 391 | tryWaitForHttp1: 392 | ( session: OriginPool ) => Promise< Response | undefined >, 393 | doFetchHttp1: 394 | ( socket: Socket, cleanup: ( ) => void ) => Promise< Response >, 395 | doFetchHttp2: 396 | ( cacheableSession: CacheableH2Session ) => Promise< Response > 397 | ) 398 | { 399 | const cacheItem = 400 | this._originCache.get( "https2", origin ) ?? 401 | this._originCache.get( "https1", origin ); 402 | 403 | if ( cacheItem ) 404 | { 405 | if ( cacheItem.protocol === "https1" ) 406 | { 407 | shortcut( ); 408 | const resp = await tryWaitForHttp1( cacheItem.session ); 409 | if ( resp ) 410 | return resp; 411 | } 412 | else if ( cacheItem.protocol === "https2" ) 413 | { 414 | cacheItem.session.ref( ); 415 | shortcut( ); 416 | return doFetchHttp2( cacheItem.session ); 417 | } 418 | } 419 | 420 | // Use ALPN to figure out protocol lazily 421 | const { protocol, socket, altNameMatch } = await connectTLS( 422 | hostname, 423 | port, 424 | getByOrigin( this._httpsProtocols, origin ), 425 | getByOrigin( this._sessionOptions, origin ) 426 | ); 427 | 428 | const disconnect = once( ( ) => 429 | { 430 | if ( !socket.destroyed ) 431 | { 432 | socket.destroy( ); 433 | socket.unref( ); 434 | } 435 | } ); 436 | 437 | if ( protocol === "http2" ) 438 | { 439 | // Convert socket into http2 session, this will ref (*) 440 | // const { cleanup, session, didCreate } = 441 | const cacheableSession = this.h2Context.createHttp2( 442 | origin, 443 | ( ) => { this._originCache.delete( cacheableSession ); }, 444 | { 445 | createConnection: ( ) => socket, 446 | } 447 | ); 448 | 449 | this._originCache.set( 450 | origin, 451 | "https2", 452 | cacheableSession, 453 | altNameMatch, 454 | disconnect 455 | ); 456 | 457 | shortcut( ); 458 | 459 | // Session now lingering, it will be re-used by the next get() 460 | return doFetchHttp2( cacheableSession ); 461 | } 462 | else // protocol === "http1" 463 | { 464 | const session = 465 | cacheItem?.session ?? 466 | this.h1Context.getSessionForOrigin( origin ); 467 | 468 | // TODO: Update the alt-name list in the origin cache (if the new 469 | // TLS socket contains more/other alt-names). 470 | if ( !cacheItem ) 471 | this._originCache.set( 472 | origin, 473 | "https1", 474 | session, 475 | altNameMatch, 476 | disconnect 477 | ); 478 | 479 | const cleanup = this.h1Context.addUsedSocket( 480 | session, 481 | socket 482 | ); 483 | 484 | shortcut( ); 485 | 486 | return doFetchHttp1( socket, cleanup ); 487 | } 488 | } 489 | 490 | private getHttp1( 491 | url: string, 492 | socket: Socket, 493 | request: Request, 494 | rejectUnauthorized?: boolean 495 | ) 496 | : ClientRequest 497 | { 498 | return this.h1Context.connect( 499 | new URL( url ), 500 | { 501 | createConnection: ( ) => socket, 502 | rejectUnauthorized, 503 | }, 504 | request 505 | ); 506 | } 507 | 508 | private parseInput( input: string | Request ) 509 | { 510 | const { hostname, origin, port, protocol, url } = 511 | parseInput( typeof input !== "string" ? input.url : input ); 512 | 513 | const defaultHttp = this._httpProtocol; 514 | 515 | if ( 516 | ( protocol === "http" && defaultHttp === "http1" ) 517 | || protocol === "http1" 518 | ) 519 | return { 520 | hostname, 521 | origin, 522 | port, 523 | protocol: "http1", 524 | url, 525 | }; 526 | else if ( 527 | ( protocol === "http" && defaultHttp === "http2" ) 528 | || protocol === "http2" 529 | ) 530 | return { 531 | hostname, 532 | origin, 533 | port, 534 | protocol: "http2", 535 | url, 536 | }; 537 | else if ( protocol === "https" ) 538 | return { 539 | hostname, 540 | origin, 541 | port, 542 | protocol: "https", 543 | url, 544 | }; 545 | else 546 | throw new FetchError( `Invalid protocol "${protocol}"` ); 547 | } 548 | } 549 | -------------------------------------------------------------------------------- /test/fetch-h2/body.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { buffer as getStreamBuffer } from "get-stream"; 3 | import * as through2 from "through2"; 4 | 5 | import { createIntegrity } from "../lib/utils"; 6 | 7 | import { 8 | Body, 9 | DataBody, 10 | JsonBody, 11 | StreamBody, 12 | } from "../../index"; 13 | 14 | 15 | async function makeSync< T >( fn: ( ) => PromiseLike< T > ) 16 | : Promise< ( ) => T > 17 | { 18 | try 19 | { 20 | const val = await fn( ); 21 | return ( ) => val; 22 | } 23 | catch ( err ) 24 | { 25 | return ( ) => { throw err; }; 26 | } 27 | } 28 | 29 | function setHash( body: any, data: string, hashType = "sha256" ) 30 | { 31 | body._integrity = createIntegrity( data, hashType ); 32 | } 33 | 34 | class IntegrityBody extends Body 35 | { 36 | constructor( 37 | data: string | Buffer | NodeJS.ReadableStream | null, 38 | hashData: string, 39 | integrityHashType = "sha256" 40 | ) 41 | { 42 | super( ); 43 | 44 | const hash = createHash( "sha256" ); 45 | hash.update( hashData ); 46 | const v = integrityHashType + "-" + hash.digest( "base64" ); 47 | 48 | this.setBody( data, null, v ); 49 | } 50 | } 51 | 52 | describe( "body", ( ) => 53 | { 54 | describe( "multiple reads", ( ) => 55 | { 56 | it( "throw on multiple reads", async ( ) => 57 | { 58 | const body = new DataBody( "foo" ); 59 | expect( body.bodyUsed ).toBe( false ); 60 | expect( await body.text( ) ).toBe( "foo" ); 61 | expect( body.bodyUsed ).toBe( true ); 62 | expect( await makeSync( ( ) => body.text( ) ) ) 63 | .toThrow( ReferenceError ); 64 | } ); 65 | } ); 66 | 67 | describe( "unimplemented", ( ) => 68 | { 69 | it( "throw on unimplemented blob()", async ( ) => 70 | { 71 | const body = new DataBody( "foo" ); 72 | expect( await makeSync( ( ) => ( < any >body ).blob( ) ) ) 73 | .toThrow( ); 74 | } ); 75 | 76 | it( "throw on unimplemented formData()", async ( ) => 77 | { 78 | const body = new DataBody( "foo" ); 79 | expect( await makeSync( ( ) => body.formData( ) ) ).toThrow( ); 80 | } ); 81 | } ); 82 | 83 | describe( "invalid data", ( ) => 84 | { 85 | it( "handle invalid body type when reading as arrayBuffer", 86 | async ( ) => 87 | { 88 | const body = new DataBody( < string >< any >1 ); 89 | expect( await makeSync( ( ) => body.arrayBuffer( ) ) ) 90 | .toThrow( "Unknown body data" ); 91 | } ); 92 | 93 | it( "handle invalid body type when reading as json", async ( ) => 94 | { 95 | const body = new DataBody( < string >< any >1 ); 96 | expect( await makeSync( ( ) => body.json( ) ) ) 97 | .toThrow( "Unknown body data" ); 98 | } ); 99 | 100 | it( "handle invalid body type when reading as text", async ( ) => 101 | { 102 | const body = new DataBody( < string >< any >1 ); 103 | expect( await makeSync( ( ) => body.text( ) ) ) 104 | .toThrow( "Unknown body data" ); 105 | } ); 106 | 107 | it( "handle invalid body type when reading as readable", async ( ) => 108 | { 109 | const body = new DataBody( < string >< any >1 ); 110 | expect( await makeSync( ( ) => body.readable( ) ) ) 111 | .toThrow( "Unknown body data" ); 112 | } ); 113 | } ); 114 | 115 | describe( "arrayBuffer", ( ) => 116 | { 117 | describe( "without validation", ( ) => 118 | { 119 | it( "handle null", async ( ) => 120 | { 121 | const body = new DataBody( null ); 122 | const data = Buffer.from( await body.arrayBuffer( ) ); 123 | expect( data.toString( ) ).toBe( "" ); 124 | } ); 125 | 126 | it( "handle string", async ( ) => 127 | { 128 | const body = new DataBody( "foo" ); 129 | const data = Buffer.from( await body.arrayBuffer( ) ); 130 | expect( data.toString( ) ).toBe( "foo" ); 131 | } ); 132 | 133 | it( "handle buffer", async ( ) => 134 | { 135 | const body = new DataBody( Buffer.from( "foo" ) ); 136 | const data = Buffer.from( await body.arrayBuffer( ) ); 137 | expect( data.toString( ) ).toBe( "foo" ); 138 | } ); 139 | 140 | it( "handle JsonBody", async ( ) => 141 | { 142 | const body = new JsonBody( { foo: "bar" } ); 143 | const data = Buffer.from( await body.arrayBuffer( ) ); 144 | expect( data.toString( ) ).toBe( '{"foo":"bar"}' ); 145 | } ); 146 | 147 | it( "handle stream", async ( ) => 148 | { 149 | const stream = through2( ); 150 | stream.end( "foo" ); 151 | const body = new StreamBody( stream ); 152 | const data = Buffer.from( await body.arrayBuffer( ) ); 153 | expect( data.toString( ) ).toBe( "foo" ); 154 | } ); 155 | } ); 156 | 157 | describe( "matching validation", ( ) => 158 | { 159 | it( "handle null", async ( ) => 160 | { 161 | const body = new IntegrityBody( null, "" ); 162 | const data = Buffer.from( await body.arrayBuffer( ) ); 163 | expect( data.toString( ) ).toBe( "" ); 164 | } ); 165 | 166 | it( "handle string", async ( ) => 167 | { 168 | const testData = "foo"; 169 | const body = new IntegrityBody( testData, testData ); 170 | const data = Buffer.from( await body.arrayBuffer( ) ); 171 | expect( data.toString( ) ).toBe( testData ); 172 | } ); 173 | 174 | it( "handle buffer", async ( ) => 175 | { 176 | const testData = "foo"; 177 | const body = new IntegrityBody( 178 | Buffer.from( testData ), testData ); 179 | const data = Buffer.from( await body.arrayBuffer( ) ); 180 | expect( data.toString( ) ).toBe( testData ); 181 | } ); 182 | 183 | it( "handle stream", async ( ) => 184 | { 185 | const testData = "foo"; 186 | const stream = through2( ); 187 | stream.end( testData ); 188 | const body = new IntegrityBody( stream, testData ); 189 | const data = Buffer.from( await body.arrayBuffer( ) ); 190 | expect( data.toString( ) ).toBe( testData ); 191 | } ); 192 | } ); 193 | 194 | describe( "mismatching validation", ( ) => 195 | { 196 | it( "handle invalid hash type", async ( ) => 197 | { 198 | const body = new IntegrityBody( null, "", "acme-hash" ); 199 | expect( await makeSync( ( ) => body.arrayBuffer( ) ) ) 200 | .toThrow( "not supported" ); 201 | } ); 202 | 203 | it( "handle null", async ( ) => 204 | { 205 | const body = new IntegrityBody( null, "" + "x" ); 206 | expect( await makeSync( ( ) => body.arrayBuffer( ) ) ) 207 | .toThrow( "Resource integrity mismatch" ); 208 | } ); 209 | 210 | it( "handle string", async ( ) => 211 | { 212 | const testData = "foo"; 213 | const body = new IntegrityBody( testData, testData + "x" ); 214 | expect( await makeSync( ( ) => body.arrayBuffer( ) ) ) 215 | .toThrow( "Resource integrity mismatch" ); 216 | } ); 217 | 218 | it( "handle buffer", async ( ) => 219 | { 220 | const testData = "foo"; 221 | const body = new IntegrityBody( 222 | Buffer.from( testData ), testData + "x" ); 223 | expect( await makeSync( ( ) => body.arrayBuffer( ) ) ) 224 | .toThrow( "Resource integrity mismatch" ); 225 | } ); 226 | 227 | it( "handle stream", async ( ) => 228 | { 229 | const testData = "foo"; 230 | const stream = through2( ); 231 | stream.end( testData ); 232 | const body = new IntegrityBody( stream, testData + "x" ); 233 | expect( await makeSync( ( ) => body.arrayBuffer( ) ) ) 234 | .toThrow( "Resource integrity mismatch" ); 235 | } ); 236 | } ); 237 | } ); 238 | 239 | describe( "json", ( ) => 240 | { 241 | describe( "without validation", ( ) => 242 | { 243 | it( "handle null", async ( ) => 244 | { 245 | const body = new DataBody( null ); 246 | expect( await body.json( ) ).toBe( null ); 247 | } ); 248 | 249 | it( "handle invalid string", async ( ) => 250 | { 251 | const body = new DataBody( "invalid json" ); 252 | expect( await makeSync( ( ) => body.json( ) ) ).toThrow( ); 253 | } ); 254 | 255 | it( "handle valid string", async ( ) => 256 | { 257 | const body = new DataBody( '{"foo":"bar"}' ); 258 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 259 | } ); 260 | 261 | it( "handle invalid buffer", async ( ) => 262 | { 263 | const body = new DataBody( Buffer.from( "invalid json" ) ); 264 | expect( await makeSync( ( ) => body.json( ) ) ).toThrow( ); 265 | } ); 266 | 267 | it( "handle valid buffer", async ( ) => 268 | { 269 | const body = new DataBody( Buffer.from( '{"foo":"bar"}' ) ); 270 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 271 | } ); 272 | 273 | it( "handle valid JsonBody", async ( ) => 274 | { 275 | const body = new JsonBody( { foo: "bar" } ); 276 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 277 | } ); 278 | 279 | it( "handle invalid stream", async ( ) => 280 | { 281 | const stream = through2( ); 282 | stream.end( "invalid json" ); 283 | const body = new StreamBody( stream ); 284 | expect( await makeSync( ( ) => body.json( ) ) ).toThrow( ); 285 | } ); 286 | 287 | it( "handle valid stream", async ( ) => 288 | { 289 | const stream = through2( ); 290 | stream.end( '{"foo":"bar"}' ); 291 | const body = new StreamBody( stream ); 292 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 293 | } ); 294 | } ); 295 | 296 | describe( "matching validation", ( ) => 297 | { 298 | it( "handle null", async ( ) => 299 | { 300 | const body = new DataBody( null ); 301 | setHash( body, "" ); 302 | expect( await body.json( ) ).toBe( null ); 303 | } ); 304 | 305 | it( "handle string", async ( ) => 306 | { 307 | const testData = '{"foo":"bar"}'; 308 | const body = new DataBody( testData ); 309 | setHash( body, testData ); 310 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 311 | } ); 312 | 313 | it( "handle buffer", async ( ) => 314 | { 315 | const testData = '{"foo":"bar"}'; 316 | const body = new DataBody( Buffer.from( testData ) ); 317 | setHash( body, testData ); 318 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 319 | } ); 320 | 321 | it( "handle JsonBody", async ( ) => 322 | { 323 | const body = new JsonBody( { foo: "bar" } ); 324 | setHash( body, '{"foo":"bar"}' ); 325 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 326 | } ); 327 | 328 | it( "handle stream", async ( ) => 329 | { 330 | const testData = '{"foo":"bar"}'; 331 | const stream = through2( ); 332 | stream.end( testData ); 333 | const body = new StreamBody( stream ); 334 | setHash( body, testData ); 335 | expect( await body.json( ) ).toEqual( { foo: "bar" } ); 336 | } ); 337 | } ); 338 | 339 | describe( "mismatching validation", ( ) => 340 | { 341 | it( "handle null", async ( ) => 342 | { 343 | const body = new DataBody( null ); 344 | setHash( body, "" + "x" ); 345 | expect( await makeSync( ( ) => body.json( ) ) ) 346 | .toThrow( "Resource integrity mismatch" ); 347 | } ); 348 | 349 | it( "handle string", async ( ) => 350 | { 351 | const testData = '{"foo":"bar"}'; 352 | const body = new DataBody( testData ); 353 | setHash( body, testData + "x" ); 354 | expect( await makeSync( ( ) => body.json( ) ) ) 355 | .toThrow( "Resource integrity mismatch" ); 356 | } ); 357 | 358 | it( "handle buffer", async ( ) => 359 | { 360 | const testData = '{"foo":"bar"}'; 361 | const body = new DataBody( Buffer.from( testData ) ); 362 | setHash( body, testData + "x" ); 363 | expect( await makeSync( ( ) => body.json( ) ) ) 364 | .toThrow( "Resource integrity mismatch" ); 365 | } ); 366 | 367 | it( "handle JsonBody", async ( ) => 368 | { 369 | const body = new JsonBody( { foo: "bar" } ); 370 | setHash( body, '{"foo":"bar"}' + "x" ); 371 | expect( await makeSync( ( ) => body.json( ) ) ) 372 | .toThrow( "Resource integrity mismatch" ); 373 | } ); 374 | 375 | it( "handle stream", async ( ) => 376 | { 377 | const testData = '{"foo":"bar"}'; 378 | const stream = through2( ); 379 | stream.end( testData ); 380 | const body = new StreamBody( stream ); 381 | setHash( body, testData + "x" ); 382 | expect( await makeSync( ( ) => body.json( ) ) ) 383 | .toThrow( "Resource integrity mismatch" ); 384 | } ); 385 | } ); 386 | } ); 387 | 388 | describe( "text", ( ) => 389 | { 390 | describe( "without validation", ( ) => 391 | { 392 | it( "handle null", async ( ) => 393 | { 394 | const body = new DataBody( null ); 395 | expect( await body.text( ) ).toBe( null ); 396 | } ); 397 | 398 | it( "handle string", async ( ) => 399 | { 400 | const body = new DataBody( "foo" ); 401 | expect( await body.text( ) ).toBe( "foo" ); 402 | } ); 403 | 404 | it( "handle buffer", async ( ) => 405 | { 406 | const body = new DataBody( Buffer.from( "foo" ) ); 407 | expect( await body.text( ) ).toBe( "foo" ); 408 | } ); 409 | 410 | it( "handle stream", async ( ) => 411 | { 412 | const stream = through2( ); 413 | stream.end( "foo" ); 414 | const body = new StreamBody( stream ); 415 | expect( await body.text( ) ).toBe( "foo" ); 416 | } ); 417 | } ); 418 | 419 | describe( "matching validation", ( ) => 420 | { 421 | it( "handle null", async ( ) => 422 | { 423 | const body = new DataBody( null ); 424 | setHash( body, "" ); 425 | expect( await body.text( ) ).toBe( null ); 426 | } ); 427 | 428 | it( "handle string", async ( ) => 429 | { 430 | const testData = "foo"; 431 | const body = new DataBody( testData ); 432 | setHash( body, testData ); 433 | expect( await body.text( ) ).toBe( testData ); 434 | } ); 435 | 436 | it( "handle buffer", async ( ) => 437 | { 438 | const testData = "foo"; 439 | const body = new DataBody( Buffer.from( testData ) ); 440 | setHash( body, testData ); 441 | expect( await body.text( ) ).toBe( testData ); 442 | } ); 443 | 444 | it( "handle stream", async ( ) => 445 | { 446 | const testData = "foo"; 447 | const stream = through2( ); 448 | stream.end( testData ); 449 | const body = new StreamBody( stream ); 450 | setHash( body, testData ); 451 | expect( await body.text( ) ).toBe( testData ); 452 | } ); 453 | } ); 454 | 455 | describe( "mismatching validation", ( ) => 456 | { 457 | it( "handle null", async ( ) => 458 | { 459 | const body = new DataBody( null ); 460 | setHash( body, "" + "x" ); 461 | expect( await makeSync( ( ) => body.text( ) ) ) 462 | .toThrow( "Resource integrity mismatch" ); 463 | } ); 464 | 465 | it( "handle string", async ( ) => 466 | { 467 | const testData = "foo"; 468 | const body = new DataBody( testData ); 469 | setHash( body, testData + "x" ); 470 | expect( await makeSync( ( ) => body.text( ) ) ) 471 | .toThrow( "Resource integrity mismatch" ); 472 | } ); 473 | 474 | it( "handle buffer", async ( ) => 475 | { 476 | const testData = "foo"; 477 | const body = new DataBody( Buffer.from( testData ) ); 478 | setHash( body, testData + "x" ); 479 | expect( await makeSync( ( ) => body.text( ) ) ) 480 | .toThrow( "Resource integrity mismatch" ); 481 | } ); 482 | 483 | it( "handle stream", async ( ) => 484 | { 485 | const testData = "foo"; 486 | const stream = through2( ); 487 | stream.end( testData ); 488 | const body = new StreamBody( stream ); 489 | setHash( body, testData + "x" ); 490 | expect( await makeSync( ( ) => body.text( ) ) ) 491 | .toThrow( "Resource integrity mismatch" ); 492 | } ); 493 | } ); 494 | } ); 495 | 496 | describe( "readable", ( ) => 497 | { 498 | it( "handle null", async ( ) => 499 | { 500 | const body = new DataBody( null ); 501 | const data = await getStreamBuffer( await body.readable( ) ); 502 | expect( data.toString( ) ).toBe( "" ); 503 | } ); 504 | 505 | it( "handle string", async ( ) => 506 | { 507 | const body = new DataBody( "foo" ); 508 | const data = await getStreamBuffer( await body.readable( ) ); 509 | expect( data.toString( ) ).toBe( "foo" ); 510 | } ); 511 | 512 | it( "handle buffer", async ( ) => 513 | { 514 | const body = new DataBody( Buffer.from( "foo" ) ); 515 | const data = await getStreamBuffer( await body.readable( ) ); 516 | expect( data.toString( ) ).toBe( "foo" ); 517 | } ); 518 | 519 | it( "handle stream", async ( ) => 520 | { 521 | const stream = through2( ); 522 | stream.end( "foo" ); 523 | const body = new StreamBody( stream ); 524 | const data = await getStreamBuffer( await body.readable( ) ); 525 | expect( data.toString( ) ).toBe( "foo" ); 526 | } ); 527 | } ); 528 | } ); 529 | --------------------------------------------------------------------------------