fortune-http
Version:
HTTP implementation for Fortune.js.
491 lines (407 loc) • 16.7 kB
JavaScript
var zlib = require('zlib')
var crc32 = (require('@node-rs/crc32')).crc32
var Negotiator = require('negotiator')
var HttpSerializer = require('./serializer')
var jsonSerializer = require('./json_serializer')
var htmlSerializer = require('./html_serializer')
var HttpFormSerializer = require('./form_serializer')
var statusMapFn = require('./status_map')
var instantiateSerializer = require('./instantiate_serializer')
var beforeSemicolon = /[^;]*/
var availableEncodings = [ 'gzip', 'deflate' ]
var payloadMethods = [ 'POST', 'PATCH', 'PUT' ]
var buffer = Buffer.from || Buffer
/**
* **Node.js only**: This function implements a HTTP server for Fortune.
*
* ```js
* const http = require('http')
* const fortuneHTTP = require('fortune-http')
*
* const listener = fortuneHTTP(fortuneInstance, options)
* const server = http.createServer((request, response) =>
* listener(request, response)
* .catch(error => {
* // error logging
* }))
* ```
*
* It determines which serializer to use, assigns request headers
* to the `meta` object, reads the request body, and maps the response from
* the `request` method on to the HTTP response. The listener function ends the
* response and returns a promise that is resolved when the response is ended.
* The returned promise may be rejected with the error response, providing a
* hook for error logging.
*
* The options object may be formatted as follows:
*
* ```js
* {
* // An array of HTTP serializers, ordered by priority. Defaults to ad hoc
* // JSON and form serializers if none are specified. If a serializer value
* // is not an array, its settings will be considered omitted.
* serializers: [
* [
* // A function that subclasses the HTTP Serializer.
* HttpSerializerSubclass,
*
* // Settings to pass to the constructor, optional.
* { ... }
* ]
* ],
* settings: {
* // By default, the listener will end the response, set this to `false`
* // if the response will be ended later.
* endResponse: true,
*
* // Use compression if the request `Accept-Encoding` header allows it.
* // Note that Buffer-typed responses will not be compressed. This option
* // should be disabled in case of a reverse proxy which handles
* // compression.
* useCompression: true,
*
* // Use built-in ETag implementation, which uses CRC32 for generating
* // weak ETags under the hood. This option should be disabled in case of
* // a reverse proxy which handles ETags.
* useETag: true,
*
* // Ensure that the request is sent at an acceptable rate, to prevent
* // abusive slow requests. This is given in terms of kilobits per second
* // (kbps). Default: `28.8`, based on slow modem speed.
* minimumRateKBPS: 28.8,
*
* // Ensure that requests can not be larger than a specific size, to
* // prevent abusive large requests. This is given in terms of megabytes
* // (MB). Default: `2`, based on unformatted 3.5" floppy disk capacity.
* // Use a falsy value to turn this off (not recommended).
* maximumSizeMB: 2,
*
* // How often to check for request rate in milliseconds (ms).
* // Default: 3000.
* rateCheckMS: 3000
* }
* }
* ```
*
* The main export contains the following keys:
*
* - `Serializer`: HTTP Serializer class.
* - `JsonSerializer`: JSON over HTTP serializer.
* - `HtmlSerializer`: HTML serializer.
* - `FormDataSerializer`: Serializer for `multipart/formdata`.
* - `FormUrlEncodedSerializer`: Serializer for
* `application/x-www-form-urlencoded`.
* - `instantiateSerializer`: an internal function with the signature
* (`instance`, `serializer`, `options`), useful if one needs to get an
* instance of the serializer without the HTTP listener.
*
* @param {Fortune} instance
* @param {Object} [options]
* @return {Function}
*/
function createListener (instance, options) {
var mediaTypes = []
var serializers = {}
var serializer, input
var settings, endResponse, useCompression, useETag
var minimumRate, maximumSize, rateCheckMS, minimumRateSize
var errors, nativeErrors
var BadRequestError, UnsupportedError, NotAcceptableError
var assign, message, responses, statusMap
var i, j
if (!instance.request || !instance.common)
throw new TypeError('An instance of Fortune is required.')
assign = instance.common.assign
message = instance.message || instance.common.message
responses = instance.common.responses
statusMap = statusMapFn(responses)
errors = instance.common.errors
nativeErrors = errors.nativeErrors
BadRequestError = errors.BadRequestError
UnsupportedError = errors.UnsupportedError
NotAcceptableError = errors.NotAcceptableError
if (options === void 0) options = {}
if (!('serializers' in options))
options.serializers = [
jsonSerializer(HttpSerializer),
htmlSerializer(HttpSerializer),
HttpFormSerializer.formData,
HttpFormSerializer.formUrlEncoded
]
if (!('settings' in options)) options.settings = {}
settings = options.settings
if (!options.serializers.length)
throw new Error('At least one serializer must be defined.')
for (i = 0, j = options.serializers.length; i < j; i++) {
input = Array.isArray(options.serializers[i]) ?
options.serializers[i] : [ options.serializers[i] ]
serializer = instantiateSerializer(
instance, input[0], input[1], serializers)
serializers[serializer.mediaType] = serializer
mediaTypes.push(serializer.mediaType)
}
endResponse = 'endResponse' in settings ? settings.endResponse : true
useETag = 'useETag' in settings ? settings.useETag : true
useCompression = 'useCompression' in settings ?
settings.useCompression : true
// Values are converted from bits to bytes, since buffer lengths are
// measured in bytes.
minimumRate = 'minimumRateKBPS' in settings ?
settings.minimumRateKBPS * Math.pow(2, 3) : 28.8 * Math.pow(2, 3)
// Convert from MB to bytes.
maximumSize = 'maximumSizeMB' in settings ?
settings.maximumSizeMB * Math.pow(2, 20) : 2 * Math.pow(2, 20)
rateCheckMS = 'rateCheckMS' in settings ?
settings.rateCheckMS : 3000
minimumRateSize = Math.floor(minimumRate / (rateCheckMS / 1000))
// Expose HTTP status code map.
listener.statusMap = statusMap
return listener
// We can take advantage of the closure which has a reference to the
// Fortune instance.
function listener (request, response) {
var encoding, payload, isProcessing, contextResponse
var negotiator, language, serializerOutput, serializerInput, contextRequest
negotiator = new Negotiator(request)
language = negotiator.language()
// Using Negotiator to get the highest priority media type.
serializerOutput = negotiator.mediaType(mediaTypes)
// Get the media type of the request.
// See RFC 2045: https://www.ietf.org/rfc/rfc2045.txt
serializerInput = beforeSemicolon
.exec(request.headers['content-type'] || '')[0] || null
// Invalid media type requested. The `undefined` return value comes from
// the Negotiator library.
if (serializerOutput === void 0)
serializerOutput = negotiator.mediaType()
if (serializerOutput)
response.setHeader('Content-Type', serializerOutput)
if (useCompression) {
encoding = negotiator.encoding(availableEncodings)
if (encoding) response.setHeader('Content-Encoding', encoding)
}
// Set status code to obscure value meaning server not implemented,
// which we can check later if it should be overwritten or not.
response.statusCode = 501
// Initialize the request object.
contextRequest = {
meta: {
serializerInput: serializerInput,
serializerOutput: serializerOutput,
request: request,
headers: request.headers,
language: language
}
}
return (!serializers.hasOwnProperty(serializerOutput) ?
Promise.reject(new NotAcceptableError(message(
'SerializerNotFound', language, { id: serializerOutput }))) :
new Promise(function (resolve, reject) {
var chunks, previousLength, currentLength
var requestLength, rateInterval
if (!~payloadMethods.indexOf(request.method) &&
!('content-length' in request.headers))
return resolve()
// All requests with payloads expected must have a
// Content-Length header.
if (!('content-length' in request.headers)) {
response.statusCode = 411
return reject(Error())
}
// Read the request body before continuing.
requestLength = parseInt(request.headers['content-length'], 10)
if (requestLength === 0) return resolve()
if (requestLength > maximumSize) {
response.statusCode = 413
return reject(Error())
}
chunks = []
previousLength = 0
currentLength = 0
rateInterval = setInterval(function () {
if (currentLength - previousLength < minimumRateSize) {
clearInterval(rateInterval)
response.statusCode = 408
reject(Error())
}
else previousLength = currentLength
}, rateCheckMS)
request.on('error', function (error) {
response.setHeader('Content-Type', 'text/plain')
error.payload = message('InvalidBody', language)
Object.defineProperty(error, 'isInputError', { value: true })
reject(error)
})
request.on('data', function (chunk) {
currentLength += chunk.length
if (currentLength > maximumSize) {
response.statusCode = 413
reject(Error())
}
chunks.push(buffer(chunk))
})
request.on('end', function () {
clearInterval(rateInterval)
resolve(Buffer.concat(chunks))
})
return null
}))
.then(function (body) {
if (body && body.length) payload = body
return serializers[serializerOutput]
.processRequest(contextRequest, request, response)
})
.then(function (result) {
if (result) contextRequest = result
if (!serializerInput) return contextRequest
if (!serializers.hasOwnProperty(serializerInput))
throw new UnsupportedError(message(
'SerializerNotFound', language, { id: serializerInput }))
contextRequest.payload = payload
return Promise.resolve()
.then(function () {
return payload && payload.length ?
serializers[serializerInput]
.parsePayload(contextRequest, request, response) : null
})
.then(function (result) {
if (result) contextRequest.payload = result
return contextRequest
}, function (error) {
Object.defineProperty(error, 'isInputError', { value: true })
throw error
})
})
.then(function (contextRequest) {
return instance.request(contextRequest)
})
.then(function (result) {
contextResponse = result
isProcessing = true
return serializers[serializerOutput]
.processResponse(contextResponse, request, response)
})
.then(function (result) {
return end(result || contextResponse, request, response)
})
.catch(function (error) {
var exposedError = error
return Promise.resolve()
.then(function () {
if (!('payload' in error || 'meta' in error) &&
~nativeErrors.indexOf(error.constructor)) {
if (contextResponse) delete contextResponse.payload
exposedError = assign(error.isInputError ?
new BadRequestError(message('InvalidBody', language)) :
new Error(message('GenericError', language)),
contextResponse)
}
return !isProcessing &&
serializers.hasOwnProperty(serializerOutput) ?
serializers[serializerOutput]
.processResponse(exposedError, request, response) :
exposedError
})
.then(function (result) {
return end(result || exposedError, request, response)
}, function () {
return end(new Error(message('GenericError', language)),
request, response)
})
.then(function () {
// Do not reject exceptions that result in non-error status codes.
if (response.statusCode < 400) return error
throw error
})
})
}
// Internal function to end the response.
function end (contextResponse, request, response) {
var encoding, payload, meta
if (!('meta' in contextResponse)) contextResponse.meta = {}
if (!('headers' in contextResponse.meta)) contextResponse.meta.headers = {}
meta = contextResponse.meta
payload = contextResponse.payload
// Expose response object downstream.
meta.response = response
if (response.statusCode === 501)
response.statusCode = statusMap.get(contextResponse.constructor) ||
statusMap.get(Error)
return new Promise(function (resolve, reject) {
if (Buffer.isBuffer(payload) || typeof payload === 'string') {
encoding = response.getHeader('content-encoding')
if (encoding && ~availableEncodings.indexOf(encoding))
return zlib[encoding](payload, function (error, result) {
if (error) throw error
payload = contextResponse.payload = result
response.setHeader('Content-Length', String(payload.length))
return resolve()
})
response.removeHeader('content-encoding')
payload = contextResponse.payload = buffer(payload)
response.setHeader('Content-Length', String(payload.length))
return resolve()
}
if (payload) {
response.statusCode = statusMap.get(Error)
return reject(new Error('Response payload type is invalid.'))
}
// Handle empty response.
response.removeHeader('content-encoding')
response.removeHeader('content-type')
if (response.statusCode === statusMap.get(responses.OK))
response.statusCode = (statusMap.get(responses.Empty))
payload = contextResponse.payload = ''
return resolve()
})
.then(function () {
var field, etag
for (field in meta.headers)
response.setHeader(field, meta.headers[field])
if (useETag && payload) {
etag = 'W/' + crc32(payload).toString(16)
response.setHeader('ETag', etag)
if (!endResponse) return contextResponse
if (request.headers['if-none-match'] === etag) {
response.statusCode = 304
response.removeHeader('content-encoding')
response.removeHeader('content-type')
response.removeHeader('content-length')
response.writeHead(response.statusCode, response.headers)
// Ignore error if client doesn't receive response.
response.end()
return contextResponse
}
}
else if (!endResponse) return contextResponse
response.writeHead(response.statusCode, response.headers)
// Ignore error if client doesn't receive response.
response.end(payload.length ? payload : contextResponse.toString())
return contextResponse
})
.catch(function (error) {
var message = error.toString()
if (response.statusCode === 501)
response.statusCode = statusMap.get(Error)
response.removeHeader('content-encoding')
if (message) {
response.setHeader('Content-Type', 'text/plain')
response.setHeader('Content-Length', Buffer.byteLength(message))
}
response.writeHead(response.statusCode, response.headers)
// Ignore error if client doesn't receive response.
response.end(message)
return error
})
}
}
// Expose instantiation method.
createListener.instantiateSerializer = instantiateSerializer
// Expose HTTP Serializer class, and defaults.
createListener.Serializer = HttpSerializer
createListener.JsonSerializer = jsonSerializer(HttpSerializer)
createListener.HtmlSerializer = htmlSerializer(HttpSerializer)
createListener.FormDataSerializer = HttpFormSerializer.formData
createListener.FormUrlEncodedSerializer = HttpFormSerializer.formUrlEncoded
module.exports = createListener