faunadb
Version:
FaunaDB Javascript driver for Node.JS and Browsers
345 lines (293 loc) • 8.78 kB
JavaScript
var { AbortController } = require('node-abort-controller')
var util = require('../_util')
var faunaErrors = require('../errors')
var errors = require('./errors')
/**
* Http client adapter built around fetch API.
*
* @constructor
* @param {?object} options FetchAdapter options.
* @param {?boolean} options.keepAlive Whether use keep-alive connection.
* @param {?boolean} options.isHttps Whether use https connection.
* @param {?function} options.fetch Fetch compatible API.
* @private
*/
function FetchAdapter(options) {
options = options || {}
/**
* Identifies a type of adapter.
*
* @type {string}
*/
this.type = 'fetch'
/**
* Indicates whether the .close method has been called.
*
* @type {boolean}
* @private
*/
this._closed = false
this._fetch = util.resolveFetch(options.fetch)
/**
* A map that tracks ongoing requests to be able to cancel them when
* the .close method is called.
*
* @type {Map<Object, Object>}
* @private
*/
this._pendingRequests = new Map()
if (util.isNodeEnv() && options.keepAlive) {
this._keepAliveEnabledAgent = new (options.isHttps
? require('https')
: require('http')
).Agent({ keepAlive: true, timeout: 3000 })
}
}
/**
* Issues http requests using fetch API.
*
* @param {object} options Request options.
* @param {string} options.origin Request origin.
* @param {string} options.path Request path.
* @param {?object} options.query Request query.
* @param {string} options.method Request method.
* @param {?object} options.headers Request headers.
* @param {?string} options.body Request body utf8 string.
* @params {?object} options.streamConsumer Stream consumer.
* @param {?object} options.signal Abort signal object.
* @param {?number} options.timeout Request timeout.
* @returns {Promise} Request result.
*/
FetchAdapter.prototype.execute = function(options) {
if (this._closed) {
return Promise.reject(
new faunaErrors.ClientClosed(
'The Client has already been closed',
'No subsequent requests can be issued after the .close method is called. ' +
'Consider creating a new Client instance'
)
)
}
var self = this
var timerId = null
var isStreaming = options.streamConsumer != null
// Use timeout only if no signal provided
var useTimeout = !options.signal && !!options.timeout
var ctrl = new AbortController()
var pendingRequest = {
isStreaming: isStreaming,
isAbortedByClose: false,
// This callback can be set during the .close method call to be notified
// on request ending to resolve .close's Promise only after all of the requests complete.
onComplete: null,
}
self._pendingRequests.set(ctrl, pendingRequest)
var onComplete = function() {
self._pendingRequests.delete(ctrl)
if (options.signal) {
options.signal.removeEventListener('abort', onAbort)
}
if (pendingRequest.onComplete) {
pendingRequest.onComplete()
}
}
var onSettle = function() {
if (timerId) {
clearTimeout(timerId)
}
}
var onResponse = function(response) {
onSettle()
var headers = responseHeadersAsObject(response.headers)
var processStream = isStreaming && response.ok
// Regular request - return text content immediately.
if (!processStream) {
onComplete()
return response.text().then(function(content) {
return {
body: content,
headers: headers,
status: response.status,
}
})
}
attachStreamConsumer(response, options.streamConsumer, onComplete)
return {
// Syntactic stream representation.
body: '[stream]',
headers: headers,
status: response.status,
}
}
var onError = function(error) {
onSettle()
onComplete()
return Promise.reject(
remapIfAbortError(error, function() {
if (!isStreaming && pendingRequest.isAbortedByClose) {
return new faunaErrors.ClientClosed(
'The request is aborted due to the Client#close ' +
'call with the force=true option'
)
}
return useTimeout ? new errors.TimeoutError() : new errors.AbortError()
})
)
}
var onAbort = function() {
ctrl.abort()
}
if (useTimeout) {
timerId = setTimeout(function() {
timerId = null
ctrl.abort()
}, options.timeout)
}
if (options.signal) {
options.signal.addEventListener('abort', onAbort)
}
return this._fetch(
util.formatUrl(options.origin, options.path, options.query),
{
method: options.method,
headers: options.headers,
body: options.body,
agent: this._keepAliveEnabledAgent,
signal: ctrl.signal,
}
)
.then(onResponse)
.catch(onError)
}
/**
* Moves to the closed state, aborts streaming requests.
* Aborts non-streaming requests if force is true,
* waits until they complete otherwise.
*
* @param {?object} opts Close options.
* @param {?boolean} opts.force Whether to force resources clean up.
* @returns {Promise<void>}
*/
FetchAdapter.prototype.close = function(opts) {
opts = opts || {}
this._closed = true
var promises = []
var abortOrWait = function(pendingRequest, ctrl) {
var shouldAbort = pendingRequest.isStreaming || opts.force
if (shouldAbort) {
pendingRequest.isAbortedByClose = true
return ctrl.abort()
}
promises.push(
new Promise(function(resolve) {
pendingRequest.onComplete = resolve
})
)
}
this._pendingRequests.forEach(abortOrWait)
var noop = function() {}
return Promise.all(promises).then(noop)
}
/**
* Attaches streamConsumer specifically either for browser or NodeJS.
* Minimum browser compatibility based on current code:
* Chrome 52
* Edge 79
* Firefox 65
* IE NA
* Opera 39
* Safari 10.1
* Android Webview 52
* Chrome for Android 52
* Firefox for Android 65
* Opera for Android 41
* Safari on iOS 10.3
* Samsung Internet 6.0
*
* @param {object} response Fetch response.
* @param {object} consumer StreamConsumer.
* @param {function} onComplete Callback fired when the stream ends or errors.
* @private
*/
function attachStreamConsumer(response, consumer, onComplete) {
var onError = function(error) {
onComplete()
consumer.onError(remapIfAbortError(error))
}
if (util.isNodeEnv()) {
response.body
.on('error', onError)
.on('data', consumer.onData)
.on('end', function() {
onComplete()
// To simulate how browsers behave in case of "end" event.
consumer.onError(new TypeError('network error'))
})
return
}
// ATTENTION: The following code is meant to run in browsers and is not
// covered by current test automation. Manual testing on major browsers
// is required after making changes to it.
try {
var reader = response.body.getReader()
var decoder = new TextDecoder('utf-8')
function pump() {
return reader.read().then(function(msg) {
if (!msg.done) {
var chunk = decoder.decode(msg.value, { stream: true })
consumer.onData(chunk)
return pump()
}
onComplete()
// In case a browser hasn't thrown the "network error" on stream's end
// we need to force it in order to provide a way to handle stream's
// ending.
consumer.onError(new TypeError('network error'))
})
}
pump().catch(onError)
} catch (err) {
throw new faunaErrors.StreamsNotSupported(
'Please, consider providing a Fetch API-compatible function ' +
'with streamable response bodies. ' +
err
)
}
}
/**
* Remaps an AbortError thrown by fetch to HttpClient's one
* for timeout and abort use-cases.
*
* @param {Error} error Error object.
* @param {?function} errorFactory A factory called to construct an abort error.
* @returns {Error} Remapped or original error.
* @private
*/
function remapIfAbortError(error, errorFactory) {
var isAbortError = error && error.name === 'AbortError'
if (!isAbortError) {
return error
}
if (errorFactory) {
return errorFactory()
}
return new errors.AbortError()
}
/**
* Converts fetch Headers object into POJO.
*
* @param {object} headers Fetch Headers object.
* @returns {object} Response headers as a plain object.
* @private
*/
function responseHeadersAsObject(headers) {
var result = {}
for (var header of headers.entries()) {
var key = header[0]
var value = header[1]
result[key] = value
}
return result
}
module.exports = FetchAdapter