You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

242 lines
7.6 KiB

10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
  1. 'use strict'
  2. const path = require('path')
  3. const http = require('http')
  4. const url = require('url')
  5. const Koa = require('koa')
  6. const convert = require('koa-convert')
  7. const cors = require('kcors')
  8. const _ = require('koa-route')
  9. const pathToRegexp = require('path-to-regexp')
  10. /**
  11. * @module local-web-server
  12. */
  13. module.exports = localWebServer
  14. /**
  15. * Returns a Koa application
  16. *
  17. * @param [options] {object} - options
  18. * @param [options.static] {object} - koa-static config
  19. * @param [options.static.root] {string} - root directory
  20. * @param [options.static.options] {string} - [options](https://github.com/koajs/static#options)
  21. * @param [options.serveIndex] {object} - koa-serve-index config
  22. * @param [options.serveIndex.path] {string} - root directory
  23. * @param [options.serveIndex.options] {string} - [options](https://github.com/expressjs/serve-index#options)
  24. * @param [options.forbid] {string[]} - A list of forbidden routes, each route being an [express route-path](http://expressjs.com/guide/routing.html#route-paths).
  25. * @param [options.spa] {string} - specify an SPA file to catch requests for everything but static assets.
  26. * @param [options.log] {object} - [morgan](https://github.com/expressjs/morgan) config
  27. * @param [options.log.format] {string} - [log format](https://github.com/expressjs/morgan#predefined-formats)
  28. * @param [options.log.options] {object} - [options](https://github.com/expressjs/morgan#options)
  29. * @param [options.compress] {boolean} - Serve gzip-compressed resources, where applicable
  30. * @param [options.mime] {object} - A list of mime-type overrides, passed directly to [mime.define()](https://github.com/broofa/node-mime#mimedefine)
  31. * @param [options.rewrite] {module:local-web-server~rewriteRule[]} - One or more rewrite rules
  32. * @param [options.verbose] {boolean} - Print detailed output, useful for debugging
  33. *
  34. * @alias module:local-web-server
  35. * @example
  36. * const localWebServer = require('local-web-server')
  37. * localWebServer().listen(8000)
  38. */
  39. function localWebServer (options) {
  40. options = Object.assign({
  41. static: {},
  42. serveIndex: {},
  43. spa: null,
  44. log: {},
  45. compress: false,
  46. mime: {},
  47. forbid: [],
  48. rewrite: [],
  49. verbose: false
  50. }, options)
  51. const log = options.log
  52. log.options = log.options || {}
  53. const app = new Koa()
  54. const _use = app.use
  55. app.use = x => _use.call(app, convert(x))
  56. function verbose (category, message) {
  57. if (options.verbose) {
  58. process.nextTick(() => app.emit('verbose', category, message))
  59. }
  60. }
  61. app._verbose = verbose
  62. if (options.verbose && !log.format) {
  63. log.format = 'none'
  64. }
  65. /* CORS: allow from any origin */
  66. app.use(cors())
  67. /* rewrite rules */
  68. if (options.rewrite && options.rewrite.length) {
  69. options.rewrite.forEach(route => {
  70. if (route.to) {
  71. if (url.parse(route.to).host) {
  72. verbose('proxy rewrite', `${route.from} -> ${route.to}`)
  73. app.use(_.all(route.from, proxyRequest(route, app)))
  74. } else {
  75. const rewrite = require('koa-rewrite')
  76. verbose('local rewrite', `${route.from} -> ${route.to}`)
  77. app.use(rewrite(route.from, route.to))
  78. }
  79. }
  80. })
  81. }
  82. /* path blacklist */
  83. if (options.forbid.length) {
  84. verbose('forbid', options.forbid.join(', '))
  85. app.use(blacklist(options.forbid))
  86. }
  87. /* Cache */
  88. if (!options['no-cache']) {
  89. const conditional = require('koa-conditional-get')
  90. const etag = require('koa-etag')
  91. verbose('etag caching', 'enabled')
  92. app.use(conditional())
  93. app.use(etag())
  94. }
  95. /* mime-type overrides */
  96. if (options.mime) {
  97. verbose('mime override', JSON.stringify(options.mime))
  98. app.use((ctx, next) => {
  99. return next().then(() => {
  100. const reqPathExtension = path.extname(ctx.path).slice(1)
  101. Object.keys(options.mime).forEach(mimeType => {
  102. const extsToOverride = options.mime[mimeType]
  103. if (extsToOverride.indexOf(reqPathExtension) > -1) ctx.type = mimeType
  104. })
  105. })
  106. })
  107. }
  108. /* compress response */
  109. if (options.compress) {
  110. const compress = require('koa-compress')
  111. verbose('compression', 'enabled')
  112. app.use(compress())
  113. }
  114. /* Logging */
  115. if (log.format !== 'none') {
  116. const morgan = require('koa-morgan')
  117. if (!log.format) {
  118. const streamLogStats = require('stream-log-stats')
  119. log.options.stream = streamLogStats({ refreshRate: 500 })
  120. app.use(morgan.middleware('common', log.options))
  121. } else if (log.format === 'logstalgia') {
  122. morgan.token('date', logstalgiaDate)
  123. app.use(morgan.middleware('combined', log.options))
  124. } else {
  125. app.use(morgan.middleware(log.format, log.options))
  126. }
  127. }
  128. /* serve static files */
  129. if (options.static.root) {
  130. const serve = require('koa-static')
  131. verbose('static', `root: ${options.static.root} options: ${JSON.stringify(options.static.options)}` )
  132. app.use(serve(options.static.root, options.static.options))
  133. }
  134. /* serve directory index */
  135. if (options.serveIndex.path) {
  136. const serveIndex = require('koa-serve-index')
  137. verbose('serve-index', `root: ${options.serveIndex.path} options: ${JSON.stringify(options.serveIndex.options)}` )
  138. app.use(serveIndex(options.serveIndex.path, options.serveIndex.options))
  139. }
  140. /* for any URL not matched by static (e.g. `/search`), serve the SPA */
  141. if (options.spa) {
  142. const send = require('koa-send')
  143. verbose('SPA', options.spa)
  144. app.use(_.all('*', function * () {
  145. yield send(this, options.spa, { root: path.resolve(options.static.root) || process.cwd() })
  146. }))
  147. }
  148. return app
  149. }
  150. function logstalgiaDate () {
  151. var d = new Date()
  152. return (`${d.getDate()}/${d.getUTCMonth()}/${d.getFullYear()}:${d.toTimeString()}`).replace('GMT', '').replace(' (BST)', '')
  153. }
  154. function proxyRequest (route, app) {
  155. const httpProxy = require('http-proxy')
  156. const proxy = httpProxy.createProxyServer({
  157. changeOrigin: true
  158. })
  159. return function * proxyMiddleware () {
  160. const next = arguments[arguments.length - 1]
  161. const keys = []
  162. route.re = pathToRegexp(route.from, keys)
  163. route.new = this.path.replace(route.re, route.to)
  164. keys.forEach((key, index) => {
  165. const re = RegExp(`:${key.name}`, 'g')
  166. route.new = route.new
  167. .replace(re, arguments[index] || '')
  168. })
  169. /* test no keys remain in the new path */
  170. keys.length = 0
  171. pathToRegexp(route.new, keys)
  172. if (keys.length) {
  173. this.throw(500, `[PROXY] Invalid target URL: ${route.new}`)
  174. return next()
  175. }
  176. this.response = false
  177. app._verbose('proxy request', `from: ${this.path}, to: ${url.parse(route.new).href}`)
  178. proxy.once('error', err => {
  179. this.throw(500, `[PROXY] ${err.message}: ${route.new}`)
  180. })
  181. proxy.once('proxyReq', function (proxyReq) {
  182. proxyReq.path = url.parse(route.new).path
  183. })
  184. proxy.web(this.req, this.res, { target: route.new })
  185. }
  186. }
  187. function blacklist (forbid) {
  188. return function blacklist (ctx, next) {
  189. if (forbid.some(expression => pathToRegexp(expression).test(ctx.path))) {
  190. ctx.throw(403, http.STATUS_CODES[403])
  191. } else {
  192. return next()
  193. }
  194. }
  195. }
  196. process.on('unhandledRejection', (reason, p) => {
  197. throw reason
  198. })
  199. /**
  200. * The `from` and `to` routes are specified using [express route-paths](http://expressjs.com/guide/routing.html#route-paths)
  201. *
  202. * @example
  203. * ```json
  204. * {
  205. * "rewrite": [
  206. * { "from": "/css/*", "to": "/build/styles/$1" },
  207. * { "from": "/npm/*", "to": "http://registry.npmjs.org/$1" },
  208. * { "from": "/:user/repos/:name", "to": "https://api.github.com/repos/:user/:name" }
  209. * ]
  210. * }
  211. * ```
  212. *
  213. * @typedef rewriteRule
  214. * @property from {string} - request route
  215. * @property to {string} - target route
  216. */