first commit
This commit is contained in:
3
libraries/fetch-utils/.gitignore
vendored
Normal file
3
libraries/fetch-utils/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
# managed by monorepo$ bin/update_build_scripts
|
||||
.npmrc
|
6
libraries/fetch-utils/.mocharc.json
Normal file
6
libraries/fetch-utils/.mocharc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ui": "bdd",
|
||||
"recursive": "true",
|
||||
"reporter": "spec",
|
||||
"require": "test/setup.js"
|
||||
}
|
1
libraries/fetch-utils/.nvmrc
Normal file
1
libraries/fetch-utils/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
20.18.2
|
10
libraries/fetch-utils/buildscript.txt
Normal file
10
libraries/fetch-utils/buildscript.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fetch-utils
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=20.18.2
|
||||
--public-repo=False
|
||||
--script-version=4.7.0
|
320
libraries/fetch-utils/index.js
Normal file
320
libraries/fetch-utils/index.js
Normal file
@@ -0,0 +1,320 @@
|
||||
const _ = require('lodash')
|
||||
const { Readable } = require('node:stream')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const fetch = require('node-fetch')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
/**
|
||||
* @import { Response } from 'node-fetch'
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make a request and return the parsed JSON response.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {any} [opts] - fetch options
|
||||
* @return {Promise<any>} the parsed JSON response
|
||||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchJson(url, opts = {}) {
|
||||
const { json } = await fetchJsonWithResponse(url, opts)
|
||||
return json
|
||||
}
|
||||
|
||||
async function fetchJsonWithResponse(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
|
||||
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
return { json, response }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and return a stream.
|
||||
*
|
||||
* If the response body is destroyed, the request is aborted.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {any} [opts] - fetch options
|
||||
* @return {Promise<Readable>}
|
||||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchStream(url, opts = {}) {
|
||||
const { stream } = await fetchStreamWithResponse(url, opts)
|
||||
return stream
|
||||
}
|
||||
|
||||
async function fetchStreamWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, abortController } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
}
|
||||
|
||||
abortOnDestroyedResponse(abortController, response)
|
||||
|
||||
const stream = response.body
|
||||
return { stream, response }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and discard the response.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {any} [opts] - fetch options
|
||||
* @return {Promise<Response>}
|
||||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchNothing(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
}
|
||||
await discardResponseBody(response)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and extract the redirect from the response.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {any} [opts] - fetch options
|
||||
* @return {Promise<string>}
|
||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirect(url, opts = {}) {
|
||||
const { location } = await fetchRedirectWithResponse(url, opts)
|
||||
return location
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and extract the redirect from the response.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {object} opts - fetch options
|
||||
* @return {Promise<{location: string, response: Response}>}
|
||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirectWithResponse(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
fetchOpts.redirect = 'manual'
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (response.status < 300 || response.status >= 400) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
}
|
||||
const location = response.headers.get('Location')
|
||||
if (!location) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body).withCause(
|
||||
new OError('missing Location response header on 3xx response', {
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
})
|
||||
)
|
||||
}
|
||||
await discardResponseBody(response)
|
||||
return { location, response }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and return a string.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {any} [opts] - fetch options
|
||||
* @return {Promise<string>}
|
||||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchString(url, opts = {}) {
|
||||
const { body } = await fetchStringWithResponse(url, opts)
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchStringWithResponse(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
}
|
||||
const body = await response.text()
|
||||
return { body, response }
|
||||
}
|
||||
|
||||
class RequestFailedError extends OError {
|
||||
constructor(url, opts, response, body) {
|
||||
super('request failed', {
|
||||
url,
|
||||
method: opts.method ?? 'GET',
|
||||
status: response.status,
|
||||
})
|
||||
|
||||
this.response = response
|
||||
if (body != null) {
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpts(opts) {
|
||||
const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
|
||||
if (opts.json) {
|
||||
setupJsonBody(fetchOpts, opts.json)
|
||||
}
|
||||
if (opts.basicAuth) {
|
||||
setupBasicAuth(fetchOpts, opts.basicAuth)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
fetchOpts.signal = abortController.signal
|
||||
if (opts.signal) {
|
||||
abortOnSignal(abortController, opts.signal)
|
||||
}
|
||||
if (opts.body instanceof Readable) {
|
||||
abortOnDestroyedRequest(abortController, fetchOpts.body)
|
||||
}
|
||||
return { fetchOpts, abortController }
|
||||
}
|
||||
|
||||
function setupJsonBody(fetchOpts, json) {
|
||||
fetchOpts.body = JSON.stringify(json)
|
||||
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||
fetchOpts.headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
function setupBasicAuth(fetchOpts, basicAuth) {
|
||||
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||
fetchOpts.headers.Authorization =
|
||||
'Basic ' +
|
||||
Buffer.from(`${basicAuth.user}:${basicAuth.password}`).toString('base64')
|
||||
}
|
||||
|
||||
function abortOnSignal(abortController, signal) {
|
||||
const listener = () => {
|
||||
abortController.abort(signal.reason)
|
||||
}
|
||||
if (signal.aborted) {
|
||||
abortController.abort(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', listener)
|
||||
}
|
||||
|
||||
function abortOnDestroyedRequest(abortController, stream) {
|
||||
stream.on('close', () => {
|
||||
if (!stream.readableEnded) {
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function abortOnDestroyedResponse(abortController, response) {
|
||||
response.body.on('close', () => {
|
||||
if (!response.bodyUsed) {
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function performRequest(url, fetchOpts) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, fetchOpts)
|
||||
} catch (err) {
|
||||
if (fetchOpts.body instanceof Readable) {
|
||||
fetchOpts.body.destroy()
|
||||
}
|
||||
throw OError.tag(err, err.message, {
|
||||
url,
|
||||
method: fetchOpts.method ?? 'GET',
|
||||
})
|
||||
}
|
||||
if (fetchOpts.body instanceof Readable) {
|
||||
response.body.on('close', () => {
|
||||
if (!fetchOpts.body.readableEnded) {
|
||||
fetchOpts.body.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function discardResponseBody(response) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
for await (const chunk of response.body) {
|
||||
// discard the body
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
*/
|
||||
async function maybeGetResponseBody(response) {
|
||||
try {
|
||||
return await response.text()
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Define custom http and https agents with support for connect timeouts
|
||||
|
||||
class ConnectTimeoutError extends OError {
|
||||
constructor(options) {
|
||||
super('connect timeout', options)
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout(createConnection, options, callback) {
|
||||
if (options.connectTimeout) {
|
||||
// Wrap createConnection in a timeout
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy(new ConnectTimeoutError(options))
|
||||
}, options.connectTimeout)
|
||||
const socket = createConnection(options, (err, stream) => {
|
||||
clearTimeout(timer)
|
||||
callback(err, stream)
|
||||
})
|
||||
return socket
|
||||
} else {
|
||||
// Fallback to default createConnection
|
||||
return createConnection(options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
class CustomHttpAgent extends http.Agent {
|
||||
createConnection(options, callback) {
|
||||
return withTimeout(super.createConnection.bind(this), options, callback)
|
||||
}
|
||||
}
|
||||
class CustomHttpsAgent extends https.Agent {
|
||||
createConnection(options, callback) {
|
||||
return withTimeout(super.createConnection.bind(this), options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchJson,
|
||||
fetchJsonWithResponse,
|
||||
fetchStream,
|
||||
fetchStreamWithResponse,
|
||||
fetchNothing,
|
||||
fetchRedirect,
|
||||
fetchRedirectWithResponse,
|
||||
fetchString,
|
||||
fetchStringWithResponse,
|
||||
RequestFailedError,
|
||||
ConnectTimeoutError,
|
||||
CustomHttpAgent,
|
||||
CustomHttpsAgent,
|
||||
}
|
33
libraries/fetch-utils/package.json
Normal file
33
libraries/fetch-utils/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@overleaf/fetch-utils",
|
||||
"version": "0.1.0",
|
||||
"description": "utilities for node-fetch",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Overleaf (https://www.overleaf.com)",
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"body-parser": "^1.20.3",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"express": "^4.21.2",
|
||||
"mocha": "^11.1.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@overleaf/o-error": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.7.0",
|
||||
"selfsigned": "^2.4.1"
|
||||
}
|
||||
}
|
4
libraries/fetch-utils/test/setup.js
Normal file
4
libraries/fetch-utils/test/setup.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const chai = require('chai')
|
||||
const chaiAsPromised = require('chai-as-promised')
|
||||
|
||||
chai.use(chaiAsPromised)
|
414
libraries/fetch-utils/test/unit/FetchUtilsTests.js
Normal file
414
libraries/fetch-utils/test/unit/FetchUtilsTests.js
Normal file
@@ -0,0 +1,414 @@
|
||||
const { expect } = require('chai')
|
||||
const { FetchError, AbortError } = require('node-fetch')
|
||||
const { Readable } = require('node:stream')
|
||||
const { once } = require('node:events')
|
||||
const { TestServer } = require('./helpers/TestServer')
|
||||
const selfsigned = require('selfsigned')
|
||||
const {
|
||||
fetchJson,
|
||||
fetchStream,
|
||||
fetchNothing,
|
||||
fetchRedirect,
|
||||
fetchString,
|
||||
RequestFailedError,
|
||||
CustomHttpAgent,
|
||||
CustomHttpsAgent,
|
||||
} = require('../..')
|
||||
|
||||
const HTTP_PORT = 30001
|
||||
const HTTPS_PORT = 30002
|
||||
|
||||
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
||||
const pems = selfsigned.generate(attrs, { days: 365 })
|
||||
|
||||
const PRIVATE_KEY = pems.private
|
||||
const PUBLIC_CERT = pems.cert
|
||||
|
||||
const dns = require('node:dns')
|
||||
const _originalLookup = dns.lookup
|
||||
// Custom DNS resolver function
|
||||
dns.lookup = (hostname, options, callback) => {
|
||||
if (hostname === 'example.com') {
|
||||
// If the hostname is our test case, return the ip address for the test server
|
||||
if (options?.all) {
|
||||
callback(null, [{ address: '127.0.0.1', family: 4 }])
|
||||
} else {
|
||||
callback(null, '127.0.0.1', 4)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, use the default lookup
|
||||
_originalLookup(hostname, options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
describe('fetch-utils', function () {
|
||||
before(async function () {
|
||||
this.server = new TestServer()
|
||||
await this.server.start(HTTP_PORT, HTTPS_PORT, {
|
||||
key: PRIVATE_KEY,
|
||||
cert: PUBLIC_CERT,
|
||||
})
|
||||
this.url = path => `http://example.com:${HTTP_PORT}${path}`
|
||||
this.httpsUrl = path => `https://example.com:${HTTPS_PORT}${path}`
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
this.server.lastReq = undefined
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await this.server.stop()
|
||||
})
|
||||
|
||||
describe('fetchJson', function () {
|
||||
it('parses a JSON response', async function () {
|
||||
const json = await fetchJson(this.url('/json/hello'))
|
||||
expect(json).to.deep.equal({ msg: 'hello' })
|
||||
})
|
||||
|
||||
it('parses JSON in the request', async function () {
|
||||
const json = await fetchJson(this.url('/json/add'), {
|
||||
method: 'POST',
|
||||
json: { a: 2, b: 3 },
|
||||
})
|
||||
expect(json).to.deep.equal({ sum: 5 })
|
||||
})
|
||||
|
||||
it('accepts stringified JSON as body', async function () {
|
||||
const json = await fetchJson(this.url('/json/add'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ a: 2, b: 3 }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(json).to.deep.equal({ sum: 5 })
|
||||
})
|
||||
|
||||
it('throws a FetchError when the payload is not JSON', async function () {
|
||||
await expect(fetchJson(this.url('/hello'))).to.be.rejectedWith(FetchError)
|
||||
})
|
||||
|
||||
it('aborts the request if JSON parsing fails', async function () {
|
||||
await expect(fetchJson(this.url('/large'))).to.be.rejectedWith(FetchError)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('handles errors when the payload is JSON', async function () {
|
||||
await expect(fetchJson(this.url('/json/500'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('handles errors when the payload is not JSON', async function () {
|
||||
await expect(fetchJson(this.url('/500'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('supports abort signals', async function () {
|
||||
await expect(
|
||||
abortOnceReceived(
|
||||
signal => fetchJson(this.url('/hang'), { signal }),
|
||||
this.server
|
||||
)
|
||||
).to.be.rejectedWith(AbortError)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('supports basic auth', async function () {
|
||||
const json = await fetchJson(this.url('/json/basic-auth'), {
|
||||
basicAuth: { user: 'user', password: 'pass' },
|
||||
})
|
||||
expect(json).to.deep.equal({ key: 'verysecret' })
|
||||
})
|
||||
|
||||
it("destroys the request body if it doesn't get consumed", async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
await fetchJson(this.url('/json/ignore-request'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
})
|
||||
expect(stream.destroyed).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchStream', function () {
|
||||
it('returns a stream', async function () {
|
||||
const stream = await fetchStream(this.url('/large'))
|
||||
const text = await streamToString(stream)
|
||||
expect(text).to.equal(this.server.largePayload)
|
||||
})
|
||||
|
||||
it('aborts the request when the stream is destroyed', async function () {
|
||||
const stream = await fetchStream(this.url('/large'))
|
||||
stream.destroy()
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('aborts the request when the request body is destroyed before transfer', async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
const promise = fetchStream(this.url('/hang'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
})
|
||||
stream.destroy()
|
||||
await expect(promise).to.be.rejectedWith(AbortError)
|
||||
await wait(80)
|
||||
expect(this.server.lastReq).to.be.undefined
|
||||
})
|
||||
|
||||
it('aborts the request when the request body is destroyed during transfer', async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
// Note: this test won't work on `/hang`
|
||||
const promise = fetchStream(this.url('/sink'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
})
|
||||
await once(this.server.events, 'request-received')
|
||||
stream.destroy()
|
||||
await expect(promise).to.be.rejectedWith(AbortError)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('handles errors', async function () {
|
||||
await expect(fetchStream(this.url('/500'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('supports abort signals', async function () {
|
||||
await expect(
|
||||
abortOnceReceived(
|
||||
signal => fetchStream(this.url('/hang'), { signal }),
|
||||
this.server
|
||||
)
|
||||
).to.be.rejectedWith(AbortError)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('destroys the request body when an error occurs', async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
await expect(
|
||||
abortOnceReceived(
|
||||
signal =>
|
||||
fetchStream(this.url('/hang'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
signal,
|
||||
}),
|
||||
this.server
|
||||
)
|
||||
).to.be.rejectedWith(AbortError)
|
||||
expect(stream.destroyed).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNothing', function () {
|
||||
it('closes the connection', async function () {
|
||||
await fetchNothing(this.url('/large'))
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('aborts the request when the request body is destroyed before transfer', async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
const promise = fetchNothing(this.url('/hang'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
})
|
||||
stream.destroy()
|
||||
await expect(promise).to.be.rejectedWith(AbortError)
|
||||
expect(this.server.lastReq).to.be.undefined
|
||||
})
|
||||
|
||||
it('aborts the request when the request body is destroyed during transfer', async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
// Note: this test won't work on `/hang`
|
||||
const promise = fetchNothing(this.url('/sink'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
})
|
||||
await once(this.server.events, 'request-received')
|
||||
stream.destroy()
|
||||
await expect(promise).to.be.rejectedWith(AbortError)
|
||||
await wait(80)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it("doesn't abort the request if the request body ends normally", async function () {
|
||||
const stream = Readable.from('hello there')
|
||||
await fetchNothing(this.url('/sink'), { method: 'POST', body: stream })
|
||||
})
|
||||
|
||||
it('handles errors', async function () {
|
||||
await expect(fetchNothing(this.url('/500'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('supports abort signals', async function () {
|
||||
await expect(
|
||||
abortOnceReceived(
|
||||
signal => fetchNothing(this.url('/hang'), { signal }),
|
||||
this.server
|
||||
)
|
||||
).to.be.rejectedWith(AbortError)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('destroys the request body when an error occurs', async function () {
|
||||
const stream = Readable.from(infiniteIterator())
|
||||
await expect(
|
||||
abortOnceReceived(
|
||||
signal =>
|
||||
fetchNothing(this.url('/hang'), {
|
||||
method: 'POST',
|
||||
body: stream,
|
||||
signal,
|
||||
}),
|
||||
this.server
|
||||
)
|
||||
).to.be.rejectedWith(AbortError)
|
||||
expect(stream.destroyed).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchString', function () {
|
||||
it('returns a string', async function () {
|
||||
const body = await fetchString(this.url('/hello'))
|
||||
expect(body).to.equal('hello')
|
||||
})
|
||||
|
||||
it('handles errors', async function () {
|
||||
await expect(fetchString(this.url('/500'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRedirect', function () {
|
||||
it('returns the immediate redirect', async function () {
|
||||
const body = await fetchRedirect(this.url('/redirect/1'))
|
||||
expect(body).to.equal(this.url('/redirect/2'))
|
||||
})
|
||||
|
||||
it('rejects status 200', async function () {
|
||||
await expect(fetchRedirect(this.url('/hello'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('rejects empty redirect', async function () {
|
||||
await expect(fetchRedirect(this.url('/redirect/empty-location')))
|
||||
.to.be.rejectedWith(RequestFailedError)
|
||||
.and.eventually.have.property('cause')
|
||||
.and.to.have.property('message')
|
||||
.to.equal('missing Location response header on 3xx response')
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
|
||||
it('handles errors', async function () {
|
||||
await expect(fetchRedirect(this.url('/500'))).to.be.rejectedWith(
|
||||
RequestFailedError
|
||||
)
|
||||
await expectRequestAborted(this.server.lastReq)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomHttpAgent', function () {
|
||||
it('makes an http request successfully', async function () {
|
||||
const agent = new CustomHttpAgent({ connectTimeout: 100 })
|
||||
const body = await fetchString(this.url('/hello'), { agent })
|
||||
expect(body).to.equal('hello')
|
||||
})
|
||||
|
||||
it('times out when accessing a non-routable address', async function () {
|
||||
const agent = new CustomHttpAgent({ connectTimeout: 10 })
|
||||
await expect(fetchString('http://10.255.255.255/', { agent }))
|
||||
.to.be.rejectedWith(FetchError)
|
||||
.and.eventually.have.property('message')
|
||||
.and.to.equal(
|
||||
'request to http://10.255.255.255/ failed, reason: connect timeout'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomHttpsAgent', function () {
|
||||
it('makes an https request successfully', async function () {
|
||||
const agent = new CustomHttpsAgent({
|
||||
connectTimeout: 100,
|
||||
ca: PUBLIC_CERT,
|
||||
})
|
||||
const body = await fetchString(this.httpsUrl('/hello'), { agent })
|
||||
expect(body).to.equal('hello')
|
||||
})
|
||||
|
||||
it('rejects an untrusted server', async function () {
|
||||
const agent = new CustomHttpsAgent({
|
||||
connectTimeout: 100,
|
||||
})
|
||||
await expect(fetchString(this.httpsUrl('/hello'), { agent }))
|
||||
.to.be.rejectedWith(FetchError)
|
||||
.and.eventually.have.property('code')
|
||||
.and.to.equal('DEPTH_ZERO_SELF_SIGNED_CERT')
|
||||
})
|
||||
|
||||
it('times out when accessing a non-routable address', async function () {
|
||||
const agent = new CustomHttpsAgent({ connectTimeout: 10 })
|
||||
await expect(fetchString('https://10.255.255.255/', { agent }))
|
||||
.to.be.rejectedWith(FetchError)
|
||||
.and.eventually.have.property('message')
|
||||
.and.to.equal(
|
||||
'request to https://10.255.255.255/ failed, reason: connect timeout'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function streamToString(stream) {
|
||||
let s = ''
|
||||
for await (const chunk of stream) {
|
||||
s += chunk
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
async function* infiniteIterator() {
|
||||
let i = 1
|
||||
while (true) {
|
||||
yield `chunk ${i++}\n`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(signal: AbortSignal) => Promise<any>} func
|
||||
* @param {TestServer} server
|
||||
*/
|
||||
async function abortOnceReceived(func, server) {
|
||||
const controller = new AbortController()
|
||||
const promise = func(controller.signal)
|
||||
await once(server.events, 'request-received')
|
||||
controller.abort()
|
||||
return await promise
|
||||
}
|
||||
|
||||
async function expectRequestAborted(req) {
|
||||
if (!req.destroyed) {
|
||||
try {
|
||||
await once(req, 'close')
|
||||
} catch (err) {
|
||||
// `once` throws if req emits an 'error' event.
|
||||
// We ignore `Error: aborted` when the request is aborted.
|
||||
if (err.message !== 'aborted') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(req.destroyed).to.be.true
|
||||
}
|
||||
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
130
libraries/fetch-utils/test/unit/helpers/TestServer.js
Normal file
130
libraries/fetch-utils/test/unit/helpers/TestServer.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
const { promisify } = require('node:util')
|
||||
|
||||
class TestServer {
|
||||
constructor() {
|
||||
this.app = express()
|
||||
this.events = new EventEmitter()
|
||||
|
||||
this.app.use(bodyParser.json())
|
||||
this.app.use((req, res, next) => {
|
||||
this.events.emit('request-received')
|
||||
this.lastReq = req
|
||||
next()
|
||||
})
|
||||
|
||||
// Plain text endpoints
|
||||
|
||||
this.app.get('/hello', (req, res) => {
|
||||
res.send('hello')
|
||||
})
|
||||
|
||||
this.largePayload = 'x'.repeat(16 * 1024 * 1024)
|
||||
this.app.get('/large', (req, res) => {
|
||||
res.send(this.largePayload)
|
||||
})
|
||||
|
||||
this.app.get('/204', (req, res) => {
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
this.app.get('/empty', (req, res) => {
|
||||
res.end()
|
||||
})
|
||||
|
||||
this.app.get('/500', (req, res) => {
|
||||
res.sendStatus(500)
|
||||
})
|
||||
|
||||
this.app.post('/sink', (req, res) => {
|
||||
req.on('data', () => {})
|
||||
req.on('end', () => {
|
||||
res.status(204).end()
|
||||
})
|
||||
})
|
||||
|
||||
// JSON endpoints
|
||||
|
||||
this.app.get('/json/hello', (req, res) => {
|
||||
res.json({ msg: 'hello' })
|
||||
})
|
||||
|
||||
this.app.post('/json/add', (req, res) => {
|
||||
const { a, b } = req.body
|
||||
res.json({ sum: a + b })
|
||||
})
|
||||
|
||||
this.app.get('/json/500', (req, res) => {
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
})
|
||||
|
||||
this.app.get('/json/basic-auth', (req, res) => {
|
||||
const expectedAuth =
|
||||
'Basic ' + Buffer.from('user:pass').toString('base64')
|
||||
if (req.headers.authorization === expectedAuth) {
|
||||
res.json({ key: 'verysecret' })
|
||||
} else {
|
||||
res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
})
|
||||
|
||||
this.app.post('/json/ignore-request', (req, res) => {
|
||||
res.json({ msg: 'hello' })
|
||||
})
|
||||
|
||||
// Never returns
|
||||
|
||||
this.app.get('/hang', (req, res) => {})
|
||||
this.app.post('/hang', (req, res) => {})
|
||||
|
||||
// Redirect
|
||||
|
||||
this.app.get('/redirect/1', (req, res) => {
|
||||
res.redirect('/redirect/2')
|
||||
})
|
||||
this.app.get('/redirect/2', (req, res) => {
|
||||
res.send('body after redirect')
|
||||
})
|
||||
this.app.get('/redirect/empty-location', (req, res) => {
|
||||
res.sendStatus(302)
|
||||
})
|
||||
}
|
||||
|
||||
start(port, httpsPort, httpsOptions) {
|
||||
const startHttp = new Promise((resolve, reject) => {
|
||||
this.server = http.createServer(this.app).listen(port, err => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
const startHttps = new Promise((resolve, reject) => {
|
||||
this.https_server = https
|
||||
.createServer(httpsOptions, this.app)
|
||||
.listen(httpsPort, err => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
return Promise.all([startHttp, startHttps])
|
||||
}
|
||||
|
||||
stop() {
|
||||
const stopHttp = promisify(this.server.close).bind(this.server)
|
||||
const stopHttps = promisify(this.https_server.close).bind(this.https_server)
|
||||
this.server.closeAllConnections()
|
||||
this.https_server.closeAllConnections()
|
||||
return Promise.all([stopHttp(), stopHttps()])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TestServer }
|
7
libraries/fetch-utils/tsconfig.json
Normal file
7
libraries/fetch-utils/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs"
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user