├── .npmignore ├── README.md ├── test ├── fixtures │ └── controllers │ │ ├── unused.js │ │ ├── a.js │ │ └── nested │ │ └── b.js ├── application.test.js └── router.test.js ├── lib ├── lodash.js ├── router │ ├── route.js │ └── router.js ├── response.js └── request.js ├── benchmarks ├── controllers │ └── welcome.js ├── run ├── Makefile ├── middleware.js └── middleware_async.js ├── exceptions.js ├── .gitignore ├── LICENSE ├── package.json ├── .eslintrc ├── controller.js └── application.js /.npmignore: -------------------------------------------------------------------------------- 1 | benchmarks 2 | .vscode 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bay 2 | The framework 3 | -------------------------------------------------------------------------------- /test/fixtures/controllers/unused.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/controllers/a.js: -------------------------------------------------------------------------------- 1 | module.exports = 'a' 2 | -------------------------------------------------------------------------------- /test/fixtures/controllers/nested/b.js: -------------------------------------------------------------------------------- 1 | module.exports = 'b' 2 | -------------------------------------------------------------------------------- /lib/lodash.js: -------------------------------------------------------------------------------- 1 | exports.defaults = require('lodash.defaults') 2 | exports.clone = require('lodash.clone') 3 | exports.set = require('lodash.set') 4 | exports.pick = require('lodash.pick') 5 | -------------------------------------------------------------------------------- /benchmarks/controllers/welcome.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('../../controller'); 4 | 5 | class Welcome extends Controller { 6 | * index () { 7 | return 'hello bay!'; 8 | } 9 | } 10 | 11 | module.exports = Welcome; 12 | -------------------------------------------------------------------------------- /benchmarks/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 4 | MW=$1 node $2 & 5 | pid=$! 6 | 7 | sleep 2 8 | 9 | wrk 'http://localhost:3000/?foo[bar]=baz' \ 10 | -d 3 \ 11 | -c 50 \ 12 | -t 8 \ 13 | | grep 'Requests/sec' \ 14 | | awk '{ print " " $2 }' 15 | 16 | kill $pid -------------------------------------------------------------------------------- /benchmarks/Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: middleware middleware_async 3 | 4 | middleware: 5 | @./run 1 $@ 6 | @./run 5 $@ 7 | @./run 10 $@ 8 | @./run 15 $@ 9 | @./run 20 $@ 10 | @./run 30 $@ 11 | @./run 50 $@ 12 | @./run 100 $@ 13 | @echo 14 | 15 | middleware_async: 16 | @./run 1 $@ 17 | @./run 5 $@ 18 | @./run 10 $@ 19 | @./run 15 $@ 20 | @./run 20 $@ 21 | @./run 30 $@ 22 | @./run 50 $@ 23 | @./run 100 $@ 24 | @echo 25 | 26 | .PHONY: all middleware middleware_async -------------------------------------------------------------------------------- /exceptions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class BayError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = this.constructor.name; 7 | this.message = message; 8 | Error.captureStackTrace(this, this.constructor.name); 9 | } 10 | } 11 | 12 | /** 13 | * When no router is found 14 | */ 15 | class RoutingError extends BayError { 16 | constructor(message) { 17 | super(message); 18 | this.status = 404; 19 | } 20 | } 21 | exports.RoutingError = RoutingError; 22 | -------------------------------------------------------------------------------- /benchmarks/middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BayApplication = require('../application'); 4 | 5 | class Application extends BayApplication { 6 | constructor (options) { 7 | super(options) 8 | 9 | this.router.get('/', 'welcome#index') 10 | } 11 | } 12 | 13 | // number of middleware 14 | let n = parseInt(process.env.MW || '1', 10); 15 | 16 | console.log(`Bay ${n} middlewares`); 17 | 18 | const app = new Application({ 19 | base: __dirname 20 | }); 21 | 22 | while (n--) { 23 | app.use(function * (next) { yield next }); 24 | } 25 | 26 | app.listen(3000); 27 | -------------------------------------------------------------------------------- /benchmarks/middleware_async.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BayApplication = require('../application'); 4 | 5 | class Application extends BayApplication { 6 | constructor (options) { 7 | super(options) 8 | 9 | this.router.get('/', 'welcome#index') 10 | } 11 | } 12 | 13 | // number of middleware 14 | let n = parseInt(process.env.MW || '1', 10); 15 | 16 | console.log(`Bay ${n} async middlewares`); 17 | 18 | const app = new Application({ 19 | base: __dirname 20 | }); 21 | 22 | while (n--) { 23 | app.use(async (next) => { await next() }); 24 | } 25 | 26 | app.listen(3000); 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /test/application.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {expect} = require('chai') 3 | const Application = require('../application') 4 | 5 | describe('application', () => { 6 | describe('#_requireControllers()', () => { 7 | it('supports nested controllers', () => { 8 | const app = new Application({ 9 | base: path.join(__dirname, 'fixtures') 10 | }) 11 | app.router.get('/users', 'a#index') 12 | app.router.namespace('nested/:nested', (router) => { 13 | router.get('/users', 'b#index') 14 | }) 15 | 16 | expect(app._requireControllers()).to.eql({ 17 | a: 'a', 18 | nested: { 19 | b: 'b' 20 | } 21 | }) 22 | }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/router.test.js: -------------------------------------------------------------------------------- 1 | const Router = require('../lib/router/router') 2 | const {expect} = require('chai') 3 | 4 | describe('Router', () => { 5 | describe('#getControllers()', () => { 6 | it('returns all controllers', () => { 7 | const router = new Router() 8 | router.get('/users', 'users#index') 9 | router.namespace('provider/:provider', router => { 10 | router.resource('file') 11 | }) 12 | router.namespace('oauth', router => { 13 | router.post('/token', 'index#token') 14 | }) 15 | router.resource('captcha', (member, collection) => { 16 | collection.get('/action/needs', 'captcha#needs') 17 | }) 18 | 19 | expect(router.getControllers().sort()).to.eql([ 20 | 'captcha', 21 | 'oauth/index', 22 | 'provider/file', 23 | 'users' 24 | ]) 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Zihua Li 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bay", 3 | "version": "0.6.2", 4 | "description": "The framework", 5 | "main": "application.js", 6 | "directories": { 7 | "example": "example" 8 | }, 9 | "scripts": { 10 | "test": "mocha", 11 | "bench": "make -C benchmarks" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "accepts": "^1.3.0", 17 | "bay-compose": "^3.0.0", 18 | "content-disposition": "^0.5.0", 19 | "content-type": "^1.0.1", 20 | "cookies": "^0.5.1", 21 | "delegates": "^1.0.0", 22 | "destroy": "^1.0.3", 23 | "error-inject": "^1.0.0", 24 | "escape-html": "^1.0.3", 25 | "filter-match": "0.0.3", 26 | "fresh": "^0.5.2", 27 | "http-assert": "^1.1.1", 28 | "http-errors": "^1.3.1", 29 | "i": "^0.3.3", 30 | "koa-is-json": "^1.0.0", 31 | "lodash.clone": "^4.5.0", 32 | "lodash.defaults": "^4.2.0", 33 | "lodash.pick": "^4.4.0", 34 | "lodash.set": "^4.3.2", 35 | "methods": "^1.1.1", 36 | "mime-types": "^2.1.8", 37 | "on-finished": "^2.3.0", 38 | "parseurl": "^1.3.0", 39 | "path-to-regexp": "^1.2.1", 40 | "qs": "^6.2.0", 41 | "require-dir": "^0.3.0", 42 | "resolve-keypath": "^1.1.0", 43 | "statuses": "^1.2.1", 44 | "type-is": "^1.6.10", 45 | "vary": "^1.1.0" 46 | }, 47 | "devDependencies": { 48 | "chai": "^4.2.0", 49 | "mocha": "^5.2.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/router/route.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pathToRegexp = require('path-to-regexp'); 4 | const _ = require('../lodash'); 5 | 6 | class Route { 7 | constructor(methods, path, handler, options) { 8 | this.options = _.defaults(options ? _.clone(options) : {}, { 9 | middleware: [] 10 | }); 11 | if (typeof this.options.middleware === 'string') { 12 | this.options.middleware = [this.options.middleware]; 13 | } 14 | 15 | this.methods = (Array.isArray(methods) ? methods : [methods]).map(s => s.toUpperCase()); 16 | this.path = path; 17 | this.regexp = pathToRegexp(path, this.keys = []); 18 | if (typeof handler === 'string') { 19 | const key = handler.split('#'); 20 | this.handler = { 21 | controller: key[0], 22 | action: key[1] 23 | }; 24 | } else { 25 | this.handler = handler; 26 | } 27 | } 28 | 29 | match(path, method) { 30 | const m = this.regexp.exec(path); 31 | 32 | if (!m) { 33 | return; 34 | } 35 | 36 | if (this.methods.indexOf(method) === -1) { 37 | return; 38 | } 39 | 40 | const params = {}; 41 | 42 | for (let i = 1; i < m.length; i++) { 43 | params[this.keys[i - 1].name] = decodeParam(m[i]); 44 | } 45 | 46 | return { 47 | params, 48 | path: m[0], 49 | middlewares: this.options.middleware, 50 | handler: this.handler 51 | }; 52 | } 53 | 54 | getControllers() { 55 | return [this.handler.controller]; 56 | } 57 | } 58 | 59 | function decodeParam(val) { 60 | if (typeof val !== 'string' || val.length === 0) { 61 | return val; 62 | } 63 | 64 | try { 65 | return decodeURIComponent(val); 66 | } catch (err) { 67 | if (err instanceof URIError) { 68 | err.message = `Failed to decode param '${val}'`; 69 | err.status = err.statusCode = 400; 70 | } 71 | 72 | throw err; 73 | } 74 | } 75 | 76 | module.exports = Route; 77 | -------------------------------------------------------------------------------- /lib/router/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const methods = require('methods'); 4 | const Route = require('./route'); 5 | const inflect = require('i')(); 6 | const _ = require('../lodash'); 7 | 8 | class Router { 9 | constructor(parent, prefix, options) { 10 | this.prefix = prefix || ''; 11 | this.stack = []; 12 | this.options = _.defaults(options ? _.clone(options) : {}, { 13 | middleware: [], 14 | controllerPrefix: '', 15 | sensitive: false, 16 | strict: false, 17 | end: true 18 | }); 19 | if (typeof this.options.middleware === 'string') { 20 | this.options.middleware = [this.options.middleware]; 21 | } 22 | if (parent) { 23 | this.prefix = parent.prefix + this.prefix; 24 | 25 | if (parent.options.middleware.length) { 26 | this.options.middleware = parent.options.middleware.concat(this.options.middleware); 27 | } 28 | if (parent.options.controllerPrefix) { 29 | if (this.options.controllerPrefix) { 30 | this.options.controllerPrefix = `${parent.options.controllerPrefix}/${this.options.controllerPrefix}`; 31 | } else { 32 | this.options.controllerPrefix = parent.options.controllerPrefix; 33 | } 34 | } 35 | parent.stack.push(this); 36 | this.parent = parent; 37 | } 38 | 39 | this.isPlainPrefix = this.prefix.indexOf(':') === -1; 40 | } 41 | 42 | resource(name, cb, options) { 43 | if (typeof cb !== 'function') { 44 | options = cb; 45 | cb = null; 46 | } 47 | const controller = (options && options.controller) || name; 48 | const names = inflect.pluralize(name); 49 | 50 | const collectionRouter = new Router(this, `/${names}`, options); 51 | const memberRouter = new Router(collectionRouter, `/:${name}`, options); 52 | 53 | if (cb) { 54 | cb(memberRouter, collectionRouter); 55 | } 56 | 57 | collectionRouter.get('', `${controller}#index`); 58 | collectionRouter.post('', `${controller}#create`); 59 | 60 | memberRouter.get('', `${controller}#show`); 61 | memberRouter.put('', `${controller}#update`); 62 | memberRouter.patch('', `${controller}#patch`); 63 | memberRouter.delete('', `${controller}#destroy`); 64 | 65 | // lower the priority of member router 66 | collectionRouter.stack.push(collectionRouter.stack.shift()); 67 | 68 | return this; 69 | } 70 | 71 | namespace(namespace, cb, options) { 72 | if (typeof cb !== 'function') { 73 | options = cb; 74 | cb = null; 75 | } 76 | const match = namespace.match(/^\/?:?(\w+)/); 77 | const controllerPrefix = match ? match[1] : null; 78 | const router = new Router(this, `/${namespace}`, _.defaults(options || {}, { controllerPrefix })); 79 | if (cb) { 80 | cb(router); 81 | } 82 | 83 | return this; 84 | } 85 | 86 | group(cb, options) { 87 | if (typeof cb !== 'function') { 88 | options = cb; 89 | cb = null; 90 | } 91 | const router = new Router(this, options); 92 | if (cb) { 93 | cb(router); 94 | } 95 | 96 | return this; 97 | } 98 | 99 | match(path, method) { 100 | if (this.isPlainPrefix && !path.startsWith(this.prefix)) { 101 | return; 102 | } 103 | for (let i = 0; i < this.stack.length; i++) { 104 | const match = this.stack[i].match(path, method); 105 | if (match) { 106 | return match; 107 | } 108 | } 109 | } 110 | 111 | add (methods, path, handler, options) { 112 | create(this, methods, path, handler, options); 113 | } 114 | 115 | /** 116 | * 获取路由所依赖的所有 Controller 列表 117 | * 118 | * @memberof Router 119 | */ 120 | getControllers () { 121 | const controllers = {} 122 | for (let i = 0; i < this.stack.length; i++) { 123 | this.stack[i].getControllers().forEach((controllerName) => { 124 | controllers[controllerName] = true 125 | }) 126 | } 127 | return Object.keys(controllers); 128 | } 129 | } 130 | 131 | methods.forEach(function (method) { 132 | Router.prototype[method] = function (path, handler, options) { 133 | create(this, method, path, handler, options); 134 | }; 135 | }); 136 | 137 | module.exports = Router; 138 | 139 | function create (router, method, path, handler, options) { 140 | path = router.prefix + path; 141 | if (options) { 142 | if (options.middleware && router.options.middleware) { 143 | options.middleware = router.options.middleware.concat(options.middleware); 144 | } 145 | options = _.defaults(options, router.options.middleware); 146 | } else { 147 | options = router.options; 148 | } 149 | if (typeof handler === 'string' && router.options.controllerPrefix) { 150 | handler = `${router.options.controllerPrefix}/${handler}`; 151 | } 152 | router.stack.push(new Route(method, path, handler, options)); 153 | } 154 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "rules": { 7 | "no-var": 2, 8 | "no-const-assign": 2, 9 | "strict": [2, "global"], 10 | 11 | "comma-dangle": [2, "never"], 12 | "no-cond-assign": [2, "except-parens"], 13 | "no-console": 0, 14 | "no-control-regex": 2, 15 | "no-debugger": 2, 16 | "no-dupe-args": 2, 17 | "no-dupe-keys": 2, 18 | "no-duplicate-case": 0, 19 | "no-empty-character-class": 2, 20 | "no-empty": 2, 21 | "no-extra-boolean-cast": 2, 22 | "no-extra-parens": 0, 23 | "no-extra-semi": 2, 24 | "no-func-assign": 2, 25 | "no-inner-declarations": [2, "functions"], 26 | "no-invalid-regexp": 2, 27 | "no-irregular-whitespace": 2, 28 | "no-negated-in-lhs": 2, 29 | "no-obj-calls": 2, 30 | "no-regex-spaces": 2, 31 | "no-reserved-keys": 0, 32 | "no-sparse-arrays": 2, 33 | "no-unexpected-multiline": 2, 34 | "no-unreachable": 2, 35 | "use-isnan": 2, 36 | "valid-typeof": 2, 37 | 38 | "accessor-pairs": 0, 39 | "block-scoped-var": 0, 40 | "complexity": 0, 41 | "consistent-return": 0, 42 | "curly": [2, "all"], 43 | "default-case": 0, 44 | "dot-notation": [2, { "allowKeywords": true, "allowPattern": "" }], 45 | "dot-location": [2, "property"], 46 | "eqeqeq": 2, 47 | "guard-for-in": 0, 48 | "no-caller": 2, 49 | "no-div-regex": 2, 50 | "no-else-return": 2, 51 | "no-empty-label": 2, 52 | "no-eq-null": 0, 53 | "no-eval": 2, 54 | "no-extend-native": 2, 55 | "no-extra-bind": 2, 56 | "no-fallthrough": 0, 57 | "no-floating-decimal": 2, 58 | "no-implied-eval": 2, 59 | "no-iterator": 2, 60 | "no-labels": 2, 61 | "no-lone-blocks": 2, 62 | "no-loop-func": 2, 63 | "no-multi-spaces": 2, 64 | "no-multi-str": 2, 65 | "no-native-reassign": 2, 66 | "no-new-func": 2, 67 | "no-new-wrappers": 2, 68 | "no-new": 2, 69 | "no-octal-escape": 2, 70 | "no-octal": 2, 71 | "no-param-reassign": 0, 72 | "no-process-env": 0, 73 | "no-proto": 2, 74 | "no-redeclare": 2, 75 | "no-return-assign": 2, 76 | "no-script-url": 2, 77 | "no-self-compare": 2, 78 | "no-sequences": 2, 79 | "no-throw-literal": 2, 80 | "no-unused-expressions": 0, 81 | "no-void": 0, 82 | "no-warning-comments": [2, { "terms": ["todo", "tofix"], "location": "start" }], 83 | "no-with": 2, 84 | "radix": 2, 85 | "vars-on-top": 2, 86 | "wrap-iife": [2, "inside"], 87 | "yoda": [2, "never"], 88 | 89 | "no-catch-shadow": 0, 90 | "no-delete-var": 2, 91 | "no-label-var": 2, 92 | "no-shadow-restricted-names": 2, 93 | "no-shadow": 0, 94 | "no-undef-init": 2, 95 | "no-undef": 2, 96 | "no-unused-vars": [2, { "vars": "local", "args": "after-used" }], 97 | no-use-before-define: [2, "nofunc"], 98 | 99 | "handle-callback-err": 2, 100 | "no-mixed-requires": 2, 101 | "no-new-require": 2, 102 | "no-path-concat": 2, 103 | "no-process-exit": 2, 104 | "no-restricted-modules": [2, ""], // add any unwanted Node.js core modules 105 | 106 | "array-bracket-spacing": [2, "never"], 107 | "brace-style": [2], 108 | "comma-spacing": [2, { "before": false, "after": true }], 109 | "comma-style": [2, "last"], 110 | "computed-property-spacing": 0, 111 | "consistent-this": 0, 112 | "eol-last": 2, 113 | "func-style": 0, 114 | "indent": [2, 2], 115 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }], 116 | "linebreak-style": 0, 117 | "max-nested-callbacks": [0, 3], 118 | "new-cap": 2, 119 | "new-parens": 2, 120 | "newline-after-var": 0, 121 | "no-array-constructor": 2, 122 | "no-continue": 0, 123 | "no-inline-comments": 0, 124 | "no-lonely-if": 1, 125 | "no-mixed-spaces-and-tabs": 2, 126 | "no-multiple-empty-lines": [2, { "max": 1 }], 127 | "no-nested-ternary": 0, 128 | "no-new-object": 2, 129 | "no-spaced-func": 2, 130 | "no-ternary": 0, 131 | "no-trailing-spaces": 2, 132 | "no-underscore-dangle": 0, 133 | "no-unneeded-ternary": 2, 134 | "object-curly-spacing": [2, "always"], 135 | "one-var": [2, "never"], 136 | "padded-blocks": [0, "never"], 137 | "quote-props": [0, "as-needed"], 138 | "quotes": [2, "single"], 139 | "semi-spacing": [2, { "before": false, "after": true }], 140 | "semi": [2, "always"], 141 | "sort-vars": 0, 142 | "space-after-keywords": 0, 143 | "space-before-blocks": [2, "always"], 144 | "space-before-function-paren": [2, { "anonymous": "always", "named": "never" }], 145 | "space-in-parens": [2, "never"], 146 | "space-infix-ops": 2, 147 | "space-return-throw-case": 2, 148 | "space-unary-ops": 0, 149 | "spaced-comment": [2, "always"], 150 | "wrap-regex": 2, 151 | 152 | "constructor-super": 2, 153 | "generator-star-spacing": [2, { "before": true, "after": false }], 154 | "no-this-before-super": 2, 155 | "no-var": 2, 156 | "object-shorthand": [2, "always"], 157 | "prefer-const": 2, 158 | 159 | "max-depth": [0, 3], 160 | "max-len": [2, 121, 2], 161 | "max-params": 0, 162 | "max-statements": 0, 163 | "no-bitwise": 2, 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const delegate = require('delegates'); 4 | const requestLib = require('./lib/request'); 5 | const responseLib = require('./lib/response'); 6 | const Cookies = require('cookies'); 7 | const accepts = require('accepts'); 8 | const createError = require('http-errors'); 9 | const httpAssert = require('http-assert'); 10 | const Stream = require('stream'); 11 | const isJSON = require('koa-is-json'); 12 | const statuses = require('statuses'); 13 | 14 | function BayController(app, req, res) { 15 | const request = this.request = Object.create(requestLib); 16 | const response = this.response = Object.create(responseLib); 17 | 18 | this.app = request.app = response.app = app; 19 | this.req = request.req = response.req = req; 20 | this.res = request.res = response.res = res; 21 | 22 | request.ctx = response.ctx = this; 23 | request.response = response; 24 | response.request = request; 25 | 26 | this.originalUrl = request.originalUrl = req.url; 27 | this.cookies = new Cookies(req, res, this.keys); 28 | this.accept = request.accept = accepts(req); 29 | this.state = {}; 30 | } 31 | 32 | /** 33 | * Return JSON representation. 34 | * 35 | * Here we explicitly invoke .toJSON() on each 36 | * object, as iteration will otherwise fail due 37 | * to the getters and cause utilities such as 38 | * clone() to fail. 39 | * 40 | * @return {Object} 41 | * @api public 42 | */ 43 | BayController.prototype.toJSON = function () { 44 | return { 45 | request: this.request.toJSON(), 46 | response: this.response.toJSON(), 47 | app: this.app.toJSON(), 48 | originalUrl: this.originalUrl, 49 | req: '', 50 | res: '', 51 | socket: '' 52 | }; 53 | }; 54 | 55 | /** 56 | * Similar to .throw(), adds assertion. 57 | * 58 | * this.assert(this.user, 401, 'Please login!'); 59 | * 60 | * See: https://github.com/jshttp/http-assert 61 | * 62 | * @param {Mixed} test 63 | * @param {Number} [status=400] 64 | * @param {String} message 65 | * @api public 66 | */ 67 | BayController.prototype.assert = function (res, code, message) { 68 | if (arguments.length === 1 || (arguments.length === 2 && typeof code === 'string')) { 69 | return httpAssert(res, 400, code); 70 | } 71 | return httpAssert(res, code, message); 72 | }; 73 | 74 | /** 75 | * Throw an error with `msg` and optional `status` 76 | * defaulting to 500. Note that these are user-level 77 | * errors, and the message may be exposed to the client. 78 | * 79 | * this.throw(403) 80 | * this.throw('name required', 400) 81 | * this.throw(400, 'name required') 82 | * this.throw('something exploded') 83 | * this.throw(new Error('invalid'), 400); 84 | * this.throw(400, new Error('invalid')); 85 | * 86 | * See: https://github.com/jshttp/http-errors 87 | * 88 | * @param {String|Number|Error} err, msg or status 89 | * @param {String|Number|Error} [err, msg or status] 90 | * @param {Object} [props] 91 | * @api public 92 | */ 93 | 94 | BayController.prototype.throw = function (code, message, props) { 95 | if (arguments.length === 1 && typeof code === 'string') { 96 | throw createError(400, code, props); 97 | } 98 | throw createError(code, message, props); 99 | }; 100 | 101 | /** 102 | * Response helper. 103 | */ 104 | BayController.prototype.respond = function () { 105 | const res = this.res; 106 | if (res.headersSent || !this.writable) { 107 | return; 108 | } 109 | 110 | let body = this.body; 111 | const code = this.status; 112 | 113 | // ignore body 114 | if (statuses.empty[code]) { 115 | // strip headers 116 | this.body = null; 117 | return res.end(); 118 | } 119 | 120 | if (this.method === 'HEAD') { 121 | if (isJSON(body)) { 122 | this.length = Buffer.byteLength(JSON.stringify(body)); 123 | } 124 | return res.end(); 125 | } 126 | 127 | // status body 128 | if (body === null || typeof body === 'undefined') { 129 | this.type = 'text'; 130 | body = this.message || String(code); 131 | this.length = Buffer.byteLength(body); 132 | return res.end(body); 133 | } 134 | 135 | // responses 136 | if (Buffer.isBuffer(body)) { 137 | return res.end(body); 138 | } 139 | if (typeof body === 'string') { 140 | return res.end(body); 141 | } 142 | if (body instanceof Stream) { 143 | return body.pipe(res); 144 | } 145 | 146 | // body: json 147 | body = JSON.stringify(body); 148 | this.length = Buffer.byteLength(body); 149 | res.end(body); 150 | }; 151 | 152 | BayController.prototype.aroundAction = function (name, options) { 153 | if (!options) { 154 | options = {}; 155 | } 156 | options.name = name; 157 | if (!this._middleware) { 158 | this._middleware = []; 159 | } 160 | this._middleware.push(options); 161 | }; 162 | 163 | /** 164 | * Response delegation. 165 | */ 166 | 167 | delegate(BayController.prototype, 'response') 168 | .method('attachment') 169 | .method('redirect') 170 | .method('remove') 171 | .method('vary') 172 | .method('set') 173 | .method('append') 174 | .access('status') 175 | .access('message') 176 | .access('body') 177 | .access('length') 178 | .access('type') 179 | .access('lastModified') 180 | .access('etag') 181 | .getter('headerSent') 182 | .getter('writable'); 183 | 184 | /** 185 | * Request delegation. 186 | */ 187 | 188 | delegate(BayController.prototype, 'request') 189 | .method('acceptsLanguages') 190 | .method('acceptsEncodings') 191 | .method('acceptsCharsets') 192 | .method('accepts') 193 | .method('get') 194 | .method('is') 195 | .access('querystring') 196 | .access('idempotent') 197 | .access('socket') 198 | .access('search') 199 | .access('method') 200 | .access('query') 201 | .access('path') 202 | .access('url') 203 | .getter('origin') 204 | .getter('href') 205 | .getter('subdomains') 206 | .getter('protocol') 207 | .getter('host') 208 | .getter('hostname') 209 | .getter('header') 210 | .getter('headers') 211 | .getter('secure') 212 | .getter('stale') 213 | .getter('fresh') 214 | .getter('ips') 215 | .getter('ip'); 216 | 217 | module.exports = BayController; 218 | -------------------------------------------------------------------------------- /application.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('./lib/lodash'); 4 | const http = require('http'); 5 | const compose = require('bay-compose'); 6 | const filter = require('filter-match').filter; 7 | const Router = require('./lib/router/router'); 8 | const resolver = require('resolve-keypath').resolver; 9 | const requireDir = require('require-dir'); 10 | const onFinished = require('on-finished'); 11 | const parse = require('parseurl'); 12 | const exceptions = require('./exceptions'); 13 | const statuses = require('statuses'); 14 | 15 | const path = require('path'); 16 | 17 | const PATH_SEPARATOR = '/' 18 | 19 | class BayApplication { 20 | constructor(options) { 21 | if (!options) { 22 | options = {}; 23 | } 24 | 25 | this.env = process.env.NODE_ENV || 'development'; 26 | this.subdomainOffset = 2; 27 | this.middleware = []; 28 | 29 | this.getVersion = options.getVersion; 30 | this.proxy = options.proxy; 31 | this.base = options.base; 32 | this.specifiedControllers = options.controllers 33 | 34 | if (!this.base) { 35 | throw new Error('missing base path'); 36 | } 37 | 38 | this.router = new Router(); 39 | 40 | if (this.getVersion) { 41 | this.getVersionTransformer = resolver(options.versions || requireDir(path.join(this.base, 'versions'), { recurse: true }), PATH_SEPARATOR); 42 | } 43 | } 44 | 45 | getController (controllerName) { 46 | if (!this._controllerResolver) { 47 | this.initControllers() 48 | this._controllerResolver = resolver(this.specifiedControllers, PATH_SEPARATOR) 49 | } 50 | return this._controllerResolver(controllerName); 51 | } 52 | 53 | initControllers () { 54 | if (!this.specifiedControllers) { 55 | // Lazy loading all controllers before processing any 56 | // HTTP requests. That introduces an limitation that all 57 | // routes defined after `#listen()` will be ignored. 58 | this.specifiedControllers = this._requireControllers(); 59 | } 60 | } 61 | 62 | /** 63 | * Load all controllers from disk 64 | * 65 | * @returns 66 | * @private 67 | * @memberof BayApplication 68 | */ 69 | _requireControllers () { 70 | const ret = {} 71 | this.router.getControllers().forEach((controllerName) => { 72 | const pathParts = controllerName.split(PATH_SEPARATOR); 73 | const subPath = path.join.apply(path, pathParts); 74 | const requiredModule = require(path.join(this.base, 'controllers', subPath)) 75 | _.set(ret, pathParts, requiredModule) 76 | }) 77 | 78 | return ret 79 | } 80 | 81 | /** 82 | * Use the given middleware `fn`. 83 | * 84 | * @param {GeneratorFunction} fn 85 | * @return {Application} self 86 | * @api public 87 | */ 88 | use(fn) { 89 | this.middleware.push(fn); 90 | return this; 91 | } 92 | 93 | listen() { 94 | this.initControllers(); 95 | const server = http.createServer(this.callback()); 96 | return server.listen.apply(server, arguments); 97 | } 98 | 99 | /** 100 | * Return JSON representation. 101 | * We only bother showing settings. 102 | * 103 | * @return {Object} 104 | * @api public 105 | */ 106 | toJSON() { 107 | return _.pick(this, ['subdomainOffset', 'proxy', 'env']); 108 | } 109 | 110 | callback() { 111 | const self = this; 112 | 113 | return function (req, res) { 114 | res.statusCode = 404; 115 | 116 | // Find the matching route 117 | let match 118 | try { 119 | match = self.router.match(parse(req).pathname, req.method); 120 | } catch (err) { 121 | return self.handleError(req, res, err); 122 | } 123 | if (!match) { 124 | return self.handleError(req, res, new exceptions.RoutingError('No route matches')); 125 | } 126 | 127 | // Resolve the controller 128 | const actionName = match.handler.action; 129 | const controllerName = match.handler.controller; 130 | const ControllerClass = self.getController(controllerName); 131 | if (!ControllerClass) { 132 | return self.handleError(req, res, new exceptions.RoutingError(`Controller ${controllerName} not found`)); 133 | } 134 | if (!ControllerClass.prototype[actionName]) { 135 | return self.handleError(req, res, 136 | new exceptions.RoutingError(`Action ${controllerName}#${actionName} not found`)); 137 | } 138 | 139 | onFinished(res, function (err) { 140 | if (err) { 141 | self.handleError(req, res, convertToError(err)); 142 | } 143 | }); 144 | 145 | const controller = new ControllerClass(self, req, res); 146 | 147 | controller.route = match; 148 | controller.params = match.params; 149 | 150 | const middlewares = self.middleware.slice(); 151 | 152 | if (self.getVersion) { 153 | const version = self.getVersion(controller); 154 | const versionTransformer = self.getVersionTransformer(`${version}/${controllerName}`); 155 | if (versionTransformer && versionTransformer[actionName]) { 156 | middlewares.push(versionTransformer[actionName]); 157 | } 158 | } 159 | 160 | if (controller._middleware) { 161 | filter(actionName, controller._middleware).forEach(v => { 162 | middlewares.push(typeof v.name === 'function' ? v.name : controller[v.name]); 163 | }); 164 | } 165 | 166 | middlewares.push(function *fillRespondBody(next) { 167 | const fn = controller[actionName]; 168 | let body 169 | 170 | if (typeof fn === 'function') { 171 | const ret = fn.call(this); 172 | 173 | // function, promise, generator, array, or object 174 | if (ret != null && (typeof ret === 'object' || typeof ret === 'function')) { 175 | body = yield ret; 176 | } else { 177 | body = ret; 178 | } 179 | } 180 | 181 | if (typeof body !== 'undefined') { 182 | controller.body = body; 183 | } 184 | yield next; 185 | }); 186 | 187 | // Make Bay work with async function 188 | compose(middlewares)(controller) 189 | .then(function () { controller.respond() }) 190 | .catch(function (err) { self.handleError(req, res, convertToError(err)) }); 191 | }; 192 | } 193 | 194 | handleError(req, res, err) { 195 | if (res.headersSent || !res.socket || !res.socket.writable) { 196 | err.headerSent = true; 197 | return; 198 | } 199 | 200 | // unset all headers 201 | res._headers = {}; 202 | 203 | // force text/plain 204 | res.setHeader('Content-Type', 'text/plain'); 205 | 206 | // ENOENT support 207 | if (err.code === 'ENOENT') { 208 | err.status = 404; 209 | } 210 | 211 | // default to 500 212 | if (typeof err.status !== 'number' || !statuses[err.status]) { 213 | err.status = 500; 214 | } 215 | 216 | // respond 217 | const msg = err.expose ? err.message : statuses[err.status]; 218 | res.statusCode = err.status; 219 | res.setHeader('Content-Length', Buffer.byteLength(msg)); 220 | res.end(msg); 221 | } 222 | } 223 | 224 | function convertToError(err) { 225 | return err instanceof Error ? err : new Error(`non-error thrown: ${err}`); 226 | } 227 | 228 | module.exports = BayApplication; 229 | exports.exceptions = exceptions; 230 | -------------------------------------------------------------------------------- /lib/response.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var contentDisposition = require('content-disposition'); 8 | var ensureErrorHandler = require('error-inject'); 9 | var getType = require('mime-types').contentType; 10 | var onFinish = require('on-finished'); 11 | var isJSON = require('koa-is-json'); 12 | var escape = require('escape-html'); 13 | var typeis = require('type-is').is; 14 | var statuses = require('statuses'); 15 | var destroy = require('destroy'); 16 | var assert = require('assert'); 17 | var path = require('path'); 18 | var vary = require('vary'); 19 | var extname = path.extname; 20 | 21 | /** 22 | * Prototype. 23 | */ 24 | 25 | module.exports = { 26 | 27 | /** 28 | * Return the request socket. 29 | * 30 | * @return {Connection} 31 | * @api public 32 | */ 33 | 34 | get socket() { 35 | // TODO: TLS 36 | return this.ctx.req.socket; 37 | }, 38 | 39 | /** 40 | * Return response header. 41 | * 42 | * @return {Object} 43 | * @api public 44 | */ 45 | 46 | get header() { 47 | // TODO: wtf 48 | return this.res._headers || {}; 49 | }, 50 | 51 | /** 52 | * Return response header, alias as response.header 53 | * 54 | * @return {Object} 55 | * @api public 56 | */ 57 | 58 | get headers() { 59 | return this.header; 60 | }, 61 | 62 | /** 63 | * Get response status code. 64 | * 65 | * @return {Number} 66 | * @api public 67 | */ 68 | 69 | get status() { 70 | return this.res.statusCode; 71 | }, 72 | 73 | /** 74 | * Set response status code. 75 | * 76 | * @param {Number} code 77 | * @api public 78 | */ 79 | 80 | set status(code) { 81 | assert('number' == typeof code, 'status code must be a number'); 82 | assert(statuses[code], 'invalid status code: ' + code); 83 | this._explicitStatus = true; 84 | this.res.statusCode = code; 85 | this.res.statusMessage = statuses[code]; 86 | if (this.body && statuses.empty[code]) this.body = null; 87 | }, 88 | 89 | /** 90 | * Get response status message 91 | * 92 | * @return {String} 93 | * @api public 94 | */ 95 | 96 | get message() { 97 | return this.res.statusMessage || statuses[this.status]; 98 | }, 99 | 100 | /** 101 | * Set response status message 102 | * 103 | * @param {String} msg 104 | * @api public 105 | */ 106 | 107 | set message(msg) { 108 | this.res.statusMessage = msg; 109 | }, 110 | 111 | /** 112 | * Get response body. 113 | * 114 | * @return {Mixed} 115 | * @api public 116 | */ 117 | 118 | get body() { 119 | return this._body; 120 | }, 121 | 122 | /** 123 | * Set response body. 124 | * 125 | * @param {String|Buffer|Object|Stream} val 126 | * @api public 127 | */ 128 | 129 | set body(val) { 130 | var original = this._body; 131 | this._body = val; 132 | 133 | // no content 134 | if (null == val) { 135 | if (!statuses.empty[this.status]) this.status = 204; 136 | this.remove('Content-Type'); 137 | this.remove('Content-Length'); 138 | this.remove('Transfer-Encoding'); 139 | return; 140 | } 141 | 142 | // set the status 143 | if (!this._explicitStatus) this.status = 200; 144 | 145 | // set the content-type only if not yet set 146 | var setType = !this.header['content-type']; 147 | 148 | // string 149 | if ('string' == typeof val) { 150 | if (setType) this.type = /^\s*' + url + '.'; 266 | return; 267 | } 268 | 269 | // text 270 | this.type = 'text/plain; charset=utf-8'; 271 | this.body = 'Redirecting to ' + url + '.'; 272 | }, 273 | 274 | /** 275 | * Set Content-Disposition header to "attachment" with optional `filename`. 276 | * 277 | * @param {String} filename 278 | * @api public 279 | */ 280 | 281 | attachment: function(filename){ 282 | if (filename) this.type = extname(filename); 283 | this.set('Content-Disposition', contentDisposition(filename)); 284 | }, 285 | 286 | /** 287 | * Set Content-Type response header with `type` through `mime.lookup()` 288 | * when it does not contain a charset. 289 | * 290 | * Examples: 291 | * 292 | * this.type = '.html'; 293 | * this.type = 'html'; 294 | * this.type = 'json'; 295 | * this.type = 'application/json'; 296 | * this.type = 'png'; 297 | * 298 | * @param {String} type 299 | * @api public 300 | */ 301 | 302 | set type(type) { 303 | type = getType(type) || false; 304 | if (type) { 305 | this.set('Content-Type', type); 306 | } else { 307 | this.remove('Content-Type'); 308 | } 309 | }, 310 | 311 | /** 312 | * Set the Last-Modified date using a string or a Date. 313 | * 314 | * this.response.lastModified = new Date(); 315 | * this.response.lastModified = '2013-09-13'; 316 | * 317 | * @param {String|Date} type 318 | * @api public 319 | */ 320 | 321 | set lastModified(val) { 322 | if ('string' == typeof val) val = new Date(val); 323 | this.set('Last-Modified', val.toUTCString()); 324 | }, 325 | 326 | /** 327 | * Get the Last-Modified date in Date form, if it exists. 328 | * 329 | * @return {Date} 330 | * @api public 331 | */ 332 | 333 | get lastModified() { 334 | var date = this.get('last-modified'); 335 | if (date) return new Date(date); 336 | }, 337 | 338 | /** 339 | * Set the ETag of a response. 340 | * This will normalize the quotes if necessary. 341 | * 342 | * this.response.etag = 'md5hashsum'; 343 | * this.response.etag = '"md5hashsum"'; 344 | * this.response.etag = 'W/"123456789"'; 345 | * 346 | * @param {String} etag 347 | * @api public 348 | */ 349 | 350 | set etag(val) { 351 | if (!/^(W\/)?"/.test(val)) val = '"' + val + '"'; 352 | this.set('ETag', val); 353 | }, 354 | 355 | /** 356 | * Get the ETag of a response. 357 | * 358 | * @return {String} 359 | * @api public 360 | */ 361 | 362 | get etag() { 363 | return this.get('ETag'); 364 | }, 365 | 366 | /** 367 | * Return the response mime type void of 368 | * parameters such as "charset". 369 | * 370 | * @return {String} 371 | * @api public 372 | */ 373 | 374 | get type() { 375 | var type = this.get('Content-Type'); 376 | if (!type) return ''; 377 | return type.split(';')[0]; 378 | }, 379 | 380 | /** 381 | * Check whether the response is one of the listed types. 382 | * Pretty much the same as `this.request.is()`. 383 | * 384 | * @param {String|Array} types... 385 | * @return {String|false} 386 | * @api public 387 | */ 388 | 389 | is: function(types){ 390 | var type = this.type; 391 | if (!types) return type || false; 392 | if (!Array.isArray(types)) types = [].slice.call(arguments); 393 | return typeis(type, types); 394 | }, 395 | 396 | /** 397 | * Return response header. 398 | * 399 | * Examples: 400 | * 401 | * this.get('Content-Type'); 402 | * // => "text/plain" 403 | * 404 | * this.get('content-type'); 405 | * // => "text/plain" 406 | * 407 | * @param {String} field 408 | * @return {String} 409 | * @api public 410 | */ 411 | 412 | get: function(field){ 413 | return this.header[field.toLowerCase()] || ''; 414 | }, 415 | 416 | /** 417 | * Set header `field` to `val`, or pass 418 | * an object of header fields. 419 | * 420 | * Examples: 421 | * 422 | * this.set('Foo', ['bar', 'baz']); 423 | * this.set('Accept', 'application/json'); 424 | * this.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }); 425 | * 426 | * @param {String|Object|Array} field 427 | * @param {String} val 428 | * @api public 429 | */ 430 | 431 | set: function(field, val){ 432 | if (2 == arguments.length) { 433 | if (Array.isArray(val)) val = val.map(String); 434 | else val = String(val); 435 | this.res.setHeader(field, val); 436 | } else { 437 | for (var key in field) { 438 | this.set(key, field[key]); 439 | } 440 | } 441 | }, 442 | 443 | /** 444 | * Append additional header `field` with value `val`. 445 | * 446 | * Examples: 447 | * 448 | * this.append('Link', ['', '']); 449 | * this.append('Set-Cookie', 'foo=bar; Path=/; HttpOnly'); 450 | * this.append('Warning', '199 Miscellaneous warning'); 451 | * 452 | * @param {String} field 453 | * @param {String|Array} val 454 | * @api public 455 | */ 456 | 457 | append: function(field, val){ 458 | var prev = this.get(field); 459 | 460 | if (prev) { 461 | val = Array.isArray(prev) 462 | ? prev.concat(val) 463 | : [prev].concat(val); 464 | } 465 | 466 | return this.set(field, val); 467 | }, 468 | 469 | /** 470 | * Remove header `field`. 471 | * 472 | * @param {String} name 473 | * @api public 474 | */ 475 | 476 | remove: function(field){ 477 | this.res.removeHeader(field); 478 | }, 479 | 480 | /** 481 | * Checks if the request is writable. 482 | * Tests for the existence of the socket 483 | * as node sometimes does not set it. 484 | * 485 | * @return {Boolean} 486 | * @api private 487 | */ 488 | 489 | get writable() { 490 | var socket = this.res.socket; 491 | if (!socket) return false; 492 | return socket.writable; 493 | }, 494 | 495 | /** 496 | * Inspect implementation. 497 | * 498 | * @return {Object} 499 | * @api public 500 | */ 501 | 502 | inspect: function(){ 503 | if (!this.res) return; 504 | var o = this.toJSON(); 505 | o.body = this.body; 506 | return o; 507 | }, 508 | 509 | /** 510 | * Return JSON representation. 511 | * 512 | * @return {Object} 513 | * @api public 514 | */ 515 | 516 | toJSON: function(){ 517 | return { 518 | status: this.status, 519 | message: this.message, 520 | header: this.header 521 | }; 522 | } 523 | }; 524 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var contentType = require('content-type'); 8 | var stringify = require('url').format; 9 | var parse = require('parseurl'); 10 | var qs = require('qs'); 11 | var typeis = require('type-is'); 12 | var fresh = require('fresh'); 13 | 14 | /** 15 | * Prototype. 16 | */ 17 | 18 | module.exports = { 19 | 20 | /** 21 | * Return request header. 22 | * 23 | * @return {Object} 24 | * @api public 25 | */ 26 | 27 | get header() { 28 | return this.req.headers; 29 | }, 30 | 31 | /** 32 | * Return request header, alias as request.header 33 | * 34 | * @return {Object} 35 | * @api public 36 | */ 37 | 38 | get headers() { 39 | return this.req.headers; 40 | }, 41 | 42 | /** 43 | * Get request URL. 44 | * 45 | * @return {String} 46 | * @api public 47 | */ 48 | 49 | get url() { 50 | return this.req.url; 51 | }, 52 | 53 | /** 54 | * Set request URL. 55 | * 56 | * @api public 57 | */ 58 | 59 | set url(val) { 60 | this.req.url = val; 61 | }, 62 | 63 | /** 64 | * Get origin of URL. 65 | * 66 | * @return {String} 67 | * @api public 68 | */ 69 | 70 | get origin() { 71 | return this.protocol + '://' + this.host; 72 | }, 73 | 74 | /** 75 | * Get full request URL. 76 | * 77 | * @return {String} 78 | * @api public 79 | */ 80 | 81 | get href() { 82 | // support: `GET http://example.com/foo` 83 | if (/^https?:\/\//i.test(this.originalUrl)) { 84 | return this.originalUrl; 85 | } 86 | return this.origin + this.originalUrl; 87 | }, 88 | 89 | /** 90 | * Get request method. 91 | * 92 | * @return {String} 93 | * @api public 94 | */ 95 | 96 | get method() { 97 | return this.req.method; 98 | }, 99 | 100 | /** 101 | * Set request method. 102 | * 103 | * @param {String} val 104 | * @api public 105 | */ 106 | 107 | set method(val) { 108 | this.req.method = val; 109 | }, 110 | 111 | /** 112 | * Get request pathname. 113 | * 114 | * @return {String} 115 | * @api public 116 | */ 117 | 118 | get path() { 119 | return parse(this.req).pathname; 120 | }, 121 | 122 | /** 123 | * Set pathname, retaining the query-string when present. 124 | * 125 | * @param {String} path 126 | * @api public 127 | */ 128 | 129 | set path(path) { 130 | var url = parse(this.req); 131 | if (url.pathname === path) return; 132 | 133 | url.pathname = path; 134 | url.path = null; 135 | 136 | this.url = stringify(url); 137 | }, 138 | 139 | /** 140 | * Get parsed query-string. 141 | * 142 | * @return {Object} 143 | * @api public 144 | */ 145 | 146 | get query() { 147 | var str = this.querystring; 148 | var c = this._querycache = this._querycache || {}; 149 | return c[str] || (c[str] = qs.parse(str, { arrayLimit: Infinity })); 150 | }, 151 | 152 | /** 153 | * Set query-string as an object. 154 | * 155 | * @param {Object} obj 156 | * @api public 157 | */ 158 | 159 | set query(obj) { 160 | this.querystring = qs.stringify(obj); 161 | }, 162 | 163 | /** 164 | * Get query string. 165 | * 166 | * @return {String} 167 | * @api public 168 | */ 169 | 170 | get querystring() { 171 | if (!this.req) return ''; 172 | return parse(this.req).query || ''; 173 | }, 174 | 175 | /** 176 | * Set querystring. 177 | * 178 | * @param {String} str 179 | * @api public 180 | */ 181 | 182 | set querystring(str) { 183 | var url = parse(this.req); 184 | if (url.search === '?' + str) return; 185 | 186 | url.search = str; 187 | url.path = null; 188 | 189 | this.url = stringify(url); 190 | }, 191 | 192 | /** 193 | * Get the search string. Same as the querystring 194 | * except it includes the leading ?. 195 | * 196 | * @return {String} 197 | * @api public 198 | */ 199 | 200 | get search() { 201 | if (!this.querystring) return ''; 202 | return '?' + this.querystring; 203 | }, 204 | 205 | /** 206 | * Set the search string. Same as 207 | * response.querystring= but included for ubiquity. 208 | * 209 | * @param {String} str 210 | * @api public 211 | */ 212 | 213 | set search(str) { 214 | this.querystring = str; 215 | }, 216 | 217 | /** 218 | * Parse the "Host" header field host 219 | * and support X-Forwarded-Host when a 220 | * proxy is enabled. 221 | * 222 | * @return {String} hostname:port 223 | * @api public 224 | */ 225 | 226 | get host() { 227 | var proxy = this.app.proxy; 228 | var host = proxy && this.get('X-Forwarded-Host'); 229 | host = host || this.get('Host'); 230 | if (!host) return ''; 231 | return host.split(/\s*,\s*/)[0]; 232 | }, 233 | 234 | /** 235 | * Parse the "Host" header field hostname 236 | * and support X-Forwarded-Host when a 237 | * proxy is enabled. 238 | * 239 | * @return {String} hostname 240 | * @api public 241 | */ 242 | 243 | get hostname() { 244 | var host = this.host; 245 | if (!host) return ''; 246 | return host.split(':')[0]; 247 | }, 248 | 249 | /** 250 | * Check if the request is fresh, aka 251 | * Last-Modified and/or the ETag 252 | * still match. 253 | * 254 | * @return {Boolean} 255 | * @api public 256 | */ 257 | 258 | get fresh() { 259 | var method = this.method; 260 | var s = this.ctx.status; 261 | 262 | // GET or HEAD for weak freshness validation only 263 | if ('GET' != method && 'HEAD' != method) return false; 264 | 265 | // 2xx or 304 as per rfc2616 14.26 266 | if ((s >= 200 && s < 300) || 304 == s) { 267 | return fresh(this.header, this.ctx.response.header); 268 | } 269 | 270 | return false; 271 | }, 272 | 273 | /** 274 | * Check if the request is stale, aka 275 | * "Last-Modified" and / or the "ETag" for the 276 | * resource has changed. 277 | * 278 | * @return {Boolean} 279 | * @api public 280 | */ 281 | 282 | get stale() { 283 | return !this.fresh; 284 | }, 285 | 286 | /** 287 | * Check if the request is idempotent. 288 | * 289 | * @return {Boolean} 290 | * @api public 291 | */ 292 | 293 | get idempotent() { 294 | var methods = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']; 295 | return !!~methods.indexOf(this.method); 296 | }, 297 | 298 | /** 299 | * Return the request socket. 300 | * 301 | * @return {Connection} 302 | * @api public 303 | */ 304 | 305 | get socket() { 306 | // TODO: TLS 307 | return this.req.socket; 308 | }, 309 | 310 | /** 311 | * Get the charset when present or undefined. 312 | * 313 | * @return {String} 314 | * @api public 315 | */ 316 | 317 | get charset() { 318 | var type = this.get('Content-Type'); 319 | if (!type) return ''; 320 | 321 | return contentType.parse(type).parameters.charset || ''; 322 | }, 323 | 324 | /** 325 | * Return parsed Content-Length when present. 326 | * 327 | * @return {Number} 328 | * @api public 329 | */ 330 | 331 | get length() { 332 | var len = this.get('Content-Length'); 333 | if (len == '') return; 334 | return ~~len; 335 | }, 336 | 337 | /** 338 | * Return the protocol string "http" or "https" 339 | * when requested with TLS. When the proxy setting 340 | * is enabled the "X-Forwarded-Proto" header 341 | * field will be trusted. If you're running behind 342 | * a reverse proxy that supplies https for you this 343 | * may be enabled. 344 | * 345 | * @return {String} 346 | * @api public 347 | */ 348 | 349 | get protocol() { 350 | var proxy = this.app.proxy; 351 | if (this.socket.encrypted) return 'https'; 352 | if (!proxy) return 'http'; 353 | var proto = this.get('X-Forwarded-Proto') || 'http'; 354 | return proto.split(/\s*,\s*/)[0]; 355 | }, 356 | 357 | /** 358 | * Short-hand for: 359 | * 360 | * this.protocol == 'https' 361 | * 362 | * @return {Boolean} 363 | * @api public 364 | */ 365 | 366 | get secure() { 367 | return 'https' == this.protocol; 368 | }, 369 | 370 | /** 371 | * Return the remote address, or when 372 | * `app.proxy` is `true` return 373 | * the upstream addr. 374 | * 375 | * @return {String} 376 | * @api public 377 | */ 378 | 379 | get ip() { 380 | return this.ips[0] || this.socket.remoteAddress || ''; 381 | }, 382 | 383 | /** 384 | * When `app.proxy` is `true`, parse 385 | * the "X-Forwarded-For" ip address list. 386 | * 387 | * For example if the value were "client, proxy1, proxy2" 388 | * you would receive the array `["client", "proxy1", "proxy2"]` 389 | * where "proxy2" is the furthest down-stream. 390 | * 391 | * @return {Array} 392 | * @api public 393 | */ 394 | 395 | get ips() { 396 | var proxy = this.app.proxy; 397 | var val = this.get('X-Forwarded-For'); 398 | return proxy && val 399 | ? val.split(/\s*,\s*/) 400 | : []; 401 | }, 402 | 403 | /** 404 | * Return subdomains as an array. 405 | * 406 | * Subdomains are the dot-separated parts of the host before the main domain of 407 | * the app. By default, the domain of the app is assumed to be the last two 408 | * parts of the host. This can be changed by setting `app.subdomainOffset`. 409 | * 410 | * For example, if the domain is "tobi.ferrets.example.com": 411 | * If `app.subdomainOffset` is not set, this.subdomains is `["ferrets", "tobi"]`. 412 | * If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`. 413 | * 414 | * @return {Array} 415 | * @api public 416 | */ 417 | 418 | get subdomains() { 419 | var offset = this.app.subdomainOffset; 420 | return (this.host || '') 421 | .split('.') 422 | .reverse() 423 | .slice(offset); 424 | }, 425 | 426 | /** 427 | * Check if the given `type(s)` is acceptable, returning 428 | * the best match when true, otherwise `undefined`, in which 429 | * case you should respond with 406 "Not Acceptable". 430 | * 431 | * The `type` value may be a single mime type string 432 | * such as "application/json", the extension name 433 | * such as "json" or an array `["json", "html", "text/plain"]`. When a list 434 | * or array is given the _best_ match, if any is returned. 435 | * 436 | * Examples: 437 | * 438 | * // Accept: text/html 439 | * this.accepts('html'); 440 | * // => "html" 441 | * 442 | * // Accept: text/*, application/json 443 | * this.accepts('html'); 444 | * // => "html" 445 | * this.accepts('text/html'); 446 | * // => "text/html" 447 | * this.accepts('json', 'text'); 448 | * // => "json" 449 | * this.accepts('application/json'); 450 | * // => "application/json" 451 | * 452 | * // Accept: text/*, application/json 453 | * this.accepts('image/png'); 454 | * this.accepts('png'); 455 | * // => undefined 456 | * 457 | * // Accept: text/*;q=.5, application/json 458 | * this.accepts(['html', 'json']); 459 | * this.accepts('html', 'json'); 460 | * // => "json" 461 | * 462 | * @param {String|Array} type(s)... 463 | * @return {String|Array|Boolean} 464 | * @api public 465 | */ 466 | 467 | accepts: function(){ 468 | return this.accept.types.apply(this.accept, arguments); 469 | }, 470 | 471 | /** 472 | * Return accepted encodings or best fit based on `encodings`. 473 | * 474 | * Given `Accept-Encoding: gzip, deflate` 475 | * an array sorted by quality is returned: 476 | * 477 | * ['gzip', 'deflate'] 478 | * 479 | * @param {String|Array} encoding(s)... 480 | * @return {String|Array} 481 | * @api public 482 | */ 483 | 484 | acceptsEncodings: function(){ 485 | return this.accept.encodings.apply(this.accept, arguments); 486 | }, 487 | 488 | /** 489 | * Return accepted charsets or best fit based on `charsets`. 490 | * 491 | * Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5` 492 | * an array sorted by quality is returned: 493 | * 494 | * ['utf-8', 'utf-7', 'iso-8859-1'] 495 | * 496 | * @param {String|Array} charset(s)... 497 | * @return {String|Array} 498 | * @api public 499 | */ 500 | 501 | acceptsCharsets: function(){ 502 | return this.accept.charsets.apply(this.accept, arguments); 503 | }, 504 | 505 | /** 506 | * Return accepted languages or best fit based on `langs`. 507 | * 508 | * Given `Accept-Language: en;q=0.8, es, pt` 509 | * an array sorted by quality is returned: 510 | * 511 | * ['es', 'pt', 'en'] 512 | * 513 | * @param {String|Array} lang(s)... 514 | * @return {Array|String} 515 | * @api public 516 | */ 517 | 518 | acceptsLanguages: function(){ 519 | return this.accept.languages.apply(this.accept, arguments); 520 | }, 521 | 522 | /** 523 | * Check if the incoming request contains the "Content-Type" 524 | * header field, and it contains any of the give mime `type`s. 525 | * If there is no request body, `null` is returned. 526 | * If there is no content type, `false` is returned. 527 | * Otherwise, it returns the first `type` that matches. 528 | * 529 | * Examples: 530 | * 531 | * // With Content-Type: text/html; charset=utf-8 532 | * this.is('html'); // => 'html' 533 | * this.is('text/html'); // => 'text/html' 534 | * this.is('text/*', 'application/json'); // => 'text/html' 535 | * 536 | * // When Content-Type is application/json 537 | * this.is('json', 'urlencoded'); // => 'json' 538 | * this.is('application/json'); // => 'application/json' 539 | * this.is('html', 'application/*'); // => 'application/json' 540 | * 541 | * this.is('html'); // => false 542 | * 543 | * @param {String|Array} types... 544 | * @return {String|false|null} 545 | * @api public 546 | */ 547 | 548 | is: function(types){ 549 | if (!types) return typeis(this.req); 550 | if (!Array.isArray(types)) types = [].slice.call(arguments); 551 | return typeis(this.req, types); 552 | }, 553 | 554 | /** 555 | * Return the request mime type void of 556 | * parameters such as "charset". 557 | * 558 | * @return {String} 559 | * @api public 560 | */ 561 | 562 | get type() { 563 | var type = this.get('Content-Type'); 564 | if (!type) return ''; 565 | return type.split(';')[0]; 566 | }, 567 | 568 | /** 569 | * Return request header. 570 | * 571 | * The `Referrer` header field is special-cased, 572 | * both `Referrer` and `Referer` are interchangeable. 573 | * 574 | * Examples: 575 | * 576 | * this.get('Content-Type'); 577 | * // => "text/plain" 578 | * 579 | * this.get('content-type'); 580 | * // => "text/plain" 581 | * 582 | * this.get('Something'); 583 | * // => undefined 584 | * 585 | * @param {String} field 586 | * @return {String} 587 | * @api public 588 | */ 589 | 590 | get: function(field){ 591 | var req = this.req; 592 | switch (field = field.toLowerCase()) { 593 | case 'referer': 594 | case 'referrer': 595 | return req.headers.referrer || req.headers.referer || ''; 596 | default: 597 | return req.headers[field] || ''; 598 | } 599 | }, 600 | 601 | /** 602 | * Inspect implementation. 603 | * 604 | * @return {Object} 605 | * @api public 606 | */ 607 | 608 | inspect: function(){ 609 | if (!this.req) return; 610 | return this.toJSON(); 611 | }, 612 | 613 | /** 614 | * Return JSON representation. 615 | * 616 | * @return {Object} 617 | * @api public 618 | */ 619 | 620 | toJSON: function(){ 621 | return { 622 | method: this.method, 623 | url: this.url, 624 | header: this.header 625 | }; 626 | } 627 | }; 628 | --------------------------------------------------------------------------------