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.

304 lines
9.1 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
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, pathToRegexp
  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. 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. }
  67. }
  68. app._verbose = verbose
  69. if (options.verbose && !log.format) {
  70. log.format = 'none'
  71. }
  72. /* CORS: allow from any origin */
  73. app.use(cors())
  74. /* rewrite rules */
  75. if (options.rewrite && options.rewrite.length) {
  76. options.rewrite.forEach(route => {
  77. if (route.to) {
  78. if (url.parse(route.to).host) {
  79. verbose('proxy rewrite', `${route.from} -> ${route.to}`)
  80. app.use(_.all(route.from, proxyRequest(route, app)))
  81. } else {
  82. const rewrite = require('koa-rewrite')
  83. const mw = rewrite(route.from, route.to)
  84. mw._name = 'rewrite'
  85. app.use(mw)
  86. }
  87. }
  88. })
  89. }
  90. /* path blacklist */
  91. if (options.forbid.length) {
  92. verbose('forbid', options.forbid.join(', '))
  93. app.use(blacklist(options.forbid))
  94. }
  95. /* Cache */
  96. if (!options['no-cache']) {
  97. const conditional = require('koa-conditional-get')
  98. const etag = require('koa-etag')
  99. app.use(conditional())
  100. app.use(etag())
  101. }
  102. /* mime-type overrides */
  103. if (options.mime) {
  104. verbose('mime override', JSON.stringify(options.mime))
  105. app.use((ctx, next) => {
  106. return next().then(() => {
  107. const reqPathExtension = path.extname(ctx.path).slice(1)
  108. Object.keys(options.mime).forEach(mimeType => {
  109. const extsToOverride = options.mime[mimeType]
  110. if (extsToOverride.indexOf(reqPathExtension) > -1) ctx.type = mimeType
  111. })
  112. })
  113. })
  114. }
  115. /* compress response */
  116. if (options.compress) {
  117. const compress = require('koa-compress')
  118. verbose('compression', 'enabled')
  119. app.use(compress())
  120. }
  121. /* Logging */
  122. if (log.format !== 'none') {
  123. const morgan = require('koa-morgan')
  124. if (!log.format) {
  125. const streamLogStats = require('stream-log-stats')
  126. log.options.stream = streamLogStats({ refreshRate: 500 })
  127. app.use(morgan.middleware('common', log.options))
  128. } else if (log.format === 'logstalgia') {
  129. morgan.token('date', logstalgiaDate)
  130. app.use(morgan.middleware('combined', log.options))
  131. } else {
  132. app.use(morgan.middleware(log.format, log.options))
  133. }
  134. }
  135. /* Mock Responses */
  136. app.use(mockResponses({ root: options.static.root, verbose: verbose }))
  137. /* serve static files */
  138. if (options.static.root) {
  139. const serve = require('koa-static')
  140. // verbose('static', 'enabled')
  141. app.use(serve(options.static.root, options.static.options))
  142. }
  143. /* serve directory index */
  144. if (options.serveIndex.path) {
  145. const serveIndex = require('koa-serve-index')
  146. // verbose('serve-index', 'enabled')
  147. app.use(serveIndex(options.serveIndex.path, options.serveIndex.options))
  148. }
  149. /* for any URL not matched by static (e.g. `/search`), serve the SPA */
  150. if (options.spa) {
  151. const send = require('koa-send')
  152. verbose('SPA', options.spa)
  153. app.use(_.all('*', function * () {
  154. yield send(this, options.spa, { root: path.resolve(options.static.root) || process.cwd() })
  155. }))
  156. }
  157. return app
  158. }
  159. function logstalgiaDate () {
  160. var d = new Date()
  161. return (`${d.getDate()}/${d.getUTCMonth()}/${d.getFullYear()}:${d.toTimeString()}`).replace('GMT', '').replace(' (BST)', '')
  162. }
  163. function proxyRequest (route, app) {
  164. const httpProxy = require('http-proxy')
  165. const proxy = httpProxy.createProxyServer({
  166. changeOrigin: true
  167. })
  168. return function * proxyMiddleware () {
  169. const next = arguments[arguments.length - 1]
  170. const keys = []
  171. route.re = pathToRegexp(route.from, keys)
  172. route.new = this.url.replace(route.re, route.to)
  173. keys.forEach((key, index) => {
  174. const re = RegExp(`:${key.name}`, 'g')
  175. route.new = route.new
  176. .replace(re, arguments[index] || '')
  177. })
  178. /* test no keys remain in the new path */
  179. keys.length = 0
  180. pathToRegexp(url.parse(route.new).path, keys)
  181. if (keys.length) {
  182. this.throw(500, `[PROXY] Invalid target URL: ${route.new}`)
  183. return next()
  184. }
  185. this.response = false
  186. app._verbose('proxy request', `from: ${this.path}, to: ${url.parse(route.new).href}`)
  187. proxy.once('error', err => {
  188. this.throw(500, `[PROXY] ${err.message}: ${route.new}`)
  189. })
  190. proxy.once('proxyReq', function (proxyReq) {
  191. proxyReq.path = url.parse(route.new).path
  192. })
  193. proxy.web(this.req, this.res, { target: route.new })
  194. }
  195. }
  196. function blacklist (forbid) {
  197. return function blacklist (ctx, next) {
  198. if (forbid.some(expression => pathToRegexp(expression).test(ctx.path))) {
  199. ctx.throw(403, http.STATUS_CODES[403])
  200. } else {
  201. return next()
  202. }
  203. }
  204. }
  205. function mockResponses (options) {
  206. options = options || { root: process.cwd() }
  207. return function mockResponses (ctx, next) {
  208. if (/\.mock.js$/.test(ctx.path)) {
  209. const mocks = arrayify(require(path.join(options.root, ctx.path)))
  210. const testValue = require('test-value')
  211. const t = require('typical')
  212. /* find a mock with compatible method and accepts */
  213. let mock = mocks.find(mock => {
  214. return testValue(mock, {
  215. request: {
  216. method: [ ctx.method, undefined ],
  217. accepts: type => ctx.accepts(type)
  218. }
  219. })
  220. })
  221. /* else take the first mock without a request (no request means 'all requests') */
  222. if (!mock) {
  223. mock = mocks.find(mock => !mock.request)
  224. }
  225. const mockedReponse = {}
  226. /* resolve any functions on the mock */
  227. Object.keys(mock.response).forEach(key => {
  228. if (t.isFunction(mock.response[key])) {
  229. mockedReponse[key] = mock.response[key](ctx)
  230. } else {
  231. mockedReponse[key] = mock.response[key]
  232. }
  233. })
  234. if (mock) {
  235. Object.assign(ctx.response, mockedReponse)
  236. options.verbose('mocked response', JSON.stringify(mockedReponse))
  237. options.verbose('actual response', JSON.stringify(ctx.response))
  238. }
  239. } else {
  240. return next()
  241. }
  242. }
  243. }
  244. process.on('unhandledRejection', (reason, p) => {
  245. throw reason
  246. })
  247. /**
  248. * The `from` and `to` routes are specified using [express route-paths](http://expressjs.com/guide/routing.html#route-paths)
  249. *
  250. * @example
  251. * ```json
  252. * {
  253. * "rewrite": [
  254. * { "from": "/css/*", "to": "/build/styles/$1" },
  255. * { "from": "/npm/*", "to": "http://registry.npmjs.org/$1" },
  256. * { "from": "/:user/repos/:name", "to": "https://api.github.com/repos/:user/:name" }
  257. * ]
  258. * }
  259. * ```
  260. *
  261. * @typedef rewriteRule
  262. * @property from {string} - request route
  263. * @property to {string} - target route
  264. */
  265. /**
  266. * @external KoaApplication
  267. * @see https://github.com/koajs/koa/blob/master/docs/api/index.md#application
  268. */