diff --git a/examples/vhost/index.js b/examples/vhost/index.js index 3eeec61f8e..934a993859 100644 --- a/examples/vhost/index.js +++ b/examples/vhost/index.js @@ -43,8 +43,8 @@ redirect.use(function(req, res){ var app = module.exports = express(); -app.use(vhost('*.example.com', redirect)); // Serves all subdomains via Redirect app -app.use(vhost('example.com', main)); // Serves top level domain via Main server app +app.use(vhost('*.example.com', redirect.handle.bind(redirect))); // Serves all subdomains via Redirect app +app.use(vhost('example.com', main.handle.bind(main))); // Serves top level domain via Main server app /* istanbul ignore next */ if (!require.main) { diff --git a/lib/application.js b/lib/application.js index 3a1166c22c..fd4dcf19fc 100644 --- a/lib/application.js +++ b/lib/application.js @@ -6,623 +6,813 @@ * MIT Licensed */ -'use strict'; +'use strict' /** * Module dependencies. * @private */ -var finalhandler = require('finalhandler'); -var Router = require('router'); -var methods = require('methods'); -var debug = require('debug')('express:application'); -var View = require('./view'); -var http = require('http'); -var compileETag = require('./utils').compileETag; -var compileQueryParser = require('./utils').compileQueryParser; -var compileTrust = require('./utils').compileTrust; -var merge = require('utils-merge'); -var resolve = require('path').resolve; -var slice = Array.prototype.slice; - -/** - * Application prototype. - */ - -var app = exports = module.exports = {}; +var finalhandler = require('finalhandler') +var Router = require('./router') +var methods = require('methods') +var debug = require('debug')('express:application') +var View = require('./view') +var http = require('http') +var compileETag = require('./utils').compileETag +var compileQueryParser = require('./utils').compileQueryParser +var compileTrust = require('./utils').compileTrust +var merge = require('utils-merge') +var resolve = require('path').resolve +const EventEmitter = require('node:events') +const ExpressResponse = require('./response') +const ExpressRequest = require('./request') /** * Variable for trust proxy inheritance back-compat * @private */ -var trustProxyDefaultSymbol = '@@symbol:trust_proxy_default'; +const trustProxyDefaultSymbol = '@@symbol:trust_proxy_default' /** - * Initialize the server. - * - * - setup default configuration - * - setup default middleware - * - setup route reflection methods - * + * Try rendering a view. * @private */ -app.init = function init() { - var router = null; - - this.cache = {}; - this.engines = {}; - this.settings = {}; - - this.defaultConfiguration(); - - // Setup getting to lazily add base router - Object.defineProperty(this, 'router', { - configurable: true, - enumerable: true, - get: function getrouter() { - if (router === null) { - router = new Router({ - caseSensitive: this.enabled('case sensitive routing'), - strict: this.enabled('strict routing') - }); - } +function tryRender(view, options, callback) { + try { + view.render(options, callback) + } catch (err) { + callback(err) + } +} - return router; - } - }); -}; /** - * Initialize application configuration. - * @private + * Application prototype. */ - -app.defaultConfiguration = function defaultConfiguration() { - var env = process.env.NODE_ENV || 'development'; - - // default settings - this.enable('x-powered-by'); - this.set('etag', 'weak'); - this.set('env', env); - this.set('query parser', 'simple') - this.set('subdomain offset', 2); - this.set('trust proxy', false); - - // trust proxy inherit back-compat - Object.defineProperty(this.settings, trustProxyDefaultSymbol, { - configurable: true, - value: true - }); - - debug('booting in %s mode', env); - - this.on('mount', function onmount(parent) { - // inherit trust proxy - if (this.settings[trustProxyDefaultSymbol] === true - && typeof parent.settings['trust proxy fn'] === 'function') { - delete this.settings['trust proxy']; - delete this.settings['trust proxy fn']; - } - - // inherit protos - Object.setPrototypeOf(this.request, parent.request) - Object.setPrototypeOf(this.response, parent.response) - Object.setPrototypeOf(this.engines, parent.engines) - Object.setPrototypeOf(this.settings, parent.settings) - }); - - // setup locals - this.locals = Object.create(null); - - // top-most app is mounted at / - this.mountpath = '/'; - - // default locals - this.locals.settings = this.settings; - - // default configuration - this.set('view', View); - this.set('views', resolve('views')); - this.set('jsonp callback name', 'callback'); - - if (env === 'production') { - this.enable('view cache'); +class ExpressApp extends EventEmitter { + #server = null + constructor() { + super() + this.init() + this.request = {} + this.response = {} + this.mounts = {} + this.mountpath = '/' + } + /** + * Initialize the server. + * + * - setup default configuration + * - setup default middleware + * - setup route reflection methods + * + * @private + */ + + init() { + var router = null + + this.cache = {} + this.engines = {} + this.settings = {} + + this.defaultConfiguration() + + // Setup getting to lazily add base router + Object.defineProperty(this, 'router', { + configurable: true, + enumerable: true, + get: function getrouter() { + if (router === null) { + router = new Router({ + caseSensitive: this.enabled('case sensitive routing'), + strict: this.enabled('strict routing') + }) + } + + return router + } + }) } -}; -/** - * Dispatch a req, res pair into the application. Starts pipeline processing. - * - * If no callback is provided, then default error handlers will respond - * in the event of an error bubbling through the stack. - * - * @private - */ + /** + * Initialize application configuration. + * @private + */ + + defaultConfiguration() { + var env = process.env.NODE_ENV || 'development' + + // default settings + this.enable('x-powered-by') + this.set('etag', 'weak') + this.set('env', env) + this.set('query parser', 'simple') + this.set('subdomain offset', 2) + this.set('trust proxy', false) + + // trust proxy inherit back-compat + Object.defineProperty(this.settings, trustProxyDefaultSymbol, { + configurable: true, + value: true + }) + + debug('booting in %s mode', env) + + this.on('mount', function onmount(parent) { + // inherit trust proxy + if (this.settings[trustProxyDefaultSymbol] === true + && typeof parent.settings['trust proxy fn'] === 'function') { + delete this.settings['trust proxy'] + delete this.settings['trust proxy fn'] + } + }) -app.handle = function handle(req, res, callback) { - // final handler - var done = callback || finalhandler(req, res, { - env: this.get('env'), - onerror: logerror.bind(this) - }); + // setup locals + this.locals = Object.create(null) - // set powered by header - if (this.enabled('x-powered-by')) { - res.setHeader('X-Powered-By', 'Express'); - } + // top-most app is mounted at / + this.mountpath = '/' - // set circular references - req.res = res; - res.req = req; + // default locals + this.locals.settings = this.settings - // alter the prototypes - Object.setPrototypeOf(req, this.request) - Object.setPrototypeOf(res, this.response) + // default configuration + this.set('view', View) + this.set('views', resolve('views')) + this.set('jsonp callback name', 'callback') - // setup locals - if (!res.locals) { - res.locals = Object.create(null); + if (env === 'production') { + this.enable('view cache') + } } - this.router.handle(req, res, done); -}; - -/** - * Proxy `Router#use()` to add middleware to the app router. - * See Router#use() documentation for details. - * - * If the _fn_ parameter is an express app, then it will be - * mounted at the _route_ specified. - * - * @public - */ + /** + * Dispatch a req, res pair into the application. Starts pipeline processing. + * + * If no callback is provided, then default error handlers will respond + * in the event of an error bubbling through the stack. + * + * @private + */ + + handle(req, res, callback) { + res.app = this + req.app = this + req.res = res + + // final handler + var done = callback || finalhandler(req, res, { + env: this.get('env'), + onerror: this.logerror.bind(this) + }) + + // set powered by header + if (this.enabled('x-powered-by')) { + res.setHeader('X-Powered-By', 'Express') + } -app.use = function use(fn) { - var offset = 0; - var path = '/'; + // setup locals + if (!res.locals) { + res.locals = Object.create(null) + } - // default path to '/' - // disambiguate app.use([fn]) - if (typeof fn !== 'function') { - var arg = fn; + this.#mergeMethodsFromTargetToSource(this.request, req) + this.#mergeMethodsFromTargetToSource(this.response, res) - while (Array.isArray(arg) && arg.length !== 0) { - arg = arg[0]; + if(this.parent) { + this.#mergeMethodsFromTargetToSource(this.parent.request, req) + this.#mergeMethodsFromTargetToSource(this.parent.response, res) } - // first arg is the path - if (typeof arg !== 'function') { - offset = 1; - path = fn; + if(this.parent && this.mountpath === req.originalUrl) { + this.#mergeMethodsFromTargetToSource(this.request, req) + this.#mergeMethodsFromTargetToSource(this.response, res) } + this.router.handle(req, res, done) } - - var fns = Array.from(arguments).slice(offset).flat(Infinity); - - if (fns.length === 0) { - throw new TypeError('app.use() requires a middleware function') + #mergeMethodsFromTargetToSource(source, target) { + Object.keys(source).forEach(key => target[key] = source[key].bind(target)) } - - // get router - var router = this.router; - - fns.forEach(function (fn) { - // non-express app - if (!fn || !fn.handle || !fn.set) { - return router.use(path, fn); + #mergeRequestAndResponse (req, res, fn) { + if (req.originalUrl === req.baseUrl) { + this.#mergeMethodsFromTargetToSource(fn.request, req) + this.#mergeMethodsFromTargetToSource(fn.response, res) + } else { + this.#mergeMethodsFromTargetToSource(this.request, req) + this.#mergeMethodsFromTargetToSource(this.response, res) } - - debug('.use app under %s', path); - fn.mountpath = path; - fn.parent = this; - - // restore .app property on req and res - router.use(path, function mounted_app(req, res, next) { - var orig = req.app; - fn.handle(req, res, function (err) { - Object.setPrototypeOf(req, orig.request) - Object.setPrototypeOf(res, orig.response) - next(err); - }); - }); - - // mounted an app - fn.emit('mount', this); - }, this); - - return this; -}; - -/** - * Proxy to the app `Router#route()` - * Returns a new `Route` instance for the _path_. - * - * Routes are isolated middleware stacks for specific paths. - * See the Route api docs for details. - * - * @public - */ - -app.route = function route(path) { - return this.router.route(path); -}; - -/** - * Register the given template engine callback `fn` - * as `ext`. - * - * By default will `require()` the engine based on the - * file extension. For example if you try to render - * a "foo.ejs" file Express will invoke the following internally: - * - * app.engine('ejs', require('ejs').__express); - * - * For engines that do not provide `.__express` out of the box, - * or if you wish to "map" a different extension to the template engine - * you may use this method. For example mapping the EJS template engine to - * ".html" files: - * - * app.engine('html', require('ejs').renderFile); - * - * In this case EJS provides a `.renderFile()` method with - * the same signature that Express expects: `(path, options, callback)`, - * though note that it aliases this method as `ejs.__express` internally - * so if you're using ".ejs" extensions you don't need to do anything. - * - * Some template engines do not follow this convention, the - * [Consolidate.js](https://github.com/tj/consolidate.js) - * library was created to map all of node's popular template - * engines to follow this convention, thus allowing them to - * work seamlessly within Express. - * - * @param {String} ext - * @param {Function} fn - * @return {app} for chaining - * @public - */ - -app.engine = function engine(ext, fn) { - if (typeof fn !== 'function') { - throw new Error('callback function required'); } - - // get file extension - var extension = ext[0] !== '.' - ? '.' + ext - : ext; - - // store engine - this.engines[extension] = fn; - - return this; -}; - -/** - * Proxy to `Router#param()` with one added api feature. The _name_ parameter - * can be an array of names. - * - * See the Router#param() docs for more details. - * - * @param {String|Array} name - * @param {Function} fn - * @return {app} for chaining - * @public - */ - -app.param = function param(name, fn) { - if (Array.isArray(name)) { - for (var i = 0; i < name.length; i++) { - this.param(name[i], fn); + #wrapper (fn) { + return (req, res, next) => { + this.#mergeMethodsFromTargetToSource(this.request, req) + this.#mergeMethodsFromTargetToSource(this.response, res) + fn(req, res, next) } - - return this; } + /** + * Proxy `Router#use()` to add middleware to the app router. + * See Router#use() documentation for details. + * + * If the _fn_ parameter is an express app, then it will be + * mounted at the _route_ specified. + * + * @public + */ + + use(fn) { + var offset = 0; + var path = '/'; + + // default path to '/' + // disambiguate app.use([fn]) + if (typeof fn !== 'function' && !(fn instanceof ExpressApp)) { + var arg = fn; + + while (Array.isArray(arg) && arg.length !== 0) { + arg = arg[0]; + } - this.router.param(name, fn); + // first arg is the path + if (typeof arg !== 'function') { + offset = 1; + path = fn; + } + } - return this; -}; + var fns = Array.from(arguments).slice(offset).flat(Infinity); -/** - * Assign `setting` to `val`, or return `setting`'s value. - * - * app.set('foo', 'bar'); - * app.set('foo'); - * // => "bar" - * - * Mounted servers inherit their parent server's settings. - * - * @param {String} setting - * @param {*} [val] - * @return {Server} for chaining - * @public - */ + if (fns.length === 0) { + throw new TypeError('app.use() requires a middleware function') + } -app.set = function set(setting, val) { - if (arguments.length === 1) { - // app.get(setting) - return this.settings[setting]; + // get router + var router = this.router + fns.forEach(fn => { + if (fn instanceof ExpressApp) { + fn.mountpath = path + fn.parent = fn === this ? null : this + this.router.use(path, fn.handle.bind(fn)) + if(!this.mounts[path]) this.mounts[path] = new Set() + this.mounts[path].add(fn) + fn.emit('mount', this) + } else { + router.use(path, fn) + } + }) + return this } - debug('set "%s" to %o', setting, val); - - // set value - this.settings[setting] = val; - - // trigger matched settings - switch (setting) { - case 'etag': - this.set('etag fn', compileETag(val)); - break; - case 'query parser': - this.set('query parser fn', compileQueryParser(val)); - break; - case 'trust proxy': - this.set('trust proxy fn', compileTrust(val)); - - // trust proxy inherit back-compat - Object.defineProperty(this.settings, trustProxyDefaultSymbol, { - configurable: true, - value: false - }); - - break; + /** + * Proxy to the app `Router#route()` + * Returns a new `Route` instance for the _path_. + * + * Routes are isolated middleware stacks for specific paths. + * See the Route api docs for details. + * + * @public + */ + + route(path) { + return this.router.route(path) } - return this; -}; - -/** - * Return the app's absolute pathname - * based on the parent(s) that have - * mounted it. - * - * For example if the application was - * mounted as "/admin", which itself - * was mounted as "/blog" then the - * return value would be "/blog/admin". - * - * @return {String} - * @private - */ - -app.path = function path() { - return this.parent - ? this.parent.path() + this.mountpath - : ''; -}; - -/** - * Check if `setting` is enabled (truthy). - * - * app.enabled('foo') - * // => false - * - * app.enable('foo') - * app.enabled('foo') - * // => true - * - * @param {String} setting - * @return {Boolean} - * @public - */ - -app.enabled = function enabled(setting) { - return Boolean(this.set(setting)); -}; - -/** - * Check if `setting` is disabled. - * - * app.disabled('foo') - * // => true - * - * app.enable('foo') - * app.disabled('foo') - * // => false - * - * @param {String} setting - * @return {Boolean} - * @public - */ - -app.disabled = function disabled(setting) { - return !this.set(setting); -}; - -/** - * Enable `setting`. - * - * @param {String} setting - * @return {app} for chaining - * @public - */ + /** + * Register the given template engine callback `fn` + * as `ext`. + * + * By default will `require()` the engine based on the + * file extension. For example if you try to render + * a "foo.ejs" file Express will invoke the following internally: + * + * app.engine('ejs', require('ejs').__express) + * + * For engines that do not provide `.__express` out of the box, + * or if you wish to "map" a different extension to the template engine + * you may use this method. For example mapping the EJS template engine to + * ".html" files: + * + * app.engine('html', require('ejs').renderFile) + * + * In this case EJS provides a `.renderFile()` method with + * the same signature that Express expects: `(path, options, callback)`, + * though note that it aliases this method as `ejs.__express` internally + * so if you're using ".ejs" extensions you don't need to do anything. + * + * Some template engines do not follow this convention, the + * [Consolidate.js](https://github.com/tj/consolidate.js) + * library was created to map all of node's popular template + * engines to follow this convention, thus allowing them to + * work seamlessly within Express. + * + * @param {String} ext + * @param {Function} fn + * @return {app} for chaining + * @public + */ + + engine(ext, fn) { + if (typeof fn !== 'function') { + throw new Error('callback function required') + } -app.enable = function enable(setting) { - return this.set(setting, true); -}; + // get file extension + var extension = ext[0] !== '.' + ? '.' + ext + : ext -/** - * Disable `setting`. - * - * @param {String} setting - * @return {app} for chaining - * @public - */ + // store engine + this.engines[extension] = fn -app.disable = function disable(setting) { - return this.set(setting, false); -}; - -/** - * Delegate `.VERB(...)` calls to `router.VERB(...)`. - */ + return this + } -methods.forEach(function(method){ - app[method] = function(path){ - if (method === 'get' && arguments.length === 1) { - // app.get(setting) - return this.set(path); + /** + * Proxy to `Router#param()` with one added api feature. The _name_ parameter + * can be an array of names. + * + * See the Router#param() docs for more details. + * + * @param {String|Array} name + * @param {Function} fn + * @return {app} for chaining + * @public + */ + + param(name, fn) { + if (Array.isArray(name)) { + for (var i = 0; i < name.length; i++) { + this.param(name[i], fn) + } + return this } + this.router.param(name, fn) + return this + } - var route = this.route(path); - route[method].apply(route, slice.call(arguments, 1)); - return this; - }; -}); - -/** - * Special-cased "all" method, applying the given route `path`, - * middleware, and callback to _every_ HTTP method. - * - * @param {String} path - * @param {Function} ... - * @return {app} for chaining - * @public - */ + /** + * Assign `setting` to `val`, or return `setting`'s value. + * + * app.set('foo', 'bar') + * app.set('foo') + * // => "bar" + * + * Mounted servers inherit their parent server's settings. + * + * @param {String} setting + * @param {*} [val] + * @return {Server} for chaining + * @public + */ + + set(setting, val) { + // Sat, Jan 6, 2024 - from jguerra + // Sometimes, multiple apps may share settings, but inherit settings + // I don't know the exact scenario yet, but this is a workaround for now + const app = this.parent ? (!setting.includes('trust proxy') || !this.settings[setting] ? this.parent : this) : this + if (arguments.length === 1) { + if (this.settings[setting]) { + return this.settings[setting] + } else if (this.parent) { + return this.parent.settings[setting] + } else { + return this.settings[setting] + } + } -app.all = function all(path) { - var route = this.route(path); - var args = slice.call(arguments, 1); + debug('set "%s" to %o', setting, val) + + // set value + app.settings[setting] = val + + // trigger matched settings + switch (setting) { + case 'etag': + app.set('etag fn', compileETag(val)) + break + case 'query parser': + app.set('query parser fn', compileQueryParser(val)) + break + case 'trust proxy': + // child should not use parent's trust proxy setting + this.set('trust proxy fn', compileTrust(val)) + + // trust proxy inherit back-compat + Object.defineProperty(this.settings, trustProxyDefaultSymbol, { + configurable: true, + value: false + }) + break + } + return this + } - for (var i = 0; i < methods.length; i++) { - route[methods[i]].apply(route, args); + /** + * Return the app's absolute pathname + * based on the parent(s) that have + * mounted it. + * + * For example if the application was + * mounted as "/admin", which itself + * was mounted as "/blog" then the + * return value would be "/blog/admin". + * + * @return {String} + * @private + */ + + path() { + return this.parent + ? this.parent.path() + this.mountpath + : '' } - return this; -}; + /** + * Check if `setting` is enabled (truthy). + * + * app.enabled('foo') + * // => false + * + * app.enable('foo') + * app.enabled('foo') + * // => true + * + * @param {String} setting + * @return {Boolean} + * @public + */ + + enabled(setting) { + return Boolean(this.set(setting)) + } -/** - * Render the given view `name` name with `options` - * and a callback accepting an error and the - * rendered template string. - * - * Example: - * - * app.render('email', { name: 'Tobi' }, function(err, html){ - * // ... - * }) - * - * @param {String} name - * @param {Object|Function} options or fn - * @param {Function} callback - * @public - */ + /** + * Check if `setting` is disabled. + * + * app.disabled('foo') + * // => true + * + * app.enable('foo') + * app.disabled('foo') + * // => false + * + * @param {String} setting + * @return {Boolean} + * @public + */ + + disabled(setting) { + return !this.set(setting) + } -app.render = function render(name, options, callback) { - var cache = this.cache; - var done = callback; - var engines = this.engines; - var opts = options; - var renderOptions = {}; - var view; + /** + * Enable `setting`. + * + * @param {String} setting + * @return {app} for chaining + * @public + */ - // support callback function as second arg - if (typeof options === 'function') { - done = options; - opts = {}; + enable(setting) { + return this.set(setting, true) } - // merge app.locals - merge(renderOptions, this.locals); + /** + * Disable `setting`. + * + * @param {String} setting + * @return {app} for chaining + * @public + */ - // merge options._locals - if (opts._locals) { - merge(renderOptions, opts._locals); + disable(setting) { + return this.set(setting, false) } - // merge options - merge(renderOptions, opts); + // Explicitly defining http method registration to make code more transparent; less opaque. + get(...args) { + if (args.length === 1) { + return this.set(args.shift()) + } - // set .cache unless explicitly provided - if (renderOptions.cache == null) { - renderOptions.cache = this.enabled('view cache'); + var route = this.route(args.shift()) + route.get(...args) + return this + } + post(...args) { + var route = this.route(args.shift()) + route.post(...args) + return this + } + put(...args) { + var route = this.route(args.shift()) + route.put(...args) + return this + } + delete(...args) { + var route = this.route(args.shift()) + route.delete(...args) + return this + } + patch(...args) { + var route = this.route(args.shift()) + route.patch(...args) + return this + } + head(...args) { + var route = this.route(args.shift()) + route.head(...args) + return this + } + options(...args) { + var route = this.route(args.shift()) + route.options(...args) + return this } - // primed cache - if (renderOptions.cache) { - view = cache[name]; + /** + * Special-cased "all" method, applying the given route `path`, + * middleware, and callback to _every_ HTTP method. + * + * @param {String} path + * @param {Function} ... + * @return {app} for chaining + * @public + */ + all(...args) { + var route = this.route(args.shift()) + for (var i = 0; i < methods.length; i++) { + route[methods[i]].apply(route, args) + } + return this + } + delete(...args) { + var route = this.route(args.shift()) + route.delete(...args) + return this + } + checkout(...args) { + var route = this.route(args.shift()) + route.checkout(...args) + return this + } + copy(...args) { + var route = this.route(args.shift()) + route.copy(...args) + return this + } + lock(...args) { + var route = this.route(args.shift()) + route.lock(...args) + return this + } + merge(...args) { + var route = this.route(args.shift()) + route.merge(...args) + return this + } + mkactivity(...args) { + var route = this.route(args.shift()) + route.mkactivity(...args) + return this + } + mkcol(...args) { + var route = this.route(args.shift()) + route.mkcol(...args) + return this + } + move(...args) { + var route = this.route(args.shift()) + route.move(...args) + return this + } + 'm-search'(...args) { + var route = this.route(args.shift()) + route.m-search(...args) + return this + } + notify(...args) { + var route = this.route(args.shift()) + route.notify(...args) + return this + } + propfind(...args) { + var route = this.route(args.shift()) + route.propfind(...args) + return this + } + proppatch(...args) { + var route = this.route(args.shift()) + route.proppatch(...args) + return this + } + purge(...args) { + var route = this.route(args.shift()) + route.purge(...args) + return this + } + report(...args) { + var route = this.route(args.shift()) + route.report(...args) + return this + } + search(...args) { + var route = this.route(args.shift()) + route.search(...args) + return this + } + subscribe(...args) { + var route = this.route(args.shift()) + route.subscribe(...args) + return this + } + trace(...args) { + var route = this.route(args.shift()) + route.trace(...args) + return this + } + unlock(...args) { + var route = this.route(args.shift()) + route.unlock(...args) + return this + } + unsubscribe(...args) { + var route = this.route(args.shift()) + route.unsubscribe(...args) + return this + } + acl(...args) { + var route = this.route(args.shift()) + route.acl(...args) + return this + } + link(...args) { + var route = this.route(args.shift()) + route.link(...args) + return this + } + unlink(...args) { + var route = this.route(args.shift()) + route.unlink(...args) + return this + } + source(...args) { + var route = this.route(args.shift()) + route.source(...args) + return this + } + rebind(...args) { + var route = this.route(args.shift()) + route.rebind(...args) + return this + } + mkcalendar(...args) { + var route = this.route(args.shift()) + route.mkcalendar(...args) + return this + } + 'm-search'(...args) { + var route = this.route(args.shift()) + route['m-search'](...args) + return this + } + bind(...args) { + var route = this.route(args.shift()) + route.bind(...args) + return this + } + unbind(...args) { + var route = this.route(args.shift()) + route.unbind(...args) + return this } - // view - if (!view) { - var View = this.get('view'); + /** + * Render the given view `name` name with `options` + * and a callback accepting an error and the + * rendered template string. + * + * Example: + * + * app.render('email', { name: 'Tobi' }, function(err, html){ + * // ... + * }) + * + * @param {String} name + * @param {Object|Function} options or fn + * @param {Function} callback + * @public + */ + + render(name, options, callback) { + var cache = this.cache + var done = callback + var engines = this.engines + var opts = options + var renderOptions = {} + var view + + // support callback function as second arg + if (typeof options === 'function') { + done = options + opts = {} + } - view = new View(name, { - defaultEngine: this.get('view engine'), - root: this.get('views'), - engines: engines - }); + // merge app.locals + merge(renderOptions, this.locals) - if (!view.path) { - var dirs = Array.isArray(view.root) && view.root.length > 1 - ? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"' - : 'directory "' + view.root + '"' - var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs); - err.view = view; - return done(err); + // merge options._locals + if (opts._locals) { + merge(renderOptions, opts._locals) } - // prime the cache - if (renderOptions.cache) { - cache[name] = view; + // merge options + merge(renderOptions, opts) + + // set .cache unless explicitly provided + if (renderOptions.cache == null) { + renderOptions.cache = this.enabled('view cache') } - } - // render - tryRender(view, renderOptions, done); -}; + // primed cache + if (renderOptions.cache) { + view = cache[name] + } -/** - * Listen for connections. - * - * A node `http.Server` is returned, with this - * application (which is a `Function`) as its - * callback. If you wish to create both an HTTP - * and HTTPS server you may do so with the "http" - * and "https" modules as shown here: - * - * var http = require('http') - * , https = require('https') - * , express = require('express') - * , app = express(); - * - * http.createServer(app).listen(80); - * https.createServer({ ... }, app).listen(443); - * - * @return {http.Server} - * @public - */ + // view + if (!view) { + var View = this.get('view') + + view = new View(name, { + defaultEngine: this.get('view engine'), + root: this.get('views'), + engines: engines + }) + + if (!view.path) { + var dirs = Array.isArray(view.root) && view.root.length > 1 + ? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"' + : 'directory "' + view.root + '"' + var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs) + err.view = view + return done(err) + } -app.listen = function listen() { - var server = http.createServer(this); - return server.listen.apply(server, arguments); -}; + // prime the cache + if (renderOptions.cache) { + cache[name] = view + } + } -/** - * Log error using console.error. - * - * @param {Error} err - * @private - */ + // render + tryRender(view, renderOptions, done) + } -function logerror(err) { - /* istanbul ignore next */ - if (this.get('env') !== 'test') console.error(err.stack || err.toString()); -} + /** + * Listen for connections. + * + * A node `http.Server` is returned. This + * application is no longer a `Function` so the registered + * callback is the `handle` method, `bound` to `this`. + * If you wish to create both an HTTP + * and HTTPS server you may do so with the "http" + * and "https" modules as shown here: + * + * var http = require('http') + * , https = require('https') + * , express = require('express') + * , app = express() + * + * http.createServer({ IncomingMessage: ExpressRequest, ServerResponse: ExpressResponse }, this.handle.bind(this)).listen(80) + * https.createServer({ IncomingMessage: ExpressRequest, ServerResponse: ExpressResponse }, this.handle.bind(this)).listen(443) + * + * If you want to create an http2 server, currently, you would have to create custom classes that extend the http2.IncomingMessage and http2.ServerResponse classes. Then you would pass those classes into + * and implement the Decorator pattern, composing instances of ExpressRequest and ExpressResponse, and passing those into the http2.createSecureServer as options. See the http2 documentation for more information. + * @return {http.Server} + * @public + */ + + listen(...args) { + this.#server = http.createServer({ IncomingMessage: ExpressRequest, ServerResponse: ExpressResponse }, this.handle.bind(this)) + return this.#server.listen(...args) + } + address () { + return this.#server?.address() ?? null + } -/** - * Try rendering a view. - * @private - */ + /** + * Log error using console.error. + * + * @param {Error} err + * @private + */ -function tryRender(view, options, callback) { - try { - view.render(options, callback); - } catch (err) { - callback(err); + logerror(err) { + /* istanbul ignore next */ + if (this.get('env') !== 'test') console.error(err.stack || err.toString()) } } +module.exports = ExpressApp diff --git a/lib/express.js b/lib/express.js index b4ef299636..334e5750dd 100644 --- a/lib/express.js +++ b/lib/express.js @@ -13,18 +13,14 @@ */ var bodyParser = require('body-parser') -var EventEmitter = require('events').EventEmitter; -var mixin = require('merge-descriptors'); -var proto = require('./application'); -var Router = require('router'); -var req = require('./request'); -var res = require('./response'); +var ExpressApp = require('./application') +var Router = require('./router') /** * Expose `createApplication()`. */ -exports = module.exports = createApplication; +exports = module.exports = createApplication /** * Create an express application. @@ -33,36 +29,10 @@ exports = module.exports = createApplication; * @api public */ -function createApplication() { - var app = function(req, res, next) { - app.handle(req, res, next); - }; - - mixin(app, EventEmitter.prototype, false); - mixin(app, proto, false); - - // expose the prototype that will get set on requests - app.request = Object.create(req, { - app: { configurable: true, enumerable: true, writable: true, value: app } - }) - - // expose the prototype that will get set on responses - app.response = Object.create(res, { - app: { configurable: true, enumerable: true, writable: true, value: app } - }) - - app.init(); - return app; +function createApplication(app) { + return app ? new app() : new ExpressApp() } -/** - * Expose the prototypes. - */ - -exports.application = proto; -exports.request = req; -exports.response = res; - /** * Expose constructors. */ @@ -76,6 +46,6 @@ exports.Router = Router; exports.json = bodyParser.json exports.raw = bodyParser.raw -exports.static = require('serve-static'); +exports.static = require('serve-static') exports.text = bodyParser.text exports.urlencoded = bodyParser.urlencoded diff --git a/lib/layer.js b/lib/layer.js new file mode 100644 index 0000000000..f60080515f --- /dev/null +++ b/lib/layer.js @@ -0,0 +1,215 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +var { pathToRegexp } = require('path-to-regexp') + +/** + * Module variables. + * @private + */ + +var hasOwnProperty = Object.prototype.hasOwnProperty +var TRAILING_SLASH_REGEXP = /\/+$/ + +/** + * Expose `Layer`. + */ + +module.exports = Layer + +function Layer (path, options, fn) { + if (!(this instanceof Layer)) { + return new Layer(path, options, fn) + } + + var opts = options || {} + + this.handle = fn + this.keys = [] + this.name = fn.name || '' + this.params = undefined + this.path = undefined + this.regexp = pathToRegexp((opts.strict ? path : loosen(path)), this.keys, opts) + + // set fast path flags + this.regexp._slash = path === '/' && opts.end === false +} + +/** + * Handle the error for the layer. + * + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {function} next + * @api private + */ + +Layer.prototype.handleError = function handleError (error, req, res, next) { + var fn = this.handle + + if (fn.length !== 4) { + // not a standard error handler + return next(error) + } + + try { + // invoke function + var ret = fn(error, req, res, next) + + // wait for returned promise + if (isPromise(ret)) { + ret.then(null, function (error) { + next(error || new Error('Rejected promise')) + }) + } + } catch (err) { + next(err) + } +} + +/** + * Handle the request for the layer. + * + * @param {Request} req + * @param {Response} res + * @param {function} next + * @api private + */ + +Layer.prototype.handleRequest = function handleRequest (req, res, next) { + var fn = this.handle + + if (fn.length > 3) { + // not a standard request handler + return next() + } + + try { + // invoke function + var ret = fn(req, res, next) + + // wait for returned promise + if (isPromise(ret)) { + ret.then(null, function (error) { + next(error || new Error('Rejected promise')) + }) + } + } catch (err) { + next(err) + } +} + +/** + * Check if this route matches `path`, if so + * populate `.params`. + * + * @param {String} path + * @return {Boolean} + * @api private + */ + +Layer.prototype.match = function match (path) { + var match + + if (path != null) { + // fast path non-ending match for / (any path matches) + if (this.regexp._slash) { + this.params = {} + this.path = '' + return true + } + + // match the path + match = this.regexp.exec(path) + } + + if (!match) { + this.params = undefined + this.path = undefined + return false + } + + // store values + this.params = {} + this.path = match[0] + + // iterate matches + var keys = this.keys + var params = this.params + + for (var i = 1; i < match.length; i++) { + var key = keys[i - 1] + var prop = key.name + var val = decodeParam(match[i]) + + if (val !== undefined || !(hasOwnProperty.call(params, prop))) { + params[prop] = val + } + } + + return true +} + +/** + * Decode param value. + * + * @param {string} val + * @return {string} + * @private + */ + +function decodeParam (val) { + if (typeof val !== 'string' || val.length === 0) { + return val + } + + try { + return decodeURIComponent(val) + } catch (err) { + if (err instanceof URIError) { + err.message = 'Failed to decode param \'' + val + '\'' + err.status = 400 + } + + throw err + } +} + +/** + * Returns true if the val is a Promise. + * + * @param {*} val + * @return {boolean} + * @private + */ + +function isPromise (val) { + return val && + typeof val === 'object' && + typeof val.then === 'function' +} + +/** + * Loosens the given path for path-to-regexp matching. + */ +function loosen (path) { + if (path instanceof RegExp) { + return path + } + + return Array.isArray(path) + ? path.map(function (p) { return loosen(p) }) + : String(path).replace(TRAILING_SLASH_REGEXP, '') +} diff --git a/lib/request.js b/lib/request.js index c528186aa1..f513219172 100644 --- a/lib/request.js +++ b/lib/request.js @@ -6,510 +6,497 @@ * MIT Licensed */ -'use strict'; +'use strict' /** * Module dependencies. * @private */ -var accepts = require('accepts'); -var isIP = require('net').isIP; -var typeis = require('type-is'); -var http = require('http'); -var fresh = require('fresh'); -var parseRange = require('range-parser'); -var parse = require('parseurl'); -var proxyaddr = require('proxy-addr'); +var accepts = require('accepts') +var isIP = require('net').isIP +var typeis = require('type-is') +var http = require('http') +var fresh = require('fresh') +var parseRange = require('range-parser') +var parse = require('parseurl') +var proxyaddr = require('proxy-addr') /** * Request prototype. * @public */ +class ExpressRequest extends http.IncomingMessage { + constructor(socket) { + super(socket) + this.app = null + this.res = null + this.ctx = null + } -var req = Object.create(http.IncomingMessage.prototype) - -/** - * Module exports. - * @public - */ + /** + * Return request header. + * + * The `Referrer` header field is special-cased, + * both `Referrer` and `Referer` are interchangeable. + * + * Examples: + * + * req.get('Content-Type') + * // => "text/plain" + * + * req.get('content-type') + * // => "text/plain" + * + * req.get('Something') + * // => undefined + * + * Aliased as `req.header()`. + * + * @param {String} name + * @return {String} + * @public + */ + + get(name) { + return this.header(name) + } + header(name) { + if (!name) { + throw new TypeError('name argument is required to req.get') + } -module.exports = req + if (typeof name !== 'string') { + throw new TypeError('name must be a string to req.get') + } -/** - * Return request header. - * - * The `Referrer` header field is special-cased, - * both `Referrer` and `Referer` are interchangeable. - * - * Examples: - * - * req.get('Content-Type'); - * // => "text/plain" - * - * req.get('content-type'); - * // => "text/plain" - * - * req.get('Something'); - * // => undefined - * - * Aliased as `req.header()`. - * - * @param {String} name - * @return {String} - * @public - */ + var lc = name.toLowerCase() -req.get = -req.header = function header(name) { - if (!name) { - throw new TypeError('name argument is required to req.get'); + switch (lc) { + case 'referer': + case 'referrer': + return this.headers.referrer + || this.headers.referer + default: + return this.headers[lc] + } } - if (typeof name !== 'string') { - throw new TypeError('name must be a string to req.get'); + /** + * To do: update docs. + * + * Check if the given `type(s)` is acceptable, returning + * the best match when true, otherwise `undefined`, in which + * case you should respond with 406 "Not Acceptable". + * + * The `type` value may be a single MIME type string + * such as "application/json", an extension name + * such as "json", a comma-delimited list such as "json, html, text/plain", + * an argument list such as `"json", "html", "text/plain"`, + * or an array `["json", "html", "text/plain"]`. When a list + * or array is given, the _best_ match, if any is returned. + * + * Examples: + * + * // Accept: text/html + * req.accepts('html') + * // => "html" + * + * // Accept: text/*, application/json + * req.accepts('html') + * // => "html" + * req.accepts('text/html') + * // => "text/html" + * req.accepts('json, text') + * // => "json" + * req.accepts('application/json') + * // => "application/json" + * + * // Accept: text/*, application/json + * req.accepts('image/png') + * req.accepts('png') + * // => undefined + * + * // Accept: text/*q=.5, application/json + * req.accepts(['html', 'json']) + * req.accepts('html', 'json') + * req.accepts('html, json') + * // => "json" + * + * @param {String|Array} type(s) + * @return {String|Array|Boolean} + * @public + */ + + accepts(){ + var accept = accepts(this) + return accept.types.apply(accept, arguments) } - var lc = name.toLowerCase(); - - switch (lc) { - case 'referer': - case 'referrer': - return this.headers.referrer - || this.headers.referer; - default: - return this.headers[lc]; + /** + * Check if the given `encoding`s are accepted. + * + * @param {String} ...encoding + * @return {String|Array} + * @public + */ + + acceptsEncodings(){ + var accept = accepts(this) + return accept.encodings.apply(accept, arguments) } -}; -/** - * To do: update docs. - * - * Check if the given `type(s)` is acceptable, returning - * the best match when true, otherwise `undefined`, in which - * case you should respond with 406 "Not Acceptable". - * - * The `type` value may be a single MIME type string - * such as "application/json", an extension name - * such as "json", a comma-delimited list such as "json, html, text/plain", - * an argument list such as `"json", "html", "text/plain"`, - * or an array `["json", "html", "text/plain"]`. When a list - * or array is given, the _best_ match, if any is returned. - * - * Examples: - * - * // Accept: text/html - * req.accepts('html'); - * // => "html" - * - * // Accept: text/*, application/json - * req.accepts('html'); - * // => "html" - * req.accepts('text/html'); - * // => "text/html" - * req.accepts('json, text'); - * // => "json" - * req.accepts('application/json'); - * // => "application/json" - * - * // Accept: text/*, application/json - * req.accepts('image/png'); - * req.accepts('png'); - * // => undefined - * - * // Accept: text/*;q=.5, application/json - * req.accepts(['html', 'json']); - * req.accepts('html', 'json'); - * req.accepts('html, json'); - * // => "json" - * - * @param {String|Array} type(s) - * @return {String|Array|Boolean} - * @public - */ - -req.accepts = function(){ - var accept = accepts(this); - return accept.types.apply(accept, arguments); -}; - -/** - * Check if the given `encoding`s are accepted. - * - * @param {String} ...encoding - * @return {String|Array} - * @public - */ - -req.acceptsEncodings = function(){ - var accept = accepts(this); - return accept.encodings.apply(accept, arguments); -}; - -/** - * Check if the given `charset`s are acceptable, - * otherwise you should respond with 406 "Not Acceptable". - * - * @param {String} ...charset - * @return {String|Array} - * @public - */ - -req.acceptsCharsets = function(){ - var accept = accepts(this); - return accept.charsets.apply(accept, arguments); -}; - -/** - * Check if the given `lang`s are acceptable, - * otherwise you should respond with 406 "Not Acceptable". - * - * @param {String} ...lang - * @return {String|Array} - * @public - */ - -req.acceptsLanguages = function(){ - var accept = accepts(this); - return accept.languages.apply(accept, arguments); -}; - -/** - * Parse Range header field, capping to the given `size`. - * - * Unspecified ranges such as "0-" require knowledge of your resource length. In - * the case of a byte range this is of course the total number of bytes. If the - * Range header field is not given `undefined` is returned, `-1` when unsatisfiable, - * and `-2` when syntactically invalid. - * - * When ranges are returned, the array has a "type" property which is the type of - * range that is required (most commonly, "bytes"). Each array element is an object - * with a "start" and "end" property for the portion of the range. - * - * The "combine" option can be set to `true` and overlapping & adjacent ranges - * will be combined into a single range. - * - * NOTE: remember that ranges are inclusive, so for example "Range: users=0-3" - * should respond with 4 users when available, not 3. - * - * @param {number} size - * @param {object} [options] - * @param {boolean} [options.combine=false] - * @return {number|array} - * @public - */ - -req.range = function range(size, options) { - var range = this.get('Range'); - if (!range) return; - return parseRange(size, range, options); -}; - -/** - * Parse the query string of `req.url`. - * - * This uses the "query parser" setting to parse the raw - * string into an object. - * - * @return {String} - * @api public - */ - -defineGetter(req, 'query', function query(){ - var queryparse = this.app.get('query parser fn'); - - if (!queryparse) { - // parsing is disabled - return Object.create(null); + /** + * Check if the given `charset`s are acceptable, + * otherwise you should respond with 406 "Not Acceptable". + * + * @param {String} ...charset + * @return {String|Array} + * @public + */ + + acceptsCharsets(){ + var accept = accepts(this) + return accept.charsets.apply(accept, arguments) } - var querystring = parse(this).query; - - return queryparse(querystring); -}); - -/** - * Check if the incoming request contains the "Content-Type" - * header field, and it contains the given mime `type`. - * - * Examples: - * - * // With Content-Type: text/html; charset=utf-8 - * req.is('html'); - * req.is('text/html'); - * req.is('text/*'); - * // => true - * - * // When Content-Type is application/json - * req.is('json'); - * req.is('application/json'); - * req.is('application/*'); - * // => true - * - * req.is('html'); - * // => false - * - * @param {String|Array} types... - * @return {String|false|null} - * @public - */ - -req.is = function is(types) { - var arr = types; - - // support flattened arguments - if (!Array.isArray(types)) { - arr = new Array(arguments.length); - for (var i = 0; i < arr.length; i++) { - arr[i] = arguments[i]; - } + /** + * Check if the given `lang`s are acceptable, + * otherwise you should respond with 406 "Not Acceptable". + * + * @param {String} ...lang + * @return {String|Array} + * @public + */ + + acceptsLanguages(){ + var accept = accepts(this) + return accept.languages.apply(accept, arguments) } - return typeis(this, arr); -}; - -/** - * Return the protocol string "http" or "https" - * when requested with TLS. When the "trust proxy" - * setting trusts the socket address, the - * "X-Forwarded-Proto" header field will be trusted - * and used if present. - * - * If you're running behind a reverse proxy that - * supplies https for you this may be enabled. - * - * @return {String} - * @public - */ - -defineGetter(req, 'protocol', function protocol(){ - var proto = this.connection.encrypted - ? 'https' - : 'http'; - var trust = this.app.get('trust proxy fn'); - - if (!trust(this.connection.remoteAddress, 0)) { - return proto; + /** + * Parse Range header field, capping to the given `size`. + * + * Unspecified ranges such as "0-" require knowledge of your resource length. In + * the case of a byte range this is of course the total number of bytes. If the + * Range header field is not given `undefined` is returned, `-1` when unsatisfiable, + * and `-2` when syntactically invalid. + * + * When ranges are returned, the array has a "type" property which is the type of + * range that is required (most commonly, "bytes"). Each array element is an object + * with a "start" and "end" property for the portion of the range. + * + * The "combine" option can be set to `true` and overlapping & adjacent ranges + * will be combined into a single range. + * + * NOTE: remember that ranges are inclusive, so for example "Range: users=0-3" + * should respond with 4 users when available, not 3. + * + * @param {number} size + * @param {object} [options] + * @param {boolean} [options.combine=false] + * @return {number|array} + * @public + */ + + range(size, options) { + var range = this.get('Range') + if (!range) return + return parseRange(size, range, options) } - // Note: X-Forwarded-Proto is normally only ever a - // single value, but this is to be safe. - var header = this.get('X-Forwarded-Proto') || proto - var index = header.indexOf(',') - - return index !== -1 - ? header.substring(0, index).trim() - : header.trim() -}); - -/** - * Short-hand for: - * - * req.protocol === 'https' - * - * @return {Boolean} - * @public - */ - -defineGetter(req, 'secure', function secure(){ - return this.protocol === 'https'; -}); - -/** - * Return the remote address from the trusted proxy. - * - * The is the remote address on the socket unless - * "trust proxy" is set. - * - * @return {String} - * @public - */ - -defineGetter(req, 'ip', function ip(){ - var trust = this.app.get('trust proxy fn'); - return proxyaddr(this, trust); -}); - -/** - * When "trust proxy" is set, trusted proxy addresses + client. - * - * For example if the value were "client, proxy1, proxy2" - * you would receive the array `["client", "proxy1", "proxy2"]` - * where "proxy2" is the furthest down-stream and "proxy1" and - * "proxy2" were trusted. - * - * @return {Array} - * @public - */ - -defineGetter(req, 'ips', function ips() { - var trust = this.app.get('trust proxy fn'); - var addrs = proxyaddr.all(this, trust); - - // reverse the order (to farthest -> closest) - // and remove socket address - addrs.reverse().pop() - - return addrs -}); - -/** - * Return subdomains as an array. - * - * Subdomains are the dot-separated parts of the host before the main domain of - * the app. By default, the domain of the app is assumed to be the last two - * parts of the host. This can be changed by setting "subdomain offset". - * - * For example, if the domain is "tobi.ferrets.example.com": - * If "subdomain offset" is not set, req.subdomains is `["ferrets", "tobi"]`. - * If "subdomain offset" is 3, req.subdomains is `["tobi"]`. - * - * @return {Array} - * @public - */ - -defineGetter(req, 'subdomains', function subdomains() { - var hostname = this.hostname; - - if (!hostname) return []; - - var offset = this.app.get('subdomain offset'); - var subdomains = !isIP(hostname) - ? hostname.split('.').reverse() - : [hostname]; + /** + * Parse the query string of `req.url`. + * + * This uses the "query parser" setting to parse the raw + * string into an object. + * + * @return {String} + * @api public + */ + + get query(){ + var queryparse = this.app.get('query parser fn') + + if (!queryparse) { + // parsing is disabled + return Object.create(null) + } - return subdomains.slice(offset); -}); + var querystring = parse(this).query -/** - * Short-hand for `url.parse(req.url).pathname`. - * - * @return {String} - * @public - */ + return queryparse(querystring) + } -defineGetter(req, 'path', function path() { - return parse(this).pathname; -}); + /** + * Check if the incoming request contains the "Content-Type" + * header field, and it contains the given mime `type`. + * + * Examples: + * + * // With Content-Type: text/html charset=utf-8 + * req.is('html') + * req.is('text/html') + * req.is('text/*') + * // => true + * + * // When Content-Type is application/json + * req.is('json') + * req.is('application/json') + * req.is('application/*') + * // => true + * + * req.is('html') + * // => false + * + * @param {String|Array} types... + * @return {String|false|null} + * @public + */ + + is(types) { + var arr = types + + // support flattened arguments + if (!Array.isArray(types)) { + arr = new Array(arguments.length) + for (var i = 0; i < arr.length; i++) { + arr[i] = arguments[i] + } + } -/** - * Parse the "Host" header field to a host. - * - * When the "trust proxy" setting trusts the socket - * address, the "X-Forwarded-Host" header field will - * be trusted. - * - * @return {String} - * @public - */ + return typeis(this, arr) + } -defineGetter(req, 'host', function host(){ - var trust = this.app.get('trust proxy fn'); - var val = this.get('X-Forwarded-Host'); + /** + * Return the protocol string "http" or "https" + * when requested with TLS. When the "trust proxy" + * setting trusts the socket address, the + * "X-Forwarded-Proto" header field will be trusted + * and used if present. + * + * If you're running behind a reverse proxy that + * supplies https for you this may be enabled. + * + * @return {String} + * @public + */ + + get protocol(){ + var proto = this.connection.encrypted + ? 'https' + : 'http' + var trust = this.app.get('trust proxy fn') + + if (!trust(this.connection.remoteAddress, 0)) { + return proto + } - if (!val || !trust(this.connection.remoteAddress, 0)) { - val = this.get('Host'); - } else if (val.indexOf(',') !== -1) { - // Note: X-Forwarded-Host is normally only ever a + // Note: X-Forwarded-Proto is normally only ever a // single value, but this is to be safe. - val = val.substring(0, val.indexOf(',')).trimRight() - } + var header = this.get('X-Forwarded-Proto') || proto + var index = header.indexOf(',') - return val || undefined; -}); - -/** - * Parse the "Host" header field to a hostname. - * - * When the "trust proxy" setting trusts the socket - * address, the "X-Forwarded-Host" header field will - * be trusted. - * - * @return {String} - * @api public - */ + return index !== -1 + ? header.substring(0, index).trim() + : header.trim() + } -defineGetter(req, 'hostname', function hostname(){ - var host = this.host; + /** + * Short-hand for: + * + * req.protocol === 'https' + * + * @return {Boolean} + * @public + */ + + get secure(){ + return this.protocol === 'https' + } - if (!host) return; + /** + * Return the remote address from the trusted proxy. + * + * The is the remote address on the socket unless + * "trust proxy" is set. + * + * @return {String} + * @public + */ + + get ip(){ + var trust = this.app.get('trust proxy fn') + return proxyaddr(this, trust) + } - // IPv6 literal support - var offset = host[0] === '[' - ? host.indexOf(']') + 1 - : 0; - var index = host.indexOf(':', offset); + /** + * When "trust proxy" is set, trusted proxy addresses + client. + * + * For example if the value were "client, proxy1, proxy2" + * you would receive the array `["client", "proxy1", "proxy2"]` + * where "proxy2" is the furthest down-stream and "proxy1" and + * "proxy2" were trusted. + * + * @return {Array} + * @public + */ + + get ips() { + var trust = this.app.get('trust proxy fn') + var addrs = proxyaddr.all(this, trust) + + // reverse the order (to farthest -> closest) + // and remove socket address + addrs.reverse().pop() + + return addrs + } - return index !== -1 - ? host.substring(0, index) - : host; -}); + /** + * Return subdomains as an array. + * + * Subdomains are the dot-separated parts of the host before the main domain of + * the app. By default, the domain of the app is assumed to be the last two + * parts of the host. This can be changed by setting "subdomain offset". + * + * For example, if the domain is "tobi.ferrets.example.com": + * If "subdomain offset" is not set, req.subdomains is `["ferrets", "tobi"]`. + * If "subdomain offset" is 3, req.subdomains is `["tobi"]`. + * + * @return {Array} + * @public + */ + + get subdomains() { + var hostname = this.hostname + + if (!hostname) return [] + + var offset = this.app.get('subdomain offset') + var subdomains = !isIP(hostname) + ? hostname.split('.').reverse() + : [hostname] + + return subdomains.slice(offset) + } -/** - * Check if the request is fresh, aka - * Last-Modified and/or the ETag - * still match. - * - * @return {Boolean} - * @public - */ + /** + * Short-hand for `url.parse(req.url).pathname`. + * + * @return {String} + * @public + */ -defineGetter(req, 'fresh', function(){ - var method = this.method; - var res = this.res - var status = res.statusCode + get path() { + return parse(this).pathname + } - // GET or HEAD for weak freshness validation only - if ('GET' !== method && 'HEAD' !== method) return false; + /** + * Parse the "Host" header field to a host. + * + * When the "trust proxy" setting trusts the socket + * address, the "X-Forwarded-Host" header field will + * be trusted. + * + * @return {String} + * @public + */ + + get host(){ + var trust = this.app.get('trust proxy fn') + var val = this.get('X-Forwarded-Host') + + if (!val || !trust(this.connection.remoteAddress, 0)) { + val = this.get('Host') + } else if (val.indexOf(',') !== -1) { + // Note: X-Forwarded-Host is normally only ever a + // single value, but this is to be safe. + val = val.substring(0, val.indexOf(',')).trimRight() + } - // 2xx or 304 as per rfc2616 14.26 - if ((status >= 200 && status < 300) || 304 === status) { - return fresh(this.headers, { - 'etag': res.get('ETag'), - 'last-modified': res.get('Last-Modified') - }) + return val || undefined } - return false; -}); + /** + * Parse the "Host" header field to a hostname. + * + * When the "trust proxy" setting trusts the socket + * address, the "X-Forwarded-Host" header field will + * be trusted. + * + * @return {String} + * @api public + */ + + get hostname(){ + var host = this.host + + if (!host) return + + // IPv6 literal support + var offset = host[0] === '[' + ? host.indexOf(']') + 1 + : 0 + var index = host.indexOf(':', offset) + + return index !== -1 + ? host.substring(0, index) + : host + } -/** - * Check if the request is stale, aka - * "Last-Modified" and / or the "ETag" for the - * resource has changed. - * - * @return {Boolean} - * @public - */ + /** + * Check if the request is fresh, aka + * Last-Modified and/or the ETag + * still match. + * + * @return {Boolean} + * @public + */ + + get fresh(){ + var method = this.method + var res = this.res + var status = res.statusCode + + // GET or HEAD for weak freshness validation only + if ('GET' !== method && 'HEAD' !== method) return false + + // 2xx or 304 as per rfc2616 14.26 + if ((status >= 200 && status < 300) || 304 === status) { + return fresh(this.headers, { + 'etag': res.get('ETag'), + 'last-modified': res.get('Last-Modified') + }) + } -defineGetter(req, 'stale', function stale(){ - return !this.fresh; -}); + return false + } -/** - * Check if the request was an _XMLHttpRequest_. - * - * @return {Boolean} - * @public - */ + /** + * Check if the request is stale, aka + * "Last-Modified" and / or the "ETag" for the + * resource has changed. + * + * @return {Boolean} + * @public + */ + + get stale(){ + return !this.fresh + } -defineGetter(req, 'xhr', function xhr(){ - var val = this.get('X-Requested-With') || ''; - return val.toLowerCase() === 'xmlhttprequest'; -}); + /** + * Check if the request was an _XMLHttpRequest_. + * + * @return {Boolean} + * @public + */ -/** - * Helper function for creating a getter on an object. - * - * @param {Object} obj - * @param {String} name - * @param {Function} getter - * @private - */ -function defineGetter(obj, name, getter) { - Object.defineProperty(obj, name, { - configurable: true, - enumerable: true, - get: getter - }); + get xhr(){ + var val = this.get('X-Requested-With') || '' + return val.toLowerCase() === 'xmlhttprequest' + } } + +module.exports = ExpressRequest diff --git a/lib/response.js b/lib/response.js index 7fc981ccd9..1eaa7a2956 100644 --- a/lib/response.js +++ b/lib/response.js @@ -5,981 +5,996 @@ * MIT Licensed */ -'use strict'; +'use strict' /** * Module dependencies. * @private */ -var contentDisposition = require('content-disposition'); -var encodeUrl = require('encodeurl'); -var escapeHtml = require('escape-html'); -var http = require('http'); -var onFinished = require('on-finished'); +var contentDisposition = require('content-disposition') +var encodeUrl = require('encodeurl') +var escapeHtml = require('escape-html') +var http = require('node:http') +var onFinished = require('on-finished') var mime = require('mime-types') -var path = require('path'); -var pathIsAbsolute = require('path-is-absolute'); +var path = require('node:path') +var pathIsAbsolute = require('path-is-absolute') var statuses = require('statuses') -var merge = require('utils-merge'); -var sign = require('cookie-signature').sign; -var normalizeType = require('./utils').normalizeType; -var normalizeTypes = require('./utils').normalizeTypes; -var setCharset = require('./utils').setCharset; -var cookie = require('cookie'); -var send = require('send'); -var extname = path.extname; -var resolve = path.resolve; -var vary = require('vary'); - -/** - * Response prototype. - * @public - */ - -var res = Object.create(http.ServerResponse.prototype) - -/** - * Module exports. - * @public - */ - -module.exports = res - -/** - * Set status `code`. - * - * @param {Number} code - * @return {ServerResponse} - * @public - */ - -res.status = function status(code) { - this.statusCode = code; - return this; -}; - -/** - * Set Link header field with the given `links`. - * - * Examples: - * - * res.links({ - * next: 'http://api.example.com/users?page=2', - * last: 'http://api.example.com/users?page=5' - * }); - * - * @param {Object} links - * @return {ServerResponse} - * @public - */ - -res.links = function(links){ - var link = this.get('Link') || ''; - if (link) link += ', '; - return this.set('Link', link + Object.keys(links).map(function(rel){ - return '<' + links[rel] + '>; rel="' + rel + '"'; - }).join(', ')); -}; - -/** - * Send a response. - * - * Examples: - * - * res.send(Buffer.from('wahoo')); - * res.send({ some: 'json' }); - * res.send('

some html

'); - * - * @param {string|number|boolean|object|Buffer} body - * @public - */ - -res.send = function send(body) { - var chunk = body; - var encoding; - var req = this.req; - var type; - - // settings - var app = this.app; - - switch (typeof chunk) { - // string defaulting to html - case 'string': - if (!this.get('Content-Type')) { - this.type('html'); - } - break; - case 'boolean': - case 'number': - case 'object': - if (chunk === null) { - chunk = ''; - } else if (Buffer.isBuffer(chunk)) { - if (!this.get('Content-Type')) { - this.type('bin'); - } - } else { - return this.json(chunk); - } - break; +var merge = require('utils-merge') +var sign = require('cookie-signature').sign +var normalizeType = require('./utils').normalizeType +var normalizeTypes = require('./utils').normalizeTypes +var setCharset = require('./utils').setCharset +var cookie = require('cookie') +var send = require('send') +var extname = path.extname +var resolve = path.resolve +var vary = require('vary') + +class HeaderSetter { + constructor(res) { + this.res = res } - - // write strings in utf-8 - if (typeof chunk === 'string') { - encoding = 'utf8'; - type = this.get('Content-Type'); - - // reflect this in content-type - if (typeof type === 'string') { - this.set('Content-Type', setCharset(type, 'utf-8')); + 'content-type'(val) { + if (Array.isArray(val)) { + throw new TypeError('Content-Type cannot be set to an Array') } - } - - // determine if ETag should be generated - var etagFn = app.get('etag fn') - var generateETag = !this.get('ETag') && typeof etagFn === 'function' - - // populate Content-Length - var len - if (chunk !== undefined) { - if (Buffer.isBuffer(chunk)) { - // get length of Buffer - len = chunk.length - } else if (!generateETag && chunk.length < 1000) { - // just calculate length when no ETag + small chunk - len = Buffer.byteLength(chunk, encoding) - } else { - // convert chunk to Buffer and calculate - chunk = Buffer.from(chunk, encoding) - encoding = undefined; - len = chunk.length + if (!mime.contentType(val)) { + throw new TypeError('Invalid content type: ' + val) } - - this.set('Content-Length', len); + this.res.setHeader('Content-Type', mime.contentType(val)) } - - // populate ETag - var etag; - if (generateETag && len !== undefined) { - if ((etag = etagFn(chunk, encoding))) { - this.set('ETag', etag); + 'string'(field, val) { + const value = Array.isArray(val) ? val.map(String) : String(val) + const method = field.toLowerCase() + if (this[method]) { + this[method](value) + return } + this.res.setHeader(field, value) } - - // freshness - if (req.fresh) this.statusCode = 304; - - // strip irrelevant headers - if (204 === this.statusCode || 304 === this.statusCode) { - this.removeHeader('Content-Type'); - this.removeHeader('Content-Length'); - this.removeHeader('Transfer-Encoding'); - chunk = ''; + 'number'(field, val) { + this['string'](field, `${val}`) } - - if (req.method === 'HEAD') { - // skip body for HEAD - this.end(); - } else { - // respond - this.end(chunk, encoding); + 'object'(field) { + Object.keys(field).forEach(key => { + this[typeof field[key]](key, field[key]) + }) } - - return this; -}; - -/** - * Send JSON response. - * - * Examples: - * - * res.json(null); - * res.json({ user: 'tj' }); - * - * @param {string|number|boolean|object} obj - * @public - */ - -res.json = function json(obj) { - // settings - var app = this.app; - var escape = app.get('json escape') - var replacer = app.get('json replacer'); - var spaces = app.get('json spaces'); - var body = stringify(obj, replacer, spaces, escape) - - // content-type - if (!this.get('Content-Type')) { - this.set('Content-Type', 'application/json'); + 'undefined'(field, val) { + throw new TypeError('header field cannot be undefined') } +} - return this.send(body); -}; - -/** - * Send JSON response with JSONP callback support. - * - * Examples: - * - * res.jsonp(null); - * res.jsonp({ user: 'tj' }); - * - * @param {string|number|boolean|object} obj - * @public - */ - -res.jsonp = function jsonp(obj) { - // settings - var app = this.app; - var escape = app.get('json escape') - var replacer = app.get('json replacer'); - var spaces = app.get('json spaces'); - var body = stringify(obj, replacer, spaces, escape) - var callback = this.req.query[app.get('jsonp callback name')]; - - // content-type - if (!this.get('Content-Type')) { - this.set('X-Content-Type-Options', 'nosniff'); - this.set('Content-Type', 'application/json'); +class ExpressResponse extends http.ServerResponse { + constructor(req, options) { + super(req, options) + this.app = null } - // fixup callback - if (Array.isArray(callback)) { - callback = callback[0]; + /** + * Set status `code`. + * + * @param {Number} code + * @return {ServerResponse} + * @public + */ + + status(code) { + this.statusCode = code + return this } - // jsonp - if (typeof callback === 'string' && callback.length !== 0) { - this.set('X-Content-Type-Options', 'nosniff'); - this.set('Content-Type', 'text/javascript'); - - // restrict callback charset - callback = callback.replace(/[^\[\]\w$.]/g, ''); + /** + * Set Link header field with the given `links`. + * + * Examples: + * + * res.links({ + * next: 'http://api.example.com/users?page=2', + * last: 'http://api.example.com/users?page=5' + * }) + * + * @param {Object} links + * @return {ServerResponse} + * @public + */ + + links(links){ + var link = this.get('Link') || '' + if (link) link += ', ' + return this.set('Link', link + Object.keys(links).map(function(rel){ + return '<' + links[rel] + '>; rel="' + rel + '"' + }).join(', ')) + } - if (body === undefined) { - // empty argument - body = '' - } else if (typeof body === 'string') { - // replace chars not allowed in JavaScript that are in JSON - body = body - .replace(/\u2028/g, '\\u2028') - .replace(/\u2029/g, '\\u2029') + /** + * Send a response. + * + * Examples: + * + * res.send(Buffer.from('wahoo')) + * res.send({ some: 'json' }) + * res.send('

some html

') + * + * @param {string|number|boolean|object|Buffer} body + * @public + */ + + send(body) { + var chunk = body + var encoding + var req = this.req + var type + + // settings + var app = this.app + + switch (typeof chunk) { + // string defaulting to html + case 'string': + if (!this.get('Content-Type')) { + this.type('html') + } + break + case 'boolean': + case 'number': + case 'object': + if (chunk === null) { + chunk = '' + } else if (Buffer.isBuffer(chunk)) { + if (!this.get('Content-Type')) { + this.type('bin') + } + } else { + return this.json(chunk) + } + break } - // 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 + ');'; - } + // write strings in utf-8 + if (typeof chunk === 'string') { + encoding = 'utf8' + type = this.get('Content-Type') - return this.send(body); -}; + // reflect this in content-type + if (typeof type === 'string') { + this.set('Content-Type', setCharset(type, 'utf-8')) + } + } -/** - * Send given HTTP status code. - * - * Sets the response status to `statusCode` and the body of the - * response to the standard description from node's http.STATUS_CODES - * or the statusCode number if no description. - * - * Examples: - * - * res.sendStatus(200); - * - * @param {number} statusCode - * @public - */ + // determine if ETag should be generated + var etagFn = app.get('etag fn') + var generateETag = !this.get('ETag') && typeof etagFn === 'function' + + // populate Content-Length + var len + if (chunk !== undefined) { + if (Buffer.isBuffer(chunk)) { + // get length of Buffer + len = chunk.length + } else if (!generateETag && chunk.length < 1000) { + // just calculate length when no ETag + small chunk + len = Buffer.byteLength(chunk, encoding) + } else { + // convert chunk to Buffer and calculate + chunk = Buffer.from(chunk, encoding) + encoding = undefined + len = chunk.length + } -res.sendStatus = function sendStatus(statusCode) { - var body = statuses.message[statusCode] || String(statusCode); + this.set('Content-Length', len) + } - this.statusCode = statusCode; - this.type('txt'); + // populate ETag + var etag + if (generateETag && len !== undefined) { + if ((etag = etagFn(chunk, encoding))) { + this.set('ETag', etag) + } + } - return this.send(body); -}; + // freshness + if (req.fresh) this.statusCode = 304 -/** - * Transfer the file at the given `path`. - * - * Automatically sets the _Content-Type_ response header field. - * The callback `callback(err)` is invoked when the transfer is complete - * or when an error occurs. Be sure to check `res.headersSent` - * if you wish to attempt responding, as the header and some data - * may have already been transferred. - * - * Options: - * - * - `maxAge` defaulting to 0 (can be string converted by `ms`) - * - `root` root directory for relative filenames - * - `headers` object of headers to serve with file - * - `dotfiles` serve dotfiles, defaulting to false; can be `"allow"` to send them - * - * Other options are passed along to `send`. - * - * Examples: - * - * The following example illustrates how `res.sendFile()` may - * be used as an alternative for the `static()` middleware for - * dynamic situations. The code backing `res.sendFile()` is actually - * the same code, so HTTP cache support etc is identical. - * - * app.get('/user/:uid/photos/:file', function(req, res){ - * var uid = req.params.uid - * , file = req.params.file; - * - * req.user.mayViewFilesFrom(uid, function(yes){ - * if (yes) { - * res.sendFile('/uploads/' + uid + '/' + file); - * } else { - * res.send(403, 'Sorry! you cant see that.'); - * } - * }); - * }); - * - * @public - */ + // strip irrelevant headers + if (204 === this.statusCode || 304 === this.statusCode) { + this.removeHeader('Content-Type') + this.removeHeader('Content-Length') + this.removeHeader('Transfer-Encoding') + chunk = '' + } -res.sendFile = function sendFile(path, options, callback) { - var done = callback; - var req = this.req; - var res = this; - var next = req.next; - var opts = options || {}; + if (req.method === 'HEAD') { + // skip body for HEAD + this.end() + } else { + // respond + this.end(chunk, encoding) + } - if (!path) { - throw new TypeError('path argument is required to res.sendFile'); + return this } - if (typeof path !== 'string') { - throw new TypeError('path must be a string to res.sendFile') - } + /** + * Send JSON response. + * + * Examples: + * + * res.json(null) + * res.json({ user: 'tj' }) + * + * @param {string|number|boolean|object} obj + * @public + */ + + json(obj) { + // settings + var app = this.app + var escape = app.get('json escape') + var replacer = app.get('json replacer') + var spaces = app.get('json spaces') + var body = stringify(obj, replacer, spaces, escape) + + // content-type + if (!this.get('Content-Type')) { + this.set('Content-Type', 'application/json') + } - // support function as second arg - if (typeof options === 'function') { - done = options; - opts = {}; + return this.send(body) } - if (!opts.root && !pathIsAbsolute(path)) { - throw new TypeError('path must be absolute or specify root to res.sendFile'); - } + /** + * Send JSON response with JSONP callback support. + * + * Examples: + * + * res.jsonp(null) + * res.jsonp({ user: 'tj' }) + * + * @param {string|number|boolean|object} obj + * @public + */ + + jsonp(obj) { + // settings + var app = this.app + var escape = app.get('json escape') + var replacer = app.get('json replacer') + var spaces = app.get('json spaces') + var body = stringify(obj, replacer, spaces, escape) + var callback = this.req.query[app.get('jsonp callback name')] + + // content-type + if (!this.get('Content-Type')) { + this.set('X-Content-Type-Options', 'nosniff') + this.set('Content-Type', 'application/json') + } - // create file stream - var pathname = encodeURI(path); - var file = send(req, pathname, opts); - if (opts.headers) { - for (var name in opts.headers) { - res.setHeader(name, opts.headers[name]); + // fixup callback + if (Array.isArray(callback)) { + callback = callback[0] } - } - // transfer - sendfile(res, file, opts, function (err) { - if(err) { - for (var name in res._headers) { - res.removeHeader(name); + // jsonp + if (typeof callback === 'string' && callback.length !== 0) { + this.set('X-Content-Type-Options', 'nosniff') + this.set('Content-Type', 'text/javascript') + + // restrict callback charset + callback = callback.replace(/[^\[\]\w$.]/g, '') + + if (body === undefined) { + // empty argument + body = '' + } else if (typeof body === 'string') { + // replace chars not allowed in JavaScript that are in JSON + body = body + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029') } - } - 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') { - return next(err); + // 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 + ');' } - }); -}; - -/** - * Transfer the file at the given `path` as an attachment. - * - * Optionally providing an alternate attachment `filename`, - * and optional callback `callback(err)`. The callback is invoked - * when the data transfer is complete, or when an error has - * occurred. Be sure to check `res.headersSent` if you plan to respond. - * - * Optionally providing an `options` object to use with `res.sendFile()`. - * This function will set the `Content-Disposition` header, overriding - * any `Content-Disposition` header passed as header options in order - * to set the attachment and filename. - * - * This method uses `res.sendFile()`. - * - * @public - */ -res.download = function download (path, filename, options, callback) { - var done = callback; - var name = filename; - var 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; + return this.send(body) } - // support optional filename, where options may be in it's place - if (typeof filename === 'object' && - (typeof options === 'function' || options === undefined)) { - name = null; - opts = filename; + /** + * Send given HTTP status code. + * + * Sets the response status to `statusCode` and the body of the + * response to the standard description from node's http.STATUS_CODES + * or the statusCode number if no description. + * + * Examples: + * + * res.sendStatus(200) + * + * @param {number} statusCode + * @public + */ + + sendStatus(statusCode) { + var body = statuses.message[statusCode] || String(statusCode) + + this.statusCode = statusCode + this.type('txt') + + return this.send(body) } - // set Content-Disposition when file is sent - var headers = { - 'Content-Disposition': contentDisposition(name || path) - }; - - // merge user-provided headers - if (opts && opts.headers) { - var keys = Object.keys(opts.headers); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - if (key.toLowerCase() !== 'content-disposition') { - headers[key] = opts.headers[key]; - } + /** + * Transfer the file at the given `path`. + * + * Automatically sets the _Content-Type_ response header field. + * The callback `callback(err)` is invoked when the transfer is complete + * or when an error occurs. Be sure to check `res.headersSent` + * if you wish to attempt responding, as the header and some data + * may have already been transferred. + * + * Options: + * + * - `maxAge` defaulting to 0 (can be string converted by `ms`) + * - `root` root directory for relative filenames + * - `headers` object of headers to serve with file + * - `dotfiles` serve dotfiles, defaulting to false can be `"allow"` to send them + * + * Other options are passed along to `send`. + * + * Examples: + * + * The following example illustrates how `res.sendFile()` may + * be used as an alternative for the `static()` middleware for + * dynamic situations. The code backing `res.sendFile()` is actually + * the same code, so HTTP cache support etc is identical. + * + * app.get('/user/:uid/photos/:file', function(req, res){ + * var uid = req.params.uid + * , file = req.params.file + * + * req.user.mayViewFilesFrom(uid, function(yes){ + * if (yes) { + * res.sendFile('/uploads/' + uid + '/' + file) + * } else { + * res.send(403, 'Sorry! you cant see that.') + * } + * }) + * }) + * + * @public + */ + + sendFile(path, options, callback) { + var done = callback + var req = this.req + var res = this + var next = req.next + var opts = options || {} + + if (!path) { + throw new TypeError('path argument is required to res.sendFile') } - } - - // merge user-provided options - opts = Object.assign({}, opts); - opts.headers = headers; - - // Resolve the full path for sendFile - var fullPath = !opts.root - ? resolve(path) - : path; - // send file - return this.sendFile(fullPath, opts, done); -}; + if (typeof path !== 'string') { + throw new TypeError('path must be a string to res.sendFile') + } -/** - * Set _Content-Type_ response header with `type` through `mime.contentType()` - * when it does not contain "/", or set the Content-Type to `type` otherwise. - * When no mapping is found though `mime.contentType()`, the type is set to - * "application/octet-stream". - * - * Examples: - * - * res.type('.html'); - * res.type('html'); - * res.type('json'); - * res.type('application/json'); - * res.type('png'); - * - * @param {String} type - * @return {ServerResponse} for chaining - * @public - */ + // support function as second arg + if (typeof options === 'function') { + done = options + opts = {} + } -res.contentType = -res.type = function contentType(type) { - var ct = type.indexOf('/') === -1 - ? (mime.contentType(type) || 'application/octet-stream') - : type; + if (!opts.root && !pathIsAbsolute(path)) { + throw new TypeError('path must be absolute or specify root to res.sendFile') + } - return this.set('Content-Type', ct); -}; + // create file stream + var pathname = encodeURI(path) + var file = send(req, pathname, opts) + if (opts.headers) { + for (var name in opts.headers) { + res.setHeader(name, opts.headers[name]) + } + } -/** - * Respond to the Acceptable formats using an `obj` - * of mime-type callbacks. - * - * This method uses `req.accepted`, an array of - * acceptable types ordered by their quality values. - * When "Accept" is not present the _first_ callback - * is invoked, otherwise the first match is used. When - * no match is performed the server responds with - * 406 "Not Acceptable". - * - * Content-Type is set for you, however if you choose - * you may alter this within the callback using `res.type()` - * or `res.set('Content-Type', ...)`. - * - * res.format({ - * 'text/plain': function(){ - * res.send('hey'); - * }, - * - * 'text/html': function(){ - * res.send('

hey

'); - * }, - * - * 'application/json': function () { - * res.send({ message: 'hey' }); - * } - * }); - * - * In addition to canonicalized MIME types you may - * also use extnames mapped to these types: - * - * res.format({ - * text: function(){ - * res.send('hey'); - * }, - * - * html: function(){ - * res.send('

hey

'); - * }, - * - * json: function(){ - * res.send({ message: 'hey' }); - * } - * }); - * - * By default Express passes an `Error` - * with a `.status` of 406 to `next(err)` - * if a match is not made. If you provide - * a `.default` callback it will be invoked - * instead. - * - * @param {Object} obj - * @return {ServerResponse} for chaining - * @public - */ + // transfer + this.#sendfile(res, file, opts, function (err) { + if(err) { + for (var name in res._headers) { + res.removeHeader(name) + } + } + if (done) return done(err) + if (err && err.code === 'EISDIR') return next() -res.format = function(obj){ - var req = this.req; - var next = req.next; - - var fn = obj.default; - if (fn) delete obj.default; - var keys = Object.keys(obj); - - var key = keys.length > 0 - ? req.accepts(keys) - : false; - - this.vary("Accept"); - - if (key) { - this.set('Content-Type', normalizeType(key).value); - obj[key](req, this, next); - } else if (fn) { - fn(); - } else { - var err = new Error('Not Acceptable'); - err.status = err.statusCode = 406; - err.types = normalizeTypes(keys).map(function(o){ return o.value }); - next(err); + // next() all but write errors + if (err && err.code !== 'ECONNABORTED' && err.syscall !== 'write') { + return next(err) + } + }) } - return this; -}; - -/** - * Set _Content-Disposition_ header to _attachment_ with optional `filename`. - * - * @param {String} filename - * @return {ServerResponse} - * @public - */ + /** + * Transfer the file at the given `path` as an attachment. + * + * Optionally providing an alternate attachment `filename`, + * and optional callback `callback(err)`. The callback is invoked + * when the data transfer is complete, or when an error has + * occurred. Be sure to check `res.headersSent` if you plan to respond. + * + * Optionally providing an `options` object to use with `res.sendFile()`. + * This function will set the `Content-Disposition` header, overriding + * any `Content-Disposition` header passed as header options in order + * to set the attachment and filename. + * + * This method uses `res.sendFile()`. + * + * @public + */ + + download (path, filename, options, callback) { + var done = callback + var name = filename + var 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 + } -res.attachment = function attachment(filename) { - if (filename) { - this.type(extname(filename)); - } + // support optional filename, where options may be in it's place + if (typeof filename === 'object' && + (typeof options === 'function' || options === undefined)) { + name = null + opts = filename + } - this.set('Content-Disposition', contentDisposition(filename)); + // set Content-Disposition when file is sent + var headers = { + 'Content-Disposition': contentDisposition(name || path) + } - return this; -}; + // merge user-provided headers + if (opts && opts.headers) { + var keys = Object.keys(opts.headers) + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + if (key.toLowerCase() !== 'content-disposition') { + headers[key] = opts.headers[key] + } + } + } -/** - * Append additional header `field` with value `val`. - * - * Example: - * - * res.append('Link', ['', '']); - * res.append('Set-Cookie', 'foo=bar; Path=/; HttpOnly'); - * res.append('Warning', '199 Miscellaneous warning'); - * - * @param {String} field - * @param {String|Array} val - * @return {ServerResponse} for chaining - * @public - */ + // merge user-provided options + opts = Object.assign({}, opts) + opts.headers = headers -res.append = function append(field, val) { - var prev = this.get(field); - var value = val; + // Resolve the full path for sendFile + var fullPath = !opts.root + ? resolve(path) + : path - if (prev) { - // concat the new and prev vals - value = Array.isArray(prev) ? prev.concat(val) - : Array.isArray(val) ? [prev].concat(val) - : [prev, val] + // send file + return this.sendFile(fullPath, opts, done) } - return this.set(field, value); -}; + /** + * Set _Content-Type_ response header with `type` through `mime.contentType()` + * when it does not contain "/", or set the Content-Type to `type` otherwise. + * When no mapping is found though `mime.contentType()`, the type is set to + * "application/octet-stream". + * + * Examples: + * + * res.type('.html') + * res.type('html') + * res.type('json') + * res.type('application/json') + * res.type('png') + * + * @param {String} type + * @return {ServerResponse} for chaining + * @public + */ + + contentType(type) { + return this.type(type) + } + type(type) { + var ct = type.indexOf('/') === -1 + ? (mime.contentType(type) || 'application/octet-stream') + : type -/** - * Set header `field` to `val`, or pass - * an object of header fields. - * - * Examples: - * - * res.set('Foo', ['bar', 'baz']); - * res.set('Accept', 'application/json'); - * res.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }); - * - * Aliased as `res.header()`. - * - * When the set header is "Content-Type", the type is expanded to include - * the charset if not present using `mime.contentType()`. - * - * @param {String|Object} field - * @param {String|Array} val - * @return {ServerResponse} for chaining - * @public - */ + return this.set('Content-Type', ct) + } -res.set = -res.header = function header(field, val) { - if (arguments.length === 2) { - var 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'); - } - value = mime.contentType(value) + /** + * Respond to the Acceptable formats using an `obj` + * of mime-type callbacks. + * + * This method uses `req.accepted`, an array of + * acceptable types ordered by their quality values. + * When "Accept" is not present the _first_ callback + * is invoked, otherwise the first match is used. When + * no match is performed the server responds with + * 406 "Not Acceptable". + * + * Content-Type is set for you, however if you choose + * you may alter this within the callback using `res.type()` + * or `res.set('Content-Type', ...)`. + * + * res.format({ + * 'text/plain': function(){ + * res.send('hey') + * }, + * + * 'text/html': function(){ + * res.send('

hey

') + * }, + * + * 'application/json': function () { + * res.send({ message: 'hey' }) + * } + * }) + * + * In addition to canonicalized MIME types you may + * also use extnames mapped to these types: + * + * res.format({ + * text: function(){ + * res.send('hey') + * }, + * + * html: function(){ + * res.send('

hey

') + * }, + * + * json: function(){ + * res.send({ message: 'hey' }) + * } + * }) + * + * By default Express passes an `Error` + * with a `.status` of 406 to `next(err)` + * if a match is not made. If you provide + * a `.default` callback it will be invoked + * instead. + * + * @param {Object} obj + * @return {ServerResponse} for chaining + * @public + */ + + format(obj){ + var req = this.req + var next = req.next + + var fn = obj.default + if (fn) delete obj.default + var keys = Object.keys(obj) + + var key = keys.length > 0 + ? req.accepts(keys) + : false + + this.vary("Accept") + + if (key) { + this.set('Content-Type', normalizeType(key).value) + obj[key](req, this, next) + } else if (fn) { + fn() + } else { + var err = new Error('Not Acceptable') + err.status = err.statusCode = 406 + err.types = normalizeTypes(keys).map(function(o){ return o.value }) + next(err) } - this.setHeader(field, value); - } else { - for (var key in field) { - this.set(key, field[key]); - } + return this } - return this; -}; - -/** - * Get value for header `field`. - * - * @param {String} field - * @return {String} - * @public - */ - -res.get = function(field){ - return this.getHeader(field); -}; -/** - * Clear cookie `name`. - * - * @param {String} name - * @param {Object} [options] - * @return {ServerResponse} for chaining - * @public - */ - -res.clearCookie = function clearCookie(name, options) { - var opts = merge({ expires: new Date(1), path: '/' }, options); - - return this.cookie(name, '', opts); -}; - -/** - * Set cookie `name` to `value`, with the given `options`. - * - * Options: - * - * - `maxAge` max-age in milliseconds, converted to `expires` - * - `signed` sign the cookie - * - `path` defaults to "/" - * - * Examples: - * - * // "Remember Me" for 15 minutes - * res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true }); - * - * // same as above - * res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true }) - * - * @param {String} name - * @param {String|Object} value - * @param {Object} [options] - * @return {ServerResponse} for chaining - * @public - */ + /** + * Set _Content-Disposition_ header to _attachment_ with optional `filename`. + * + * @param {String} filename + * @return {ServerResponse} + * @public + */ + + attachment(filename) { + if (filename) { + this.type(extname(filename)) + } -res.cookie = function (name, value, options) { - var opts = merge({}, options); - var secret = this.req.secret; - var signed = opts.signed; + this.set('Content-Disposition', contentDisposition(filename)) - if (signed && !secret) { - throw new Error('cookieParser("secret") required for signed cookies'); + return this } - var val = typeof value === 'object' - ? 'j:' + JSON.stringify(value) - : String(value); + /** + * Append additional header `field` with value `val`. + * + * Example: + * + * res.append('Link', ['', '']) + * res.append('Set-Cookie', 'foo=bar Path=/ HttpOnly') + * res.append('Warning', '199 Miscellaneous warning') + * + * @param {String} field + * @param {String|Array} val + * @return {ServerResponse} for chaining + * @public + */ + + append(field, val) { + var prev = this.get(field) + var 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] + } - if (signed) { - val = 's:' + sign(val, secret); + return this.set(field, value) } - if ('maxAge' in opts) { - opts.expires = new Date(Date.now() + opts.maxAge); - opts.maxAge /= 1000; + /** + * Set header `field` to `val`, or pass + * an object of header fields. + * + * Examples: + * + * res.set('Foo', ['bar', 'baz']) + * res.set('Accept', 'application/json') + * res.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }) + * + * Aliased as `res.header()`. + * + * When the set header is "Content-Type", the type is expanded to include + * the charset if not present using `mime.contentType()`. + * + * @param {String|Object} field + * @param {String|Array} val + * @return {ServerResponse} for chaining + * @public + */ + + set(field, val) { + return this.header(field, val) } - - if (opts.path == null) { - opts.path = '/'; + header(field, val) { + const setter = new HeaderSetter(this) + setter[typeof field](field, val) + return this } - this.append('Set-Cookie', cookie.serialize(name, String(val), opts)); + /** + * Get value for header `field`. + * + * @param {String} field + * @return {String} + * @public + */ - return this; -}; + get(field){ + return this.getHeader(field) + } -/** - * Set the location header to `url`. - * - * The given `url` can also be "back", which redirects - * to the _Referrer_ or _Referer_ headers or "/". - * - * Examples: - * - * res.location('/foo/bar').; - * res.location('http://example.com'); - * res.location('../login'); - * - * @param {String} url - * @return {ServerResponse} for chaining - * @public - */ + /** + * Clear cookie `name`. + * + * @param {String} name + * @param {Object} [options] + * @return {ServerResponse} for chaining + * @public + */ -res.location = function location(url) { - var loc = url; + clearCookie(name, options) { + var opts = merge({ expires: new Date(1), path: '/' }, options) - // "back" is an alias for the referrer - if (url === 'back') { - loc = this.req.get('Referrer') || '/'; + return this.cookie(name, '', opts) } - // set location - return this.set('Location', encodeUrl(loc)); -}; + /** + * Set cookie `name` to `value`, with the given `options`. + * + * Options: + * + * - `maxAge` max-age in milliseconds, converted to `expires` + * - `signed` sign the cookie + * - `path` defaults to "/" + * + * Examples: + * + * // "Remember Me" for 15 minutes + * res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true }) + * + * // same as above + * res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true }) + * + * @param {String} name + * @param {String|Object} value + * @param {Object} [options] + * @return {ServerResponse} for chaining + * @public + */ + + cookie(name, value, options) { + var opts = merge({}, options) + var secret = this.req.secret + var signed = opts.signed + + if (signed && !secret) { + throw new Error('cookieParser("secret") required for signed cookies') + } -/** - * Redirect to the given `url` with optional response `status` - * defaulting to 302. - * - * The resulting `url` is determined by `res.location()`, so - * it will play nicely with mounted apps, relative paths, - * `"back"` etc. - * - * Examples: - * - * res.redirect('/foo/bar'); - * res.redirect('http://example.com'); - * res.redirect(301, 'http://example.com'); - * res.redirect('../login'); // /blog/post/1 -> /blog/login - * - * @public - */ + var val = typeof value === 'object' + ? 'j:' + JSON.stringify(value) + : String(value) -res.redirect = function redirect(url) { - var address = url; - var body; - var status = 302; + if (signed) { + val = 's:' + sign(val, secret) + } - // allow status / url - if (arguments.length === 2) { - status = arguments[0] - address = arguments[1] - } + if ('maxAge' in opts) { + opts.expires = new Date(Date.now() + opts.maxAge) + opts.maxAge /= 1000 + } - // Set location header - address = this.location(address).get('Location'); + if (opts.path == null) { + opts.path = '/' + } - // Support text/{plain,html} by default - this.format({ - text: function(){ - body = statuses.message[status] + '. Redirecting to ' + address - }, + this.append('Set-Cookie', cookie.serialize(name, String(val), opts)) - html: function(){ - var u = escapeHtml(address); - body = '

' + statuses.message[status] + '. Redirecting to ' + u + '

' - }, + return this + } - default: function(){ - body = ''; + /** + * Set the location header to `url`. + * + * The given `url` can also be "back", which redirects + * to the _Referrer_ or _Referer_ headers or "/". + * + * Examples: + * + * res.location('/foo/bar'). + * res.location('http://example.com') + * res.location('../login') + * + * @param {String} url + * @return {ServerResponse} for chaining + * @public + */ + + location(url) { + var loc = url + + // "back" is an alias for the referrer + if (url === 'back') { + loc = this.req.get('Referrer') || '/' } - }); - - // Respond - this.statusCode = status; - this.set('Content-Length', Buffer.byteLength(body)); - if (this.req.method === 'HEAD') { - this.end(); - } else { - this.end(body); + // set location + return this.set('Location', encodeUrl(loc)) } -}; -/** - * Add `field` to Vary. If already present in the Vary set, then - * this call is simply ignored. - * - * @param {Array|String} field - * @return {ServerResponse} for chaining - * @public - */ + /** + * Redirect to the given `url` with optional response `status` + * defaulting to 302. + * + * The resulting `url` is determined by `res.location()`, so + * it will play nicely with mounted apps, relative paths, + * `"back"` etc. + * + * Examples: + * + * res.redirect('/foo/bar') + * res.redirect('http://example.com') + * res.redirect(301, 'http://example.com') + * res.redirect('../login') // /blog/post/1 -> /blog/login + * + * @public + */ + + redirect(url) { + var address = url + var body + var status = 302 + + // allow status / url + if (arguments.length === 2) { + status = arguments[0] + address = arguments[1] + } -res.vary = function(field){ - vary(this, field); + // Set location header + address = this.location(address).get('Location') - return this; -}; + // Support text/{plain,html} by default + this.format({ + text: function(){ + body = statuses.message[status] + '. Redirecting to ' + address + }, -/** - * Render `view` with the given `options` and optional callback `fn`. - * When a callback function is given a response will _not_ be made - * automatically, otherwise a response of _200_ and _text/html_ is given. - * - * Options: - * - * - `cache` boolean hinting to the engine it should cache - * - `filename` filename of the view being rendered - * - * @public - */ + html: function(){ + var u = escapeHtml(address) + body = '

' + statuses.message[status] + '. Redirecting to ' + u + '

' + }, + + default: function(){ + body = '' + } + }) + + // Respond + this.statusCode = status + this.set('Content-Length', Buffer.byteLength(body)) -res.render = function render(view, options, callback) { - var app = this.req.app; - var done = callback; - var opts = options || {}; - var req = this.req; - var self = this; - - // support callback function as second arg - if (typeof options === 'function') { - done = options; - opts = {}; + if (this.req.method === 'HEAD') { + this.end() + } else { + this.end(body) + } } - // merge res.locals - opts._locals = self.locals; + /** + * Add `field` to Vary. If already present in the Vary set, then + * this call is simply ignored. + * + * @param {Array|String} field + * @return {ServerResponse} for chaining + * @public + */ - // default callback to respond - done = done || function (err, str) { - if (err) return req.next(err); - self.send(str); - }; + vary(field){ + vary(this, field) - // render - app.render(view, opts, done); -}; + return this + } + + /** + * Render `view` with the given `options` and optional callback `fn`. + * When a callback function is given a response will _not_ be made + * automatically, otherwise a response of _200_ and _text/html_ is given. + * + * Options: + * + * - `cache` boolean hinting to the engine it should cache + * - `filename` filename of the view being rendered + * + * @public + */ + + render(view, options, callback) { + var app = this.app + var done = callback + var opts = options || {} + var req = this.req + var self = this + + // support callback function as second arg + if (typeof options === 'function') { + done = options + opts = {} + } -// pipe the send file stream -function sendfile(res, file, options, callback) { - var done = false; - var streaming; + // merge res.locals + opts._locals = self.locals - // request aborted - function onaborted() { - if (done) return; - done = true; + // default callback to respond + done = done || function (err, str) { + if (err) return req.next(err) + self.send(str) + } - var err = new Error('Request aborted'); - err.code = 'ECONNABORTED'; - callback(err); + // render + app.render(view, opts, done) } - // directory - function ondirectory() { - if (done) return; - done = true; + // pipe the send file stream + #sendfile(res, file, options, callback) { + var done = false + var streaming - var err = new Error('EISDIR, read'); - err.code = 'EISDIR'; - callback(err); - } + // request aborted + function onaborted() { + if (done) return + done = true - // errors - function onerror(err) { - if (done) return; - done = true; - callback(err); - } + var err = new Error('Request aborted') + err.code = 'ECONNABORTED' + callback(err) + } - // ended - function onend() { - if (done) return; - done = true; - callback(); - } + // directory + function ondirectory() { + if (done) return + done = true - // file - function onfile() { - streaming = false; - } + var err = new Error('EISDIR, read') + err.code = 'EISDIR' + callback(err) + } - // finished - function onfinish(err) { - if (err && err.code === 'ECONNRESET') return onaborted(); - if (err) return onerror(err); - if (done) return; + // errors + function onerror(err) { + if (done) return + done = true + callback(err) + } - setImmediate(function () { - if (streaming !== false && !done) { - onaborted(); - return; - } + // ended + function onend() { + if (done) return + done = true + callback() + } - if (done) return; - done = true; - callback(); - }); - } + // file + function onfile() { + streaming = false + } - // streaming - function onstream() { - streaming = true; - } + // finished + function onfinish(err) { + if (err && err.code === 'ECONNRESET') return onaborted() + if (err) return onerror(err) + if (done) return - file.on('directory', ondirectory); - file.on('end', onend); - file.on('error', onerror); - file.on('file', onfile); - file.on('stream', onstream); - onFinished(res, onfinish); + setImmediate(function () { + if (streaming !== false && !done) { + onaborted() + return + } - if (options.headers) { - // set headers on successful transfer - file.on('headers', function headers(res) { - var obj = options.headers; - var keys = Object.keys(obj); + if (done) return + done = true + callback() + }) + } - for (var i = 0; i < keys.length; i++) { - var k = keys[i]; - res.setHeader(k, obj[k]); - } - }); - } + // streaming + function onstream() { + streaming = true + } - // pipe - file.pipe(res); + 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) { + var obj = options.headers + var keys = Object.keys(obj) + + for (var i = 0; i < keys.length; i++) { + var k = keys[i] + res.setHeader(k, obj[k]) + } + }) + } + + // pipe + file.pipe(res) + } } /** @@ -999,7 +1014,7 @@ function stringify (value, replacer, spaces, escape) { // https://bugs.chromium.org/p/v8/issues/detail?id=4730 var json = replacer || spaces ? JSON.stringify(value, replacer, spaces) - : JSON.stringify(value); + : JSON.stringify(value) if (escape && typeof json === 'string') { json = json.replace(/[<>&]/g, function (c) { @@ -1019,3 +1034,5 @@ function stringify (value, replacer, spaces, escape) { return json } + +module.exports = ExpressResponse diff --git a/lib/route.js b/lib/route.js new file mode 100644 index 0000000000..d7b64cd478 --- /dev/null +++ b/lib/route.js @@ -0,0 +1,212 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +var Layer = require('./layer') +var methods = require('methods') + +/** + * Expose `Route`. + */ + +module.exports = Route + +/** + * Initialize `Route` with the given `path`, + * + * @param {String} path + * @api private + */ + +function Route (path) { + this.path = path + this.stack = [] + + // route handlers for various http methods + this.methods = Object.create(null) +} + +/** + * @private + */ + +Route.prototype._handlesMethod = function _handlesMethod (method) { + if (this.methods._all) { + return true + } + + // normalize name + var name = method.toLowerCase() + + if (name === 'head' && !this.methods.head) { + name = 'get' + } + + return Boolean(this.methods[name]) +} + +/** + * @return {array} supported HTTP methods + * @private + */ + +Route.prototype._methods = function _methods () { + var methods = Object.keys(this.methods) + + // append automatic head + if (this.methods.get && !this.methods.head) { + methods.push('head') + } + + for (var i = 0; i < methods.length; i++) { + // make upper case + methods[i] = methods[i].toUpperCase() + } + + return methods +} + +/** + * dispatch req, res into this route + * + * @private + */ + +Route.prototype.dispatch = function dispatch (req, res, done) { + var idx = 0 + var stack = this.stack + if (stack.length === 0) { + return done() + } + + var method = req.method.toLowerCase() + if (method === 'head' && !this.methods.head) { + method = 'get' + } + + req.route = this + + next() + + function next (err) { + // signal to exit route + if (err && err === 'route') { + return done() + } + + // signal to exit router + if (err && err === 'router') { + return done(err) + } + + // no more matching layers + if (idx >= stack.length) { + return done(err) + } + + var layer + var match + + // find next matching layer + while (match !== true && idx < stack.length) { + layer = stack[idx++] + match = !layer.method || layer.method === method + } + + // no match + if (match !== true) { + return done(err) + } + + if (err) { + layer.handleError(err, req, res, next) + } else { + layer.handleRequest(req, res, next) + } + } +} + +/** + * Add a handler for all HTTP verbs to this route. + * + * Behaves just like middleware and can respond or call `next` + * to continue processing. + * + * You can use multiple `.all` call to add multiple handlers. + * + * function check_something(req, res, next){ + * next() + * } + * + * function validate_user(req, res, next){ + * next() + * } + * + * route + * .all(validate_user) + * .all(check_something) + * .get(function(req, res, next){ + * res.send('hello world') + * }) + * + * @param {array|function} handler + * @return {Route} for chaining + * @api public + */ + +Route.prototype.all = function all (handler) { + var callbacks = Array.from(arguments).slice().flat(Infinity); + if (callbacks.length === 0) { + throw new TypeError('argument handler is required') + } + + for (var i = 0; i < callbacks.length; i++) { + var fn = callbacks[i] + + if (typeof fn !== 'function') { + throw new TypeError('argument handler must be a function') + } + + var layer = Layer('/', {}, fn) + layer.method = undefined + + this.methods._all = true + this.stack.push(layer) + } + + return this +} + +methods.forEach(function (method) { + Route.prototype[method] = function (...callbacks) { + if (callbacks.length === 0) { + throw new TypeError('argument handler is required') + } + + for (var i = 0; i < callbacks.length; i++) { + var fn = callbacks[i] + + if (typeof fn !== 'function') { + throw new TypeError('argument handler must be a function') + } + + var layer = Layer('/', {}, fn) + layer.method = method + + this.methods[method] = true + this.stack.push(layer) + } + + return this + } +}) diff --git a/lib/router.js b/lib/router.js new file mode 100644 index 0000000000..2065d05681 --- /dev/null +++ b/lib/router.js @@ -0,0 +1,719 @@ +/*! + * router + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +var Layer = require('./layer') +var methods = require('methods') +var mixin = require('utils-merge') +var parseUrl = require('parseurl') +var Route = require('./route') +var setPrototypeOf = require('setprototypeof') + +/** + * Module variables. + * @private + */ + +var slice = Array.prototype.slice + +/** + * Expose `Router`. + */ + +module.exports = Router + +/** + * Expose `Route`. + */ + +module.exports.Route = Route + +/** + * Initialize a new `Router` with the given `options`. + * + * @param {object} [options] + * @return {Router} which is a callable function + * @public + */ + +function Router (options) { + if (!(this instanceof Router)) { + return new Router(options) + } + + var opts = options || {} + + function router (req, res, next) { + router.handle(req, res, next) + } + + // inherit from the correct prototype + setPrototypeOf(router, this) + + router.caseSensitive = opts.caseSensitive + router.mergeParams = opts.mergeParams + router.params = {} + router.strict = opts.strict + router.stack = [] + + return router +} + +/** + * Router prototype inherits from a Function. + */ + +/* istanbul ignore next */ +Router.prototype = function () {} + +/** + * Map the given param placeholder `name`(s) to the given callback. + * + * Parameter mapping is used to provide pre-conditions to routes + * which use normalized placeholders. For example a _:user_id_ parameter + * could automatically load a user's information from the database without + * any additional code. + * + * The callback uses the same signature as middleware, the only difference + * being that the value of the placeholder is passed, in this case the _id_ + * of the user. Once the `next()` function is invoked, just like middleware + * it will continue on to execute the route, or subsequent parameter functions. + * + * Just like in middleware, you must either respond to the request or call next + * to avoid stalling the request. + * + * router.param('user_id', function(req, res, next, id){ + * User.find(id, function(err, user){ + * if (err) { + * return next(err) + * } else if (!user) { + * return next(new Error('failed to load user')) + * } + * req.user = user + * next() + * }) + * }) + * + * @param {string} name + * @param {function} fn + * @public + */ + +Router.prototype.param = function param (name, fn) { + if (!name) { + throw new TypeError('argument name is required') + } + + if (typeof name !== 'string') { + throw new TypeError('argument name must be a string') + } + + if (!fn) { + throw new TypeError('argument fn is required') + } + + if (typeof fn !== 'function') { + throw new TypeError('argument fn must be a function') + } + + var params = this.params[name] + + if (!params) { + params = this.params[name] = [] + } + + params.push(fn) + + return this +} + +/** + * Dispatch a req, res into the router. + * + * @private + */ + +Router.prototype.handle = function handle (req, res, callback) { + if (!callback) { + throw new TypeError('argument callback is required') + } + + var idx = 0 + var methods + var protohost = getProtohost(req.url) || '' + var removed = '' + var self = this + var slashAdded = false + var paramcalled = {} + + // middleware and routes + var stack = this.stack + + // manage inter-router variables + var parentParams = req.params + var parentUrl = req.baseUrl || '' + var done = restore(callback, req, 'baseUrl', 'next', 'params') + + // setup next layer + req.next = next + + // for options requests, respond with a default if nothing else responds + if (req.method === 'OPTIONS') { + methods = [] + done = wrap(done, generateOptionsResponder(res, methods)) + } + + // setup basic req values + req.baseUrl = parentUrl + req.originalUrl = req.originalUrl || req.url + + next() + + function next (err) { + var layerError = err === 'route' + ? null + : err + + // remove added slash + if (slashAdded) { + req.url = req.url.substr(1) + slashAdded = false + } + + // restore altered req.url + if (removed.length !== 0) { + req.baseUrl = parentUrl + req.url = protohost + removed + req.url.substr(protohost.length) + removed = '' + } + + // signal to exit router + if (layerError === 'router') { + setImmediate(done, null) + return + } + + // no more matching layers + if (idx >= stack.length) { + setImmediate(done, layerError) + return + } + + // get pathname of request + var path = getPathname(req) + + if (path == null) { + return done(layerError) + } + + // find next matching layer + var layer + var match + var route + + while (match !== true && idx < stack.length) { + layer = stack[idx++] + match = matchLayer(layer, path) // params are also set in this call. :judging look: + route = layer.route + + if (typeof match !== 'boolean') { + // hold on to layerError + layerError = layerError || match + } + + if (match !== true) { + continue + } + + if (!route) { + // process non-route handlers normally + continue + } + + if (layerError) { + // routes do not match with a pending error + match = false + continue + } + + var method = req.method + var hasMethod = route._handlesMethod(method) + + // build up automatic options response + if (!hasMethod && method === 'OPTIONS' && methods) { + methods.push.apply(methods, route._methods()) + } + + // don't even bother matching route + if (!hasMethod && method !== 'HEAD') { + match = false + continue + } + } + + // no match + if (match !== true) { + return done(layerError) + } + + // store route for dispatch on change + if (route) { + req.route = route + } + + // Capture one-time layer values + req.params = self.mergeParams + ? mergeParams(layer.params, parentParams) + : layer.params + var layerPath = layer.path + + // this should be done for the layer + processParams(self.params, layer, paramcalled, req, res, function (err) { + if (err) { + return next(layerError || err) + } + + if (route) { + return layer.handleRequest(req, res, next) + } + + trimPrefix(layer, layerError, layerPath, path) + }) + } + + function trimPrefix (layer, layerError, layerPath, path) { + if (layerPath.length !== 0) { + // Validate path is a prefix match + if (layerPath !== path.substr(0, layerPath.length)) { + next(layerError) + return + } + + // Trim off the part of the url that matches the route + // middleware (.use stuff) needs to have the path stripped + removed = layerPath + req.url = protohost + req.url.substr(protohost.length + removed.length) + + // Ensure leading slash + if (!protohost && req.url[0] !== '/') { + req.url = '/' + req.url + slashAdded = true + } + + // Setup base URL (no trailing slash) + req.baseUrl = parentUrl + (removed[removed.length - 1] === '/' + ? removed.substring(0, removed.length - 1) + : removed) + } + + if (layerError) { + layer.handleError(layerError, req, res, next) + } else { + layer.handleRequest(req, res, next) + } + } +} + +/** + * Use the given middleware function, with optional path, defaulting to "/". + * + * Use (like `.all`) will run for any http METHOD, but it will not add + * handlers for those methods so OPTIONS requests will not consider `.use` + * functions even if they could respond. + * + * The other difference is that _route_ path is stripped and not visible + * to the handler function. The main effect of this feature is that mounted + * handlers can operate without any code changes regardless of the "prefix" + * pathname. + * + * @public + */ + +Router.prototype.use = function use (handler) { + var offset = 0 + var path = '/' + + // default path to '/' + // disambiguate router.use([handler]) + if (typeof handler !== 'function') { + var arg = handler + + while (Array.isArray(arg) && arg.length !== 0) { + arg = arg[0] + } + + // first arg is the path + if (typeof arg !== 'function') { + offset = 1 + path = handler + } + } + + var callbacks = Array.from(arguments).slice(offset).flat(Infinity); + + if (callbacks.length === 0) { + throw new TypeError('argument handler is required') + } + + for (var i = 0; i < callbacks.length; i++) { + var fn = callbacks[i] + + if (typeof fn !== 'function') { + throw new TypeError('argument handler must be a function') + } + + // add the middleware + var layer = new Layer(path, { + sensitive: this.caseSensitive, + strict: this.strict, + end: false + }, fn) + + layer.route = undefined + + this.stack.push(layer) + } + + return this +} + +/** + * Create a new Route for the given path. + * + * Each route contains a separate middleware stack and VERB handlers. + * + * See the Route api documentation for details on adding handlers + * and middleware to routes. + * + * @param {string} path + * @return {Route} + * @public + */ + +Router.prototype.route = function route (path) { + var route = new Route(path) + + var layer = new Layer(path, { + sensitive: this.caseSensitive, + strict: this.strict, + end: true + }, handle) + + function handle (req, res, next) { + route.dispatch(req, res, next) + } + + layer.route = route + + this.stack.push(layer) + return route +} + +// create Router#VERB functions +methods.concat('all').forEach(function (method) { + Router.prototype[method] = function (path) { + var route = this.route(path) + route[method].apply(route, slice.call(arguments, 1)) + return this + } +}) + +/** + * Generate a callback that will make an OPTIONS response. + * + * @param {OutgoingMessage} res + * @param {array} methods + * @private + */ + +function generateOptionsResponder (res, methods) { + return function onDone (fn, err) { + if (err || methods.length === 0) { + return fn(err) + } + + trySendOptionsResponse(res, methods, fn) + } +} + +/** + * Get pathname of request. + * + * @param {IncomingMessage} req + * @private + */ +// TODO replace with new URL +function getPathname (req) { + try { + return parseUrl(req).pathname + } catch (err) { + return undefined + } +} + +/** + * Get get protocol + host for a URL. + * + * @param {string} url + * @private + */ + +function getProtohost (url) { + if (typeof url !== 'string' || url.length === 0 || url[0] === '/') { + return undefined + } + + var searchIndex = url.indexOf('?') + var pathLength = searchIndex !== -1 + ? searchIndex + : url.length + var fqdnIndex = url.substr(0, pathLength).indexOf('://') + + return fqdnIndex !== -1 + ? url.substr(0, url.indexOf('/', 3 + fqdnIndex)) + : undefined +} + +/** + * Match path to a layer. + * + * @param {Layer} layer + * @param {string} path + * @private + */ + +function matchLayer (layer, path) { + try { + return layer.match(path) + } catch (err) { + return err + } +} + +/** + * Merge params with parent params + * + * @private + */ + +function mergeParams (params, parent) { + if (typeof parent !== 'object' || !parent) { + return params + } + + // make copy of parent for base + var obj = mixin({}, parent) + + // simple non-numeric merging + if (!(0 in params) || !(0 in parent)) { + return mixin(obj, params) + } + + var i = 0 + var o = 0 + + // determine numeric gap in params + while (i in params) { + i++ + } + + // determine numeric gap in parent + while (o in parent) { + o++ + } + + // offset numeric indices in params before merge + for (i--; i >= 0; i--) { + params[i + o] = params[i] + + // create holes for the merge when necessary + if (i < o) { + delete params[i] + } + } + + return mixin(obj, params) +} + +/** + * Process any parameters for the layer. + * + * @private + */ + +function processParams (params, layer, called, req, res, done) { + // captured parameters from the layer, keys and values + var keys = layer.keys + + // fast track + if (!keys || keys.length === 0) { + return done() + } + + var i = 0 + var name + var paramIndex = 0 + var key + var paramVal + var paramCallbacks + var paramCalled + + // process params in order + // param callbacks can be async + function param (err) { + if (err) { + return done(err) + } + + if (i >= keys.length) { + return done() + } + + paramIndex = 0 + key = keys[i++] + name = key.name + paramVal = req.params[name] + paramCallbacks = params[name] + paramCalled = called[name] + + if (paramVal === undefined || !paramCallbacks) { + return param() + } + + // param previously called with same value or error occurred + if (paramCalled && (paramCalled.match === paramVal || + (paramCalled.error && paramCalled.error !== 'route'))) { + // restore value + req.params[name] = paramCalled.value + + // next param + return param(paramCalled.error) + } + + called[name] = paramCalled = { + error: null, + match: paramVal, + value: paramVal + } + + paramCallback() + } + + // single param callbacks + function paramCallback (err) { + var fn = paramCallbacks[paramIndex++] + + // store updated value + paramCalled.value = req.params[key.name] + + if (err) { + // store error + paramCalled.error = err + param(err) + return + } + + if (!fn) return param() + + try { + fn(req, res, paramCallback, paramVal, key.name) + } catch (e) { + paramCallback(e) + } + } + + param() +} + +/** + * Restore obj props after function + * + * @private + */ + +function restore (fn, obj) { + var props = new Array(arguments.length - 2) + var vals = new Array(arguments.length - 2) + + for (var i = 0; i < props.length; i++) { + props[i] = arguments[i + 2] + vals[i] = obj[props[i]] + } + + return function () { + // restore vals + for (var i = 0; i < props.length; i++) { + obj[props[i]] = vals[i] + } + + return fn.apply(this, arguments) + } +} + +/** + * Send an OPTIONS response. + * + * @private + */ + +function sendOptionsResponse (res, methods) { + var options = Object.create(null) + + // build unique method map + for (var i = 0; i < methods.length; i++) { + options[methods[i]] = true + } + + // construct the allow list + var allow = Object.keys(options).sort().join(', ') + + // send response + res.setHeader('Allow', allow) + res.setHeader('Content-Length', Buffer.byteLength(allow)) + res.setHeader('Content-Type', 'text/plain') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.end(allow) +} + +/** + * Try to send an OPTIONS response. + * + * @private + */ + +function trySendOptionsResponse (res, methods, next) { + try { + sendOptionsResponse(res, methods) + } catch (err) { + next(err) + } +} + +/** + * Wrap a function + * + * @private + */ + +function wrap (old, fn) { + return function proxy () { + var args = new Array(arguments.length + 1) + + args[0] = old + for (var i = 0, len = arguments.length; i < len; i++) { + args[i + 1] = arguments[i] + } + + fn.apply(this, args) + } +} diff --git a/package-lock.json b/package-lock.json index 08861243bf..98a9455b5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,14 +24,14 @@ "fresh": "^0.5.2", "merge-descriptors": "^2.0.0", "methods": "^1.1.2", - "mime-types": "^2.1.34", + "mime-types": "^2.1.35", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "path-is-absolute": "1.0.1", + "path-to-regexp": "^6.2.1", "proxy-addr": "^2.0.7", "qs": "^6.11.2", "range-parser": "^1.2.1", - "router": "2.0.0-beta.1", "send": "^1.0.0-beta.1", "serve-static": "2.0.0-beta.1", "statuses": "^2.0.1", @@ -296,11 +296,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/array-flatten": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", - "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -1920,9 +1915,9 @@ } }, "node_modules/path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" }, "node_modules/pbkdf2-password": { "version": "1.2.1", @@ -2077,22 +2072,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/router": { - "version": "2.0.0-beta.1", - "resolved": "https://registry.npmjs.org/router/-/router-2.0.0-beta.1.tgz", - "integrity": "sha512-GLoYgkhAGAiwVda5nt6Qd4+5RAPuQ4WIYLlZ+mxfYICI+22gnIB3eCfmhgV8+uJNPS1/39DOYi/vdrrz0/ouKA==", - "dependencies": { - "array-flatten": "3.0.0", - "methods": "~1.1.2", - "parseurl": "~1.3.3", - "path-to-regexp": "3.2.0", - "setprototypeof": "1.2.0", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 884360b6d1..1ba198419f 100644 --- a/package.json +++ b/package.json @@ -46,14 +46,14 @@ "fresh": "^0.5.2", "merge-descriptors": "^2.0.0", "methods": "^1.1.2", - "mime-types": "^2.1.34", + "mime-types": "^2.1.35", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "path-is-absolute": "1.0.1", + "path-to-regexp": "^6.2.1", "proxy-addr": "^2.0.7", "qs": "^6.11.2", "range-parser": "^1.2.1", - "router": "2.0.0-beta.1", "send": "^1.0.0-beta.1", "serve-static": "2.0.0-beta.1", "statuses": "^2.0.1", diff --git a/test/App.mjs b/test/App.mjs index 3cacbafa79..6b72c5f53e 100644 --- a/test/App.mjs +++ b/test/App.mjs @@ -14,11 +14,12 @@ describe('app', () => { it('should be callable', () => { const app = express() - assert.equal(typeof app, 'function') + assert.equal(typeof app.handle, 'function') }) it('should 404 without routes', (t, done) => { - request(express()) + const app = express() + request(app.handle.bind(app)) .get('/') .expect(404, done) }) diff --git a/test/AppAll.mjs b/test/AppAll.mjs index 27707bed71..c52e3394b3 100644 --- a/test/AppAll.mjs +++ b/test/AppAll.mjs @@ -14,11 +14,11 @@ describe('app.all()', () => { res.end(req.method) }) - request(app) + request(app.handle.bind(app)) .put('/tobi') .expect(200, 'PUT', cb) - request(app) + request(app.handle.bind(app)) .get('/tobi') .expect(200, 'GET', cb) }) @@ -27,12 +27,12 @@ describe('app.all()', () => { const app = express() let n = 0 - app.all('/*', (req, res, next) => { + app.all('/(.*)', (req, res, next) => { if (n++) return done(new Error('DELETE called several times')) next() }) - request(app) + request(app.handle.bind(app)) .del('/tobi') .expect(404, done) }) diff --git a/test/AppHead.mjs b/test/AppHead.mjs index 1ea9422c9c..3de77edb84 100644 --- a/test/AppHead.mjs +++ b/test/AppHead.mjs @@ -7,39 +7,47 @@ import request from 'supertest' describe('HEAD', () => { it('should default to GET', (t, done) => { - const app = express() - + let app = express() + const server = app.listen(0) app.get('/tobi', (req, res) => { // send() detects HEAD res.send('tobi') }) - request(app) + request(server) .head('/tobi') - .expect(200, done) + .expect(200, () => { + server.close(done) + }) }) it('should output the same headers as GET requests', (t, done) => { const app = express() - + const server = app.listen(0) app.get('/tobi', (req, res) => { // send() detects HEAD res.send('tobi') }) - request(app) + request(server) .head('/tobi') .expect(200, function(err, res){ - if (err) return done(err) + if (err) { + server.close() + return done(err) + } var headers = res.headers - request(app) + request(server) .get('/tobi') .expect(200, function(err, res){ - if (err) return done(err) - delete headers.date + if (err) { + server.close() + return done(err) + } + delete headers.date delete res.headers.date assert.deepEqual(res.headers, headers) - done() + server.close(done) }) }) }) @@ -48,7 +56,7 @@ describe('HEAD', () => { describe('app.head()', () => { it('should override', (t, done) => { const app = express() - + const server = app.listen(0) app.head('/tobi', (req, res) => { res.header('x-method', 'head') res.end() @@ -59,9 +67,9 @@ describe('app.head()', () => { res.send('tobi') }) - request(app) + request(server) .head('/tobi') .expect('x-method', 'head') - .expect(200, done) + .expect(200, () => server.close(done)) }) }) diff --git a/test/AppOptions.mjs b/test/AppOptions.mjs index deaa6c528a..ae22428a7d 100644 --- a/test/AppOptions.mjs +++ b/test/AppOptions.mjs @@ -7,33 +7,35 @@ import request from 'supertest' describe('OPTIONS', () => { it('should default to the routes defined', (t, done) => { const app = express() - + const server = app.listen() app.delete('/', () => {}) app.get('/users', (req, res) => {}) app.put('/users', (req, res) => {}) - request(app) + request(server) .options('/users') .expect('Allow', 'GET, HEAD, PUT') - .expect(200, 'GET, HEAD, PUT', done) + .expect(200, 'GET, HEAD, PUT', () => server.close(done)) }) it('should only include each method once', (t, done) => { const app = express() + const server = app.listen() app.delete('/', () => {}) app.get('/users', (req, res) => {}) app.put('/users', (req, res) => {}) app.get('/users', (req, res) => {}) - request(app) + request(server) .options('/users') .expect('Allow', 'GET, HEAD, PUT') - .expect(200, 'GET, HEAD, PUT', done) + .expect(200, 'GET, HEAD, PUT', () => server.close(done)) }) it('should not be affected by app.all', (t, done) => { const app = express() + const server = app.listen() app.get('/', () => {}) app.get('/users', (req, res) => {}) @@ -43,40 +45,43 @@ describe('OPTIONS', () => { next() }) - request(app) + request(server) .options('/users') .expect('x-hit', '1') .expect('Allow', 'GET, HEAD, PUT') - .expect(200, 'GET, HEAD, PUT', done) + .expect(200, 'GET, HEAD, PUT', () => server.close(done)) }) it('should not respond if the path is not defined', (t, done) => { const app = express() + const server = app.listen() app.get('/users', (req, res) => {}) - request(app) + request(server) .options('/other') - .expect(404, done) + .expect(404, () => server.close(done)) }) it('should forward requests down the middleware chain', (t, done) => { const app = express() + const server = app.listen() const router = new express.Router() router.get('/users', (req, res) => {}) app.use(router) app.get('/other', (req, res) => {}) - request(app) + request(server) .options('/other') .expect('Allow', 'GET, HEAD') - .expect(200, 'GET, HEAD', done) + .expect(200, 'GET, HEAD', () => server.close(done)) }) describe('when error occurs in response handler', () => { it('should pass error to callback', (t, done) => { const app = express() + const server = app.listen() const router = express.Router() router.get('/users', (req, res) => {}) @@ -90,9 +95,9 @@ describe('OPTIONS', () => { res.end('true') }) - request(app) + request(server) .options('/users') - .expect(200, 'true', done) + .expect(200, 'true', () => server.close(done)) }) }) }) @@ -100,7 +105,7 @@ describe('OPTIONS', () => { describe('app.options()', () => { it('should override the default behavior', (t, done) => { const app = express() - + const server = app.listen() app.options('/users', (req, res) => { res.set('Allow', 'GET') res.send('GET') @@ -109,9 +114,9 @@ describe('app.options()', () => { app.get('/users', (req, res) => {}) app.put('/users', (req, res) => {}) - request(app) + request(server) .options('/users') .expect('GET') - .expect('Allow', 'GET', done) + .expect('Allow', 'GET', () => server.close(done)) }) }) diff --git a/test/AppParam.mjs b/test/AppParam.mjs index ad6eb6645d..38f5cc7d46 100644 --- a/test/AppParam.mjs +++ b/test/AppParam.mjs @@ -9,7 +9,7 @@ describe('app', () => { describe('.param(names, fn)', () => { it('should map the array', (t, done) => { const app = express() - + const server = app.listen() app.param(['id', 'uid'], function(req, res, next, id){ id = Number(id) if (isNaN(id)) return next('route') @@ -27,13 +27,16 @@ describe('app', () => { res.send((typeof id) + ':' + id) }) - request(app) + request(server) .get('/user/123') .expect(200, 'number:123', err => { - if (err) return done(err) + if (err) { + server.close() + return done(err) + } request(app) .get('/post/123') - .expect('number:123', done) + .expect('number:123', () => server.close(done)) }) }) }) @@ -41,7 +44,7 @@ describe('app', () => { describe('.param(name, fn)', () => { it('should map logic for a single param', (t, done) => { const app = express() - + const server = app.listen() app.param('id', function(req, res, next, id){ id = Number(id) if (isNaN(id)) return next('route') @@ -54,13 +57,14 @@ describe('app', () => { res.send((typeof id) + ':' + id) }) - request(app) + request(server) .get('/user/123') - .expect(200, 'number:123', done) + .expect(200, 'number:123', () => server.close(done)) }) it('should only call once per request', (t, done) => { const app = express() + const server = app.listen() var called = 0 var count = 0 @@ -82,13 +86,14 @@ describe('app', () => { res.end([count, called, req.user].join(' ')) }) - request(app) + request(server) .get('/foo/bob') - .expect('2 1 bob', done) + .expect('2 1 bob', ()=> server.close(done)) }) it('should call when values differ', (t, done) => { const app = express() + const server = app.listen() var called = 0 var count = 0 @@ -110,14 +115,14 @@ describe('app', () => { res.end([count, called, req.users.join(',')].join(' ')) }) - request(app) + request(server) .get('/foo/bob') - .expect('2 2 foo,bob', done) + .expect('2 2 foo,bob', ()=> server.close(done)) }) it('should support altering req.params across routes', (t, done) => { const app = express() - + const server = app.listen() app.param('user', function(req, res, next, user) { req.params.user = 'loki' next() @@ -130,14 +135,14 @@ describe('app', () => { res.send(req.params.user) }) - request(app) + request(server) .get('/bob') - .expect('loki', done) + .expect('loki', ()=> server.close(done)) }) it('should not invoke without route handler', (t, done) => { const app = express() - + const server = app.listen() app.param('thing', function(req, res, next, thing) { req.thing = thing next() @@ -155,14 +160,14 @@ describe('app', () => { res.send(req.thing) }) - request(app) + request(server) .get('/bob') - .expect(200, 'bob', done) + .expect(200, 'bob', ()=> server.close(done)) }) it('should work with encoded values', (t, done) => { const app = express() - + const server = app.listen() app.param('name', function(req, res, next, name){ req.params.name = name next() @@ -173,14 +178,14 @@ describe('app', () => { res.send('' + name) }) - request(app) + request(server) .get('/user/foo%25bar') - .expect('foo%bar', done) + .expect('foo%bar', ()=> server.close(done)) }) it('should catch thrown error', (t, done) => { const app = express() - + const server = app.listen() app.param('id', function(req, res, next, id){ throw new Error('err!') }) @@ -190,14 +195,14 @@ describe('app', () => { res.send('' + id) }) - request(app) + request(server) .get('/user/123') - .expect(500, done) + .expect(500, server.close(done)) }) it('should catch thrown secondary error', (t, done) => { const app = express() - + const server = app.listen() app.param('id', function(req, res, next, val){ process.nextTick(next) }) @@ -211,14 +216,14 @@ describe('app', () => { res.send('' + id) }) - request(app) + request(server) .get('/user/123') - .expect(500, done) + .expect(500, ()=> server.close(done)) }) it('should defer to next route', (t, done) => { const app = express() - + const server = app.listen() app.param('id', function(req, res, next, id){ next('route') }) @@ -232,14 +237,14 @@ describe('app', () => { res.send('name') }) - request(app) + request(server) .get('/user/123') - .expect('name', done) + .expect('name', ()=> server.close(done)) }) it('should defer all the param routes', (t, done) => { const app = express() - + const server = app.listen() app.param('id', function(req, res, next, val){ if (val === 'new') return next('route') return next() @@ -257,13 +262,14 @@ describe('app', () => { res.send('get.new') }) - request(app) + request(server) .get('/user/new') - .expect('get.new', done) + .expect('get.new', () => server.close(done)) }) it('should not call when values differ on error', (t, done) => { const app = express() + const server = app.listen() var called = 0 var count = 0 @@ -288,13 +294,14 @@ describe('app', () => { res.send([count, called, err.message].join(' ')) }) - request(app) + request(server) .get('/foo/bob') - .expect(500, '0 1 err!', done) + .expect(500, '0 1 err!', ()=> server.close(done)) }) it('should call when values differ when using "next"', (t, done) => { const app = express() + const server = app.listen() var called = 0 var count = 0 @@ -317,9 +324,9 @@ describe('app', () => { res.end([count, called, req.user].join(' ')) }) - request(app) + request(server) .get('/foo/bob') - .expect('1 2 bob', done) + .expect('1 2 bob', ()=> server.close(done)) }) }) }) diff --git a/test/AppRequest.mjs b/test/AppRequest.mjs index 5c1488a018..ffd0f0ccd3 100644 --- a/test/AppRequest.mjs +++ b/test/AppRequest.mjs @@ -10,7 +10,7 @@ describe('app', () => { describe('.request', () => { it('should extend the request prototype', (t, done) => { const app = express() - + const server = app.listen() app.request.querystring = function () { return new URL(this.url, 'http://localhost').search.replace('?', '') } @@ -19,15 +19,17 @@ describe('app', () => { res.end(req.querystring()) }) - request(app) + request(server) .get('/foo?name=tobi') - .expect('name=tobi', done) + .expect('name=tobi', ()=> server.close(done)) }) it('should only extend for the referenced app', (t, done) => { const app1 = express() const app2 = express() const cb = after(2, done) + app1.name = 'app1' + app2.name = 'app2' app1.request.foobar = function () { return 'tobi' @@ -54,6 +56,8 @@ describe('app', () => { const app1 = express() const app2 = express() const cb = after(2, done) + app1.name = 'app1' + app2.name = 'app2' app1.request.foobar = () => { return 'tobi' @@ -114,7 +118,8 @@ describe('app', () => { const app1 = express() const app2 = express() const cb = after(2, done) - + app1.name = 'app1' + app2.name = 'app2' app1.request.foobar = () => { return 'tobi' } diff --git a/test/AppRouter.mjs b/test/AppRouter.mjs index 97557f5bde..dc86d76191 100644 --- a/test/AppRouter.mjs +++ b/test/AppRouter.mjs @@ -322,10 +322,10 @@ describe('app.router', () => { res.send(keys.map(k => [k, req.params[k]] )) }) - app.use('/user/id:(\\d+)', router) + app.use('/user/id-(\\d+)', router) request(app) - .get('/user/id:10/profile.json') + .get('/user/id-10/profile.json') .expect(200, '[["0","10"],["1","profile"],["2","json"]]', done) }) @@ -338,10 +338,10 @@ describe('app.router', () => { res.send(keys.map(k => [k, req.params[k]] )) }) - app.use('/user/id:(\\d+)/name:(\\w+)', router) + app.use('/user/id-(\\d+)/name-(\\w+)', router) request(app) - .get('/user/id:10/name:tj/profile') + .get('/user/id-10/name-tj/profile') .expect(200, '[["0","10"],["1","tj"],["2","profile"]]', done) }) @@ -349,15 +349,15 @@ describe('app.router', () => { const app = express() const router = new express.Router({ mergeParams: true }) - router.get('/name:(\\w+)', (req, res) => { + router.get('/name-(\\w+)', (req, res) => { const keys = Object.keys(req.params).sort() res.send(keys.map(k => [k, req.params[k]] )) }) - app.use('/user/id:(\\d+)', router) + app.use('/user/id-(\\d+)', router) request(app) - .get('/user/id:10/name:tj') + .get('/user/id-10/name-tj') .expect(200, '[["0","10"],["1","tj"]]', done) }) @@ -384,11 +384,11 @@ describe('app.router', () => { const app = express() const router = new express.Router({ mergeParams: true }) - router.get('/user:(\\w+)/*', (req, res, next) => { + router.get('/user-(\\w+)/(.*)', (req, res, next) => { next() }) - app.use('/user/id:(\\d+)', (req, res, next) => { + app.use('/user/id-(\\d+)', (req, res, next) => { router(req, res, err => { const keys = Object.keys(req.params).sort() res.send(keys.map(k => [k, req.params[k]] )) @@ -396,7 +396,7 @@ describe('app.router', () => { }) request(app) - .get('/user/id:42/user:tj/profile') + .get('/user/id-42/user-tj/profile') .expect(200, '[["0","42"]]', done) }) }) @@ -483,17 +483,17 @@ describe('app.router', () => { .expect('tj', done) }) - it('should match middleware when omitting the trailing slash', (t, done) => { + it('should not match middleware when omitting the trailing slash and setting strict routing', (t, done) => { const app = express() - app.enable('strict routing') + app.disable('strict routing') app.use('/user/', (req, res) => { res.end('tj') }) request(app) - .get('/user') + .get('/user/') .expect(200, done) }) @@ -514,7 +514,7 @@ describe('app.router', () => { it('should match middleware when adding the trailing slash', (t, done) => { const app = express() - app.enable('strict routing') + app.disable('strict routing') app.use('/user', (req, res) => { res.end('tj') diff --git a/test/AppUse.mjs b/test/AppUse.mjs index 4ca026d2bf..058faaff7d 100644 --- a/test/AppUse.mjs +++ b/test/AppUse.mjs @@ -505,25 +505,26 @@ describe('app', () => { it('should support regexp path', (t, done) => { const app = express() - const cb = after(4, done) + const server = app.listen() + const cb = after(4, () => server.close(done)) app.use(/^\/[a-z]oo/, (req, res) => { res.send('saw ' + req.method + ' ' + req.url + ' through ' + req.originalUrl) }) - request(app) + request(server) .get('/') .expect(404, cb) - request(app) + request(server) .get('/foo') .expect(200, 'saw GET / through /foo', cb) - request(app) + request(server) .get('/zoo/bear') .expect(200, 'saw GET /bear through /zoo/bear', cb) - request(app) + request(server) .get('/get/zoo') .expect(404, cb) }) diff --git a/test/Exports.mjs b/test/Exports.mjs index e34999c9ed..1f463ee7a5 100644 --- a/test/Exports.mjs +++ b/test/Exports.mjs @@ -1,6 +1,7 @@ 'use strict' import { describe, it } from 'node:test' +import ExpressApp from '../lib/application.js' import express from '../lib/express.js' import request from 'supertest' import assert from 'node:assert' @@ -35,49 +36,49 @@ describe('exports', () => { assert.equal(express.urlencoded.length, 1) }) - it('should expose the application prototype', () => { - assert.strictEqual(typeof express.application, 'object') - assert.strictEqual(typeof express.application.set, 'function') - }) - - it('should expose the request prototype', () => { - assert.strictEqual(typeof express.request, 'object') - assert.strictEqual(typeof express.request.accepts, 'function') - }) - - it('should expose the response prototype', () => { - assert.strictEqual(typeof express.response, 'object') - assert.strictEqual(typeof express.response.send, 'function') - }) - - it('should permit modifying the .application prototype', () => { - express.application.foo = () => 'bar' - assert.strictEqual(express().foo(), 'bar') - }) - - it('should permit modifying the .request prototype', (t, done) => { - express.request.foo = () => 'bar' - const app = express() + it('should permit adding to the .request', (t, done) => { + class CustomApp extends ExpressApp { + constructor() { + super() + this.request = { + foo () { + return 'bar' + } + } + } + } + const app = express(CustomApp) + const server = app.listen() app.use((req, res, next) => { res.end(req.foo()) }) - request(app) + request(server) .get('/') - .expect('bar', done) + .expect('bar', () => server.close(done)) }) - it('should permit modifying the .response prototype', (t, done) => { - express.response.foo = function() { this.send('bar') } - const app = express() + it('should permit adding to the .response', (t, done) => { + class CustomApp extends ExpressApp { + constructor() { + super() + this.response = { + foo () { + this.send('bar') + } + } + } + } + const app = express(CustomApp) + const server = app.listen() app.use((req, res, next) => { res.foo() }) - request(app) + request(server) .get('/') - .expect('bar', done) + .expect('bar', () => server.close(done)) }) }) diff --git a/test/ExpressJson.mjs b/test/ExpressJson.mjs index 0b04a2e757..ec47349548 100644 --- a/test/ExpressJson.mjs +++ b/test/ExpressJson.mjs @@ -1,90 +1,105 @@ 'use strict' -import { describe, it, before } from 'node:test' +import { describe, it, before, after } from 'node:test' import express from '../lib/express.js' import request from 'supertest' import assert from 'node:assert' -import tryImport from './support/TryImport.mjs' describe('express.json()', () => { - it('should parse JSON', (t, done) => { - request(createApp()) - .post('/') - .set('Content-Type', 'application/json') - .send('{"user":"tobi"}') - .expect(200, '{"user":"tobi"}', done) - }) - - it('should handle Content-Length: 0', (t, done) => { - request(createApp()) - .post('/') - .set('Content-Type', 'application/json') - .set('Content-Length', '0') - .expect(200, '{}', done) - }) - - it('should handle empty message-body', (t, done) => { - request(createApp()) - .post('/') - .set('Content-Type', 'application/json') - .set('Transfer-Encoding', 'chunked') - .expect(200, '{}', done) - }) - - it('should handle no message-body', (t, done) => { - request(createApp()) - .post('/') - .set('Content-Type', 'application/json') - .unset('Transfer-Encoding') - .expect(200, '{}', done) - }) - - it('should 400 when invalid content-length', (t, done) => { - const app = express() - - app.use((req, res, next) => { - req.headers['content-length'] = '20' // bad length - next() + describe('parsing', () => { + let app = null + let server = null + before( async () => { + app = createApp() + server = app.listen() }) - - app.use(express.json()) - - app.post('/', (req, res) => { - res.json(req.body) + after( async () => { + server.close() }) - - request(app) - .post('/') - .set('Content-Type', 'application/json') - .send('{"str":') - .expect(400, /content length/, done) + it('should parse JSON', (t, done) => { + request(server) + .post('/') + .set('Content-Type', 'application/json') + .send('{"user":"tobi"}') + .expect(200, '{"user":"tobi"}', done) + }) + + it('should handle Content-Length: 0', (t, done) => { + request(server) + .post('/') + .set('Content-Type', 'application/json') + .set('Content-Length', '0') + .expect(200, '{}', done) + }) + + it('should handle empty message-body', (t, done) => { + request(server) + .post('/') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .expect(200, '{}', done) + }) + + it('should handle no message-body', (t, done) => { + request(server) + .post('/') + .set('Content-Type', 'application/json') + .unset('Transfer-Encoding') + .expect(200, '{}', done) + }) }) - it('should handle duplicated middleware', (t, done) => { - const app = express() - - app.use(express.json()) - app.use(express.json()) - - app.post('/', (req, res) => { - res.json(req.body) + describe('error handling', () => { + it('should 400 when invalid content-length', (t, done) => { + const app = express() + const server = app.listen() + app.use((req, res, next) => { + req.headers['content-length'] = '20' // bad length + next() + }) + app.use(express.json()) + app.post('/', (req, res) => { + res.json(req.body) + }) + request(server) + .post('/') + .set('Content-Type', 'application/json') + .send('{"str":') + .expect(400, /BadRequestError/, () => server.close(done)) }) + + it('should handle duplicated middleware', (t, done) => { + const app = express() + const server = app.listen() - request(app) - .post('/') - .set('Content-Type', 'application/json') - .send('{"user":"tobi"}') - .expect(200, '{"user":"tobi"}', done) + app.use(express.json()) + app.use(express.json()) + + app.post('/', (req, res) => { + res.json(req.body) + }) + + request(server) + .post('/') + .set('Content-Type', 'application/json') + .send('{"user":"tobi"}') + .expect(200, '{"user":"tobi"}', () => server.close(done)) + }) }) describe('when JSON is invalid', () => { let app = null + let server = null before(() => { app = createApp() + server = app.listen() + }) + after(() => { + server.close() }) it('should 400 for bad token', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('{:') @@ -92,7 +107,7 @@ describe('express.json()', () => { }) it('should 400 for incomplete', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('{"user"') @@ -100,7 +115,7 @@ describe('express.json()', () => { }) it('should error with type = "entity.parse.failed"', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'type') @@ -109,7 +124,7 @@ describe('express.json()', () => { }) it('should include original body on error object', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'body') @@ -119,9 +134,18 @@ describe('express.json()', () => { }) describe('with limit option', () => { + let app = null + let server = null + before(() => { + app = createApp({ limit: '1kb' }) + server = app.listen() + }) + after(() => { + server.close() + }) it('should 413 when over limit with Content-Length', (t, done) => { const buf = Buffer.alloc(1024, '.') - request(createApp({ limit: '1kb' })) + request(server) .post('/') .set('Content-Type', 'application/json') .set('Content-Length', '1034') @@ -131,7 +155,7 @@ describe('express.json()', () => { it('should error with type = "entity.too.large"', (t, done) => { const buf = Buffer.alloc(1024, '.') - request(createApp({ limit: '1kb' })) + request(server) .post('/') .set('Content-Type', 'application/json') .set('Content-Length', '1034') @@ -142,7 +166,6 @@ describe('express.json()', () => { it('should 413 when over limit with chunked encoding', (t, done) => { const buf = Buffer.alloc(1024, '.') - const server = createApp({ limit: '1kb' }) const test = request(server).post('/') test.set('Content-Type', 'application/json') test.set('Transfer-Encoding', 'chunked') @@ -150,51 +173,61 @@ describe('express.json()', () => { test.write('"' + buf.toString() + '"}') test.expect(413, done) }) + }) + describe('change limits', () => { it('should accept number of bytes', (t, done) => { const buf = Buffer.alloc(1024, '.') - request(createApp({ limit: 1024 })) + const app = createApp({ limit: 1024 }) + const server = app.listen() + request(server) .post('/') .set('Content-Type', 'application/json') .send(JSON.stringify({ str: buf.toString() })) - .expect(413, done) + .expect(413, () => server.close(done)) }) it('should not change when options altered', (t, done) => { const buf = Buffer.alloc(1024, '.') const options = { limit: '1kb' } - const server = createApp(options) - + const app = createApp(options) + const server = app.listen() options.limit = '100kb' request(server) .post('/') .set('Content-Type', 'application/json') .send(JSON.stringify({ str: buf.toString() })) - .expect(413, done) + .expect(413, () => server.close(done)) }) it('should not hang response', (t, done) => { const buf = Buffer.alloc(10240, '.') - const server = createApp({ limit: '8kb' }) + const app = createApp({ limit: '8kb' }) + const server = app.listen() const test = request(server).post('/') test.set('Content-Type', 'application/json') test.write(buf) test.write(buf) test.write(buf) - test.expect(413, done) + test.expect(413, () => server.close(done)) }) }) describe('with inflate option', () => { describe('when false', () => { let app = null + let server = null before(() => { app = createApp({ inflate: false }) + server = app.listen() + }) + after(() => { + server.close() }) it('should not accept content-encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'gzip') test.set('Content-Type', 'application/json') test.write(Buffer.from('1f8b080000000000000bab56ca4bcc4d55b2527ab16e97522d00515be1cc0e000000', 'hex')) @@ -204,12 +237,17 @@ describe('express.json()', () => { describe('when true', () => { let app = null + let server = null before(() => { app = createApp({ inflate: true }) + server = app.listen() + }) + after(() => { + server.close() }) it('should accept content-encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'gzip') test.set('Content-Type', 'application/json') test.write(Buffer.from('1f8b080000000000000bab56ca4bcc4d55b2527ab16e97522d00515be1cc0e000000', 'hex')) @@ -221,12 +259,17 @@ describe('express.json()', () => { describe('with strict option', () => { describe('when undefined', () => { let app = null + let server = null before(() => { app = createApp() + server = app.listen() + }) + after(() => { + server.close() }) it('should 400 on primitives', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('true') @@ -236,12 +279,17 @@ describe('express.json()', () => { describe('when false', () => { let app = null + let server = null before(() => { app = createApp({ strict: false }) + server = app.listen() + }) + after(() => { + server.close() }) it('should parse primitives', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('true') @@ -251,12 +299,17 @@ describe('express.json()', () => { describe('when true', () => { let app = null + let server = null before(() => { app = createApp({ strict: true }) + server = app.listen() + }) + after(() => { + server.close() }) it('should not parse primitives', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('true') @@ -264,7 +317,7 @@ describe('express.json()', () => { }) it('should not parse primitives with leading whitespaces', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send(' true') @@ -272,7 +325,7 @@ describe('express.json()', () => { }) it('should allow leading whitespaces in JSON', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send(' { "user": "tobi" }') @@ -280,7 +333,7 @@ describe('express.json()', () => { }) it('should error with type = "entity.parse.failed"', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'type') @@ -289,7 +342,7 @@ describe('express.json()', () => { }) it('should include correct message in stack trace', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'stack') @@ -304,12 +357,17 @@ describe('express.json()', () => { describe('with type option', () => { describe('when "application/vnd.api+json"', () => { let app = null + let server = null before(() => { app = createApp({ type: 'application/vnd.api+json' }) + server = app.listen() + }) + after(() => { + server.close() }) it('should parse JSON for custom type', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/vnd.api+json') .send('{"user":"tobi"}') @@ -317,7 +375,7 @@ describe('express.json()', () => { }) it('should ignore standard type', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('{"user":"tobi"}') @@ -327,14 +385,19 @@ describe('express.json()', () => { describe('when ["application/json", "application/vnd.api+json"]', () => { let app = null + let server = null before(() => { app = createApp({ type: ['application/json', 'application/vnd.api+json'] }) + server = app.listen() + }) + after(() => { + server.close() }) it('should parse JSON for "application/json"', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('{"user":"tobi"}') @@ -342,7 +405,7 @@ describe('express.json()', () => { }) it('should parse JSON for "application/vnd.api+json"', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/vnd.api+json') .send('{"user":"tobi"}') @@ -350,7 +413,7 @@ describe('express.json()', () => { }) it('should ignore "application/x-json"', (t, done) => { - request(app) + request(server) .post('/') .set('Content-Type', 'application/x-json') .send('{"user":"tobi"}') @@ -361,40 +424,40 @@ describe('express.json()', () => { describe('when a function', () => { it('should parse when truthy value returned', (t, done) => { const app = createApp({ type: accept }) - + const server = app.listen() function accept(req) { return req.headers['content-type'] === 'application/vnd.api+json' } - request(app) + request(server) .post('/') .set('Content-Type', 'application/vnd.api+json') .send('{"user":"tobi"}') - .expect(200, '{"user":"tobi"}', done) + .expect(200, '{"user":"tobi"}', () => server.close(done)) }) it('should work without content-type', (t, done) => { const app = createApp({ type: accept }) - + const server = app.listen() function accept(req) { return true } - const test = request(app).post('/') + const test = request(server).post('/') test.write('{"user":"tobi"}') - test.expect(200, '{"user":"tobi"}', done) + test.expect(200, '{"user":"tobi"}', () => server.close(done)) }) it('should not invoke without a body', (t, done) => { const app = createApp({ type: accept }) - + const server = app.listen() function accept(req) { throw new Error('oops!') } - request(app) + request(server) .get('/') - .expect(404, done) + .expect(404, () => server.close(done)) }) }) }) @@ -411,12 +474,13 @@ describe('express.json()', () => { if (buf[0] === 0x5b) throw new Error('no arrays') } }) + const server = app.listen() - request(app) + request(server) .post('/') .set('Content-Type', 'application/json') .send('["tobi"]') - .expect(403, 'no arrays', done) + .expect(403, 'no arrays', () => server.close(done)) }) it('should error with type = "entity.verify.failed"', (t, done) => { @@ -425,13 +489,13 @@ describe('express.json()', () => { if (buf[0] === 0x5b) throw new Error('no arrays') } }) - - request(app) + const server = app.listen() + request(server) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'type') .send('["tobi"]') - .expect(403, 'entity.verify.failed', done) + .expect(403, 'entity.verify.failed', () => server.close(done)) }) it('should allow custom codes', (t, done) => { @@ -443,12 +507,12 @@ describe('express.json()', () => { throw err } }) - - request(app) + const server = app.listen() + request(server) .post('/') .set('Content-Type', 'application/json') .send('["tobi"]') - .expect(400, 'no arrays', done) + .expect(400, 'no arrays', () => server.close(done)) }) it('should allow custom type', (t, done) => { @@ -460,13 +524,13 @@ describe('express.json()', () => { throw err } }) - - request(app) + const server = app.listen() + request(server) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'type') .send('["tobi"]') - .expect(403, 'foo.bar', done) + .expect(403, 'foo.bar', () => server.close(done)) }) it('should include original body on error object', (t, done) => { @@ -475,13 +539,13 @@ describe('express.json()', () => { if (buf[0] === 0x5b) throw new Error('no arrays') } }) - + const server = app.listen() request(app) .post('/') .set('Content-Type', 'application/json') .set('X-Error-Property', 'body') .send('["tobi"]') - .expect(403, '["tobi"]', done) + .expect(403, '["tobi"]', () => server.close(done)) }) it('should allow pass-through', (t, done) => { @@ -490,12 +554,12 @@ describe('express.json()', () => { if (buf[0] === 0x5b) throw new Error('no arrays') } }) - - request(app) + const server = app.listen() + request(server) .post('/') .set('Content-Type', 'application/json') .send('{"user":"tobi"}') - .expect(200, '{"user":"tobi"}', done) + .expect(200, '{"user":"tobi"}', () => server.close(done)) }) it('should work with different charsets', (t, done) => { @@ -504,11 +568,11 @@ describe('express.json()', () => { if (buf[0] === 0x5b) throw new Error('no arrays') } }) - - const test = request(app).post('/') + const server = app.listen() + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=utf-16') test.write(Buffer.from('feff007b0022006e0061006d00650022003a00228bba0022007d', 'hex')) - test.expect(200, '{"name":"论"}', done) + test.expect(200, '{"name":"论"}', () => server.close(done)) }) it('should 415 on unknown charset prior to verify', (t, done) => { @@ -517,36 +581,41 @@ describe('express.json()', () => { throw new Error('unexpected verify call') } }) - - const test = request(app).post('/') + const server = app.listen() + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=x-bogus') test.write(Buffer.from('00000000', 'hex')) - test.expect(415, 'unsupported charset "X-BOGUS"', done) + test.expect(415, 'unsupported charset "X-BOGUS"', () => server.close(done)) }) }) describe('charset', () => { let app = null + let server = null before(() => { app = createApp() + server = app.listen() + }) + after(() => { + server.close() }) it('should parse utf-8', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=utf-8') test.write(Buffer.from('7b226e616d65223a22e8aeba227d', 'hex')) test.expect(200, '{"name":"论"}', done) }) it('should parse utf-16', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=utf-16') test.write(Buffer.from('feff007b0022006e0061006d00650022003a00228bba0022007d', 'hex')) test.expect(200, '{"name":"论"}', done) }) it('should parse when content-length != char length', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=utf-8') test.set('Content-Length', '13') test.write(Buffer.from('7b2274657374223a22c3a5227d', 'hex')) @@ -554,21 +623,21 @@ describe('express.json()', () => { }) it('should default to utf-8', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json') test.write(Buffer.from('7b226e616d65223a22e8aeba227d', 'hex')) test.expect(200, '{"name":"论"}', done) }) it('should fail on unknown charset', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=koi8-r') test.write(Buffer.from('7b226e616d65223a22cec5d4227d', 'hex')) test.expect(415, 'unsupported charset "KOI8-R"', done) }) it('should error with type = "charset.unsupported"', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json; charset=koi8-r') test.set('X-Error-Property', 'type') test.write(Buffer.from('7b226e616d65223a22cec5d4227d', 'hex')) @@ -578,19 +647,24 @@ describe('express.json()', () => { describe('encoding', () => { let app = null + let server = null before(() => { app = createApp({ limit: '1kb' }) + server = app.listen() + }) + after(() => { + server.close() }) it('should parse without encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Type', 'application/json') test.write(Buffer.from('7b226e616d65223a22e8aeba227d', 'hex')) test.expect(200, '{"name":"论"}', done) }) it('should support identity encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'identity') test.set('Content-Type', 'application/json') test.write(Buffer.from('7b226e616d65223a22e8aeba227d', 'hex')) @@ -598,7 +672,7 @@ describe('express.json()', () => { }) it('should support gzip encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'gzip') test.set('Content-Type', 'application/json') test.write(Buffer.from('1f8b080000000000000bab56ca4bcc4d55b2527ab16e97522d00515be1cc0e000000', 'hex')) @@ -606,7 +680,7 @@ describe('express.json()', () => { }) it('should support deflate encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'deflate') test.set('Content-Type', 'application/json') test.write(Buffer.from('789cab56ca4bcc4d55b2527ab16e97522d00274505ac', 'hex')) @@ -614,7 +688,7 @@ describe('express.json()', () => { }) it('should be case-insensitive', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'GZIP') test.set('Content-Type', 'application/json') test.write(Buffer.from('1f8b080000000000000bab56ca4bcc4d55b2527ab16e97522d00515be1cc0e000000', 'hex')) @@ -622,7 +696,7 @@ describe('express.json()', () => { }) it('should 415 on unknown encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'nulls') test.set('Content-Type', 'application/json') test.write(Buffer.from('000000000000', 'hex')) @@ -630,7 +704,7 @@ describe('express.json()', () => { }) it('should error with type = "encoding.unsupported"', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'nulls') test.set('Content-Type', 'application/json') test.set('X-Error-Property', 'type') @@ -639,7 +713,7 @@ describe('express.json()', () => { }) it('should 400 on malformed encoding', (t, done) => { - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'gzip') test.set('Content-Type', 'application/json') test.write(Buffer.from('1f8b080000000000000bab56cc4d55b2527ab16e97522d00515be1cc0e000000', 'hex')) @@ -648,7 +722,7 @@ describe('express.json()', () => { it('should 413 when inflated value exceeds limit', (t, done) => { // gzip'd data exceeds 1kb, but deflated below 1kb - const test = request(app).post('/') + const test = request(server).post('/') test.set('Content-Encoding', 'gzip') test.set('Content-Type', 'application/json') test.write(Buffer.from('1f8b080000000000000bedc1010d000000c2a0f74f6d0f071400000000000000', 'hex'))