fyrejet
Version:
Web Framework for node.js that strives to provide (almost) perfect compatibility with Express, while providing better performance, where you need it.
770 lines (637 loc) • 19.3 kB
JavaScript
'use strict'
/*
* Fyrejet
* Copyright(c) 2021 Nicholas Schamberg
* MIT Licensed
*/
const Buffer = require('safe-buffer').Buffer
const contentDisposition = require('content-disposition')
const deprecate = require('depd')('fyrejet')
const encodeUrl = require('encodeurl')
const escapeHtml = require('escape-html')
const { isAbsolute } = require('./utils')
const onFinished = require('on-finished')
const path = require('path')
const statuses = require('statuses')
const { merge } = require('./utils')
const { sign } = require('cookie-signature')
const { normalizeType } = require('./utils')
const { normalizeTypes } = require('./utils')
const { setCharset, forEachObject } = require('./utils')
const cookie = require('cookie')
const send = require('send')
const extname = path.extname
const mime = send.mime
const resolve = path.resolve
const vary = require('vary')
module.exports = {
build,
res: build(Object.create({}))
}
const charsetRegExp = /;\s*charset\s*=/
const CONTENT_TYPE_HEADER = 'Content-Type'
const CONTENT_LENGTH_HEADER = 'Content-Length'
const X_CONTENT_TYPE_OPTIONS = 'X-Content-Type-Options'
const NOSNIFF = 'nosniff'
const TYPE_JSON = 'application/json; charset=utf-8'
const TYPE_PLAIN = 'text/plain; charset=utf-8'
const TYPE_HTML = 'text/html; charset=utf-8'
const TYPE_OCTET = 'application/octet-stream'
const LOCATION = 'Location'
const NOOP = () => {}
function build (res) {
const parseErr = (error) => {
const errorCode = error.status || error.code || error.statusCode
const statusCode = typeof errorCode === 'number' ? errorCode : 500
return {
statusCode,
data: JSON.stringify({
code: statusCode,
message: error.message,
data: error.data
})
}
}
res.status = function status (code) {
this.statusCode = code
return this
}
res.links = function (links) {
let link = this.getHeader('Link') || ''
if (link) link += ', '
return this.setHeader(
'Link',
link +
Object.keys(links)
.map(function (rel) {
return '<' + links[rel] + '>; rel="' + rel + '"'
})
.join(', ')
)
}
res.send = function send (body, ...args) {
if (args.length) {
body = [body, ...args]
return resJson(this, body)
}
switch (typeof body) {
// string defaulting to html
case 'string':
if (!this.getHeader(CONTENT_TYPE_HEADER)) {
this.req.rData_internal.encodingSet = true
this.setHeader(CONTENT_TYPE_HEADER, TYPE_HTML)
return resSend(this, body)
}
return resSend(this, body)
case 'boolean':
case 'number':
this.req.rData_internal.encodingSet = true
this.setHeader(CONTENT_TYPE_HEADER, TYPE_JSON)
body = body.toString()
return resSend(this, body)
case 'object':
if (body === null) {
body = ''
return resSend(this, body)
} else if (Buffer.isBuffer(body)) {
this.req.rData_internal.encodingSet = true // encoding is not set, but setting encoding in content-type is meaningless in this case
if (!this.getHeader(CONTENT_TYPE_HEADER)) {
this.setHeader(CONTENT_TYPE_HEADER, TYPE_OCTET)
}
return resSend(this, body)
}
return resJson(this, body) // no need to go through checks for deprecated functionality
}
return resSend(this, body)
}
function resSend (res, body) {
// split to avoid deprecation checks when returning from resJson or res.json
const req = res.req
// settings
const app = res.app
// write strings in utf-8
if (!req.rData_internal.encodingSet) {
// reflect this in content-type
let type
if (typeof (type = res.getHeader(CONTENT_TYPE_HEADER)) === 'string') {
res.setHeader(CONTENT_TYPE_HEADER, setCharset(type, 'utf-8'))
}
}
if (
body !== undefined &&
!req.rData_internal.noEtag &&
!res.getHeader('ETag')
) {
let etag
// determine if ETag should be generated
const etagFn = app.__settings.get('etag fn')
// populate ETag
if (etagFn && (etag = etagFn(body))) {
// unlike express, we don't need to check if there is len, because we know it for certain, since we placed this if block in a different place
res.setHeader('ETag', etag)
}
}
// freshness
const fresh = req.fresh()
// strip irrelevant headers
if (
(fresh && (res.statusCode = 304)) ||
res.statusCode === 204 ||
res.statusCode === 304
) {
res.removeHeader(CONTENT_TYPE_HEADER)
res.removeHeader(CONTENT_LENGTH_HEADER)
res.removeHeader('Transfer-Encoding')
body = ''
}
res.end(req.method === 'HEAD' ? null : body)
return res
}
res.json = function json (obj, ...args) {
let val = obj
if (args.length) {
val = [val, ...args]
}
return resJson(this, val)
}
function resJson (res, val) {
// this is split, so we can avoid checks for deprecated functionality
// settings
const app = res.app
const escape = app.__settings.get('json escape')
const replacer = app.__settings.get('json replacer')
const spaces = app.__settings.get('json spaces')
const body = stringify(val, replacer, spaces, escape)
// content-type
if (!res.getHeader(CONTENT_TYPE_HEADER)) {
res.req.rData_internal.encodingSet = true
res.setHeader(CONTENT_TYPE_HEADER, TYPE_JSON)
}
return resSend(res, body)
}
res.jsonp = function jsonp (obj, ...args) {
let val = obj
if (args.length) {
val = [val, ...args]
}
// settings
const app = this.app
const escape = app.__settings.get('query parser fn')
const replacer = app.__settings.get('json replacer')
const spaces = app.__settings.get('json spaces')
let body = stringify(val, replacer, spaces, escape)
let callback = this.req.query[app.__settings.get('jsonp callback name')]
// content-type
if (!this.getHeader(CONTENT_TYPE_HEADER)) {
this.setHeader(X_CONTENT_TYPE_OPTIONS, NOSNIFF)
this.req.rData_internal.encodingSet = true
this.setHeader(CONTENT_TYPE_HEADER, TYPE_JSON)
}
// fixup callback
if (Array.isArray(callback)) {
callback = callback[0]
}
// jsonp
if (typeof callback === 'string' && callback.length !== 0) {
this.setHeader(X_CONTENT_TYPE_OPTIONS, NOSNIFF)
this.set(CONTENT_TYPE_HEADER, 'text/javascript')
// restrict callback charset
callback = callback.replace(/[^\[\]\w$.]/g, '')
// replace chars not allowed in JavaScript that are in JSON
body = body.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
// the /**/ is a specific security mitigation for "Rosetta Flash JSONP abuse"
// the typeof check is just to reduce client error noise
body =
'/**/ typeof ' +
callback +
" === 'function' && " +
callback +
'(' +
body +
');'
}
return resSend(this, body)
}
res.sendStatus = function sendStatus (statusCode) {
const body = statuses[statusCode] || String(statusCode)
this.statusCode = statusCode
return this.type('txt').send(body)
}
res.sendFile = function sendFile (path, options, callback) {
let done = callback
const req = this.req
const res = this
const next = req.next
let opts = options || {}
if (!path) {
throw new TypeError('path argument is required to res.sendFile')
}
if (typeof path !== 'string') {
throw new TypeError('path must be a string to res.sendFile')
}
// support function as second arg
if (typeof options === 'function') {
done = options
opts = {}
}
if (!opts.root && !isAbsolute(path)) {
throw new TypeError(
'path must be absolute or specify root to res.sendFile'
)
}
// create file stream
const pathname = encodeURI(path)
const file = send(req, pathname, opts)
// transfer
sendfile(res, file, opts, function (err) {
if (done) return done(err)
if (err && err.code === 'EISDIR') return next()
// next() all but write errors
if (err && err.code !== 'ECONNABORTED' && err.syscall !== 'write') {
next(err)
}
})
}
res.download = function download (path, filename, options, callback) {
let done = callback
let name = filename
let opts = options || null
// support function as second or third arg
if (typeof filename === 'function') {
done = filename
name = null
opts = null
} else if (typeof options === 'function') {
done = options
opts = null
}
// set Content-Disposition when file is sent
const headers = {
'Content-Disposition': contentDisposition(name || path)
}
// merge user-provided headers
if (opts && opts.headers) {
const keys = Object.keys(opts.headers)
for (let i = 0, j = keys.length; i < j; i++) {
const key = keys[i]
if (key.toLowerCase() !== 'content-disposition') {
headers[key] = opts.headers[key]
}
}
}
// merge user-provided options
opts = Object.create(opts)
opts.headers = headers
// Resolve the full path for sendFile
const fullPath = resolve(path)
// send file
return this.sendFile(fullPath, opts, done)
}
res.contentType = res.type = function contentType (type) {
const ct = type.indexOf('/') === -1 ? mime.lookup(type) : type
return this.set(CONTENT_TYPE_HEADER, ct)
}
res.format = function (obj) {
const req = this.req
const next = req.next
const fn = obj.default
if (fn) delete obj.default
const keys = Object.keys(obj)
const key = keys.length > 0 ? req.accepts(keys) : false
this.vary('Accept')
if (key) {
this.set(CONTENT_TYPE_HEADER, normalizeType(key).value)
obj[key](req, this, next)
return this
}
if (fn) {
fn()
return this
}
const err = new Error('Not Acceptable')
err.status = err.statusCode = 406
err.types = normalizeTypes(keys).map(function (o) {
return o.value
})
next(err)
return this
}
res.attachment = function attachment (filename) {
if (filename) {
this.type(extname(filename))
}
this.setHeader('Content-Disposition', contentDisposition(filename))
return this
}
res.append = function append (field, val) {
const prev = this.getHeader(field)
let value = val
if (prev) {
// concat the new and prev vals
value = Array.isArray(prev)
? prev.concat(val)
: Array.isArray(val)
? [prev].concat(val)
: [prev, val]
}
return this.set(field, value)
}
res.set = res.header = function header (field, val) {
if (val) {
let value = Array.isArray(val) ? val.map(String) : String(val)
// add charset to content-type
if (field.toLowerCase() === 'content-type') {
if (Array.isArray(value)) {
throw new TypeError('Content-Type cannot be set to an Array')
}
if (!charsetRegExp.test(value)) {
const charset = mime.charsets.lookup(value.split(';')[0])
if (charset) value += '; charset=' + charset.toLowerCase()
}
}
this.setHeader(field, value)
return this
}
for (const key in field) {
this.set(key, field[key])
}
return this
}
res.get = function (field) {
return this.getHeader(field)
}
res.clearCookie = function clearCookie (name, options) {
const opts = merge({ expires: new Date(1), path: '/' }, options)
return this.cookie(name, '', opts)
}
res.cookie = function (name, value, options) {
const opts = merge({}, options)
const secret = this.req.secret
const signed = opts.signed
if (signed && !secret) {
throw new Error('cookieParser("secret") required for signed cookies')
}
let val =
typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
if (signed) {
val = 's:' + sign(val, secret)
}
if ('maxAge' in opts) {
opts.expires = new Date(Date.now() + opts.maxAge)
opts.maxAge /= 1000
}
if (opts.path == null) {
opts.path = '/'
}
this.append('Set-Cookie', cookie.serialize(name, String(val), opts))
return this
}
res.location = function location (url) {
let loc = url
// "back" is an alias for the referrer
if (url === 'back') {
loc = this.req.get('Referrer') || '/'
}
// set location
return this.set(LOCATION, encodeUrl(loc))
}
res.redirect = function redirect (url, arg2) {
let address = url
let body
let status = 302
// allow status / url
if (arg2) {
if (typeof url === 'number') {
status = url
address = arg2
} else {
deprecate(
'res.redirect(url, status): Use res.redirect(status, url) instead'
)
status = arg2
}
}
// Set location header
address = this.location(address).get(LOCATION)
// Support text/{plain,html} by default
this.format({
text: function () {
body = statuses[status] + '. Redirecting to ' + address
},
html: function () {
const u = escapeHtml(address)
body =
'<p>' +
statuses[status] +
'. Redirecting to <a href="' +
u +
'">' +
u +
'</a></p>'
},
default: function () {
body = ''
}
})
// Respond
this.statusCode = status
this.setHeader(CONTENT_LENGTH_HEADER, Buffer.byteLength(body))
if (this.req.method === 'HEAD') {
this.end()
return this
}
this.end(body)
return this
}
res.vary = function (field) {
// checks for back-compat
if (!field || (Array.isArray(field) && !field.length)) {
deprecate('res.vary(): Provide a field name')
return this
}
vary(this, field)
return this
}
res.render = function render (view, options, callback) {
const app = this.app
let done = callback
let opts = options || {}
const req = this.req
const self = this
// support callback function as second arg
if (typeof options === 'function') {
done = options
opts = {}
}
// merge res.locals
opts._locals = self.locals
// default callback to respond
done =
done ||
function (err, str) {
if (err) return req.next(err)
self.send(str)
}
// render
app.render(view, opts, done)
}
res.sendLite = function (data, code, headers, cb) {
const res = this
code = code || res.statusCode
headers = headers || null
cb = cb || NOOP
if (!data && typeof data !== 'string') data = res.statusCode
let contentType
if (data instanceof Error) {
const err = parseErr(data)
contentType = TYPE_JSON
code = err.statusCode
data = err.data
} else {
if (headers && typeof headers === 'object') {
forEachObject(headers, (value, key) => {
res.setHeader(key.toLowerCase(), value)
})
}
// NOTE: only retrieve content-type after setting custom headers
contentType = res.getHeader(CONTENT_TYPE_HEADER)
if (typeof data === 'number') {
code = data
data = res.body
}
if (data) {
if (typeof data === 'string') {
if (!contentType) contentType = TYPE_PLAIN
} else if (typeof data === 'object') {
if (data instanceof Buffer) {
if (!contentType) contentType = TYPE_OCTET
} else if (typeof data.pipe === 'function') {
if (!contentType) contentType = TYPE_OCTET
// NOTE: we exceptionally handle the response termination for streams
if (contentType) {
res.setHeader(CONTENT_TYPE_HEADER, contentType)
}
res.statusCode = code
data.pipe(res)
data.on('end', cb)
return
} else if (Promise.resolve(data) === data) {
// http://www.ecma-international.org/ecma-262/6.0/#sec-promise.resolve
headers = null
return data
.then((resolved) => res.sendLite(resolved, code, headers, cb))
.catch((err) => res.sendLite(err, code, headers, cb))
} else {
if (!contentType) contentType = TYPE_JSON
data = JSON.stringify(data)
}
}
}
}
if (contentType) {
res.setHeader(CONTENT_TYPE_HEADER, contentType)
}
res.statusCode = code
res.end(data, cb)
}
// pipe the send file stream
function sendfile (res, file, options, callback) {
let done = false
let streaming
// request aborted
function onaborted () {
if (done) return
done = true
const err = new Error('Request aborted')
err.code = 'ECONNABORTED'
callback(err)
}
// directory
function ondirectory () {
if (done) return
done = true
const err = new Error('EISDIR, read')
err.code = 'EISDIR'
callback(err)
}
// errors
function onerror (err) {
if (done) return
done = true
callback(err)
}
// ended
function onend () {
if (done) return
done = true
callback()
}
// file
function onfile () {
streaming = false
}
// finished
function onfinish (err) {
if (err && err.code === 'ECONNRESET') return onaborted()
if (err) return onerror(err)
if (done) return
setImmediate(function () {
if (streaming !== false && !done) {
onaborted()
return
}
if (done) return
done = true
callback()
})
}
// streaming
function onstream () {
streaming = true
}
file.on('directory', ondirectory)
file.on('end', onend)
file.on('error', onerror)
file.on('file', onfile)
file.on('stream', onstream)
onFinished(res, onfinish)
if (options.headers) {
// set headers on successful transfer
file.on('headers', function headers (res) {
const obj = options.headers
const keys = Object.keys(obj)
for (let i = 0, j = keys.length; i < j; i++) {
const k = keys[i]
res.setHeader(k, obj[k])
}
})
}
// pipe
file.pipe(res)
}
function stringify (value, replacer, spaces, escape) {
// v8 checks arguments.length for optimizing simple call
// https://bugs.chromium.org/p/v8/issues/detail?id=4730
let json =
replacer || spaces
? JSON.stringify(value, replacer, spaces)
: JSON.stringify(value)
if (escape) {
json = json.replace(/[<>&]/g, function (c) {
switch (c.charCodeAt(0)) {
case 0x3c:
return '\\u003c'
case 0x3e:
return '\\u003e'
case 0x26:
return '\\u0026'
/* istanbul ignore next: unreachable default */
default:
return c
}
})
}
return json
}
return res
}