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.

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