rc@73: /** rc@73: * Module dependencies. rc@73: */ rc@73: rc@73: var escapeHtml = require('escape-html'); rc@73: var http = require('http'); rc@73: var path = require('path'); rc@73: var mixin = require('utils-merge'); rc@73: var sign = require('cookie-signature').sign; rc@73: var normalizeType = require('./utils').normalizeType; rc@73: var normalizeTypes = require('./utils').normalizeTypes; rc@73: var setCharset = require('./utils').setCharset; rc@73: var contentDisposition = require('./utils').contentDisposition; rc@73: var deprecate = require('./utils').deprecate; rc@73: var statusCodes = http.STATUS_CODES; rc@73: var cookie = require('cookie'); rc@73: var send = require('send'); rc@73: var basename = path.basename; rc@73: var extname = path.extname; rc@73: var mime = send.mime; rc@73: var vary = require('vary'); rc@73: rc@73: /** rc@73: * Response prototype. rc@73: */ rc@73: rc@73: var res = module.exports = { rc@73: __proto__: http.ServerResponse.prototype rc@73: }; rc@73: rc@73: /** rc@73: * Set status `code`. rc@73: * rc@73: * @param {Number} code rc@73: * @return {ServerResponse} rc@73: * @api public rc@73: */ rc@73: rc@73: res.status = function(code){ rc@73: this.statusCode = code; rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Set Link header field with the given `links`. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.links({ rc@73: * next: 'http://api.example.com/users?page=2', rc@73: * last: 'http://api.example.com/users?page=5' rc@73: * }); rc@73: * rc@73: * @param {Object} links rc@73: * @return {ServerResponse} rc@73: * @api public rc@73: */ rc@73: rc@73: res.links = function(links){ rc@73: var link = this.get('Link') || ''; rc@73: if (link) link += ', '; rc@73: return this.set('Link', link + Object.keys(links).map(function(rel){ rc@73: return '<' + links[rel] + '>; rel="' + rel + '"'; rc@73: }).join(', ')); rc@73: }; rc@73: rc@73: /** rc@73: * Send a response. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.send(new Buffer('wahoo')); rc@73: * res.send({ some: 'json' }); rc@73: * res.send('

some html

'); rc@73: * res.send(404, 'Sorry, cant find that'); rc@73: * res.send(404); rc@73: * rc@73: * @api public rc@73: */ rc@73: rc@73: res.send = function(body){ rc@73: var req = this.req; rc@73: var head = 'HEAD' == req.method; rc@73: var type; rc@73: var encoding; rc@73: var len; rc@73: rc@73: // settings rc@73: var app = this.app; rc@73: rc@73: // allow status / body rc@73: if (2 == arguments.length) { rc@73: // res.send(body, status) backwards compat rc@73: if ('number' != typeof body && 'number' == typeof arguments[1]) { rc@73: this.statusCode = arguments[1]; rc@73: } else { rc@73: this.statusCode = body; rc@73: body = arguments[1]; rc@73: } rc@73: } rc@73: rc@73: switch (typeof body) { rc@73: // response status rc@73: case 'number': rc@73: this.get('Content-Type') || this.type('txt'); rc@73: this.statusCode = body; rc@73: body = http.STATUS_CODES[body]; rc@73: break; rc@73: // string defaulting to html rc@73: case 'string': rc@73: if (!this.get('Content-Type')) this.type('html'); rc@73: break; rc@73: case 'boolean': rc@73: case 'object': rc@73: if (null == body) { rc@73: body = ''; rc@73: } else if (Buffer.isBuffer(body)) { rc@73: this.get('Content-Type') || this.type('bin'); rc@73: } else { rc@73: return this.json(body); rc@73: } rc@73: break; rc@73: } rc@73: rc@73: // write strings in utf-8 rc@73: if ('string' === typeof body) { rc@73: encoding = 'utf8'; rc@73: type = this.get('Content-Type'); rc@73: rc@73: // reflect this in content-type rc@73: if ('string' === typeof type) { rc@73: this.set('Content-Type', setCharset(type, 'utf-8')); rc@73: } rc@73: } rc@73: rc@73: // populate Content-Length rc@73: if (undefined !== body && !this.get('Content-Length')) { rc@73: len = Buffer.isBuffer(body) rc@73: ? body.length rc@73: : Buffer.byteLength(body, encoding); rc@73: this.set('Content-Length', len); rc@73: } rc@73: rc@73: // ETag support rc@73: var etag = len !== undefined && app.get('etag fn'); rc@73: if (etag && ('GET' === req.method || 'HEAD' === req.method)) { rc@73: if (!this.get('ETag')) { rc@73: etag = etag(body, encoding); rc@73: etag && this.set('ETag', etag); rc@73: } rc@73: } rc@73: rc@73: // freshness rc@73: if (req.fresh) this.statusCode = 304; rc@73: rc@73: // strip irrelevant headers rc@73: if (204 == this.statusCode || 304 == this.statusCode) { rc@73: this.removeHeader('Content-Type'); rc@73: this.removeHeader('Content-Length'); rc@73: this.removeHeader('Transfer-Encoding'); rc@73: body = ''; rc@73: } rc@73: rc@73: // respond rc@73: this.end((head ? null : body), encoding); rc@73: rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Send JSON response. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.json(null); rc@73: * res.json({ user: 'tj' }); rc@73: * res.json(500, 'oh noes!'); rc@73: * res.json(404, 'I dont have that'); rc@73: * rc@73: * @api public rc@73: */ rc@73: rc@73: res.json = function(obj){ rc@73: // allow status / body rc@73: if (2 == arguments.length) { rc@73: // res.json(body, status) backwards compat rc@73: if ('number' == typeof arguments[1]) { rc@73: this.statusCode = arguments[1]; rc@73: return 'number' === typeof obj rc@73: ? jsonNumDeprecated.call(this, obj) rc@73: : jsonDeprecated.call(this, obj); rc@73: } else { rc@73: this.statusCode = obj; rc@73: obj = arguments[1]; rc@73: } rc@73: } rc@73: rc@73: // settings rc@73: var app = this.app; rc@73: var replacer = app.get('json replacer'); rc@73: var spaces = app.get('json spaces'); rc@73: var body = JSON.stringify(obj, replacer, spaces); rc@73: rc@73: // content-type rc@73: this.get('Content-Type') || this.set('Content-Type', 'application/json'); rc@73: rc@73: return this.send(body); rc@73: }; rc@73: rc@73: var jsonDeprecated = deprecate(res.json, rc@73: 'res.json(obj, status): Use res.json(status, obj) instead'); rc@73: rc@73: var jsonNumDeprecated = deprecate(res.json, rc@73: 'res.json(num, status): Use res.status(status).json(num) instead'); rc@73: rc@73: /** rc@73: * Send JSON response with JSONP callback support. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.jsonp(null); rc@73: * res.jsonp({ user: 'tj' }); rc@73: * res.jsonp(500, 'oh noes!'); rc@73: * res.jsonp(404, 'I dont have that'); rc@73: * rc@73: * @api public rc@73: */ rc@73: rc@73: res.jsonp = function(obj){ rc@73: // allow status / body rc@73: if (2 == arguments.length) { rc@73: // res.json(body, status) backwards compat rc@73: if ('number' == typeof arguments[1]) { rc@73: this.statusCode = arguments[1]; rc@73: return 'number' === typeof obj rc@73: ? jsonpNumDeprecated.call(this, obj) rc@73: : jsonpDeprecated.call(this, obj); rc@73: } else { rc@73: this.statusCode = obj; rc@73: obj = arguments[1]; rc@73: } rc@73: } rc@73: rc@73: // settings rc@73: var app = this.app; rc@73: var replacer = app.get('json replacer'); rc@73: var spaces = app.get('json spaces'); rc@73: var body = JSON.stringify(obj, replacer, spaces) rc@73: .replace(/\u2028/g, '\\u2028') rc@73: .replace(/\u2029/g, '\\u2029'); rc@73: var callback = this.req.query[app.get('jsonp callback name')]; rc@73: rc@73: // content-type rc@73: this.get('Content-Type') || this.set('Content-Type', 'application/json'); rc@73: rc@73: // fixup callback rc@73: if (Array.isArray(callback)) { rc@73: callback = callback[0]; rc@73: } rc@73: rc@73: // jsonp rc@73: if (callback && 'string' === typeof callback) { rc@73: this.set('Content-Type', 'text/javascript'); rc@73: var cb = callback.replace(/[^\[\]\w$.]/g, ''); rc@73: body = 'typeof ' + cb + ' === \'function\' && ' + cb + '(' + body + ');'; rc@73: } rc@73: rc@73: return this.send(body); rc@73: }; rc@73: rc@73: var jsonpDeprecated = deprecate(res.json, rc@73: 'res.jsonp(obj, status): Use res.jsonp(status, obj) instead'); rc@73: rc@73: var jsonpNumDeprecated = deprecate(res.json, rc@73: 'res.jsonp(num, status): Use res.status(status).jsonp(num) instead'); rc@73: rc@73: /** rc@73: * Transfer the file at the given `path`. rc@73: * rc@73: * Automatically sets the _Content-Type_ response header field. rc@73: * The callback `fn(err)` is invoked when the transfer is complete rc@73: * or when an error occurs. Be sure to check `res.sentHeader` rc@73: * if you wish to attempt responding, as the header and some data rc@73: * may have already been transferred. rc@73: * rc@73: * Options: rc@73: * rc@73: * - `maxAge` defaulting to 0 rc@73: * - `root` root directory for relative filenames rc@73: * - `hidden` serve hidden files, defaulting to false rc@73: * rc@73: * Other options are passed along to `send`. rc@73: * rc@73: * Examples: rc@73: * rc@73: * The following example illustrates how `res.sendfile()` may rc@73: * be used as an alternative for the `static()` middleware for rc@73: * dynamic situations. The code backing `res.sendfile()` is actually rc@73: * the same code, so HTTP cache support etc is identical. rc@73: * rc@73: * app.get('/user/:uid/photos/:file', function(req, res){ rc@73: * var uid = req.params.uid rc@73: * , file = req.params.file; rc@73: * rc@73: * req.user.mayViewFilesFrom(uid, function(yes){ rc@73: * if (yes) { rc@73: * res.sendfile('/uploads/' + uid + '/' + file); rc@73: * } else { rc@73: * res.send(403, 'Sorry! you cant see that.'); rc@73: * } rc@73: * }); rc@73: * }); rc@73: * rc@73: * @api public rc@73: */ rc@73: rc@73: res.sendfile = function(path, options, fn){ rc@73: options = options || {}; rc@73: var self = this; rc@73: var req = self.req; rc@73: var next = this.req.next; rc@73: var done; rc@73: rc@73: rc@73: // support function as second arg rc@73: if ('function' == typeof options) { rc@73: fn = options; rc@73: options = {}; rc@73: } rc@73: rc@73: // socket errors rc@73: req.socket.on('error', error); rc@73: rc@73: // errors rc@73: function error(err) { rc@73: if (done) return; rc@73: done = true; rc@73: rc@73: // clean up rc@73: cleanup(); rc@73: if (!self.headersSent) self.removeHeader('Content-Disposition'); rc@73: rc@73: // callback available rc@73: if (fn) return fn(err); rc@73: rc@73: // list in limbo if there's no callback rc@73: if (self.headersSent) return; rc@73: rc@73: // delegate rc@73: next(err); rc@73: } rc@73: rc@73: // streaming rc@73: function stream(stream) { rc@73: if (done) return; rc@73: cleanup(); rc@73: if (fn) stream.on('end', fn); rc@73: } rc@73: rc@73: // cleanup rc@73: function cleanup() { rc@73: req.socket.removeListener('error', error); rc@73: } rc@73: rc@73: // Back-compat rc@73: options.maxage = options.maxage || options.maxAge || 0; rc@73: rc@73: // transfer rc@73: var file = send(req, path, options); rc@73: file.on('error', error); rc@73: file.on('directory', next); rc@73: file.on('stream', stream); rc@73: file.pipe(this); rc@73: this.on('finish', cleanup); rc@73: }; rc@73: rc@73: /** rc@73: * Transfer the file at the given `path` as an attachment. rc@73: * rc@73: * Optionally providing an alternate attachment `filename`, rc@73: * and optional callback `fn(err)`. The callback is invoked rc@73: * when the data transfer is complete, or when an error has rc@73: * ocurred. Be sure to check `res.headersSent` if you plan to respond. rc@73: * rc@73: * This method uses `res.sendfile()`. rc@73: * rc@73: * @api public rc@73: */ rc@73: rc@73: res.download = function(path, filename, fn){ rc@73: // support function as second arg rc@73: if ('function' == typeof filename) { rc@73: fn = filename; rc@73: filename = null; rc@73: } rc@73: rc@73: filename = filename || path; rc@73: this.set('Content-Disposition', contentDisposition(filename)); rc@73: return this.sendfile(path, fn); rc@73: }; rc@73: rc@73: /** rc@73: * Set _Content-Type_ response header with `type` through `mime.lookup()` rc@73: * when it does not contain "/", or set the Content-Type to `type` otherwise. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.type('.html'); rc@73: * res.type('html'); rc@73: * res.type('json'); rc@73: * res.type('application/json'); rc@73: * res.type('png'); rc@73: * rc@73: * @param {String} type rc@73: * @return {ServerResponse} for chaining rc@73: * @api public rc@73: */ rc@73: rc@73: res.contentType = rc@73: res.type = function(type){ rc@73: return this.set('Content-Type', ~type.indexOf('/') rc@73: ? type rc@73: : mime.lookup(type)); rc@73: }; rc@73: rc@73: /** rc@73: * Respond to the Acceptable formats using an `obj` rc@73: * of mime-type callbacks. rc@73: * rc@73: * This method uses `req.accepted`, an array of rc@73: * acceptable types ordered by their quality values. rc@73: * When "Accept" is not present the _first_ callback rc@73: * is invoked, otherwise the first match is used. When rc@73: * no match is performed the server responds with rc@73: * 406 "Not Acceptable". rc@73: * rc@73: * Content-Type is set for you, however if you choose rc@73: * you may alter this within the callback using `res.type()` rc@73: * or `res.set('Content-Type', ...)`. rc@73: * rc@73: * res.format({ rc@73: * 'text/plain': function(){ rc@73: * res.send('hey'); rc@73: * }, rc@73: * rc@73: * 'text/html': function(){ rc@73: * res.send('

hey

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

hey

'); rc@73: * }, rc@73: * rc@73: * json: function(){ rc@73: * res.send({ message: 'hey' }); rc@73: * } rc@73: * }); rc@73: * rc@73: * By default Express passes an `Error` rc@73: * with a `.status` of 406 to `next(err)` rc@73: * if a match is not made. If you provide rc@73: * a `.default` callback it will be invoked rc@73: * instead. rc@73: * rc@73: * @param {Object} obj rc@73: * @return {ServerResponse} for chaining rc@73: * @api public rc@73: */ rc@73: rc@73: res.format = function(obj){ rc@73: var req = this.req; rc@73: var next = req.next; rc@73: rc@73: var fn = obj.default; rc@73: if (fn) delete obj.default; rc@73: var keys = Object.keys(obj); rc@73: rc@73: var key = req.accepts(keys); rc@73: rc@73: this.vary("Accept"); rc@73: rc@73: if (key) { rc@73: this.set('Content-Type', normalizeType(key).value); rc@73: obj[key](req, this, next); rc@73: } else if (fn) { rc@73: fn(); rc@73: } else { rc@73: var err = new Error('Not Acceptable'); rc@73: err.status = 406; rc@73: err.types = normalizeTypes(keys).map(function(o){ return o.value }); rc@73: next(err); rc@73: } rc@73: rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Set _Content-Disposition_ header to _attachment_ with optional `filename`. rc@73: * rc@73: * @param {String} filename rc@73: * @return {ServerResponse} rc@73: * @api public rc@73: */ rc@73: rc@73: res.attachment = function(filename){ rc@73: if (filename) this.type(extname(filename)); rc@73: this.set('Content-Disposition', contentDisposition(filename)); rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Set header `field` to `val`, or pass rc@73: * an object of header fields. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.set('Foo', ['bar', 'baz']); rc@73: * res.set('Accept', 'application/json'); rc@73: * res.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }); rc@73: * rc@73: * Aliased as `res.header()`. rc@73: * rc@73: * @param {String|Object|Array} field rc@73: * @param {String} val rc@73: * @return {ServerResponse} for chaining rc@73: * @api public rc@73: */ rc@73: rc@73: res.set = rc@73: res.header = function(field, val){ rc@73: if (2 == arguments.length) { rc@73: if (Array.isArray(val)) val = val.map(String); rc@73: else val = String(val); rc@73: if ('content-type' == field.toLowerCase() && !/;\s*charset\s*=/.test(val)) { rc@73: var charset = mime.charsets.lookup(val.split(';')[0]); rc@73: if (charset) val += '; charset=' + charset.toLowerCase(); rc@73: } rc@73: this.setHeader(field, val); rc@73: } else { rc@73: for (var key in field) { rc@73: this.set(key, field[key]); rc@73: } rc@73: } rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Get value for header `field`. rc@73: * rc@73: * @param {String} field rc@73: * @return {String} rc@73: * @api public rc@73: */ rc@73: rc@73: res.get = function(field){ rc@73: return this.getHeader(field); rc@73: }; rc@73: rc@73: /** rc@73: * Clear cookie `name`. rc@73: * rc@73: * @param {String} name rc@73: * @param {Object} options rc@73: * @param {ServerResponse} for chaining rc@73: * @api public rc@73: */ rc@73: rc@73: res.clearCookie = function(name, options){ rc@73: var opts = { expires: new Date(1), path: '/' }; rc@73: return this.cookie(name, '', options rc@73: ? mixin(opts, options) rc@73: : opts); rc@73: }; rc@73: rc@73: /** rc@73: * Set cookie `name` to `val`, with the given `options`. rc@73: * rc@73: * Options: rc@73: * rc@73: * - `maxAge` max-age in milliseconds, converted to `expires` rc@73: * - `signed` sign the cookie rc@73: * - `path` defaults to "/" rc@73: * rc@73: * Examples: rc@73: * rc@73: * // "Remember Me" for 15 minutes rc@73: * res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true }); rc@73: * rc@73: * // save as above rc@73: * res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true }) rc@73: * rc@73: * @param {String} name rc@73: * @param {String|Object} val rc@73: * @param {Options} options rc@73: * @api public rc@73: */ rc@73: rc@73: res.cookie = function(name, val, options){ rc@73: options = mixin({}, options); rc@73: var secret = this.req.secret; rc@73: var signed = options.signed; rc@73: if (signed && !secret) throw new Error('cookieParser("secret") required for signed cookies'); rc@73: if ('number' == typeof val) val = val.toString(); rc@73: if ('object' == typeof val) val = 'j:' + JSON.stringify(val); rc@73: if (signed) val = 's:' + sign(val, secret); rc@73: if ('maxAge' in options) { rc@73: options.expires = new Date(Date.now() + options.maxAge); rc@73: options.maxAge /= 1000; rc@73: } rc@73: if (null == options.path) options.path = '/'; rc@73: var headerVal = cookie.serialize(name, String(val), options); rc@73: rc@73: // supports multiple 'res.cookie' calls by getting previous value rc@73: var prev = this.get('Set-Cookie'); rc@73: if (prev) { rc@73: if (Array.isArray(prev)) { rc@73: headerVal = prev.concat(headerVal); rc@73: } else { rc@73: headerVal = [prev, headerVal]; rc@73: } rc@73: } rc@73: this.set('Set-Cookie', headerVal); rc@73: return this; rc@73: }; rc@73: rc@73: rc@73: /** rc@73: * Set the location header to `url`. rc@73: * rc@73: * The given `url` can also be "back", which redirects rc@73: * to the _Referrer_ or _Referer_ headers or "/". rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.location('/foo/bar').; rc@73: * res.location('http://example.com'); rc@73: * res.location('../login'); rc@73: * rc@73: * @param {String} url rc@73: * @api public rc@73: */ rc@73: rc@73: res.location = function(url){ rc@73: var req = this.req; rc@73: rc@73: // "back" is an alias for the referrer rc@73: if ('back' == url) url = req.get('Referrer') || '/'; rc@73: rc@73: // Respond rc@73: this.set('Location', url); rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Redirect to the given `url` with optional response `status` rc@73: * defaulting to 302. rc@73: * rc@73: * The resulting `url` is determined by `res.location()`, so rc@73: * it will play nicely with mounted apps, relative paths, rc@73: * `"back"` etc. rc@73: * rc@73: * Examples: rc@73: * rc@73: * res.redirect('/foo/bar'); rc@73: * res.redirect('http://example.com'); rc@73: * res.redirect(301, 'http://example.com'); rc@73: * res.redirect('http://example.com', 301); rc@73: * res.redirect('../login'); // /blog/post/1 -> /blog/login rc@73: * rc@73: * @param {String} url rc@73: * @param {Number} code rc@73: * @api public rc@73: */ rc@73: rc@73: res.redirect = function(url){ rc@73: var head = 'HEAD' == this.req.method; rc@73: var status = 302; rc@73: var body; rc@73: rc@73: // allow status / url rc@73: if (2 == arguments.length) { rc@73: if ('number' == typeof url) { rc@73: status = url; rc@73: url = arguments[1]; rc@73: } else { rc@73: status = arguments[1]; rc@73: } rc@73: } rc@73: rc@73: // Set location header rc@73: this.location(url); rc@73: url = this.get('Location'); rc@73: rc@73: // Support text/{plain,html} by default rc@73: this.format({ rc@73: text: function(){ rc@73: body = statusCodes[status] + '. Redirecting to ' + encodeURI(url); rc@73: }, rc@73: rc@73: html: function(){ rc@73: var u = escapeHtml(url); rc@73: body = '

' + statusCodes[status] + '. Redirecting to ' + u + '

'; rc@73: }, rc@73: rc@73: default: function(){ rc@73: body = ''; rc@73: } rc@73: }); rc@73: rc@73: // Respond rc@73: this.statusCode = status; rc@73: this.set('Content-Length', Buffer.byteLength(body)); rc@73: this.end(head ? null : body); rc@73: }; rc@73: rc@73: /** rc@73: * Add `field` to Vary. If already present in the Vary set, then rc@73: * this call is simply ignored. rc@73: * rc@73: * @param {Array|String} field rc@73: * @param {ServerResponse} for chaining rc@73: * @api public rc@73: */ rc@73: rc@73: res.vary = function(field){ rc@73: // checks for back-compat rc@73: if (!field) return this; rc@73: if (Array.isArray(field) && !field.length) return this; rc@73: rc@73: vary(this, field); rc@73: rc@73: return this; rc@73: }; rc@73: rc@73: /** rc@73: * Render `view` with the given `options` and optional callback `fn`. rc@73: * When a callback function is given a response will _not_ be made rc@73: * automatically, otherwise a response of _200_ and _text/html_ is given. rc@73: * rc@73: * Options: rc@73: * rc@73: * - `cache` boolean hinting to the engine it should cache rc@73: * - `filename` filename of the view being rendered rc@73: * rc@73: * @api public rc@73: */ rc@73: rc@73: res.render = function(view, options, fn){ rc@73: options = options || {}; rc@73: var self = this; rc@73: var req = this.req; rc@73: var app = req.app; rc@73: rc@73: // support callback function as second arg rc@73: if ('function' == typeof options) { rc@73: fn = options, options = {}; rc@73: } rc@73: rc@73: // merge res.locals rc@73: options._locals = self.locals; rc@73: rc@73: // default callback to respond rc@73: fn = fn || function(err, str){ rc@73: if (err) return req.next(err); rc@73: self.send(str); rc@73: }; rc@73: rc@73: // render rc@73: app.render(view, options, fn); rc@73: };