From 9c3db53d276c1330bc9270395c9b82c49bad530a Mon Sep 17 00:00:00 2001 From: Lloyd Brookes Date: Thu, 16 Jun 2016 23:00:07 +0100 Subject: [PATCH] refactor --- README.md | 2 +- bin/cli.js | 5 +- extend/cache-control.js | 17 +-- extend/index.html | 10 ++ extend/live-reload.js | 7 +- lib/cli-data.js | 97 ++++++--------- lib/local-web-server.js | 99 ++++++++-------- lib/middleware-stack.js | 310 ++++++++++++++++++++++++++++++------------------ lib/middleware.js | 12 -- 9 files changed, 308 insertions(+), 251 deletions(-) create mode 100644 extend/index.html diff --git a/README.md b/README.md index 14e8e6b..24ea340 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ***Requires node v4.0.0 or higher. Install the [previous release](https://github.com/75lb/local-web-server/tree/prev) for older node support.*** # local-web-server -A simple web-server for productive front-end development. Typical use cases: +A simple, extensible web-server for productive front-end development. Typical use cases: * Front-end Development * Static or Single Page App development diff --git a/bin/cli.js b/bin/cli.js index 49b1b98..328ec15 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -3,8 +3,7 @@ const LocalWebServer = require('../') const ws = new LocalWebServer() -ws.middleware - .addCors() +ws.addCors() .addJson() .addRewrite() .addBodyParser() @@ -17,4 +16,4 @@ ws.middleware .addSpa() .addStatic() .addIndex() -ws.listen() + .start() diff --git a/extend/cache-control.js b/extend/cache-control.js index c25226e..bd33057 100644 --- a/extend/cache-control.js +++ b/extend/cache-control.js @@ -1,16 +1,17 @@ 'use strict' const LocalWebServer = require('../') const cacheControl = require('koa-cache-control') -const cliData = require('../lib/cli-data') -cliData.optionDefinitions.push({ name: 'maxage', group: 'misc' }) +const optionDefinitions = { name: 'maxage', type: Number, defaultValue: 1000 } const ws = new LocalWebServer() -ws.middleware - .addLogging('dev') - .add(cacheControl({ - maxAge: 15 - })) +ws.addLogging('dev') + .add({ + optionDefinitions: optionDefinitions, + middleware: function (options) { + return cacheControl({ maxAge: options.middleware.maxage }) + } + }) .addStatic() .addIndex() -ws.listen() + .start() diff --git a/extend/index.html b/extend/index.html new file mode 100644 index 0000000..0cc6591 --- /dev/null +++ b/extend/index.html @@ -0,0 +1,10 @@ + + + + + live-reload demo + + +

Live reloaded attached

+ + diff --git a/extend/live-reload.js b/extend/live-reload.js index 90f48f3..cadb37f 100644 --- a/extend/live-reload.js +++ b/extend/live-reload.js @@ -3,8 +3,7 @@ const Cli = require('../') const liveReload = require('koa-livereload') const ws = new Cli() -ws.middleware - .addLogging('dev') - .add(liveReload()) +ws.addLogging('dev') + .add({ middleware: liveReload }) .addStatic() -ws.listen(8000) + .start() diff --git a/lib/cli-data.js b/lib/cli-data.js index bcd5fd6..bb0ce73 100644 --- a/lib/cli-data.js +++ b/lib/cli-data.js @@ -4,34 +4,6 @@ exports.optionDefinitions = [ description: 'Web server port.', group: 'server' }, { - name: 'directory', alias: 'd', type: String, typeLabel: '[underline]{path}', - description: 'Root directory, defaults to the current directory.', group: 'server' - }, - { - name: 'log-format', alias: 'f', type: String, - description: "If a format is supplied an access log is written to stdout. If not, a dynamic statistics view is displayed. Use a preset ('none', 'dev','combined', 'short', 'tiny' or 'logstalgia') or supply a custom format (e.g. ':method -> :url').", group: 'server' - }, - { - name: 'rewrite', alias: 'r', type: String, multiple: true, typeLabel: '[underline]{expression} ...', - description: "A list of URL rewrite rules. For each rule, separate the 'from' and 'to' routes with '->'. Whitespace surrounded the routes is ignored. E.g. '/from -> /to'.", group: 'server' - }, - { - name: 'spa', alias: 's', type: String, typeLabel: '[underline]{file}', - description: 'Path to a Single Page App, e.g. app.html.', group: 'server' - }, - { - name: 'compress', alias: 'c', type: Boolean, - description: 'Serve gzip-compressed resources, where applicable.', group: 'server' - }, - { - name: 'forbid', alias: 'b', type: String, multiple: true, typeLabel: '[underline]{path} ...', - description: 'A list of forbidden routes.', group: 'server' - }, - { - name: 'no-cache', alias: 'n', type: Boolean, - description: 'Disable etag-based caching - forces loading from disk each request.', group: 'server' - }, - { name: 'key', type: String, typeLabel: '[underline]{file}', group: 'server', description: 'SSL key. Supply along with --cert to launch a https server.' }, @@ -44,43 +16,52 @@ exports.optionDefinitions = [ description: 'Enable HTTPS using a built-in key and cert, registered to the domain 127.0.0.1.' }, { - name: 'verbose', type: Boolean, - description: 'Verbose output, useful for debugging.', group: 'server' - }, - { name: 'help', alias: 'h', type: Boolean, description: 'Print these usage instructions.', group: 'misc' }, { name: 'config', type: Boolean, description: 'Print the stored config.', group: 'misc' - } -] - -exports.usage = [ - { - header: 'local-web-server', - content: 'A simple web-server for productive front-end development.' - }, - { - header: 'Synopsis', - content: [ - '$ ws []', - '$ ws --config', - '$ ws --help' - ] }, { - header: 'Server options', - optionList: exports.optionDefinitions, - group: 'server' - }, - { - header: 'Misc options', - optionList: exports.optionDefinitions, - group: 'misc' - }, - { - content: 'Project home: [underline]{https://github.com/75lb/local-web-server}' + name: 'verbose', type: Boolean, + description: 'Verbose output, useful for debugging.', group: 'misc' } ] + +function usage (middlewareDefinitions) { + return [ + { + header: 'local-web-server', + content: 'A simple web-server for productive front-end development.' + }, + { + header: 'Synopsis', + content: [ + '$ ws []', + '$ ws --config', + '$ ws --help' + ] + }, + { + header: 'Server', + optionList: exports.optionDefinitions, + group: 'server' + }, + { + header: 'Middleware', + optionList: middlewareDefinitions, + group: 'middleware' + }, + { + header: 'Misc', + optionList: exports.optionDefinitions, + group: 'misc' + }, + { + content: 'Project home: [underline]{https://github.com/75lb/local-web-server}' + } + ] +} + +exports.usage = usage diff --git a/lib/local-web-server.js b/lib/local-web-server.js index 24eb566..e54afac 100644 --- a/lib/local-web-server.js +++ b/lib/local-web-server.js @@ -5,56 +5,53 @@ const path = require('path') const arrayify = require('array-back') const t = require('typical') const Tool = require('command-line-tool') +const MiddlewareStack = require('./middleware-stack') + const tool = new Tool() -class Cli { - constructor () { - this.options = collectOptions() - this.app = null - this.middleware = null +class Cli extends MiddlewareStack { + start () { + const options = collectOptions(this.getOptionDefinitions()) + this.options = options - const options = this.options + if (options.misc.verbose) { + process.env.DEBUG = '*' + } if (options.misc.config) { - tool.stop(JSON.stringify(options.server, null, ' '), 0) + tool.stop(JSON.stringify(options, null, ' '), 0) } else { const Koa = require('koa') const app = new Koa() app.on('error', err => { - if (options.server['log-format']) { + if (options.middleware['log-format']) { console.error(ansi.format(err.message, 'red')) } }) - this.app = app - const MiddlewareStack = require('./middleware-stack') - this.middleware = new MiddlewareStack(options) - } - } + app.use(this.compose(options)) - listen () { - this.app.use(this.middleware.compose()) - const options = this.options - const key = this.options.server.key - const cert = this.options.server.cert - if (options.server.https) { - key = path.resolve(__dirname, '..', 'ssl', '127.0.0.1.key') - cert = path.resolve(__dirname, '..', 'ssl', '127.0.0.1.crt') - } + let key = options.server.key + let cert = options.server.cert + if (options.server.https) { + key = path.resolve(__dirname, '..', 'ssl', '127.0.0.1.key') + cert = path.resolve(__dirname, '..', 'ssl', '127.0.0.1.crt') + } - if (key && cert) { - const https = require('https') - const fs = require('fs') + if (key && cert) { + const https = require('https') + const fs = require('fs') - const serverOptions = { - key: fs.readFileSync(key), - cert: fs.readFileSync(cert) - } + const serverOptions = { + key: fs.readFileSync(key), + cert: fs.readFileSync(cert) + } - const server = https.createServer(serverOptions, this.app.callback()) - server.listen(options.server.port, onServerUp.bind(null, options, true)) - } else { - this.app.listen(options.server.port, onServerUp.bind(null, options)) + const server = https.createServer(serverOptions, app.callback()) + server.listen(options.server.port, onServerUp.bind(null, options, true)) + } else { + app.listen(options.server.port, onServerUp.bind(null, options)) + } } } } @@ -65,9 +62,9 @@ function onServerUp (options, isHttps) { .join(', ') console.error(ansi.format( - path.resolve(options.server.directory) === process.cwd() + path.resolve(options.middleware.directory) === process.cwd() ? `serving at ${ipList}` - : `serving [underline]{${options.server.directory}} at ${ipList}` + : `serving [underline]{${options.middleware.directory}} at ${ipList}` )) } @@ -86,36 +83,38 @@ function getIPList (isHttps) { /** * Return default, stored and command-line options combined */ -function collectOptions () { +function collectOptions (mwOptionDefinitions) { const loadConfig = require('config-master') const stored = loadConfig('local-web-server') const cli = require('../lib/cli-data') /* parse command line args */ - let options = tool.getOptions(cli.optionDefinitions, cli.usage) + const definitions = cli.optionDefinitions.concat(arrayify(mwOptionDefinitions)) + let options = tool.getOptions(definitions, cli.usage(definitions)) - const builtIn = { - port: 8000, - directory: process.cwd() - } + /* override built-in defaults with stored config and then command line args */ + options.server = Object.assign({ port: 8000 }, stored.server, options.server) + options.middleware = Object.assign({ directory: process.cwd() }, stored.middleware || {}, options.middleware) - if (options.server.rewrite) { - options.server.rewrite = parseRewriteRules(options.server.rewrite) + if (options.middleware.rewrite) { + options.middleware.rewrite = parseRewriteRules(options.middleware.rewrite) } - /* override built-in defaults with stored config and then command line args */ - options.server = Object.assign(builtIn, stored, options.server) - validateOptions(options) + // console.log(options) return options } function parseRewriteRules (rules) { return rules && rules.map(rule => { - const matches = rule.match(/(\S*)\s*->\s*(\S*)/) - return { - from: matches[1], - to: matches[2] + if (t.isString(rule)) { + const matches = rule.match(/(\S*)\s*->\s*(\S*)/) + return { + from: matches[1], + to: matches[2] + } + } else { + return rule } }) } diff --git a/lib/middleware-stack.js b/lib/middleware-stack.js index 94f3205..a191d1e 100644 --- a/lib/middleware-stack.js +++ b/lib/middleware-stack.js @@ -5,17 +5,9 @@ const url = require('url') const debug = require('debug')('local-web-server') const mw = require('./middleware') const t = require('typical') +const compose = require('koa-compose') class MiddlewareStack extends Array { - constructor (options) { - super() - this.options = options - - if (options.verbose) { - process.env.DEBUG = '*' - } - } - add (middleware) { this.push(middleware) return this @@ -25,141 +17,196 @@ class MiddlewareStack extends Array { * allow from any origin */ addCors () { - this.push(require('kcors')()) + this.push({ middleware: require('kcors') }) return this } /* pretty print JSON */ addJson () { - this.push(require('koa-json')()) + this.push({ middleware: require('koa-json') }) return this } /* rewrite rules */ addRewrite (rewriteRules) { - const options = arrayify(this.options.server.rewrite || rewriteRules) - if (options.length) { - options.forEach(route => { - if (route.to) { - /* `to` address is remote if the url specifies a host */ - if (url.parse(route.to).host) { - const _ = require('koa-route') - debug('proxy rewrite', `${route.from} -> ${route.to}`) - this.push(_.all(route.from, mw.proxyRequest(route))) - } else { - const rewrite = require('koa-rewrite') - const rmw = rewrite(route.from, route.to) - rmw._name = 'rewrite' - this.push(rmw) - } + this.push({ + optionDefinitions: { + name: 'rewrite', alias: 'r', type: String, multiple: true, + typeLabel: '[underline]{expression} ...', + description: "A list of URL rewrite rules. For each rule, separate the 'from' and 'to' routes with '->'. Whitespace surrounded the routes is ignored. E.g. '/from -> /to'." + }, + middleware: function (cliOptions) { + const options = arrayify(cliOptions.middleware.rewrite || rewriteRules) + if (options.length) { + options.forEach(route => { + if (route.to) { + /* `to` address is remote if the url specifies a host */ + if (url.parse(route.to).host) { + const _ = require('koa-route') + debug('proxy rewrite', `${route.from} -> ${route.to}`) + return _.all(route.from, mw.proxyRequest(route)) + } else { + const rewrite = require('koa-rewrite') + const rmw = rewrite(route.from, route.to) + rmw._name = 'rewrite' + return rmw + } + } + }) } - }) - } + } + }) return this } /* must come after rewrite. See https://github.com/nodejitsu/node-http-proxy/issues/180. */ addBodyParser () { - this.push(require('koa-bodyparser')()) + this.push({ middleware: require('koa-bodyparser') }) return this } /* path blacklist */ addBlacklist (forbidList) { - forbidList = arrayify(this.options.server.forbid || forbidList) - if (forbidList.length) { - const pathToRegexp = require('path-to-regexp') - debug('forbid', forbidList.join(', ')) - this.push(function blacklist (ctx, next) { - if (forbidList.some(expression => pathToRegexp(expression).test(ctx.path))) { - ctx.throw(403, http.STATUS_CODES[403]) - } else { - return next() + this.push({ + optionDefinitions: { + name: 'forbid', alias: 'b', type: String, + multiple: true, typeLabel: '[underline]{path} ...', + description: 'A list of forbidden routes.' + }, + middleware: function (cliOptions) { + forbidList = arrayify(cliOptions.middleware.forbid || forbidList) + if (forbidList.length) { + const pathToRegexp = require('path-to-regexp') + debug('forbid', forbidList.join(', ')) + return function blacklist (ctx, next) { + if (forbidList.some(expression => pathToRegexp(expression).test(ctx.path))) { + const http = require('http') + ctx.throw(403, http.STATUS_CODES[403]) + } else { + return next() + } + } } - }) - } + } + }) return this } /* cache */ addCache () { - const noCache = this.options.server['no-cache'] - if (!noCache) { - this.push(require('koa-conditional-get')()) - this.push(require('koa-etag')()) - } + this.push({ + optionDefinitions: { + name: 'no-cache', alias: 'n', type: Boolean, + description: 'Disable etag-based caching - forces loading from disk each request.' + }, + middleware: function (cliOptions) { + const noCache = cliOptions.middleware['no-cache'] + if (!noCache) { + return [ + require('koa-conditional-get')(), + require('koa-etag')() + ] + } + } + }) return this } /* mime-type overrides */ addMimeType (mime) { - mime = this.options.server.mime || mime - if (mime) { - debug('mime override', JSON.stringify(mime)) - this.push(mw.mime(mime)) - } + this.push({ + middleware: function (cliOptions) { + mime = cliOptions.middleware.mime || mime + if (mime) { + debug('mime override', JSON.stringify(mime)) + return mw.mime(mime) + } + } + }) return this } /* compress response */ addCompression (compress) { - compress = t.isDefined(this.options.server.compress) - ? this.options.server.compress - : compress - if (compress) { - debug('compression', 'enabled') - this.push(require('koa-compress')()) - } + this.push({ + optionDefinitions: { + name: 'compress', alias: 'c', type: Boolean, + description: 'Serve gzip-compressed resources, where applicable.' + }, + middleware: function (cliOptions) { + compress = t.isDefined(cliOptions.middleware.compress) + ? cliOptions.middleware.compress + : compress + if (compress) { + debug('compression', 'enabled') + return require('koa-compress')() + } + } + }) return this } /* Logging */ addLogging (format, options) { - format = this.options.server['log-format'] || format options = options || {} + this.push({ + optionDefinitions: { + name: 'log-format', + alias: 'f', + type: String, + description: "If a format is supplied an access log is written to stdout. If not, a dynamic statistics view is displayed. Use a preset ('none', 'dev','combined', 'short', 'tiny' or 'logstalgia') or supply a custom format (e.g. ':method -> :url')." + }, + middleware: function (cliOptions) { + format = cliOptions.middleware['log-format'] || format - if (this.options.verbose && !format) { - format = 'none' - } - - if (format !== 'none') { - const morgan = require('koa-morgan') - - if (!format) { - const streamLogStats = require('stream-log-stats') - options.stream = streamLogStats({ refreshRate: 500 }) - this.push(morgan('common', options)) - } else if (format === 'logstalgia') { - morgan.token('date', () => { - var d = new Date() - return (`${d.getDate()}/${d.getUTCMonth()}/${d.getFullYear()}:${d.toTimeString()}`).replace('GMT', '').replace(' (BST)', '') - }) - this.push(morgan('combined', options)) - } else { - this.push(morgan(format, options)) + if (cliOptions.misc.verbose && !format) { + format = 'none' + } + + if (format !== 'none') { + const morgan = require('koa-morgan') + + if (!format) { + const streamLogStats = require('stream-log-stats') + options.stream = streamLogStats({ refreshRate: 500 }) + return morgan('common', options) + } else if (format === 'logstalgia') { + morgan.token('date', () => { + var d = new Date() + return (`${d.getDate()}/${d.getUTCMonth()}/${d.getFullYear()}:${d.toTimeString()}`).replace('GMT', '').replace(' (BST)', '') + }) + return morgan('combined', options) + } else { + return morgan(format, options) + } + } } - } + }) return this } /* Mock Responses */ addMockResponses (mocks) { - mocks = arrayify(this.options.server.mocks || mocks) - mocks.forEach(mock => { - if (mock.module) { - // TODO: ENSURE this.options.static.root is correct value - mock.responses = require(path.resolve(path.join(this.options.static.root, mock.module))) - } + this.push({ + middleware: function (cliOptions) { + mocks = arrayify(cliOptions.middleware.mocks || mocks) + mocks.forEach(mock => { + if (mock.module) { + // TODO: ENSURE cliOptions.static.root is correct value + mock.responses = require(path.resolve(path.join(cliOptions.static.root, mock.module))) + } - if (mock.responses) { - this.push(mw.mockResponses(mock.route, mock.responses)) - } else if (mock.response) { - mock.target = { - request: mock.request, - response: mock.response - } - this.push(mw.mockResponses(mock.route, mock.target)) + if (mock.responses) { + return mw.mockResponses(mock.route, mock.responses) + } else if (mock.response) { + mock.target = { + request: mock.request, + response: mock.response + } + return mw.mockResponses(mock.route, mock.target) + } + }) } }) return this @@ -167,44 +214,77 @@ class MiddlewareStack extends Array { /* for any URL not matched by static (e.g. `/search`), serve the SPA */ addSpa (spa) { - spa = t.isDefined(this.options.server.spa) ? this.options.server.spa : spa - if (spa) { - const historyApiFallback = require('koa-connect-history-api-fallback') - debug('SPA', spa) - this.push(historyApiFallback({ - index: spa, - verbose: this.options.verbose - })) - } + this.push({ + optionDefinitions: { + name: 'spa', alias: 's', type: String, typeLabel: '[underline]{file}', + description: 'Path to a Single Page App, e.g. app.html.' + }, + middleware: function (cliOptions) { + spa = t.isDefined(cliOptions.middleware.spa) ? cliOptions.middleware.spa : spa + if (spa) { + const historyApiFallback = require('koa-connect-history-api-fallback') + debug('SPA', spa) + return historyApiFallback({ + index: spa, + verbose: cliOptions.misc.verbose + }) + } + } + }) return this } /* serve static files */ addStatic (root, options) { - root = this.options.server.directory || root || process.cwd() - options = Object.assign({ hidden: true }, options) - if (root) { - const serve = require('koa-static') - this.push(serve(root, options)) - } + this.push({ + optionDefinitions: { + name: 'directory', alias: 'd', type: String, typeLabel: '[underline]{path}', + description: 'Root directory, defaults to the current directory.' + }, + middleware: function (cliOptions) { + root = cliOptions.middleware.directory || root || process.cwd() + options = Object.assign({ hidden: true }, options) + // console.log(root, options, cliOptions) + if (root) { + const serve = require('koa-static') + return serve(root, options) + } + } + }) return this } /* serve directory index */ addIndex (path, options) { - path = this.options.server.directory || path || process.cwd() - options = Object.assign({ icons: true, hidden: true }, options) - if (path) { - const serveIndex = require('koa-serve-index') - this.push(serveIndex(path, options)) - } + this.push({ + middleware: function (cliOptions) { + path = cliOptions.middleware.directory || path || process.cwd() + options = Object.assign({ icons: true, hidden: true }, options) + if (path) { + const serveIndex = require('koa-serve-index') + return serveIndex(path, options) + } + } + }) return this } + getOptionDefinitions () { + const flatten = require('reduce-flatten') + return this + .filter(mw => mw.optionDefinitions) + .map(mw => mw.optionDefinitions) + .reduce(flatten, []) + .map(def => { + def.group = 'middleware' + return def + }) + } compose (options) { - const compose = require('koa-compose') const convert = require('koa-convert') - const middlewareStack = this.map(convert) + const middlewareStack = this + .filter(mw => mw) + .map(mw => compose(arrayify(mw.middleware(options)).map(convert))) return compose(middlewareStack) } } diff --git a/lib/middleware.js b/lib/middleware.js index 650a7a0..620780e 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -1,6 +1,5 @@ 'use strict' const path = require('path') -const http = require('http') const url = require('url') const arrayify = require('array-back') const t = require('typical') @@ -11,7 +10,6 @@ const debug = require('debug')('local-web-server') * @module middleware */ exports.proxyRequest = proxyRequest -exports.blacklist = blacklist exports.mockResponses = mockResponses exports.mime = mime @@ -49,16 +47,6 @@ function proxyRequest (route) { } } -function blacklist (forbid) { - return function blacklist (ctx, next) { - if (forbid.some(expression => pathToRegexp(expression).test(ctx.path))) { - ctx.throw(403, http.STATUS_CODES[403]) - } else { - return next() - } - } -} - function mime (mimeTypes) { return function mime (ctx, next) { return next().then(() => {