├── view ├── tests │ ├── loading.dust │ ├── a │ │ ├── loading.dust │ │ └── b │ │ │ └── loading.dust │ ├── b │ │ └── loading.dust │ └── namespace.dust ├── jserve │ ├── public │ │ ├── favicon.ico │ │ ├── favicon.png │ │ └── jserve.css │ ├── error.html │ └── index.html └── test-runner.dust ├── .eslintignore ├── docs ├── diagram.png ├── shunter-logo.png ├── img │ ├── shunter-assets.png │ ├── shunter-backend-proxy.png │ └── shunter-json-intercept.png ├── migration │ ├── index.md │ ├── 3.0.md │ ├── 5.0.md │ ├── 4.0.md │ └── 2.0.md ├── middleware.md ├── web-api.md ├── output-filters.md ├── testing.md ├── sample-data.md ├── modules.md ├── input-filters.md ├── index.md ├── routing.md └── contributing-to-shunter.md ├── tests ├── .mocharc.json ├── server │ ├── mock-data │ │ └── logging │ │ │ └── transports │ │ │ ├── winston-syslog.js │ │ │ └── winston-console.js │ ├── mocks │ │ ├── each-module.js │ │ ├── glob.js │ │ ├── url.js │ │ ├── benchmark.js │ │ ├── logging.js │ │ ├── error-pages.js │ │ ├── dispatch.js │ │ ├── input-filter.js │ │ ├── watcher.js │ │ ├── log.js │ │ ├── http-proxy.js │ │ ├── router.js │ │ ├── request.js │ │ ├── os.js │ │ ├── cluster.js │ │ ├── processor.js │ │ ├── connect.js │ │ ├── response.js │ │ ├── dustjs-helpers.js │ │ ├── renderer.js │ │ ├── path.js │ │ ├── fs.js │ │ ├── mincer.js │ │ └── statsd.js │ ├── dust │ │ ├── lower.js │ │ ├── upper.js │ │ ├── strip-tags.js │ │ ├── title.js │ │ ├── amp.js │ │ ├── asset-path.js │ │ ├── html.js │ │ ├── trim.js │ │ ├── number-format.js │ │ ├── date-format.js │ │ ├── or.js │ │ └── and.js │ ├── integration │ │ ├── lib │ │ │ ├── http-request.js │ │ │ └── servers-under-test.js │ │ ├── do-not-blow-up-after-error.js │ │ └── smoke.js │ ├── core │ │ ├── content-type.js │ │ ├── benchmark.js │ │ ├── config.js │ │ ├── watcher.js │ │ ├── map-route.js │ │ ├── output-filter.js │ │ ├── renderer-whitespace.js │ │ ├── dispatch.js │ │ ├── router.js │ │ ├── dust.js │ │ ├── input-filter.js │ │ ├── logging.js │ │ ├── statsd.js │ │ └── error-pages.js │ ├── filters │ │ └── environment.js │ └── templates │ │ └── namespace.js ├── mock-app │ ├── resources │ │ ├── css │ │ │ ├── basic.css.scss │ │ │ └── main.css.ejs │ │ └── js │ │ │ └── main.js.ejs │ ├── data │ │ └── home.json │ ├── view │ │ └── home.dust │ └── app.js ├── helpers │ └── template.js └── client │ └── lib │ └── mocha.css ├── lib ├── shunter.js ├── benchmark.js ├── content-type.js ├── watcher.js ├── map-route.js ├── output-filter.js ├── input-filter.js ├── statsd.js ├── router.js ├── dust.js ├── logging.js ├── error-pages.js ├── worker.js ├── dispatch.js ├── server.js └── config.js ├── dust ├── lower.js ├── trim.js ├── upper.js ├── amp.js ├── strip-tags.js ├── title.js ├── number-format.js ├── html.js ├── date-format.js ├── and.js └── or.js ├── .gitignore ├── logging └── transports │ ├── winston-syslog.js │ └── winston-console.js ├── .editorconfig ├── public └── 500.html ├── .eslintrc ├── filters └── input │ └── environment.js ├── .github └── workflows │ └── test-on-push-and-pull.yml ├── package.json ├── bin ├── serve.js └── compile.js └── README.md /view/tests/loading.dust: -------------------------------------------------------------------------------- 1 | 2 | view/tests/loading.dust 3 | -------------------------------------------------------------------------------- /view/tests/a/loading.dust: -------------------------------------------------------------------------------- 1 | 2 | view/tests/a/loading.dust 3 | -------------------------------------------------------------------------------- /view/tests/b/loading.dust: -------------------------------------------------------------------------------- 1 | 2 | view/tests/b/loading.dust 3 | -------------------------------------------------------------------------------- /view/tests/a/b/loading.dust: -------------------------------------------------------------------------------- 1 | 2 | view/tests/a/b/loading.dust 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | tests/client/lib 3 | tests/mock-app/public/ 4 | -------------------------------------------------------------------------------- /docs/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/docs/diagram.png -------------------------------------------------------------------------------- /docs/shunter-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/docs/shunter-logo.png -------------------------------------------------------------------------------- /docs/img/shunter-assets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/docs/img/shunter-assets.png -------------------------------------------------------------------------------- /tests/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "recursive": true, 3 | "reporter": "spec", 4 | "timeout": "5000", 5 | "ui": "bdd" 6 | } -------------------------------------------------------------------------------- /view/jserve/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/view/jserve/public/favicon.ico -------------------------------------------------------------------------------- /view/jserve/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/view/jserve/public/favicon.png -------------------------------------------------------------------------------- /docs/img/shunter-backend-proxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/docs/img/shunter-backend-proxy.png -------------------------------------------------------------------------------- /docs/img/shunter-json-intercept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/springernature/shunter/HEAD/docs/img/shunter-json-intercept.png -------------------------------------------------------------------------------- /tests/server/mock-data/logging/transports/winston-syslog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = 'This is not a function.'; 3 | -------------------------------------------------------------------------------- /tests/mock-app/resources/css/basic.css.scss: -------------------------------------------------------------------------------- 1 | $orange: #FFA500; 2 | 3 | .should-be-orange { 4 | background-color: $orange; 5 | } 6 | -------------------------------------------------------------------------------- /tests/server/mocks/each-module.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub(); 6 | -------------------------------------------------------------------------------- /tests/server/mocks/glob.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | sync: sinon.stub() 7 | }; 8 | -------------------------------------------------------------------------------- /tests/server/mocks/url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | parse: sinon.stub() 7 | }; 8 | -------------------------------------------------------------------------------- /tests/server/mocks/benchmark.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns(sinon.stub()); 6 | -------------------------------------------------------------------------------- /tests/server/mocks/logging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | getLogger: sinon.stub() 7 | }); 8 | -------------------------------------------------------------------------------- /tests/server/mocks/error-pages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | getPage: sinon.stub() 7 | }); 8 | -------------------------------------------------------------------------------- /lib/shunter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.TZ = 'UTC'; 4 | module.exports = require('./server'); 5 | module.exports.testhelper = require('../tests/helpers/template.js'); 6 | -------------------------------------------------------------------------------- /tests/server/mocks/dispatch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | send: sinon.stub(), 7 | error: sinon.stub() 8 | }); 9 | -------------------------------------------------------------------------------- /tests/server/mocks/input-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | add: sinon.stub(), 7 | run: sinon.stub() 8 | }); 9 | -------------------------------------------------------------------------------- /dust/lower.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.lower = function (value) { 7 | return value.toLowerCase(); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /dust/trim.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.trim = function (value) { 7 | return value.toString().trim(); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /dust/upper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.upper = function (value) { 7 | return value.toUpperCase(); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /tests/server/mocks/watcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | watchTree: sinon.stub().returns({ 7 | on: sinon.stub() 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /tests/server/mocks/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | debug: sinon.spy(), 7 | info: sinon.spy(), 8 | warn: sinon.spy(), 9 | error: sinon.spy() 10 | }; 11 | -------------------------------------------------------------------------------- /dust/amp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.amp = function (value) { 7 | return value.replace(/&(?![#a-z0-9]+?;)/g, '&'); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /tests/mock-app/data/home.json: -------------------------------------------------------------------------------- 1 | { 2 | "layout": { 3 | "template": "home" 4 | }, 5 | "title": "Hello World!", 6 | "list": [ 7 | "foo", 8 | "bar", 9 | "baz" 10 | ] 11 | } 12 | 13 | -------------------------------------------------------------------------------- /tests/server/mocks/http-proxy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | createProxyServer: sinon.stub().returns({ 7 | web: sinon.stub(), 8 | on: sinon.stub() 9 | }) 10 | }; 11 | -------------------------------------------------------------------------------- /tests/server/mocks/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | map: sinon.stub().returns({ 7 | host: '127.0.0.1', 8 | port: 5401 9 | }) 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .nyc_output 4 | *.iml 5 | *.log 6 | *.pid 7 | config/routes/*.json 8 | coverage/* 9 | min.js 10 | node_modules 11 | public/resources 12 | shunter-*.tgz 13 | tests/mock-app/public/ 14 | timestamp.json 15 | -------------------------------------------------------------------------------- /tests/mock-app/resources/css/main.css.ejs: -------------------------------------------------------------------------------- 1 | /* 2 | *= require basic.css 3 | */ 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | text-align: center; 8 | color: #004f8b; 9 | background-color: #e0f1ff; 10 | } 11 | -------------------------------------------------------------------------------- /tests/server/mocks/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | headers: { 7 | host: 'the.request.host' 8 | }, 9 | removeAllListeners: sinon.stub(), 10 | emit: sinon.stub() 11 | }; 12 | -------------------------------------------------------------------------------- /tests/server/mocks/os.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | cpus: sinon.stub().returns([]), 7 | release: sinon.stub(), 8 | hostname: sinon.stub().returns('test-shunter.nature.com') 9 | }; 10 | -------------------------------------------------------------------------------- /tests/server/mocks/cluster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | isMaster: true, 7 | fork: sinon.stub().returns({ 8 | on: sinon.stub() 9 | }), 10 | workers: {}, 11 | on: sinon.stub() 12 | }; 13 | -------------------------------------------------------------------------------- /dust/strip-tags.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.stripTags = function (value) { 7 | return value.replace(/<[^>]+>/g, ''); 8 | }; 9 | dust.filters.strip = dust.filters.stripTags; 10 | } 11 | -------------------------------------------------------------------------------- /tests/server/mocks/processor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | timestamp: sinon.stub(), 7 | intercept: sinon.stub(), 8 | proxy: sinon.stub(), 9 | ping: sinon.stub(), 10 | api: sinon.stub() 11 | }); 12 | -------------------------------------------------------------------------------- /tests/server/mocks/connect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | var connect = sinon.stub().returns({ 6 | use: sinon.stub(), 7 | listen: sinon.stub() 8 | }); 9 | connect.utils = { 10 | error: sinon.stub().returns({}) 11 | }; 12 | 13 | module.exports = connect; 14 | -------------------------------------------------------------------------------- /dust/title.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.title = function (value) { 7 | return value.replace(/\w+/g, function (txt) { 8 | return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(); 9 | }); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /tests/server/mocks/response.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = function () { 6 | return { 7 | writeHead: sinon.stub(), 8 | write: sinon.stub(), 9 | getHeader: sinon.stub(), 10 | setHeader: sinon.stub(), 11 | end: sinon.stub() 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /tests/server/mocks/dustjs-helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | render: sinon.stub(), 7 | compile: sinon.stub(), 8 | loadSource: sinon.stub(), 9 | makeBase: sinon.stub().returns({ 10 | push: sinon.stub().returnsArg(0) 11 | }), 12 | cache: {} 13 | }; 14 | -------------------------------------------------------------------------------- /docs/migration/index.md: -------------------------------------------------------------------------------- 1 | # Migration Guide 2 | 3 | Shunter's API changes between major versions. These guides are intended to help you make the switch when this happens. 4 | 5 | * [Migrating from 1.0 to 2.0](2.0.md) 6 | * [Migrating from 2.0 to 3.0](3.0.md) 7 | * [Migrating from 3.0 to 4.0](4.0.md) 8 | * [Migrating from 4.0 to 5.0](5.0.md) 9 | -------------------------------------------------------------------------------- /dust/number-format.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initHelper; 4 | 5 | function initHelper(dust) { 6 | dust.helpers.numberFormat = function (chunk, context, bodies, params) { 7 | var num = context.resolve(params.num); 8 | if (num) { 9 | return chunk.write(num.replace(/\B(?=(\d{3})+(?!\d))/g, ',')); 10 | } 11 | return chunk.write(num); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /tests/server/mock-data/logging/transports/winston-console.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var winston = require('winston'); 3 | 4 | var format = winston.format; 5 | 6 | module.exports = function () { 7 | return new (winston.transports.Console)({ 8 | format: format.combine( 9 | format.colorize(), 10 | format.timestamp() 11 | ), 12 | level: 'THIS_IS_FINE' 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /tests/mock-app/resources/js/main.js.ejs: -------------------------------------------------------------------------------- 1 | document.body.style.backgroundColor = randomValue([ 2 | '#fadbd1', 3 | '#e1f3c8', 4 | '#b6dff2' 5 | ]); 6 | 7 | function randomValue(array) { 8 | return array[randomBetween(0, array.length - 1)]; 9 | }; 10 | 11 | function randomBetween(min, max) { 12 | return Math.floor(Math.random() * (max - min + 1)) + min; 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /tests/mock-app/view/home.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {title} 5 | 6 | 7 | 8 |

{title}

9 |
If this background is orange, SASS is working.
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/server/mocks/renderer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | initDustExtensions: sinon.stub(), 7 | compileTemplates: sinon.stub(), 8 | watchTemplates: sinon.stub(), 9 | watchDustExtensions: sinon.stub(), 10 | assetServer: sinon.stub(), 11 | render: sinon.stub(), 12 | renderPartial: sinon.stub() 13 | }); 14 | -------------------------------------------------------------------------------- /dust/html.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initFilter; 4 | 5 | function initFilter(dust) { 6 | dust.filters.html = function (value) { 7 | var escapes = { 8 | '<': '<', 9 | '>': '>', 10 | '"': '"', 11 | '\'': ''' 12 | }; 13 | return dust.filters.amp(value).replace(/[<>"']/g, function (match) { 14 | return escapes[match]; 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /tests/mock-app/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var shunter = require('../../'); // eslint-disable-line unicorn/import-index 4 | 5 | var app = shunter({ 6 | path: { 7 | themes: __dirname 8 | }, 9 | routes: { 10 | localhost: { 11 | default: { 12 | host: '127.0.0.1', 13 | port: 5401 14 | } 15 | } 16 | } 17 | 18 | }); 19 | 20 | app.start(); 21 | console.log('mock-app started'); 22 | -------------------------------------------------------------------------------- /logging/transports/winston-syslog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Syslog = require('winston-syslog').Syslog; 3 | 4 | module.exports = function (config) { 5 | if (!config.argv.syslog || !config.syslogAppName) { 6 | return null; 7 | } 8 | 9 | return new Syslog({ 10 | localhost: config.env.host(), 11 | app_name: config.syslogAppName, // eslint-disable-line camelcase 12 | level: 'debug' 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /tests/server/mocks/path.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | join: sinon.stub(), 7 | basename: function (str) { 8 | var lastSlash = str.lastIndexOf('/'); 9 | var val = str.slice(lastSlash + 1); 10 | val = val.replace('.dust', ''); 11 | return val; 12 | }, 13 | dirname: sinon.stub(), 14 | extname: sinon.stub(), 15 | relative: sinon.stub(), 16 | sep: '/' 17 | }; 18 | -------------------------------------------------------------------------------- /lib/benchmark.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (config) { 4 | var statsd = require('./statsd')(config); 5 | 6 | return function (req, res, next) { 7 | var timer = config.timer(); 8 | var end = res.end; 9 | 10 | res.end = function () { 11 | statsd.classifiedTiming(req.url, 'response_time', timer('Request completed ' + req.url)); 12 | end.apply(res, Array.prototype.slice.call(arguments, 0)); 13 | }; 14 | next(); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /tests/server/dust/lower.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: lower', function () { 8 | it('Should be able to convert a string to lower case', function (done) { 9 | helper.render('{test|lower}', { 10 | test: 'Hello World' 11 | }, function (err, dom, str) { 12 | assert.strictEqual(str, 'hello world'); 13 | done(); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/server/dust/upper.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: upper', function () { 8 | it('Should be able to convert a string to upper case', function (done) { 9 | helper.render('{test|upper}', { 10 | test: 'Hello World' 11 | }, function (err, dom, str) { 12 | assert.strictEqual(str, 'HELLO WORLD'); 13 | done(); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/server/mocks/fs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | mockStatReturn: { 7 | isDirectory: sinon.stub() 8 | }, 9 | writeFile: sinon.stub(), 10 | writeFileSync: sinon.stub(), 11 | readFileSync: sinon.stub(), 12 | unlinkSync: sinon.stub(), 13 | readdirSync: sinon.stub(), 14 | statSync: sinon.stub(), 15 | existsSync: sinon.stub() 16 | }; 17 | 18 | module.exports.statSync.returns(module.exports.mockStatReturn); 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | indent_style = tab 8 | indent_size = 4 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.json] 13 | insert_final_newline = false 14 | 15 | [package.json] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | indent_style = space 21 | trim_trailing_whitespace = false 22 | 23 | [*.yml] 24 | indent_style = space 25 | indent_size = 2 26 | -------------------------------------------------------------------------------- /tests/server/dust/strip-tags.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: stripTags', function () { 8 | it('Should be able to strip html from a string', function (done) { 9 | helper.render('{test|stripTags}', { 10 | test: '

Hello world

' 11 | }, function (err, dom, str) { 12 | assert.strictEqual(str, 'Hello world'); 13 | done(); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/server/dust/title.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: title', function () { 8 | it('Should be able to convert a string to title case', function (done) { 9 | helper.render('{test|title}', { 10 | test: 'hello this is SOME @test text' 11 | }, function (err, dom, str) { 12 | assert.strictEqual(str, 'Hello This Is Some @Test Text'); 13 | done(); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/server/mocks/mincer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = { 6 | createServer: sinon.stub(), 7 | 8 | logger: { 9 | use: sinon.stub() 10 | }, 11 | 12 | Environment: function () { 13 | this.findAsset = sinon.stub(); 14 | this.registerHelper = sinon.stub(); 15 | this.appendPath = sinon.stub(); 16 | this.prependPath = sinon.stub(); 17 | }, 18 | Manifest: function () { 19 | this.assets = { 20 | 'test.css': 'test-prod-md5.css' 21 | }; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /logging/transports/winston-console.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var winston = require('winston'); 3 | 4 | var format = winston.format; 5 | 6 | var myFormat = format.printf(function (logformMessage) { 7 | return `${logformMessage.timestamp} - ${logformMessage.level}: ${logformMessage.message}`; 8 | }); 9 | 10 | module.exports = function (config) { 11 | return new winston.transports.Console({ 12 | format: winston.format.combine( 13 | format.colorize(), 14 | format.timestamp(), 15 | myFormat 16 | ), 17 | level: config.argv.logging 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /tests/server/dust/amp.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: amp', function () { 8 | it('Should safely html-escape ampersands', function (done) { 9 | helper.render('{test1|s|amp} {test2|s|amp}', { 10 | test1: '

A & B

', 11 | test2: '

A > & 舒 B &

' 12 | }, function (err, dom, str) { 13 | assert.strictEqual(str, '

A & B

A > & 舒 B &

'); 14 | done(); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/server/mocks/statsd.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub().returns({ 6 | timing: sinon.stub(), 7 | gauge: sinon.stub(), 8 | gaugeDelta: sinon.stub(), 9 | increment: sinon.stub(), 10 | decrement: sinon.stub(), 11 | histogram: sinon.stub(), 12 | set: sinon.stub(), 13 | classifiedTiming: sinon.stub(), 14 | classifiedGauge: sinon.stub(), 15 | classifiedGaugeDelta: sinon.stub(), 16 | classifiedIncrement: sinon.stub(), 17 | classifiedDecrement: sinon.stub(), 18 | classifiedHistogram: sinon.stub(), 19 | classifiedSet: sinon.stub(), 20 | buildMetricNameForUrl: sinon.stub() 21 | }); 22 | -------------------------------------------------------------------------------- /tests/server/dust/asset-path.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Helper: assetPath', function () { 8 | it('Should render an asset path', function (done) { 9 | helper.render('{@assetPath src="test.css"/}', {}, function (err, dom, out) { 10 | assert.strictEqual('test.css', out); 11 | done(); 12 | }); 13 | }); 14 | 15 | it('Should render an asset path from a variable', function (done) { 16 | helper.render('{@assetPath src="{foo}.css"/}', {foo: 'test'}, function (err, dom, out) { 17 | assert.strictEqual('test.css', out); 18 | done(); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /dust/date-format.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var dateformat = require('dateformat'); 4 | 5 | module.exports = initHelper; 6 | 7 | function initHelper(dust, renderer, config) { 8 | dust.helpers.dateFormat = function (chunk, context, bodies, params) { 9 | var date = null; 10 | var value = null; 11 | 12 | params = params || {}; 13 | 14 | try { 15 | value = (params.date) ? context.resolve(params.date) : null; 16 | date = (value) ? new Date(value.match(/^\d+$/) ? parseInt(value, 10) : value) : new Date(); 17 | chunk.write(dateformat(date, params.format || 'yyyy-mm-dd')); 18 | } catch (err) { 19 | config.log.error(err.message); 20 | } 21 | return chunk; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /tests/server/dust/html.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: html', function () { 8 | it('Should safely escape HTML entities', function (done) { 9 | helper.render('{test1|s|html} {test2|s|html}', { 10 | test1: '', 11 | test2: '舒 & && < >>> " < >' 12 | }, function (err, dom, str) { 13 | assert.strictEqual(str, '<script>alert("foo") && alert('bar');</script> 舒 & && < >>> " < >'); 14 | done(); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/content-type.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (url, opts) { 4 | opts = opts || {}; 5 | 6 | var ext = (url.includes('.')) ? url.split('.').pop().replace(/\?.*/, '') : null; 7 | 8 | var mapping = { 9 | atom: 'application/atom+xml', 10 | json: 'application/json', 11 | rss: 'application/rss+xml', 12 | rdf: 'application/rdf+xml', 13 | xml: 'application/xml', 14 | css: 'text/css', 15 | ris: 'application/x-research-info-systems', 16 | txt: 'text/plain' 17 | }; 18 | 19 | var mimetype = Object.prototype.hasOwnProperty.call(mapping, ext) ? mapping[ext] : 'text/html'; 20 | var charset = opts.charset ? '; charset=' + opts.charset : ''; 21 | return mimetype + charset; 22 | }; 23 | -------------------------------------------------------------------------------- /docs/migration/3.0.md: -------------------------------------------------------------------------------- 1 | # Shunter Migration Guide, 2.0 to 3.0 2 | 3 | This guide outlines how to migrate from Shunter 2.x to Shunter 3.x. It outlines breaking changes which might cause issues when you upgrade. 4 | 5 | ## Route Matching 6 | 7 | If you were using regular expressions to match routes against the url in the `routes.json` file, these now need to be delimited with `/` characters. 8 | 9 | Before: 10 | 11 | ```js 12 | { 13 | "localhost": { 14 | "^\\/path": { 15 | "host": "127.0.0.1", 16 | "port": 1337 17 | } 18 | } 19 | } 20 | ``` 21 | 22 | After: 23 | 24 | ```js 25 | { 26 | "localhost": { 27 | "/^\\/path/": { 28 | "host": "127.0.0.1", 29 | "port": 1337 30 | } 31 | } 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /view/tests/namespace.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | Namespace Testing 4 | 20 | 21 | 22 | 23 |

Namespace: {layout.namespace}

24 |
25 |
loading
26 |
{>loading/}
27 |
a__loading
28 |
{>a__loading/}
29 |
b__loading
30 |
{>b__loading/}
31 |
a__b__loading
32 |
{>a__b__loading/}
33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Internal server error 5 | 6 | 7 | 8 |
500 – Internal server error
9 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/server/dust/trim.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Filter: trim', function () { 8 | it('Should trim leading and trailing whitespace from a string', function (done) { 9 | helper.render('{test|trim}', { 10 | test: ' \t\r\nhello world \n\t\r' 11 | }, function (err, dom, str) { 12 | assert.strictEqual(str, 'hello world'); 13 | done(); 14 | }); 15 | }); 16 | 17 | it('Should not throw if the input is not a string', function (done) { 18 | helper.render('hello world{test|trim}', { 19 | test: 3 20 | }, function (err, dom, str) { 21 | assert.isNull(err); 22 | assert.strictEqual(str, 'hello world3'); 23 | done(); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/server/integration/lib/http-request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var http = require('http'); 4 | 5 | // Promise-ified request. Does not handle POST. 6 | module.exports = function (options) { 7 | return new Promise(function (resolve, reject) { 8 | var req = http.request(options, function (res) { 9 | res.setEncoding('utf8'); 10 | 11 | if (res.statusCode < 200 || res.statusCode >= 300) { 12 | return reject(new Error('statusCode=' + res.statusCode)); 13 | } 14 | var body = []; 15 | res.on('data', function (chunk) { 16 | body.push(chunk); 17 | }); 18 | res.on('end', function () { 19 | res.text = body.toString(); 20 | resolve(res); 21 | }); 22 | }); 23 | 24 | req.on('error', function (error) { 25 | reject(error); 26 | }); 27 | 28 | req.end(); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@springernature/eslint-config", 4 | "@springernature/eslint-config/node" 5 | ], 6 | 7 | "rules": { 8 | "block-scoped-var": "warn", 9 | "no-lonely-if": "warn", 10 | "no-path-concat": "warn", 11 | "no-prototype-builtins": "warn", 12 | "no-redeclare": "warn", 13 | "no-undef": "warn", 14 | "no-useless-escape": "warn", 15 | "unicorn/no-new-buffer": "warn", 16 | "unicorn/prevent-abbreviations": "off", 17 | "unicorn/catch-error-name": "off" 18 | }, 19 | 20 | "overrides": [ 21 | { 22 | "files": "tests/server/core/input-filter.js", 23 | "rules": { 24 | "no-unused-vars": "warn" 25 | } 26 | }, 27 | { 28 | "files": "tests/**/*.js", 29 | "env": { 30 | "browser": true, 31 | "jquery": true, 32 | "mocha": true 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /lib/watcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Watch a file tree for changes using Gaze 4 | module.exports = function (extension) { 5 | var constructGlob = function (dirpath) { 6 | var glob = '/**/*' + extension; 7 | return dirpath + glob; 8 | }; 9 | 10 | var watchTree = function (directories, log) { 11 | var Gaze = require('gaze'); 12 | var watchedDirs = (typeof directories === 'string') ? constructGlob(directories) : directories.map(constructGlob); 13 | var watcher = new Gaze(watchedDirs); 14 | 15 | watcher.on('added', function (path) { 16 | watcher.emit('fileCreated', path); 17 | }); 18 | watcher.on('changed', function (path) { 19 | watcher.emit('fileModified', path); 20 | }); 21 | watcher.on('error', log.error); 22 | 23 | return watcher; 24 | }; 25 | 26 | return { 27 | watchTree: watchTree 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /lib/map-route.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable node/no-deprecated-api */ 4 | // node's legacy URL API handles IP addresses, 5 | // but the newer WHATWG URL API does not... :( 6 | 7 | module.exports = function (address) { 8 | var url = require('url'); 9 | 10 | var parseUrl = function (address) { 11 | var protocol = url.parse(address).protocol || null; 12 | 13 | if (protocol === 'http:' || protocol === 'https:') { 14 | return url.parse(address); 15 | } 16 | 17 | return url.parse('http://' + address); 18 | }; 19 | 20 | var map = function (address) { 21 | var mappedRoute = {}; 22 | var route = parseUrl(address); 23 | 24 | mappedRoute.protocol = route.protocol || null; 25 | mappedRoute.host = route.hostname || null; 26 | mappedRoute.port = route.port || null; 27 | 28 | return mappedRoute; 29 | }; 30 | 31 | return map(address); 32 | }; 33 | -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | Shunter uses [Connect](https://github.com/senchalabs/connect) under the hood and exposes Connect's `use` method to allow you to add your own middleware to the stack. The middleware that you specify gets mounted before Shunter's proxying behaviour so you're able to hijack certain routes. 4 | 5 | Shunter middleware works in the same way as Connect: 6 | 7 | ```js 8 | var app = shunter({}); 9 | 10 | // Mount middleware on all routes 11 | app.use(function(request, response, next) { 12 | // ... 13 | }); 14 | 15 | // Mount middleware on the /foo route 16 | app.use('/foo', function(request, response, next) { 17 | // ... 18 | }); 19 | 20 | app.start(); 21 | ``` 22 | 23 | This allows you to expose information about Shunter's environment, or add in routes that you don't wish to hit one of the back end applications that your application proxies to. 24 | -------------------------------------------------------------------------------- /docs/migration/5.0.md: -------------------------------------------------------------------------------- 1 | # Shunter Migration Guide, 4.0 to 5.0 2 | 3 | This guide outlines how to migrate from Shunter 4.x to Shunter 5.x. It outlines breaking changes which might cause issues when you upgrade. 4 | 5 | ## Minimum Node version 6 | 7 | Shunter v5 requires a minimum of Node 12, and at the time of writing is tested on Node 12 - 16. 8 | 9 | ## Logging 10 | 11 | ### Winston logging filters are deprecated 12 | 13 | Dropping support for Node 8 required ugrading [Winston](https://github.com/winstonjs/winston/) from version 2 to 3. 14 | 15 | Upgrading Winston means that custom logging filters can no longer be used (if you were using them). 16 | 17 | Instead [`filters` should be migrated to `formats`](https://github.com/winstonjs/winston/blob/master/UPGRADE-3.0.md#migrating-filters-and-rewriters-to-formats-in-winston3) and supplied via a [custom logging transport](https://github.com/springernature/shunter/blob/master/docs/configuration-reference.md#log-configuration). 18 | -------------------------------------------------------------------------------- /lib/output-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var eachModule = require('each-module'); 5 | 6 | module.exports = function (config) { 7 | var filters = []; 8 | 9 | var modulePaths = config.modules.map(function (module) { 10 | return path.join(config.path.root, 'node_modules', module); 11 | }); 12 | modulePaths.push(config.path.root); 13 | modulePaths.unshift(config.path.shunterRoot); 14 | 15 | modulePaths.forEach(function (modulePath) { 16 | var filterPath = path.join(modulePath, config.structure.filters, config.structure.filtersOutput); 17 | eachModule(filterPath, function (name, runFilter) { 18 | filters.push(runFilter); 19 | }); 20 | }); 21 | 22 | return function (content, contentType, req) { 23 | var output; 24 | 25 | for (var i = 0; filters[i]; ++i) { 26 | output = filters[i](content, contentType, req, config); 27 | if (typeof output === 'string') { 28 | content = output; 29 | } 30 | } 31 | return content; 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /docs/web-api.md: -------------------------------------------------------------------------------- 1 | # Web API 2 | 3 | Instead of/in addition to using Shunter as a proxy you can also send JSON in the body of an HTTP POST request to the `/template` endpoint and get back a response containing the rendered HTML. 4 | 5 | If our app had a template `hello.dust` containing: 6 | 7 | ```html 8 |

{data.message}

9 | ``` 10 | 11 | Then a POST request like this: 12 | 13 | ```shell 14 | curl -H 'Content-type: application/json' -X POST -d '{"data": {"message": "Hello!"}}' http://your-shunter-server/template/hello 15 | ``` 16 | 17 | would return: 18 | 19 | ```html 20 |

Hello!

21 | ``` 22 | 23 | The template to render can also be specified as part of the JSON payload, so the following request would return the same result: 24 | 25 | ```shell 26 | curl -H 'Content-type: application/json' -X POST -d '{"layout": {"template": "hello"}, "data": {"message": "Hello!"}}' http://your-shunter-server/template 27 | ``` 28 | 29 | The maximum size of the POST request can be controlled by setting the `max-post-size` option when starting your app. 30 | -------------------------------------------------------------------------------- /filters/input/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var qs = require('qs'); 4 | 5 | module.exports = function (config, req, res, data, next) { 6 | var cast = function (params) { 7 | var output = {}; 8 | 9 | params = params || {}; 10 | 11 | var transform = function (value) { 12 | var val = (typeof value === 'string') ? value.toLowerCase() : value; 13 | if (val === 'true' || val === 'false') { 14 | return val === 'true'; 15 | } 16 | if (/^\d+(\.\d+)?$/.test(val)) { 17 | return parseInt(val, 10); 18 | } 19 | return value; 20 | }; 21 | 22 | Object.keys(params).forEach(function (key) { 23 | if (Array.isArray(params[key])) { 24 | output[key] = params[key].map(transform); 25 | } else { 26 | output[key] = transform(params[key]); 27 | } 28 | }); 29 | return output; 30 | }; 31 | 32 | /* eslint-disable camelcase */ 33 | data.query_data = cast(req.query); 34 | data.query_string = qs.stringify(data.query_data); 35 | data.request_url = (req.url) ? req.url.replace(/\?.*$/, '') : ''; 36 | /* eslint-enable camelcase */ 37 | 38 | next(data); 39 | }; 40 | -------------------------------------------------------------------------------- /docs/output-filters.md: -------------------------------------------------------------------------------- 1 | # Output Filters 2 | 3 | Output filters allow the rendered output to undergo some post processing before being sent to the client. One of our use cases has been to perform HTML optimizations on the content. This includes things like removing optional closing elements and normalizing boolean attributes. 4 | 5 | Filters are defined in ``filters/output`` with their corresponding tests living in the ``tests/server/filters/output`` folder. 6 | 7 | ## Defining an Output Filter 8 | 9 | Output filters export a function that accepts up to four parameters. The parameters are a string containing the rendered content, the content type being returned, the request object and the shunter config. It should return the modified content, or undefined if it wants to pass the content through unmodified. 10 | 11 | In the following example we'll process responses with a ``text/plain`` content type and replace all instances of shunter with Shunter. 12 | 13 | ```js 14 | module.exports = function(content, contentType, request, config) { 15 | if (contentType === 'text/plain') { 16 | return content.replace(/shunter/ig, 'Shunter'); 17 | } 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /.github/workflows/test-on-push-and-pull.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, 2 | # build the source code and run tests across different versions of node 3 | # https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-nodejs 4 | 5 | name: Node.js CI tests 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | include: 21 | - node-version: 12 22 | LINT: true 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'npm' 31 | 32 | # this is a library, not an app, so ignore the package-lock 33 | - run: npm install --package-lock=false 34 | - if: ${{ matrix.LINT }} 35 | run: npm run lint 36 | 37 | - if: ${{ ! matrix.LINT }} 38 | run: npm run test-ci 39 | -------------------------------------------------------------------------------- /view/jserve/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Error {{statusCode}}: {{statusMessage}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 |
22 | 23 |

Error {{statusCode}}: {{statusMessage}}

24 | 25 | {{#is404}} 26 |

27 | The page you're looking for could not be found. 28 | Try going back to the index page. 29 |

30 | {{/is404}} 31 | 32 | {{^is404}} 33 | {{#stackTrace}} 34 |
{{stackTrace}}
35 | {{/stackTrace}} 36 | {{/is404}} 37 | 38 |
39 | 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/server/core/content-type.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | 5 | var moduleName = '../../../lib/content-type'; 6 | 7 | describe('Get the content type', function () { 8 | it('Should find the content type from the file extension', function () { 9 | var contentType = require(moduleName); 10 | assert.equal(contentType('/nutd/sitemap.xml'), 'application/xml'); 11 | assert.equal(contentType('file.txt'), 'text/plain'); 12 | }); 13 | 14 | it('Should allow the charset to be specified', function () { 15 | var contentType = require(moduleName); 16 | assert.equal(contentType('/nutd/sitemap.xml', {charset: 'utf-8'}), 'application/xml; charset=utf-8'); 17 | assert.equal(contentType('file.txt', {charset: 'ISO-8859-8'}), 'text/plain; charset=ISO-8859-8'); 18 | }); 19 | 20 | it('Should find the content type from the file extension ignoring any query params', function () { 21 | var contentType = require(moduleName); 22 | assert.equal(contentType('/feed.atom?foo=bar'), 'application/atom+xml'); 23 | }); 24 | 25 | it('Should fall back to text/html as default', function () { 26 | var contentType = require(moduleName); 27 | assert.equal(contentType('foo'), 'text/html'); 28 | assert.equal(contentType('bar.html'), 'text/html'); 29 | assert.equal(contentType('baz.fake'), 'text/html'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /dust/and.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initHelper; 4 | 5 | function initHelper(dust) { 6 | /* 7 | * Add 'and' functionality to dust conditional testing 8 | * evaluates to true if all of the keys are set in the data 9 | * if not is set to true 10 | * evaluates to true if none of the keys are set in the data 11 | */ 12 | dust.helpers.and = function (chunk, context, bodies, params) { 13 | params = params || {}; 14 | var alternate = bodies.else; 15 | var keys = context.resolve(params.keys); 16 | var not = context.resolve(params.not); 17 | 18 | var checkContext = function (arr) { 19 | var count = 0; 20 | var item; 21 | var nestedKeys; 22 | for (var i = 0; arr[i]; ++i) { 23 | nestedKeys = arr[i].split('.'); 24 | item = context.get(nestedKeys.shift()); 25 | // Handle finding nested properties like foo.bar 26 | while (nestedKeys.length > 0 && item) { 27 | item = item[nestedKeys.shift()]; 28 | } 29 | if (item && (!Array.isArray(item) || item.length > 0)) { 30 | ++count; 31 | } 32 | } 33 | return ((typeof not === 'undefined' || not === 'false') && count === arr.length) || 34 | ((typeof not !== 'undefined' && not === 'true') && count === 0); 35 | }; 36 | 37 | if (checkContext(keys.split('|'))) { 38 | return chunk.render(bodies.block, context); 39 | } 40 | if (alternate) { 41 | return chunk.render(alternate, context); 42 | } 43 | return chunk; 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /lib/input-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (config) { 4 | return { 5 | filters: [], 6 | 7 | getBase: function (data, key) { 8 | if (data && data.article && data.article[key]) { 9 | return data.article; 10 | } 11 | if (data && data[key]) { 12 | return data; 13 | } 14 | return null; 15 | }, 16 | 17 | add: function (filter) { 18 | this.filters.push(filter); 19 | }, 20 | 21 | run: function (req, res, data, callback) { 22 | var self = this; 23 | 24 | var runner = function (filters, data) { 25 | var remain; 26 | var filter; 27 | var arity; 28 | var cb; 29 | 30 | if (filters.length > 0) { 31 | filter = filters[0]; 32 | remain = filters.slice(1); 33 | cb = function (data) { 34 | runner(remain, data); 35 | }; 36 | arity = filter.length; 37 | 38 | if (arity === 1) { 39 | runner(remain, filter.call(self, data)); 40 | } else if (arity === 2) { 41 | filter.call(self, data, cb); 42 | } else if (arity === 3) { 43 | filter.call(self, config, data, cb); 44 | } else if (arity === 5) { 45 | filter.call(self, config, req, res, data, cb); 46 | } else { 47 | config.log.error('Input filters must accept 1, 2, 3 or 5 arguments ' + arity + ' found'); 48 | runner(remain, data); 49 | } 50 | } else { 51 | callback(data); 52 | } 53 | }; 54 | runner(this.filters, data); 55 | } 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /tests/server/core/benchmark.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mockery = require('mockery'); 4 | var sinon = require('sinon'); 5 | var assert = require('proclaim'); 6 | 7 | var moduleName = '../../../lib/benchmark'; 8 | 9 | describe('Benchmarking requests', function () { 10 | var statsd; 11 | 12 | before(function () { 13 | mockery.enable({ 14 | useCleanCache: true, 15 | warnOnUnregistered: false, 16 | warnOnReplace: false 17 | }); 18 | 19 | statsd = require('../mocks/statsd'); 20 | 21 | mockery.registerMock('./statsd', statsd); 22 | }); 23 | after(function () { 24 | mockery.deregisterAll(); 25 | mockery.disable(); 26 | }); 27 | 28 | it('Should record the time taken for the request when res.end is called', function () { 29 | var req = { 30 | url: '/test' 31 | }; 32 | var end = sinon.stub(); 33 | var res = { 34 | end: end 35 | }; 36 | var next = sinon.stub(); 37 | 38 | var benchmark = require(moduleName)({ 39 | timer: sinon.stub().returns(sinon.stub().returns(1337)) 40 | }); 41 | benchmark(req, res, next); 42 | 43 | assert.isTrue(next.calledOnce); 44 | assert.isTrue(end.notCalled); 45 | assert.isTrue(statsd().timing.notCalled); 46 | 47 | res.end('Content'); 48 | 49 | assert.isTrue(statsd().classifiedTiming.calledOnce); 50 | assert.isTrue(statsd().classifiedTiming.calledWith('/test', 'response_time', 1337)); 51 | assert.isTrue(end.calledOnce); 52 | assert.isTrue(end.calledWith('Content')); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /dust/or.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = initHelper; 4 | 5 | function initHelper(dust) { 6 | /* 7 | * Add 'or' functionality to dust conditional testing 8 | * evaluates to true if at least one of the keys are set in the data 9 | * if not is set to true 10 | * evaluates to true if at least one of the keys are missing from the data 11 | */ 12 | dust.helpers.or = function (chunk, context, bodies, params) { 13 | params = params || {}; 14 | var alternate = bodies.else; 15 | var keys = context.resolve(params.keys); 16 | var not = context.resolve(params.not); 17 | 18 | var checkContext = function (arr) { 19 | var count = 0; 20 | var item; 21 | var nestedKeys; 22 | for (var i = 0; arr[i]; ++i) { 23 | nestedKeys = arr[i].split('.'); 24 | item = context.get(nestedKeys.shift()); 25 | // Handle finding nested properties like foo.bar 26 | while (nestedKeys.length > 0 && item) { 27 | item = item[nestedKeys.shift()]; 28 | } 29 | if (item && (!Array.isArray(item) || item.length > 0)) { 30 | if (typeof not === 'undefined' || not === 'false') { 31 | return true; 32 | } 33 | ++count; 34 | } 35 | } 36 | return ((typeof not !== 'undefined' && not === 'true') && count < arr.length); 37 | }; 38 | 39 | if (checkContext(keys.split('|'))) { 40 | return chunk.render(bodies.block, context); 41 | } 42 | if (alternate) { 43 | return chunk.render(alternate, context); 44 | } 45 | return chunk; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /lib/statsd.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (config) { 4 | var SDC = require('statsd-client'); 5 | var client = new SDC(config.statsd); 6 | 7 | var mappings = (config.statsd && config.statsd.mappings ? config.statsd.mappings : []).map(function (item) { 8 | if (item.pattern && typeof item.pattern === 'string') { 9 | item.pattern = new RegExp(item.pattern); 10 | } 11 | return item; 12 | }); 13 | 14 | var methods = [ 15 | 'timing', 16 | 'increment', 17 | 'decrement', 18 | 'histogram', 19 | 'gauge', 20 | 'gaugeDelta', 21 | 'set' 22 | ]; 23 | 24 | var obj = { 25 | buildMetricNameForUrl: function (url, name) { 26 | if (mappings.length === 0) { 27 | return name; 28 | } 29 | for (var i = 0; mappings[i]; ++i) { 30 | if (url.match(mappings[i].pattern)) { 31 | return name + '_' + mappings[i].name; 32 | } 33 | } 34 | return name; 35 | } 36 | }; 37 | var slice = Array.prototype.slice; 38 | 39 | var noop = function () { }; 40 | var mock = config && config.statsd ? config.statsd.mock : true; 41 | 42 | methods.forEach(function (method) { 43 | var prefixedMethod = 'classified' + method.charAt(0).toUpperCase() + method.slice(1); 44 | 45 | obj[method] = (mock) ? noop : client[method].bind(client); 46 | obj[prefixedMethod] = (mock) ? noop : function (url, name) { 47 | var args = slice.call(arguments, 1); 48 | args[0] = obj.buildMetricNameForUrl(url, name); 49 | client[method].apply(client, args); 50 | }; 51 | }); 52 | 53 | return obj; 54 | }; 55 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (config) { 4 | var extend = require('extend'); 5 | var mapRoute = require('./map-route'); 6 | var routes = config.routes; 7 | var defaultRoute = config.argv['route-config'] || 'default'; 8 | var localhost = {}; 9 | var override; 10 | 11 | if (config.argv['origin-override']) { 12 | localhost.changeOrigin = true; 13 | } 14 | 15 | if (config.argv['route-override']) { 16 | override = mapRoute(config.argv['route-override']); 17 | if (override.host) { 18 | routes = {localhost: {default: ''}}; 19 | routes.localhost.default = extend(true, {}, localhost, override); 20 | defaultRoute = 'default'; 21 | } 22 | } 23 | 24 | var matchRoute = function (pattern, url) { 25 | if (pattern.match(/^\/.+?\/$/)) { 26 | return url.match(new RegExp(pattern.replace(/^\//, '').replace(/\/$/, ''), 'i')); 27 | } 28 | return false; 29 | }; 30 | 31 | return { 32 | map: function (domain, url) { 33 | domain = domain.replace(/:\d+$/, ''); 34 | if (!Object.prototype.hasOwnProperty.call(routes, domain)) { 35 | domain = 'localhost'; 36 | } 37 | if (!Object.prototype.hasOwnProperty.call(routes, domain)) { 38 | return null; 39 | } 40 | for (var pattern in routes[domain]) { 41 | if (pattern !== defaultRoute && Object.prototype.hasOwnProperty.call(routes[domain], pattern)) { 42 | if (matchRoute(pattern, url)) { 43 | return routes[domain][pattern]; 44 | } 45 | } 46 | } 47 | return routes[domain][defaultRoute]; 48 | } 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /docs/migration/4.0.md: -------------------------------------------------------------------------------- 1 | # Shunter Migration Guide, 3.0 to 4.0 2 | 3 | This guide outlines how to migrate from Shunter 3.x to Shunter 4.x. It outlines breaking changes which might cause issues when you upgrade. 4 | 5 | ## Deprecated content-type removed 6 | 7 | Shunter now requires a `Content-Type` of `application/x-shunter+json` in order to transform the response. The previously deprecated `application/x-shunter-json` will now not be transformed, but will instead be ignored like other content-types. 8 | 9 | ## Deprecated CLI options removed 10 | 11 | The following deprecated command line options have now been removed, and replaced with the following alternatives: 12 | 13 | * `--sourcedirectory` still available as `--source-directory` 14 | * `--routeoveride` still available as `--route-override` 15 | * `--originoveride` still available as `--origin-override` 16 | 17 | ## Deploy timestamp moved 18 | 19 | The command line option `--deploy-timestamp-header` has now been removed, as we are now by default adding that deploy timestamp to proxied requests as a HTTP header rather than a query string parameter. 20 | 21 | ## Syslog levels 22 | 23 | If you're using the default logger, [Winston](https://github.com/winstonjs/winston/), please note that there are [breaking changes](https://github.com/winstonjs/winston/blob/master/CHANGELOG.md#v200--2015-10-29) in this dependency that may affect your shunter-based application. 24 | 25 | ## EJS filters 26 | 27 | The update from EJS version 0.x to 2.x carries with it the [potential for breaking changes](https://github.com/mde/ejs/blob/main/CHANGELOG.md#v201-2015-01-02); most significantly the removal of the filters feature. 28 | -------------------------------------------------------------------------------- /docs/migration/2.0.md: -------------------------------------------------------------------------------- 1 | # Shunter Migration Guide, 1.0 to 2.0 2 | 3 | This guide outlines how to migrate from Shunter 1.x to Shunter 2.x. It outlines breaking changes which might cause issues when you upgrade. 4 | 5 | ## Template Testing 6 | 7 | The DOM library used in [template testing](../testing.md#testing-templates) has changed from jsdom to [Cheerio](https://github.com/cheeriojs/cheerio). This brings us closer to supporting Node.js 4.x. 8 | 9 | The `dom` object that you access in template tests is now a Cheerio instance, and regular DOM access is no longer available. You may need to update your tests. 10 | 11 | One notable change is that the `:first` and `:last` pseudo-selectors are no longer available in `$()` and `.find()` calls. You're encouraged to use the `.first()` and `.last()` methods of a Cheerio instance instead. 12 | 13 | ## PhantomJS 14 | 15 | PhantomJS installation has been removed from Shunter, which means that `shunter-test-client` will not run unless you have installed PhantomJS separately. 16 | 17 | ## EJS Extension Changes 18 | 19 | EJS extensions now receive three parameters rather than two. The original parameter ordering was: 20 | 21 | ```js 22 | function (environment, config) {} 23 | ``` 24 | 25 | Now it's: 26 | 27 | ```js 28 | function (environment, manifest, config) {} 29 | ``` 30 | 31 | See [Writing EJS Extensions](../resources.md#writing-ejs-extensions) for more information. 32 | 33 | ## CSS Compilation Changes 34 | 35 | CSS files built by Shunter no longer have automatic `rem` to `px` replacement for older browsers. 36 | 37 | Also, small image assets are no longer inlined as Base64 URLs. 38 | 39 | Both of these can be replaced using an EJS extension in your application. 40 | -------------------------------------------------------------------------------- /lib/dust.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (dust, renderer, config) { 4 | var compileOnDemandCache = {}; 5 | 6 | // Override dust load mechanism to support namespaced templates 7 | // and to not blow up if the template is not found 8 | dust.onLoad = function (name, options, callback) { 9 | var getTemplate = function (namespace) { 10 | if (namespace.length === 0) { 11 | return dust.cache[name]; 12 | } 13 | namespace.push(name); 14 | return dust.cache[namespace.join('__')] || getTemplate(namespace.slice(0, -2)); 15 | }; 16 | 17 | var ns = (options && options.namespace) ? options.namespace : ''; 18 | // DEPRECATED: If the namespace starts with the name of the host application, trim it 19 | ns = renderer.TEMPLATE_CACHE_KEY_PREFIX + '__' + ns.replace(/^shunter-[^_]+__/, ''); 20 | 21 | var template = getTemplate(ns.split('__')); 22 | if (template) { 23 | callback(null, template); 24 | } else if (config.argv['compile-on-demand'] && !compileOnDemandCache[name]) { 25 | renderer.compileOnDemand(name); 26 | compileOnDemandCache[name] = true; 27 | dust.onLoad(name, options, callback); 28 | } else { 29 | config.log.warn('Template not found ' + name); 30 | callback(null, ''); 31 | } 32 | }; 33 | 34 | dust.helpers.assetPath = function (chunk, context, bodies, params) { 35 | var path = renderer.assetPath(context.resolve(params.src)); 36 | if (!path) { 37 | return chunk; 38 | } 39 | return chunk.write(path); 40 | }; 41 | 42 | dust.helpers.linkPath = function (chunk, context, bodies, params) { 43 | var path = renderer.linkPath(context.resolve(params.src)); 44 | if (!path) { 45 | return chunk; 46 | } 47 | return chunk.write(path); 48 | }; 49 | 50 | dust.config.whitespace = typeof config === 'object' && 51 | typeof config.argv === 'object' && 52 | config.argv['preserve-whitespace']; 53 | }; 54 | -------------------------------------------------------------------------------- /view/jserve/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello Shunter! 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 |

Hello Shunter!

22 | 23 |

View pages generated from remote JSON:

24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 |
37 | 38 |

View pages generated from sample data files:

39 | 40 |
41 | 55 |
56 | 57 |
58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /lib/logging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var eachModule = require('each-module'); 5 | var winston = require('winston'); 6 | 7 | module.exports = function (config) { 8 | var loggerInstance; 9 | var moduleLoadErrors = []; 10 | 11 | var getArrayOfValidModulesByDirName = function (transportsDirName) { 12 | var modules = []; 13 | var modulePusher = function (moduleName, moduleExports, file) { 14 | if (typeof moduleExports === 'function') { 15 | modules.push(moduleExports); 16 | } else { 17 | moduleLoadErrors.push('Invalid logging transport dropped ' + file); 18 | } 19 | }; 20 | 21 | // User-defined transports take priority, but fallback to defaults if all seem invalid 22 | var locations = [config.path.root, config.path.shunterRoot]; // config.path.root = users files 23 | for (var [index, location] of locations.entries()) { 24 | var localPath = path.join(location, config.structure.logging, transportsDirName); 25 | eachModule(localPath, modulePusher); 26 | if (index === 0 && modules.length > 0) { 27 | // the user supplied at least one valid-looking transport 28 | break; 29 | } 30 | } 31 | return modules; 32 | }; 33 | 34 | return { 35 | getLogger: function () { 36 | if (loggerInstance) { 37 | return loggerInstance; 38 | } 39 | 40 | var getTransports = function (modules) { 41 | return modules.map(function (fnModule) { 42 | return fnModule(config); 43 | }).filter(function (module) { 44 | return Boolean(module); 45 | }); 46 | }; 47 | 48 | var transports = getArrayOfValidModulesByDirName(config.structure.loggingTransports); 49 | 50 | loggerInstance = winston.createLogger({ 51 | transports: getTransports(transports) 52 | }); 53 | 54 | moduleLoadErrors.forEach(function (err) { 55 | loggerInstance.error(err); 56 | }); 57 | 58 | return loggerInstance; 59 | } 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /tests/server/dust/number-format.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Helper: numberFormat', function () { 8 | it('Should display zero', function (done) { 9 | helper.render('{@numberFormat num="{num}"/}', {num: 0}, function (err, dom, out) { 10 | assert.strictEqual('0', out); 11 | done(); 12 | }); 13 | }); 14 | 15 | it('Should display small numbers without punctuation 1', function (done) { 16 | helper.render('{@numberFormat num="{num}"/}', {num: 1}, function (err, dom, out) { 17 | assert.strictEqual('1', out); 18 | done(); 19 | }); 20 | }); 21 | 22 | it('Should display small numbers without punctuation 2', function (done) { 23 | helper.render('{@numberFormat num="{num}"/}', {num: 123}, function (err, dom, out) { 24 | assert.strictEqual('123', out); 25 | done(); 26 | }); 27 | }); 28 | 29 | it('Should format numbers with commas 1', function (done) { 30 | helper.render('{@numberFormat num="{num}"/}', {num: 1234}, function (err, dom, out) { 31 | assert.strictEqual('1,234', out); 32 | done(); 33 | }); 34 | }); 35 | 36 | it('Should format numbers with commas 2', function (done) { 37 | helper.render('{@numberFormat num="{num}"/}', {num: 12345}, function (err, dom, out) { 38 | assert.strictEqual('12,345', out); 39 | done(); 40 | }); 41 | }); 42 | 43 | it('Should format numbers with commas 3', function (done) { 44 | helper.render('{@numberFormat num="{num}"/}', {num: 123456}, function (err, dom, out) { 45 | assert.strictEqual('123,456', out); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('Should format numbers with commas 4', function (done) { 51 | helper.render('{@numberFormat num="{num}"/}', {num: 1234567}, function (err, dom, out) { 52 | assert.strictEqual('1,234,567', out); 53 | done(); 54 | }); 55 | }); 56 | 57 | it('Should not error on a non numeric value', function (done) { 58 | helper.render('{@numberFormat num="{num}"/}', {num: 'pass'}, function (err, dom, out) { 59 | assert.strictEqual('pass', out); 60 | done(); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/server/core/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var mockery = require('mockery'); 5 | 6 | describe('Shunter configuration', function () { 7 | describe('No environment specified', function () { 8 | it('Should use development as the default environment', function () { 9 | var config = require('../../../lib/config')(null, null, {}); 10 | assert.equal(config.env.name, 'development'); 11 | assert.isTrue(config.env.isDevelopment()); 12 | assert.isFalse(config.env.isProduction()); 13 | }); 14 | }); 15 | 16 | describe('Specifying an environment', function () { 17 | var env; 18 | var mockedLoggingLib; 19 | 20 | beforeEach(function () { 21 | env = process.env.NODE_ENV; 22 | process.env.NODE_ENV = 'ci'; 23 | 24 | mockery.enable({ 25 | useCleanCache: true, 26 | warnOnUnregistered: false, 27 | warnOnReplace: false 28 | }); 29 | mockery.registerMock('os', require('../mocks/os')); 30 | mockedLoggingLib = require('../mocks/logging'); 31 | mockery.registerMock('./logging', mockedLoggingLib); 32 | }); 33 | 34 | afterEach(function () { 35 | process.env.NODE_ENV = env; 36 | 37 | mockery.deregisterAll(); 38 | mockery.disable(); 39 | }); 40 | 41 | it('Should be able to select the environment from an environment variable', function () { 42 | var config = require('../../../lib/config')(null, null, {}); 43 | assert.equal(config.env.name, 'ci'); 44 | assert.isFalse(config.env.isDevelopment()); 45 | assert.isFalse(config.env.isProduction()); 46 | }); 47 | 48 | it('Should be able to override an environment variable', function () { 49 | var config = require('../../../lib/config')('production', null, {}); 50 | assert.equal(config.env.name, 'production'); 51 | assert.isTrue(config.env.isProduction()); 52 | assert.isFalse(config.env.isDevelopment()); 53 | }); 54 | 55 | it('Should call the logging module if no logger is configured', function () { 56 | var config = require('../../../lib/config')('production', null, {}); 57 | var logging = mockedLoggingLib(config); 58 | assert.isTrue(logging.getLogger.callCount === 1); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/server/integration/do-not-blow-up-after-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | 5 | var httpRequest = require('./lib/http-request'); 6 | var serversUnderTest = require('./lib/servers-under-test'); 7 | 8 | // To run just this test: 9 | // ./node_modules/.bin/mocha --config=./tests/.mocharc.json ./tests/server/integration 10 | 11 | describe('error requests should not affect subsequent requests', function () { 12 | var getHomeResponseBodyBefore; 13 | var getHomeResponseBodyAfter; 14 | 15 | before(function () { 16 | // GET on the frontend server 17 | var getPath = function (path) { 18 | return new Promise(function (resolve, reject) { 19 | var testRequestPromise = httpRequest({ 20 | port: 5400, 21 | path: path 22 | }); 23 | testRequestPromise 24 | .then(function (res) { 25 | resolve(res.text); 26 | }) 27 | .catch(function (err) { 28 | reject(err); 29 | }); 30 | }); 31 | }; 32 | 33 | // wait for the servers to spin up, then hit the home page 34 | var serversReadyPromise = serversUnderTest.readyForTest(); 35 | 36 | return serversReadyPromise 37 | .then(function () { 38 | // do some request to see whether it catches the body properly 39 | var getHomepromiseBefore = getPath('/'); 40 | return getHomepromiseBefore 41 | .then(function (data) { 42 | getHomeResponseBodyBefore = data; 43 | // do some request with a response code >=400 to trigger the bug 44 | return getPath('/unknown').catch(function () { 45 | // do the first request again to show it's not returning the body properly this time 46 | var getHomepromiseAfter = getPath('/'); 47 | return getHomepromiseAfter 48 | .then(function (data) { 49 | getHomeResponseBodyAfter = data; 50 | serversUnderTest.finish(); 51 | }); 52 | }); 53 | }); 54 | }); 55 | }); 56 | 57 | after(function () { 58 | serversUnderTest.finish(); 59 | }); 60 | 61 | // start actual tests 62 | it('Should return the Shunter homepage both before and after the error', function () { 63 | assert.isTrue(getHomeResponseBodyBefore.includes('Hello Shunter!')); 64 | assert.isTrue(getHomeResponseBodyAfter.includes('Hello Shunter!')); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/server/integration/smoke.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | 5 | var httpRequest = require('./lib/http-request'); 6 | var serversUnderTest = require('./lib/servers-under-test'); 7 | 8 | // To run just this test: 9 | // ./node_modules/.bin/mocha --config=./tests/.mocharc.json ./tests/server/integration 10 | 11 | describe('Smoke', function () { 12 | var getHomeResponseBody; 13 | var getCSSResponseBody; 14 | 15 | before(function () { 16 | // GET on the frontend server 17 | var getPath = function (path) { 18 | return new Promise(function (resolve, reject) { 19 | var testRequestPromise = httpRequest({ 20 | port: 5400, 21 | path: path 22 | }); 23 | testRequestPromise 24 | .then(function (res) { 25 | resolve(res.text); 26 | }) 27 | .catch(function (err) { 28 | reject(err); 29 | }); 30 | }); 31 | }; 32 | 33 | // wait for the servers to spin up, then hit the home page 34 | var serversReadyPromise = serversUnderTest.readyForTest(); 35 | return serversReadyPromise 36 | .then(function () { 37 | var getHomePromise = getPath('/home'); 38 | return getHomePromise 39 | .then(function (data) { 40 | getHomeResponseBody = data; 41 | var regexp = /\/public\/resources\/main-.+\.css/; 42 | var found = getHomeResponseBody.match(regexp)[0]; 43 | var getCSSPromise = getPath(found); 44 | return getCSSPromise 45 | .then(function (data) { 46 | getCSSResponseBody = data; 47 | return serversUnderTest.finish(); 48 | }) 49 | .catch(function (err) { 50 | console.error(err); 51 | return serversUnderTest.finish(); 52 | }); 53 | }) 54 | .catch(function (err) { 55 | console.error(err); 56 | return serversUnderTest.finish(); 57 | }); 58 | }); 59 | }); // before 60 | 61 | after(function () { 62 | return serversUnderTest.finish(); 63 | }); 64 | 65 | // start actual tests 66 | it('Should return hello world text in response', function () { 67 | assert.isTrue(getHomeResponseBody.includes('

Hello World!

')); 68 | }); 69 | it('Should return processed SASS in CSS file response', function () { 70 | assert.isTrue(getCSSResponseBody.includes('.should-be-orange{background-color:#ffa500}')); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /lib/error-pages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | Sample client config; 5 | { 6 | "errorLayouts": { 7 | "default": "layout", 8 | "404": "layout-error-404" 9 | }, 10 | "staticData": { 11 | ...whatever data you require to render your page... 12 | */ 13 | 14 | module.exports = function (config) { 15 | var renderer = require('./renderer')(config); 16 | 17 | var renderPageForContext = function (req, res, templateContext, fn) { 18 | renderer.render(req, res, templateContext, function (err, out) { 19 | if (err || !out) { 20 | config.log.warn('Rendering custom error page failed (misconfiguration?)'); 21 | fn(null); 22 | } else { 23 | fn(out); 24 | } 25 | }); 26 | }; 27 | 28 | var getTemplateContext = function (err, req, fn) { 29 | if (!err) { 30 | fn(null); 31 | return; 32 | } 33 | 34 | // Some internal errors will throw with no status set 35 | if (!err.status) { 36 | err.status = 500; 37 | } 38 | 39 | var statusAsString = typeof err.status === 'string' ? err.status : err.status.toString(); 40 | var userData = config.errorPages; 41 | var layout = Object.prototype.hasOwnProperty.call(userData.errorLayouts, statusAsString) ? userData.errorLayouts[statusAsString] : userData.errorLayouts.default; 42 | 43 | var templateContext = { 44 | layout: { 45 | template: layout, 46 | namespace: 'custom-errors' 47 | }, 48 | errorContext: { 49 | error: err, 50 | hostname: config.env.host(), 51 | isDevelopment: config.env.isDevelopment(), 52 | isProduction: config.env.isProduction(), 53 | reqHost: req.headers.host, 54 | reqUrl: req.url 55 | } 56 | }; 57 | 58 | if (userData.staticData) { 59 | for (var key in userData.staticData) { 60 | // Prevent the user clobbering required templateContext keys & proto 61 | if (Object.prototype.hasOwnProperty.call(userData.staticData, key) && !(key in templateContext)) { 62 | templateContext[key] = userData.staticData[key]; 63 | } 64 | } 65 | } 66 | 67 | return templateContext; 68 | }; 69 | 70 | return { 71 | getPage: function (err, req, res, fn) { 72 | if (!config.errorPages || !config.errorPages.errorLayouts || !config.errorPages.errorLayouts.default) { 73 | fn(null); 74 | } else { 75 | renderPageForContext(req, res, getTemplateContext(err, req, fn), fn); 76 | } 77 | } 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | As dust templates can contain logic, there will be scenarios where you want to test that your code behaves as expected. 4 | 5 | In your test spec, you need to load in the test helper from shunter, as you will need some of shunter's features to render the templates for testing. 6 | 7 | Other dependencies are up to you. Shunter uses [Mocha](https://mochajs.org/) for testing, but you can use any other test runner. 8 | 9 | An example client test could be as follows: 10 | 11 | ```js 12 | var assert = require('assert'); 13 | var rootdir = __dirname.substring(0, __dirname.indexOf('/tests/')); 14 | var helper = require('shunter').testhelper(); 15 | 16 | describe('Foo bar', function() { 17 | before(function() { 18 | helper.setup(rootdir + '/view/template.dust', rootdir + '/view/subdir/template.dust'); 19 | }); 20 | after(helper.teardown); 21 | 22 | it('Should do something', function(done) { 23 | helper.render('template', { 24 | foo: 'bar', 25 | lorem: 'ipsum' 26 | }, function(error, $, output) { 27 | assert.strictEqual($('[data-test="fooitem"]').length, 1); 28 | done(); 29 | }); 30 | }); 31 | }); 32 | ``` 33 | 34 | In the `helper.render` callback, the `$` parameter is a [Cheerio](https://github.com/cheeriojs/cheerio) instance which allows you to use jQuery-like functions to access the render output. The `output` parameter contains the full output as a string. 35 | 36 | You can use the following syntax to pass extra arguments to the `render` function: 37 | 38 | ```js 39 | helper.render(template, req, res, data, callback); 40 | ``` 41 | 42 | This could be useful in cases where you want to test request objects, like checking for custom headers or cookies. 43 | 44 | When testing templates that are in subfolders, be sure to pass in any subfolders in the same way that you would include a partial: 45 | 46 | ```js 47 | helper.render('mysubfolder___templatename', { 48 | foo: 'bar' 49 | }, function(error, $, output) { 50 | // etc etc 51 | }); 52 | ``` 53 | 54 | You can test individual templates by running mocha directly with the command: 55 | 56 | ```shell 57 | ./node_modules/mocha/bin/mocha -R spec -u bdd test/myfolders/mytemplate-spec.js 58 | ``` 59 | 60 | In addition to these tests we recommend using [Dustmite](https://github.com/nature/dustmite) to lint your dust files and ensure that they are all syntactically valid. 61 | -------------------------------------------------------------------------------- /tests/server/core/watcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var mockery = require('mockery'); 5 | var sinon = require('sinon'); 6 | 7 | var moduleName = '../../../lib/watcher'; 8 | 9 | describe('Watcher watchTree', function () { 10 | var watchTree; 11 | var gaze; 12 | var Gaze; 13 | 14 | beforeEach(function () { 15 | var watcherMod; 16 | mockery.enable({ 17 | useCleanCache: true, 18 | warnOnUnregistered: false, 19 | warnOnReplace: false 20 | }); 21 | watcherMod = require(moduleName)('.dust'); 22 | watchTree = watcherMod.watchTree; 23 | }); 24 | 25 | afterEach(function () { 26 | mockery.deregisterAll(); 27 | mockery.disable(); 28 | }); 29 | 30 | describe('Gaze setup', function () { 31 | beforeEach(function () { 32 | Gaze = sinon.spy(); 33 | Gaze.prototype.on = sinon.spy(); 34 | Gaze.prototype.emit = sinon.spy(); 35 | mockery.registerMock('gaze', Gaze); 36 | gaze = require('gaze'); 37 | }); 38 | it('Should be a function', function () { 39 | assert.isFunction(watchTree); 40 | }); 41 | it('Should return an instance of gaze', function () { 42 | var wt = watchTree([], require('../mocks/log')); 43 | assert.isTrue(gaze.calledWithNew()); 44 | assert.instanceOf(wt, gaze); 45 | }); 46 | it('Should create a watcher with the given directory, globbed for dust templates', function () { 47 | watchTree('foo', require('../mocks/log')); 48 | assert.isTrue(gaze.withArgs('foo/**/*.dust').calledOnce); 49 | }); 50 | it('Should create a watcher with more than one directory', function () { 51 | watchTree(['foo', 'bar'], require('../mocks/log')); 52 | assert.isTrue(gaze.withArgs(['foo/**/*.dust', 'bar/**/*.dust']).calledOnce); 53 | }); 54 | }); 55 | 56 | describe('Watched Events', function () { 57 | var emitSpy; 58 | var wt; 59 | beforeEach(function () { 60 | wt = watchTree('foo', require('../mocks/log')); 61 | emitSpy = sinon.spy(wt, 'emit'); 62 | }); 63 | afterEach(function () { 64 | wt.close(); 65 | }); 66 | it('Should emit a fileCreated event when a file is created', function () { 67 | wt.emit('added', 'foopath'); 68 | assert(emitSpy.calledWith('fileCreated', 'foopath')); 69 | }); 70 | it('Should emit a fileModified event when a file is modified', function () { 71 | wt.emit('changed', 'barpath'); 72 | assert(emitSpy.calledWith('fileModified', 'barpath')); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /docs/sample-data.md: -------------------------------------------------------------------------------- 1 | # Sample Data 2 | 3 | Shunter includes a tool to serve sample JSON data to which Shunter can proxy. This is helpful for mocking up and testing templates without a functioning back end in place. 4 | 5 | This JSON-serving tool can be useful for the negotiation of data contracts between the back end and front end of an application. A format for data can be decided upon at an early stage of a feature allowing independent work to be carried out before integration. This means both parties can begin work independently with a recognition of what is expected. 6 | 7 | The example JSON sits in a `data` directory within the root of your Shunter project and contains dummy data that replicates the response expected from a back end app to load appropriate layouts and templates. This may negate the need to run a full back end locally to work on the front end of your project. 8 | 9 | If you are dealing with large and complicated JSON files you may want to reduce repetition in your sample data by including a template of repeated JSON and then expand upon and modify it. For example This example javascript file requires a template.JSON and then modifies it: 10 | 11 | ```js 12 | var json = JSON.parse(JSON.stringify(require('./template'))); 13 | json.header.title = 'article'; 14 | module.exports = json; 15 | ``` 16 | 17 | To use the `shunter-serve` command line tool, run the following: 18 | 19 | ```shell 20 | ./node_modules/.bin/shunter-serve 21 | ``` 22 | 23 | You may specify the port on which `shunter-serve` should run by using the option `-p`, this should match the port specified in the Shunter routing configuration, set in the Shunter config file or at run-time with the `-o` option. For example to listen on port 9000, run the following: 24 | 25 | ```shell 26 | ./node_modules/.bin/shunter-serve -p 9000 27 | ``` 28 | 29 | You may also set a number of milliseconds of latency for the response using the `-l` option. This can be useful for performance-related testing. 30 | 31 | Furthermore, you may use the options `-i` and `-q` to make `shunter-serve` mimic real paths. These options let you serve JSON from the index path and instruct `shunter-serve` to respect query parameters, respectively. To use them, run the following: 32 | 33 | ```shell 34 | ./node_modules/.bin/shunter-serve -i -q 35 | ``` 36 | 37 | As a result, visiting `localhost:5401` will return an `index.json` file placed in the aforementioned `data` directory and visiting `localhost:5401/search?q=test&count=10` will return `data/search/q_count.json`. 38 | -------------------------------------------------------------------------------- /docs/modules.md: -------------------------------------------------------------------------------- 1 | # Modules and Inheritance 2 | 3 | Shunter can pull in common resources, filters, helpers and templates from one or more declared npm modules. These modules are loaded in as dependencies via `package.json` and the application is made aware of them by configuration options set in `config/local.json`. 4 | 5 | These dependencies can be overridden if there are files of the same name in your application. 6 | 7 | * [Set-up Steps](#set-up-steps) 8 | * [Config](#config) 9 | * [Overriding shared code](#overriding-shared-code) 10 | * [Developing with your module](#developing-with-your-module) 11 | * [Testing your code](#testing-your-code) 12 | 13 | ## Set-up Steps 14 | 15 | 1. Set up your shared code with the same structures as you have for your main app, e.g. templates are put into a `view` folder, JavaScript goes into a `resources/js` folder, etc. 16 | 2. In your application's `package.json`, add your sharing module to dependencies, e.g. 17 | ```js 18 | "dependencies": { 19 | "shunter": "^1", 20 | "my-shared-module": "~1.0" 21 | }, 22 | ``` 23 | 3. Add the module name to `config/local.json`, e.g. 24 | ```json 25 | { 26 | "modules": ["my-shared-module"] 27 | } 28 | ``` 29 | 4. Run `npm install` to install your dependencies. 30 | 31 | ## Config 32 | 33 | When Shunter sets up configuration, it looks in your app's folders for `config/local.json` and extends the default config options object with the object found in the `local.json` file. 34 | 35 | If there is a `modules` property with an array with one or more item in it, this is used within Shunter to find the relevant directory under `node_modules` and from there to load in Dust templates and helpers, add the resources files to the asset handler load path, and apply filters. 36 | 37 | ## Overriding shared code 38 | 39 | If your shared code module and your app have a file of the same name, Shunter will pick the file in your app over the one in your shared code module. 40 | 41 | For example, if you have both `my-shared-module/resources/css/forms.css` and `my-app/resources/css/forms.css`, CSS in the `my-shared-module` version will not be loaded into the compiled `main.css` file. 42 | 43 | ## Developing with your module 44 | 45 | You can use [`npm link`](https://docs.npmjs.com/cli/link) to instruct npm to point your shunter application to your locally checked-out module code. 46 | 47 | ## Testing your code 48 | 49 | You can run tests on your code in the same way as you do for your application. 50 | 51 | The module should include Shunter as a _dev dependency_ so that the rendering helper is available (see [Testing](testing.md)). 52 | -------------------------------------------------------------------------------- /tests/server/core/map-route.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | 5 | var moduleName = '../../../lib/map-route'; 6 | 7 | describe('Mapping a route', function () { 8 | it('Should map a route with a protocol, hostname and port', function () { 9 | var route = require(moduleName)('http://www.foo.com:80'); 10 | assert.equal(route.protocol, 'http:'); 11 | assert.equal(route.host, 'www.foo.com'); 12 | assert.equal(route.port, 80); 13 | }); 14 | 15 | it('Should map a route with a protocol, subdomain, hostname and port', function () { 16 | var route = require(moduleName)('https://foo-www.somehost-name.bar.com:80'); 17 | assert.equal(route.protocol, 'https:'); 18 | assert.equal(route.host, 'foo-www.somehost-name.bar.com'); 19 | assert.equal(route.port, 80); 20 | }); 21 | 22 | it('Should map a route with a protocol, subdomain, hostname and no port', function () { 23 | var route = require(moduleName)('http://somehost-name.foo.com'); 24 | assert.equal(route.protocol, 'http:'); 25 | assert.equal(route.host, 'somehost-name.foo.com'); 26 | assert.equal(route.port, null); 27 | }); 28 | 29 | it('Should map a route with an IPv4 address and port', function () { 30 | var route = require(moduleName)('127.0.0.1:9000'); 31 | assert.equal(route.protocol, 'http:'); 32 | assert.equal(route.host, '127.0.0.1'); 33 | assert.equal(route.port, 9000); 34 | }); 35 | 36 | it('Should map a route with an IPv4 address and no port', function () { 37 | var route = require(moduleName)('8.8.8.8'); 38 | assert.equal(route.protocol, 'http:'); 39 | assert.equal(route.host, '8.8.8.8'); 40 | assert.equal(route.port, null); 41 | }); 42 | 43 | it('Should map a route with a protocol and a IPv4 address and a port', function () { 44 | var route = require(moduleName)('https://8.8.8.8:80'); 45 | assert.equal(route.protocol, 'https:'); 46 | assert.equal(route.host, '8.8.8.8'); 47 | assert.equal(route.port, 80); 48 | }); 49 | 50 | it('Should map a route with a protocol and a IPv4 address and no port', function () { 51 | var route = require(moduleName)('https://8.8.8.8'); 52 | assert.equal(route.protocol, 'https:'); 53 | assert.equal(route.host, '8.8.8.8'); 54 | assert.equal(route.port, null); 55 | }); 56 | 57 | it('Should default to mapping a hostname protocol to http', function () { 58 | var route = require(moduleName); 59 | assert.equal(route('localhost').host, 'localhost'); 60 | assert.equal(route('localhost').protocol, 'http:'); 61 | 62 | assert.equal(route('foo.com').host, 'foo.com'); 63 | assert.equal(route('localhost').protocol, 'http:'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | 5 | module.exports = function (config) { 6 | var connect = require('connect'); 7 | var bodyParser = require('body-parser'); 8 | var cookieParser = require('cookie-parser'); 9 | var serveStatic = require('serve-static'); 10 | var query = require('qs-middleware'); 11 | var app = connect(); 12 | 13 | var benchmark = require('./benchmark')(config); 14 | var renderer = require('./renderer')(config); 15 | var processor = require('./processor')(config, renderer); 16 | 17 | renderer.initDustExtensions(); 18 | if (!config.argv['compile-on-demand']) { 19 | renderer.compileTemplates(); 20 | } 21 | 22 | if (config.env.isDevelopment()) { 23 | renderer.watchTemplates(); 24 | renderer.watchDustExtensions(); 25 | } 26 | 27 | app.use(benchmark); 28 | app.use(query({ 29 | allowDots: false 30 | })); 31 | app.use(cookieParser()); 32 | app.use(config.web.tests, serveStatic(config.path.tests)); 33 | 34 | var assetMountPath = config.argv && (config.argv['mount-path'] || ''); 35 | var endpointsMountPath = assetMountPath || '/'; 36 | 37 | if (config.env.isProduction()) { 38 | app.use(path.join(assetMountPath, config.web.public), serveStatic(config.path.public, { 39 | maxAge: 1000 * 60 * 60 * 24 * 365 40 | })); 41 | } else { 42 | app.use(path.join(assetMountPath, config.web.resources), renderer.assetServer()); 43 | } 44 | 45 | config.middleware.forEach(function (args) { 46 | app.use.apply(app, args); 47 | }); 48 | 49 | app.use('/ping', processor.ping); 50 | app.use('/template', bodyParser.json({limit: config.argv['max-post-size']})); 51 | app.use('/template', processor.api); 52 | 53 | app.use(processor.timestamp); 54 | app.use(processor.shunterVersion); 55 | app.use(endpointsMountPath, processor.intercept); 56 | app.use(endpointsMountPath, processor.proxy); 57 | 58 | app.listen(config.argv.port, function () { 59 | config.log.debug('Worker process ' + process.pid + ' started in ' + config.env.name + ' mode, listening on port ' + config.argv.port); 60 | }); 61 | process.on('uncaughtException', function (err) { 62 | if (err.code === 'EADDRINUSE') { 63 | config.log.error('Worker process ' + process.pid + ' died, something is already listening on port ' + config.argv.port); 64 | // Exit with status 0, so server doesn't attempt to respawn 65 | process.exit(0); 66 | } else { 67 | config.log.error(err); 68 | process.exit(1); 69 | } 70 | }); 71 | 72 | process.on('message', function (msg) { 73 | if (msg === 'force exit') { 74 | process.exit(0); 75 | } 76 | }); 77 | process.on('disconnect', function () { 78 | process.exit(0); 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /docs/input-filters.md: -------------------------------------------------------------------------------- 1 | # Input Filters 2 | 3 | Data processing belongs to the code that deals with the model, not the view. Almost all of the time, that equates to the application that sends data to Shunter. 4 | 5 | Rarely, it is necessary to amend the JSON data passed in purely for the benefit of the view, but with logic that is too complex to put into a template. This can be done using an Input Filter. 6 | 7 | Before creating a filter, consider carefully if this really is the right place for this data processing be handled. 8 | 9 | Input filters are part of the shunter rendering process. They take the JSON data being passed to shunter and return an altered version of it before template rendering happens. 10 | 11 | If you want to add input filters to your project they are defined in ``filters/input`` their corresponding tests live in the ``tests/server/filters/input`` folder. 12 | 13 | ## Defining An Input Filter 14 | 15 | At their most basic an input filter exports a function that accepts a single parameter containing the JSON for the request and returns the data with any modifications made. 16 | 17 | ```js 18 | module.exports = function(data) { 19 | data.corresponding_authors = data.authors.filter(function(author) { 20 | return author.is_corresponding_author; 21 | }); 22 | return data; 23 | }; 24 | ``` 25 | 26 | Input filters change their behaviour based on the arity of the function you export. If you define a second parameter this will be assumed to be a callback allowing your input filter to perform asynchronously. **Be careful though, rendering will not start until all input filters have completed, so try to avoid kicking off any async processes that will take a long time to complete**. 27 | 28 | ```js 29 | module.exports = function(data, next) { 30 | setTimeout(function() { 31 | data.wait_a_second = true; 32 | next(data); 33 | }, 1000); 34 | }; 35 | ``` 36 | 37 | Adding a third argument to the filter provides access to the shunter config object. Note that even if the function is synchronous you need to use the callback to pass control to the next input filter in the chain. The following would make the hostname of the machine running shunter accessible to the template. 38 | 39 | ```js 40 | module.exports = function(config, data, next) { 41 | data.hostname = config.env.host(); 42 | next(data); 43 | }; 44 | ``` 45 | 46 | Defining a function with five arguments additionally provides access to the request and response objects. Here we populate a property on the model containing the request query parameters. 47 | 48 | ```js 49 | module.exports = function(config, request, response, data, next) { 50 | data.query_data = request.query; 51 | next(data); 52 | }; 53 | ``` 54 | -------------------------------------------------------------------------------- /view/test-runner.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mocha Test Runner 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 23 | 24 | {@scriptBlock src="main.js" theme=theme/} 25 | {@viewTemplates theme=theme /} 26 | {@scriptSpecs theme=theme /} 27 | 28 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # ![Shunter](shunter-logo.png) 2 | 3 | Welcome to the Shunter documentation. Shunter is a Node.js module built to read JSON and translate it into HTML. 4 | 5 | ## Table of contents 6 | 7 | * [Getting Started Guide](getting-started.md) 8 | * Programming with Shunter 9 | * [Modules and Inheritance](modules.md) 10 | * [Sample Data](sample-data.md) 11 | * [Input Filters](input-filters.md) 12 | * [Output Filters](output-filters.md) 13 | * [Templates](templates.md) 14 | * [Specifying a Template](templates.md#specifying-a-template) 15 | * [Dust Basics](templates.md#dust-basics) 16 | * [Using Partials](templates.md#using-partials) 17 | * [Using Layouts](templates.md#using-layouts) 18 | * [Built-In Dust Extensions](templates.md#built-in-dust-extensions) 19 | * [Writing Dust Extensions](templates.md#writing-dust-extensions) 20 | * [Resources](resources.md) 21 | * [Resource Basics](resources.md#resource-basics) 22 | * [Writing CSS](resources.md#writing-css) 23 | * [Writing JavaScript](resources.md#writing-javascript) 24 | * [Adding Images](resources.md#adding-images) 25 | * [Other Static Assets](resources.md#other-static-assets) 26 | * [Built-In EJS Extensions](resources.md#built-in-ejs-extensions) 27 | * [Writing EJS Extensions](resources.md#writing-ejs-extensions) 28 | * [In Production](resources.md#production-differences) 29 | * [Testing](testing.md) 30 | * [Configuration Reference](configuration-reference.md) 31 | * [Web](configuration-reference.md#web-configuration) 32 | * [Path](configuration-reference.md#path-configuration) 33 | * [Structure](configuration-reference.md#structure-configuration) 34 | * [Log](configuration-reference.md#log-configuration) 35 | * [StatsD](configuration-reference.md#statsd-configuration) 36 | * [Timer](configuration-reference.md#timer-configuration) 37 | * [Environment](configuration-reference.md#environment-configuration) 38 | * [Templated Error Pages](configuration-reference.md#templated-error-page-configuration) 39 | * [Custom Configurations](configuration-reference.md#adding-custom-configurations) 40 | * [Configuring Modules](configuration-reference.md#configuring-modules) 41 | * [Command Line Options](configuration-reference.md#command-line-options) 42 | * [Accessing the Configuration at Run Time](configuration-reference.md#accessing-the-configuration-at-run-time) 43 | * [Middleware](middleware.md) 44 | * [Routing](routing.md) 45 | * [Examples](routing.md#examples) 46 | * [Route Config Options](routing.md#route-config-options) 47 | * [Change Origin](routing.md#change-origin) 48 | * [Web API](web-api.md) 49 | * [Migration guide](migration/index.md) 50 | * [Contributing to Shunter](contributing-to-shunter.md) 51 | -------------------------------------------------------------------------------- /tests/helpers/template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.TZ = 'UTC'; 4 | 5 | module.exports = function (configArgument) { 6 | var fs = require('fs'); 7 | var path = require('path'); 8 | var cheerio = require('cheerio'); 9 | var sinon = require('sinon'); 10 | var config = require('../../lib/config')('development', configArgument || null, {}); 11 | 12 | var renderer = null; 13 | var assetPath = null; 14 | 15 | return { 16 | getDust: function () { 17 | return renderer.dust; 18 | }, 19 | 20 | setup: function () { 21 | var args = [].slice.call(arguments, 0); 22 | 23 | config.timer = sinon.stub().returns(sinon.stub()); 24 | renderer = require('../../lib/renderer')(config); 25 | assetPath = sinon.stub(renderer, 'assetPath').returnsArg(0); 26 | renderer.initDustExtensions(); 27 | if (args.length > 0) { 28 | if (args[0] === 'all') { 29 | // We need to pretend to the renderer it is being called from a host app, not the Mocka process 30 | renderer.compileTemplates('.'); 31 | } else { 32 | renderer.compilePaths.apply(renderer, [].slice.call(arguments, 0)); 33 | } 34 | } 35 | }, 36 | teardown: function () { 37 | renderer.dust.cache = {}; 38 | renderer = null; 39 | assetPath.restore(); 40 | }, 41 | data: function (file) { 42 | if (/\.json$/.test(file)) { 43 | var json = fs.readFileSync(path.join(config.path.root, file), 'utf8'); 44 | return JSON.parse(json); 45 | } 46 | return require(path.join(config.path.root, file)); 47 | }, 48 | render: function (template, req, res, data, callback) { 49 | var _req; 50 | var _res; 51 | var _data; 52 | var _callback; 53 | 54 | if (arguments.length === 3) { 55 | _req = {url: ''}; 56 | _res = {}; 57 | _data = req; 58 | _callback = res; 59 | } else if (arguments.length === 5) { 60 | _req = req; 61 | _res = res; 62 | _data = data; 63 | _callback = callback; 64 | } else { 65 | throw new Error('Invalid arguments for render test helper'); 66 | } 67 | 68 | if (template.includes('{')) { 69 | if (!renderer) { 70 | this.setup(); 71 | } 72 | renderer.dust.loadSource(renderer.dust.compile(template, 'test-template')); 73 | template = 'test-template'; 74 | } 75 | 76 | renderer.renderPartial(template, _req, _res, _data, function (err, raw) { 77 | if (raw) { 78 | var $ = cheerio.load(raw); 79 | $.$ = $; // Compatibility change. Will deprecate 80 | _callback(null, $, raw); 81 | } else { 82 | if (!_data) { 83 | console.error('No test data to render'); 84 | } else if (!_data.layout || !_data.layout.namespace) { 85 | console.error('Namespace was not specified in test data'); 86 | } 87 | _callback(err, null, null); 88 | } 89 | }); 90 | } 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /tests/server/filters/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var sinon = require('sinon'); 5 | 6 | var filter = require('../../../filters/input/environment.js'); 7 | 8 | describe('Populate data with environment info', function () { 9 | var callback; 10 | 11 | beforeEach(function () { 12 | callback = sinon.stub(); 13 | }); 14 | 15 | it('Should add the request url', function () { 16 | filter({}, { 17 | url: '/hello' 18 | }, {}, {}, callback); 19 | assert.strictEqual(callback.firstCall.args[0].request_url, '/hello'); 20 | }); 21 | 22 | it('Should handle query parameters', function () { 23 | var params = { 24 | test: 'Something' 25 | }; 26 | 27 | filter({}, { 28 | url: '/hello/world?test=something', 29 | query: params 30 | }, {}, {}, callback); 31 | assert.strictEqual(callback.firstCall.args[0].request_url, '/hello/world'); 32 | assert.strictEqual(callback.firstCall.args[0].query_data.test, 'Something'); 33 | }); 34 | 35 | it('Should convert a query parameter containing true or false to a truthy value', function () { 36 | /* eslint-disable camelcase */ 37 | var params = { 38 | show_ads: 'false', 39 | disable_third_party_scripts: 'true' 40 | }; 41 | /* eslint-enable camelcase */ 42 | 43 | filter({}, { 44 | url: '/hello/world?show_ads=false&disable_third_party_scripts=true', 45 | query: params 46 | }, {}, {}, callback); 47 | assert.strictEqual(callback.firstCall.args[0].request_url, '/hello/world'); 48 | assert.strictEqual(callback.firstCall.args[0].query_data.show_ads, false); 49 | assert.strictEqual(callback.firstCall.args[0].query_data.disable_third_party_scripts, true); 50 | }); 51 | 52 | it('Should convert a query parameter containing an integer to a numeric value', function () { 53 | var params = { 54 | page: '2' 55 | }; 56 | 57 | filter({}, { 58 | url: '/hello/world?page=2', 59 | query: params 60 | }, {}, {}, callback); 61 | assert.strictEqual(callback.firstCall.args[0].request_url, '/hello/world'); 62 | assert.strictEqual(callback.firstCall.args[0].query_data.page, 2); 63 | }); 64 | 65 | it('Should support passing through arrays from the query data', function () { 66 | var params = { 67 | journals: ['hortres', 'mtm', 'true', '7'] 68 | }; 69 | 70 | filter({}, { 71 | url: '/page?journals[]=hortres&journals[]=mtm', 72 | query: params 73 | }, {}, {}, callback); 74 | assert.isArray(callback.firstCall.args[0].query_data.journals); 75 | assert.strictEqual(callback.firstCall.args[0].query_data.journals.length, 4); 76 | assert.strictEqual(callback.firstCall.args[0].query_data.journals[0], 'hortres'); 77 | assert.strictEqual(callback.firstCall.args[0].query_data.journals[1], 'mtm'); 78 | assert.strictEqual(callback.firstCall.args[0].query_data.journals[2], true); 79 | assert.strictEqual(callback.firstCall.args[0].query_data.journals[3], 7); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/server/dust/date-format.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Helper: dateFormat', function () { 8 | it('Should render a date with the default format', function (done) { 9 | helper.render('{@dateFormat date="2012-11-23" /}', {}, function (err, dom, out) { 10 | assert.strictEqual('2012-11-23', out); 11 | done(); 12 | }); 13 | }); 14 | 15 | it('Should render a timestamp with the default format', function (done) { 16 | helper.render('{@dateFormat date="1353668840128" /}', {}, function (err, dom, out) { 17 | assert.strictEqual('2012-11-23', out); 18 | done(); 19 | }); 20 | }); 21 | 22 | it('Should render a timestamp with a custom format 1', function (done) { 23 | helper.render('{@dateFormat date="{date}" format="dd mmmm yyyy" /}', {date: (new Date('2012-11-23')).getTime()}, function (err, dom, out) { 24 | assert.strictEqual('23 November 2012', out); 25 | done(); 26 | }); 27 | }); 28 | 29 | it('Should render a timestamp with a custom format 2', function (done) { 30 | helper.render('{@dateFormat date="{date}" format="dd mmmm yyyy" /}', {date: (new Date('2012-11-01')).getTime()}, function (err, dom, out) { 31 | assert.strictEqual('01 November 2012', out); 32 | done(); 33 | }); 34 | }); 35 | 36 | it('Should render a timestamp with a custom format 3', function (done) { 37 | helper.render('{@dateFormat date="{date}" format="d mmmm yyyy" /}', {date: (new Date('2012-11-23')).getTime()}, function (err, dom, out) { 38 | assert.strictEqual('23 November 2012', out); 39 | done(); 40 | }); 41 | }); 42 | 43 | it('Should render a timestamp with a custom format 4', function (done) { 44 | helper.render('{@dateFormat date="{date}" format="d mmmm yyyy" /}', {date: (new Date('2012-11-01')).getTime()}, function (err, dom, out) { 45 | assert.strictEqual('1 November 2012', out); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('Should render the correct default value and format', function (done) { 51 | helper.render('{@dateFormat /}', {}, function (err, dom, out1) { 52 | helper.render('{@dateFormat date="{date}" format="yyyy-mm-dd" /}', {date: (new Date()).getTime()}, function (err, dom, out2) { 53 | assert.strictEqual(out1, out2); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | 59 | it('Should return an empty string for an invalid date 1', function (done) { 60 | helper.render('test{@dateFormat date="{date}" format="d mmmm yyyy" /}', {date: '2012-14-1000'}, function (err, dom, out) { 61 | assert.strictEqual('test', out); 62 | done(); 63 | }); 64 | }); 65 | 66 | it('Should return an empty string for an invalid date 2', function (done) { 67 | helper.render('test{@dateFormat date="{date}" format="d mmmm yyyy" /}', {date: 'hello'}, function (err, dom, out) { 68 | assert.strictEqual('test', out); 69 | done(); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/server/core/output-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var mockery = require('mockery'); 5 | var sinon = require('sinon'); 6 | 7 | describe('Output filtering', function () { 8 | var config; 9 | var filter; 10 | var eachModule; 11 | var filter1; 12 | var filter2; 13 | var filter3; 14 | var filter4; 15 | var filter5; 16 | 17 | beforeEach(function () { 18 | mockery.enable({ 19 | useCleanCache: true, 20 | warnOnUnregistered: false, 21 | warnOnReplace: false 22 | }); 23 | 24 | filter1 = sinon.spy(function (content) { 25 | return content + ':content'; 26 | }); 27 | filter2 = sinon.spy(function (content, contentType) { 28 | return content + ':contentType=' + contentType; 29 | }); 30 | filter3 = sinon.spy(function (content, contentType, req) { 31 | return content + ':req.url=' + req.url; 32 | }); 33 | filter4 = sinon.spy(function (content, contentType, req, config) { 34 | return content + ':config.foo=' + config.foo; 35 | }); 36 | filter5 = sinon.spy(function () { }); 37 | 38 | eachModule = require('../mocks/each-module'); 39 | mockery.registerMock('each-module', eachModule); 40 | 41 | eachModule.withArgs('/app/node_modules/shunter/filters/output').callsArgWith(1, null, filter1); 42 | eachModule.withArgs('/app/node_modules/foo/filters/output').callsArgWith(1, null, filter2); 43 | eachModule.withArgs('/app/node_modules/bar/filters/output').callsArgWith(1, null, filter3); 44 | eachModule.withArgs('/app/node_modules/baz/filters/output').callsArgWith(1, null, filter4); 45 | eachModule.withArgs('/app/filters/output').callsArgWith(1, null, filter5); 46 | 47 | config = { 48 | modules: ['foo', 'bar', 'baz'], 49 | path: { 50 | root: '/app', 51 | shunterRoot: '/app/node_modules/shunter' 52 | }, 53 | structure: { 54 | filters: 'filters', 55 | filtersOutput: 'output' 56 | }, 57 | foo: 'foo' 58 | }; 59 | filter = require('../../../lib/output-filter')(config); 60 | }); 61 | 62 | afterEach(function () { 63 | mockery.deregisterAll(); 64 | mockery.disable(); 65 | }); 66 | 67 | it('Should load filters from each of the expected locations', function () { 68 | assert.strictEqual(eachModule.callCount, 5); 69 | }); 70 | 71 | it('Should load filters in the expected order', function () { 72 | sinon.assert.callOrder( 73 | eachModule.withArgs('/app/node_modules/shunter/filters/output'), 74 | eachModule.withArgs('/app/node_modules/foo/filters/output'), 75 | eachModule.withArgs('/app/node_modules/bar/filters/output'), 76 | eachModule.withArgs('/app/node_modules/baz/filters/output'), 77 | eachModule.withArgs('/app/filters/output') 78 | ); 79 | }); 80 | 81 | it('Should call each filter with the correct args', function () { 82 | var result = filter('text', 'text/html', {url: '/foo'}); 83 | assert.strictEqual(result, 'text:content:contentType=text/html:req.url=/foo:config.foo=foo'); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /lib/dispatch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (config) { 4 | var statsd = require('./statsd')(config); 5 | var outputFilter = require('./output-filter')(config); 6 | var contentType = require('./content-type'); 7 | var http = require('http'); 8 | 9 | var getErrorMessage = function (err) { 10 | return err.message || err.toString(); 11 | }; 12 | 13 | var api = { 14 | send: function (err, out, req, res, status) { 15 | var timer = config.timer(); 16 | var mimeType; 17 | 18 | var logError = function (err, status) { 19 | config.log.error(err.stack ? err.stack : getErrorMessage(err)); 20 | statsd.increment('errors_' + status); 21 | }; 22 | 23 | var doSend = function (content) { 24 | var length = Buffer.byteLength(content); 25 | if (!mimeType) { 26 | mimeType = contentType(req.url, {charset: 'utf-8'}); 27 | } 28 | statsd.classifiedTiming(req.url, 'writing', timer('Writing response ' + req.url)); 29 | statsd.classifiedGauge(req.url, 'response_size', length); 30 | statsd.increment('requests'); 31 | 32 | res.setHeader('Content-type', mimeType); 33 | var chunked = res.getHeader('transfer-encoding') === 'chunked'; 34 | if (!chunked) { 35 | res.setHeader('Content-length', length); 36 | } 37 | res.writeHead(status); 38 | res.end(content); 39 | }; 40 | 41 | var getErrorContent = function (err, req, res) { 42 | mimeType = contentType('anything.html', {charset: 'utf-8'}); 43 | require('./error-pages')(config).getPage(err, req, res, function (content) { 44 | doSend(content || api.error(err, status)); 45 | }); 46 | }; 47 | 48 | if (!status) { 49 | status = 200; 50 | } 51 | 52 | if (err) { 53 | status = err.status || 500; 54 | getErrorContent(err, req, res); 55 | logError(err, status); 56 | } else if (req.isJson) { 57 | mimeType = 'application/json; charset=utf-8'; 58 | doSend(out); 59 | } else { 60 | doSend(outputFilter(out, contentType(req.url), req)); 61 | } 62 | }, 63 | error: function (err, status) { 64 | var statusMessage = http.STATUS_CODES[status] || 'Internal server error'; 65 | var out = [ 66 | '' + statusMessage + '', 67 | '', 68 | '
' + status + ' – ' + statusMessage + '
', 69 | '\n\n', 81 | '' 82 | ].join(''); 83 | 84 | return out; 85 | } 86 | }; 87 | 88 | return api; 89 | }; 90 | -------------------------------------------------------------------------------- /tests/server/core/renderer-whitespace.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var sinon = require('sinon'); 5 | 6 | var mockConfig = { 7 | env: { 8 | name: 'development' 9 | }, 10 | path: { 11 | root: __dirname, 12 | shunterRoot: __dirname, 13 | resources: __dirname, 14 | publicResources: __dirname 15 | }, 16 | structure: { 17 | filters: 'filters', 18 | filtersInput: 'input-filters', 19 | mincer: 'mincer', 20 | ejs: 'ejs', 21 | dust: 'dust', 22 | fonts: 'fonts', 23 | images: 'images', 24 | styles: 'styles', 25 | scripts: 'scripts' 26 | }, 27 | modules: [] 28 | }; 29 | 30 | describe('Whitespace Preservation Config', function () { 31 | it('preserves whitespace in a compiled template given a config option', function (done) { 32 | mockConfig.argv = { 33 | 'preserve-whitespace': true 34 | }; 35 | var renderer = require('../../../lib/renderer')(mockConfig); 36 | // Invoke the code that sets dust.mockConfig.whitespace based on our mockConfig 37 | require('../../../lib/dust')(renderer.dust, renderer, mockConfig); 38 | 39 | /* eslint-disable no-useless-concat */ 40 | var template = '{#data}{.} {/data}' + '\n\n'; 41 | var callback = sinon.stub(); 42 | 43 | renderer.dust.loadSource(renderer.dust.compile(template, 'test-template')); 44 | renderer.renderPartial('test-template', {}, {}, {data: ['a', 'b', 'c']}, callback); 45 | assert.strictEqual(callback.firstCall.args[1], 'a b c ' + '\n\n'); 46 | /* eslint-enable no-useless-concat */ 47 | 48 | done(); 49 | }); 50 | 51 | it('does not preserve whitespace in compiled template if preserve-whitespace config option is false', function (done) { 52 | mockConfig.argv = { 53 | 'preserve-whitespace': false 54 | }; 55 | var renderer = require('../../../lib/renderer')(mockConfig); 56 | // Invoke the code that sets dust.mockConfig.whitespace based on our mockConfig 57 | require('../../../lib/dust')(renderer.dust, renderer, mockConfig); 58 | 59 | /* eslint-disable no-useless-concat */ 60 | var template = '{#data}{.} {/data}' + '\n\n'; 61 | var callback = sinon.stub(); 62 | 63 | renderer.dust.loadSource(renderer.dust.compile(template, 'test-template')); 64 | renderer.renderPartial('test-template', {}, {}, {data: ['a', 'b', 'c']}, callback); 65 | assert.strictEqual(callback.firstCall.args[1], 'a b c '); 66 | /* eslint-enable no-useless-concat */ 67 | 68 | done(); 69 | }); 70 | 71 | it('removes whitespace in a compiled template by default', function (done) { 72 | mockConfig.argv = {}; 73 | var renderer = require('../../../lib/renderer')(mockConfig); 74 | // Invoke the code that sets dust.mockConfig.whitespace based on our mockConfig 75 | require('../../../lib/dust')(renderer.dust, renderer, mockConfig); 76 | 77 | /* eslint-disable no-useless-concat */ 78 | var template = '{#data}{.} {/data}' + '\n\n'; 79 | var callback = sinon.stub(); 80 | 81 | renderer.dust.loadSource(renderer.dust.compile(template, 'test-template')); 82 | renderer.renderPartial('test-template', {}, {}, {data: ['a', 'b', 'c']}, callback); 83 | assert.strictEqual(callback.firstCall.args[1], 'a b c '); 84 | /* eslint-disable no-useless-concat */ 85 | 86 | done(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /view/jserve/public/jserve.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | 4 | /* Typography */ 5 | 6 | body { 7 | font-size: 16px; 8 | color: #333; 9 | } 10 | 11 | h1, 12 | p, 13 | ul, 14 | pre { 15 | margin: 0; 16 | } 17 | 18 | h1 { 19 | font-weight: normal; 20 | margin-bottom: 22px; 21 | font-size: 22px; 22 | } 23 | h1:last-child { 24 | margin-bottom: 0; 25 | } 26 | 27 | p { 28 | margin-bottom: 22px; 29 | } 30 | .error-page p { 31 | margin-bottom: 0; 32 | } 33 | 34 | a { 35 | color: #647a34; 36 | } 37 | 38 | pre { 39 | padding: 10px; 40 | background-color: #eee; 41 | border-left: solid 5px #e3e3e3; 42 | font-family: monaco, Consolas, 'Lucida Console', monospace; 43 | font-size: 14px; 44 | } 45 | 46 | 47 | /* Utilities */ 48 | 49 | .hidden { 50 | display: none; 51 | } 52 | 53 | 54 | /* Layout */ 55 | 56 | body { 57 | background-color: #f3f3f3; 58 | } 59 | 60 | .page { 61 | margin: 22px auto; 62 | padding: 22px; 63 | padding-bottom: 30px; 64 | max-width: 600px; 65 | background-color: #fff; 66 | box-shadow: 0px 2px 60px -30px rgba(0, 0, 0, 0.6); 67 | } 68 | 69 | @media only screen and (max-width: 680px) { 70 | .page { 71 | margin: 0 auto; 72 | max-width: none; 73 | } 74 | } 75 | 76 | 77 | /* Forms */ 78 | 79 | form { 80 | display: block; 81 | margin-bottom: 22px; 82 | } 83 | label { 84 | display: block; 85 | margin-bottom: 5px; 86 | font-weight: bold; 87 | } 88 | .description { 89 | font-weight: normal; 90 | font-size: 14px; 91 | color: #999; 92 | } 93 | .text-input { 94 | width: 100%; 95 | display: block; 96 | margin-bottom: 10px; 97 | padding: 10px; 98 | box-sizing: border-box; 99 | border: solid 2px #e3e3e3; 100 | font-family: monospace; 101 | } 102 | .text-input:focus { 103 | outline: none; 104 | border-color: #91ae53; 105 | } 106 | textarea { 107 | min-height: 80px; 108 | } 109 | .button { 110 | display: block; 111 | margin-bottom: 0; 112 | padding: 10px; 113 | border: none; 114 | background: #e3e3e3; 115 | } 116 | .button:hover, 117 | .button:focus { 118 | outline: none; 119 | background: #91ae53; 120 | color: #000; 121 | } 122 | 123 | 124 | /* File Listing */ 125 | 126 | .file-listing { 127 | list-style: none; 128 | padding-left: 0; 129 | } 130 | 131 | .file { 132 | margin-bottom: 5px; 133 | line-height: 22px; 134 | } 135 | .file:last-child { 136 | margin-bottom: 0; 137 | } 138 | 139 | .file a { 140 | display: block; 141 | background-color: #eee; 142 | text-decoration: none; 143 | color: inherit; 144 | border-left: solid 5px #e3e3e3; 145 | transition: border .1s ease; 146 | } 147 | .file a:hover, 148 | .file a:focus { 149 | outline: none; 150 | border-color: #91ae53; 151 | } 152 | 153 | .file-name, 154 | .file-extension { 155 | display: block; 156 | padding: 10px; 157 | } 158 | 159 | .file-extension { 160 | display: block; 161 | float: right; 162 | background-color: #e3e3e3; 163 | text-transform: uppercase; 164 | font-size: 12px; 165 | color: #666; 166 | } 167 | 168 | .file-name-part { 169 | border-bottom: solid 2px transparent; 170 | transition: border .1s ease; 171 | } 172 | a:hover .file-name-part, 173 | a:focus .file-name-part { 174 | border-color: #ccc; 175 | } 176 | .file-name-part:after { 177 | content: " / "; 178 | color: #ccc; 179 | } 180 | .file-name-part:last-child:after { 181 | content: ""; 182 | } 183 | -------------------------------------------------------------------------------- /tests/server/dust/or.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Helper: or', function () { 8 | it('Should support a simple or test 1', function (done) { 9 | helper.render('{@or keys="foo|bar"}foobar{:else}baz{/or}', { 10 | foo: 'hello' 11 | }, function (err, dom, out) { 12 | assert.strictEqual('foobar', out); 13 | done(); 14 | }); 15 | }); 16 | 17 | it('Should support a simple or test 2', function (done) { 18 | helper.render('{@or keys="foo|bar"}foobar{:else}baz{/or}', { 19 | bar: 'hello' 20 | }, function (err, dom, out) { 21 | assert.strictEqual('foobar', out); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('Should support else in a simple or test', function (done) { 27 | helper.render('{@or keys="foo|bar"}foobar{:else}baz{/or}', { 28 | baz: 'hello' 29 | }, function (err, dom, out) { 30 | assert.strictEqual('baz', out); 31 | done(); 32 | }); 33 | }); 34 | 35 | it('Should support or with the not option 1', function (done) { 36 | helper.render('{@or keys="foo|bar" not="true"}foobar{:else}baz{/or}', { 37 | foo: 'hello', 38 | bar: 'world' 39 | }, function (err, dom, out) { 40 | assert.strictEqual('baz', out); 41 | done(); 42 | }); 43 | }); 44 | 45 | it('Should support or with the not option 2', function (done) { 46 | helper.render('{@or keys="foo|bar" not="true"}foobar{:else}baz{/or}', { 47 | foo: 'hello' 48 | }, function (err, dom, out) { 49 | assert.strictEqual('foobar', out); 50 | done(); 51 | }); 52 | }); 53 | 54 | it('Should treat an empty array as a falsy value (like dust) 1', function (done) { 55 | helper.render('{@or keys="foo|bar"}foobar{:else}baz{/or}', { 56 | foo: [], 57 | bar: 'hello' 58 | }, function (err, dom, out) { 59 | assert.strictEqual('foobar', out); 60 | done(); 61 | }); 62 | }); 63 | 64 | it('Should treat an empty array as a falsy value (like dust) 2', function (done) { 65 | helper.render('{@or keys="foo|bar"}foobar{:else}baz{/or}', { 66 | foo: [], 67 | bar: [] 68 | }, function (err, dom, out) { 69 | assert.strictEqual('baz', out); 70 | done(); 71 | }); 72 | }); 73 | 74 | it('Should support "or" with nested properties: 1 level', function (done) { 75 | helper.render('{@or keys="foo|bar.bash"}foobar{:else}baz{/or}', { 76 | bar: { 77 | bash: 1 78 | } 79 | }, function (err, dom, out) { 80 | assert.strictEqual('foobar', out); 81 | done(); 82 | }); 83 | }); 84 | 85 | it('Should support "or" with nested properties: 2 levels', function (done) { 86 | helper.render('{@or keys="foo|bar.bash.wibble"}foobar{:else}baz{/or}', { 87 | bar: { 88 | bash: { 89 | wibble: 1 90 | } 91 | } 92 | }, function (err, dom, out) { 93 | assert.strictEqual('foobar', out); 94 | done(); 95 | }); 96 | }); 97 | 98 | it('Should support "or" to not return true when only a parent property exists', function (done) { 99 | helper.render('{@or keys="foo|bar.bash.wibble"}foobar{:else}baz{/or}', { 100 | bar: { 101 | bash: 2 102 | } 103 | }, function (err, dom, out) { 104 | assert.strictEqual('baz', out); 105 | done(); 106 | }); 107 | }); 108 | 109 | it('Should support "or" to not return true when there is no parent object', function (done) { 110 | helper.render('{@or keys="foo|lorem.bash"}foobar{:else}baz{/or}', { 111 | bar: { 112 | bash: 2 113 | } 114 | }, function (err, dom, out) { 115 | assert.strictEqual('baz', out); 116 | done(); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (configArgument) { 4 | var cluster = require('cluster'); 5 | var fs = require('fs'); 6 | var path = require('path'); 7 | var config = require('./config')(process.env.NODE_ENV, configArgument); 8 | 9 | var SHUTDOWN_TIMEOUT = 10000; 10 | 11 | var init = function (count, callback) { 12 | cluster.on('exit', function (worker, code) { 13 | if (code !== 0) { 14 | config.log.error(worker.process.pid + ' died with error code ' + code + ', starting new worker...'); 15 | cluster.fork(); 16 | } 17 | }); 18 | 19 | var ready = 0; 20 | var listening = function () { 21 | if (++ready === count) { 22 | callback(); 23 | } 24 | }; 25 | 26 | for (var i = 0; i < count; ++i) { 27 | cluster.fork().on('listening', listening); 28 | } 29 | }; 30 | 31 | var restart = function () { 32 | var replace = function (workers) { 33 | if (workers.length === 0) { 34 | config.log.info('All replacement workers now running'); 35 | return; 36 | } 37 | 38 | var parasite = cluster.workers[workers[0]]; 39 | var worker = cluster.fork(); 40 | var pid = parasite.process.pid; 41 | var timeout; 42 | 43 | parasite.on('disconnect', function () { 44 | config.log.info('Shutdown complete for ' + pid); 45 | clearTimeout(timeout); 46 | }); 47 | parasite.disconnect(); 48 | timeout = setTimeout(function () { 49 | config.log.info('Timed out waiting for ' + pid + ' to disconnect, killing process'); 50 | parasite.send('force exit'); 51 | }, SHUTDOWN_TIMEOUT); 52 | 53 | worker.on('listening', function () { 54 | config.log.info('Created process ' + worker.process.pid + ' to replace ' + pid); 55 | replace(workers.slice(1)); 56 | }); 57 | }; 58 | 59 | replace(Object.keys(cluster.workers)); 60 | }; 61 | 62 | var saveProcessId = function () { 63 | var pid = process.pid; 64 | fs.writeFile(path.join(config.path.root, 'shunter.pid'), pid.toString(), function (err) { 65 | if (err) { 66 | config.log.error('Error saving shunter.pid file for process ' + pid + ' ' + (err.message || err.toString())); 67 | } else { 68 | config.log.debug('Saved shunter.pid file for process ' + pid); 69 | } 70 | }); 71 | }; 72 | 73 | var clearProcessId = function () { 74 | config.log.debug('Deleting old shunter.pid file'); 75 | fs.unlinkSync(path.join(config.path.root, 'shunter.pid')); 76 | }; 77 | 78 | var saveTimestamp = function () { 79 | fs.writeFileSync(path.join(config.path.shunterRoot, 'timestamp.json'), '{"value":' + Date.now() + '}'); 80 | }; 81 | 82 | return { 83 | use: function () { 84 | config.middleware.push(Array.prototype.slice.call(arguments)); 85 | }, 86 | start: function () { 87 | if (cluster.isMaster) { 88 | var childProcesses = Math.min( 89 | require('os').cpus().length, 90 | config.argv['max-child-processes'] 91 | ); 92 | saveTimestamp(); 93 | saveProcessId(); 94 | 95 | init(childProcesses, function () { 96 | config.log.info('Shunter started with ' + childProcesses + ' child processes listening'); 97 | }); 98 | 99 | process.on('SIGUSR2', function () { 100 | config.log.debug('SIGUSR2 received, reloading all workers'); 101 | saveTimestamp(); 102 | restart(); 103 | }); 104 | process.on('SIGINT', function () { 105 | config.log.debug('SIGINT received, exiting...'); 106 | process.exit(0); 107 | }); 108 | process.on('exit', function () { 109 | clearProcessId(); 110 | config.log.info('Goodbye!'); 111 | }); 112 | } else { 113 | require('./worker')(config); 114 | } 115 | 116 | return this; 117 | }, 118 | getConfig: function () { 119 | return config; 120 | } 121 | }; 122 | }; 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shunter", 3 | "version": "5.0.0", 4 | "license": "LGPL-3.0", 5 | "description": "A Node.js application built to read JSON and translate it into HTML", 6 | "keywords": [ 7 | "proxy", 8 | "front-end", 9 | "dust", 10 | "templates", 11 | "asset pipeline", 12 | "renderer" 13 | ], 14 | "author": "Springer Nature", 15 | "contributors": [ 16 | "Adam Tavener (http://www.tavvy.co.uk/)", 17 | "Alex Kilgour (http://kil.gr/)", 18 | "Andrew Mee (http://andrewmee.com/)", 19 | "Andrew Walker (http://www.moddular.org/)", 20 | "Ben Miles (https://github.com/benmiles)", 21 | "Craig Webster (http://barkingiguana.com/)", 22 | "Darren Oakley (http://hocuspokus.net/)", 23 | "Dawn Budge (http://www.dawnbudge.co.uk/)", 24 | "Ettore Berardi (http://www.ettomatic.com)", 25 | "Glynn Phillips (http://www.glynnphillips.co.uk/)", 26 | "Hollie Kay (http://www.hollsk.co.uk/)", 27 | "Jack Watkins (https://github.com/sky-jack)", 28 | "John Ollier (https://github.com/johnollier)", 29 | "Jorge Epuñan (http://www.csslab.cl/)", 30 | "José Bolos (https://github.com/joseluisbolos)", 31 | "Jude Robinson (https://github.com/dotcode)", 32 | "Perry Harlock (http://www.phwebs.co.uk/)", 33 | "Phil Booth (https://github.com/philbooth)", 34 | "Prayag Verma (http://www.prayagverma.com/)", 35 | "Rowan Manning (http://rowanmanning.com/)", 36 | "Squil (https://github.com/squil)", 37 | "Thomas Franquelin (https://github.com/ostapneko)", 38 | "Yomi Colledge (http://baphled.wordpress.com)", 39 | "Jon Whitlock (https://github.com/jpw)", 40 | "Allan Wazacz (https://github.com/cazwazacz)" 41 | ], 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/springernature/shunter.git" 45 | }, 46 | "homepage": "https://github.com/springernature/shunter", 47 | "bugs": "https://github.com/springernature/shunter/issues", 48 | "engines": { 49 | "node": ">=12 <=16" 50 | }, 51 | "dependencies": { 52 | "async": "3.2.4", 53 | "body-parser": "1.20.3", 54 | "cheerio": "1.0.0-rc.3", 55 | "connect": "3.7.0", 56 | "cookie-parser": "1.4.7", 57 | "csswring": "7.0.0", 58 | "dateformat": "3.0.3", 59 | "dustjs-helpers": "1.7.4", 60 | "dustjs-linkedin": "2.7.5", 61 | "each-module": "2.0.0", 62 | "ejs": "2.7.4", 63 | "extend": "3.0.2", 64 | "gaze": "1.1.3", 65 | "glob": "7.2.3", 66 | "hasbin": "1.2.3", 67 | "http-proxy": "1.18.1", 68 | "jserve": "2.0.3", 69 | "mincer": "2.1.0", 70 | "mocha-phantomjs-core": "2.1.2", 71 | "node-fetch": "2.7.0", 72 | "postcss": "5.2.17", 73 | "qs": "6.14.0", 74 | "qs-middleware": "1.0.3", 75 | "serve-static": "1.16.2", 76 | "statsd-client": "0.4.7", 77 | "uglify-js": "3.19.3", 78 | "wd": "1.14.0", 79 | "winston": "3.17.0", 80 | "winston-syslog": "2.7.1", 81 | "yargs": "7.1.2" 82 | }, 83 | "devDependencies": { 84 | "@springernature/eslint-config": "^4.0.3", 85 | "eslint": "^6.8.0", 86 | "eslint-plugin-import": "^2.25.4", 87 | "eslint-plugin-jest": "^23.20.0", 88 | "eslint-plugin-no-use-extend-native": "^0.4.1", 89 | "eslint-plugin-node": "^10.0.0", 90 | "eslint-plugin-promise": "^4.3.1", 91 | "eslint-plugin-unicorn": "^13.0.0", 92 | "mocha": "^9.2.2", 93 | "mockery": "^2.1.0", 94 | "node-sass": "^7.0.3", 95 | "nyc": "^15.1.0", 96 | "proclaim": "^3.6.0", 97 | "sinon": "^9.2.4" 98 | }, 99 | "main": "./lib/shunter.js", 100 | "bin": { 101 | "shunter-build": "./bin/compile.js", 102 | "shunter-compile": "./bin/compile.js", 103 | "shunter-serve": "./bin/serve.js" 104 | }, 105 | "scripts": { 106 | "lint": "eslint '**/*.js'", 107 | "test": "nyc mocha --config=./tests/.mocharc.json ./tests/server", 108 | "test-ci": "mocha --config=./tests/.mocharc.json ./tests/server" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/server/dust/and.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | describe('Dust Helper: and', function () { 8 | it('Should treat an empty array as a falsy value (like dust) 3', function (done) { 9 | helper.render('{@and keys="foo|bar"}foobar{:else}baz{/and}', { 10 | foo: [], 11 | bar: 'hello' 12 | }, function (err, dom, out) { 13 | assert.strictEqual('baz', out); 14 | done(); 15 | }); 16 | }); 17 | 18 | it('Should treat an empty array as a falsy value (like dust) 4', function (done) { 19 | helper.render('{@and keys="foo|bar"}foobar{:else}baz{/and}', { 20 | foo: [], 21 | bar: [] 22 | }, function (err, dom, out) { 23 | assert.strictEqual('baz', out); 24 | done(); 25 | }); 26 | }); 27 | 28 | it('Should support a simple and test 1', function (done) { 29 | helper.render('{@and keys="foo|bar"}foobar{:else}baz{/and}', { 30 | foo: 'hello' 31 | }, function (err, dom, out) { 32 | assert.strictEqual('baz', out); 33 | done(); 34 | }); 35 | }); 36 | 37 | it('Should support a simple and test 2', function (done) { 38 | helper.render('{@and keys="foo|bar"}foobar{:else}baz{/and}', { 39 | bar: 'hello' 40 | }, function (err, dom, out) { 41 | assert.strictEqual('baz', out); 42 | done(); 43 | }); 44 | }); 45 | 46 | it('Should support a simple and test 3', function (done) { 47 | helper.render('{@and keys="foo|bar"}foobar{:else}baz{/and}', { 48 | foo: 'hello', 49 | bar: 'world' 50 | }, function (err, dom, out) { 51 | assert.strictEqual('foobar', out); 52 | done(); 53 | }); 54 | }); 55 | 56 | it('Should support and with the not option 1', function (done) { 57 | helper.render('{@and keys="foo|bar" not="true"}foobar{:else}baz{/and}', { 58 | foo: 'hello', 59 | bar: 'world' 60 | }, function (err, dom, out) { 61 | assert.strictEqual('baz', out); 62 | done(); 63 | }); 64 | }); 65 | 66 | it('Should support and with the not option 2', function (done) { 67 | helper.render('{@and keys="foo|bar" not="true"}foobar{:else}baz{/and}', { 68 | foo: 'hello' 69 | }, function (err, dom, out) { 70 | assert.strictEqual('baz', out); 71 | done(); 72 | }); 73 | }); 74 | 75 | it('Should support and with the not option 3', function (done) { 76 | helper.render('{@and keys="foo|bar" not="true"}foobar{:else}baz{/and}', { 77 | baz: 'hello' 78 | }, function (err, dom, out) { 79 | assert.strictEqual('foobar', out); 80 | done(); 81 | }); 82 | }); 83 | it('Should support "and" with nested properties: 1 level', function (done) { 84 | helper.render('{@and keys="foo|bar.bash"}foobar{:else}baz{/and}', { 85 | foo: '1', 86 | bar: { 87 | bash: '2' 88 | } 89 | }, function (err, dom, out) { 90 | assert.strictEqual('foobar', out); 91 | done(); 92 | }); 93 | }); 94 | it('Should support "and" with nested properties: 2 levels', function (done) { 95 | helper.render('{@and keys="foo|bar.bash.wibble"}foobar{:else}baz{/and}', { 96 | foo: '1', 97 | bar: { 98 | bash: { 99 | wibble: '3' 100 | } 101 | } 102 | }, function (err, dom, out) { 103 | assert.strictEqual('foobar', out); 104 | done(); 105 | }); 106 | }); 107 | it('Should support "and" to not return true for parents of nested properties', function (done) { 108 | helper.render('{@and keys="foo|bar.bash.wibble"}foobar{:else}baz{/and}', { 109 | foo: '1', 110 | bar: { 111 | bash: '3' 112 | } 113 | }, function (err, dom, out) { 114 | assert.strictEqual('baz', out); 115 | done(); 116 | }); 117 | }); 118 | it('Should support "and" returning false if parent object is undefined', function (done) { 119 | helper.render('{@and keys="foo|lorem.bash"}foobar{:else}baz{/and}', { 120 | foo: '1', 121 | bar: { 122 | bash: '3' 123 | } 124 | }, function (err, dom, out) { 125 | assert.strictEqual('baz', out); 126 | done(); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /tests/server/templates/namespace.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var assert = require('proclaim'); 5 | var helper = require('../../helpers/template.js')(); 6 | 7 | var rootDir = __dirname.slice(0, __dirname.indexOf('/tests/')); 8 | var templateDir = path.join(rootDir, 'view', 'tests'); 9 | 10 | describe('Template overriding', function () { 11 | before(function () { 12 | helper.setup( 13 | path.join(templateDir, 'namespace.dust'), 14 | path.join(templateDir, 'a', 'b', 'loading.dust'), 15 | path.join(templateDir, 'a', 'loading.dust'), 16 | path.join(templateDir, 'b', 'loading.dust'), 17 | path.join(templateDir, 'loading.dust') 18 | ); 19 | }); 20 | after(helper.teardown); 21 | 22 | it('Should load templates relative to the namespace `tests`', function (done) { 23 | var json = { 24 | layout: { 25 | namespace: 'tests' 26 | } 27 | }; 28 | helper.render('namespace', json, function (err, $) { 29 | assert.isNull(err); 30 | 31 | var expected = [ 32 | { 33 | template: 'loading', 34 | path: 'view/tests/loading.dust' 35 | }, 36 | { 37 | template: 'a__loading', 38 | path: 'view/tests/a/loading.dust' 39 | }, 40 | { 41 | template: 'b__loading', 42 | path: 'view/tests/b/loading.dust' 43 | }, 44 | { 45 | template: 'a__b__loading', 46 | path: 'view/tests/a/b/loading.dust' 47 | } 48 | ]; 49 | 50 | $('[data-test="templates"]').children('dd').each(function (i, item) { 51 | assert.strictEqual(expected[i].path, $(item).text(), 'Loading template ' + expected[i].template); 52 | }); 53 | done(); 54 | }); 55 | }); 56 | 57 | it('Should load templates relative to the namespace `tests__a`', function (done) { 58 | var json = { 59 | layout: { 60 | namespace: 'tests__a' 61 | } 62 | }; 63 | helper.render('namespace', json, function (err, $) { 64 | assert.isNull(err); 65 | 66 | var expected = [ 67 | { 68 | template: 'loading', 69 | path: 'view/tests/a/loading.dust' 70 | }, 71 | { 72 | template: 'a__loading', 73 | path: 'view/tests/a/loading.dust' 74 | }, 75 | { 76 | template: 'b__loading', 77 | path: 'view/tests/a/b/loading.dust' 78 | }, 79 | { 80 | template: 'a__b__loading', 81 | path: 'view/tests/a/b/loading.dust' 82 | } 83 | ]; 84 | 85 | $('[data-test="templates"]').children('dd').each(function (i, item) { 86 | assert.strictEqual(expected[i].path, $(item).text(), 'Loading template ' + expected[i].template); 87 | }); 88 | done(); 89 | }); 90 | }); 91 | 92 | it('Should load templates relative to the namespace `tests__b`', function (done) { 93 | var json = { 94 | layout: { 95 | namespace: 'tests__b' 96 | } 97 | }; 98 | helper.render('namespace', json, function (err, $) { 99 | assert.isNull(err); 100 | 101 | var expected = [ 102 | { 103 | template: 'loading', 104 | path: 'view/tests/b/loading.dust' 105 | }, 106 | { 107 | template: 'a__loading', 108 | path: 'view/tests/a/loading.dust' 109 | }, 110 | { 111 | template: 'b__loading', 112 | path: 'view/tests/b/loading.dust' 113 | }, 114 | { 115 | template: 'a__b__loading', 116 | path: 'view/tests/a/b/loading.dust' 117 | } 118 | ]; 119 | 120 | $('[data-test="templates"]').children('dd').each(function (i, item) { 121 | assert.strictEqual(expected[i].path, $(item).text(), 'Loading template ' + expected[i].template); 122 | }); 123 | done(); 124 | }); 125 | }); 126 | 127 | it('Should load templates relative to the namespace `tests__a__b`', function (done) { 128 | var json = { 129 | layout: { 130 | namespace: 'tests__a__b' 131 | } 132 | }; 133 | helper.render('namespace', json, function (err, $) { 134 | assert.isNull(err); 135 | 136 | var expected = [ 137 | { 138 | template: 'loading', 139 | path: 'view/tests/a/b/loading.dust' 140 | }, 141 | { 142 | template: 'a__loading', 143 | path: 'view/tests/a/loading.dust' 144 | }, 145 | { 146 | template: 'b__loading', 147 | path: 'view/tests/a/b/loading.dust' 148 | }, 149 | { 150 | template: 'a__b__loading', 151 | path: 'view/tests/a/b/loading.dust' 152 | } 153 | ]; 154 | 155 | $('[data-test="templates"]').children('dd').each(function (i, item) { 156 | assert.strictEqual(expected[i].path, $(item).text(), 'Loading template ' + expected[i].template); 157 | }); 158 | done(); 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/client/lib/mocha.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | body { 3 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | padding: 60px 50px; 5 | } 6 | 7 | #mocha ul, #mocha li { 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | #mocha ul { 13 | list-style: none; 14 | } 15 | 16 | #mocha h1, #mocha h2 { 17 | margin: 0; 18 | } 19 | 20 | #mocha h1 { 21 | margin-top: 15px; 22 | font-size: 1em; 23 | font-weight: 200; 24 | } 25 | 26 | #mocha h1 a { 27 | text-decoration: none; 28 | color: inherit; 29 | } 30 | 31 | #mocha h1 a:hover { 32 | text-decoration: underline; 33 | } 34 | 35 | #mocha .suite .suite h1 { 36 | margin-top: 0; 37 | font-size: .8em; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | #mocha h2 { 45 | font-size: 12px; 46 | font-weight: normal; 47 | cursor: pointer; 48 | } 49 | 50 | #mocha .suite { 51 | margin-left: 15px; 52 | } 53 | 54 | #mocha .test { 55 | margin-left: 15px; 56 | } 57 | 58 | #mocha .test.pending:hover h2::after { 59 | content: '(pending)'; 60 | font-family: arial; 61 | } 62 | 63 | #mocha .test.pass.medium .duration { 64 | background: #C09853; 65 | } 66 | 67 | #mocha .test.pass.slow .duration { 68 | background: #B94A48; 69 | } 70 | 71 | #mocha .test.pass::before { 72 | content: '✓'; 73 | font-size: 12px; 74 | display: block; 75 | float: left; 76 | margin-right: 5px; 77 | color: #00d6b2; 78 | } 79 | 80 | #mocha .test.pass .duration { 81 | font-size: 9px; 82 | margin-left: 5px; 83 | padding: 2px 5px; 84 | color: white; 85 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 86 | -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 87 | box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 88 | -webkit-border-radius: 5px; 89 | -moz-border-radius: 5px; 90 | -ms-border-radius: 5px; 91 | -o-border-radius: 5px; 92 | border-radius: 5px; 93 | } 94 | 95 | #mocha .test.pass.fast .duration { 96 | display: none; 97 | } 98 | 99 | #mocha .test.pending { 100 | color: #0b97c4; 101 | } 102 | 103 | #mocha .test.pending::before { 104 | content: '◦'; 105 | color: #0b97c4; 106 | } 107 | 108 | #mocha .test.fail { 109 | color: #c00; 110 | } 111 | 112 | #mocha .test.fail pre { 113 | color: black; 114 | } 115 | 116 | #mocha .test.fail::before { 117 | content: '✖'; 118 | font-size: 12px; 119 | display: block; 120 | float: left; 121 | margin-right: 5px; 122 | color: #c00; 123 | } 124 | 125 | #mocha .test pre.error { 126 | color: #c00; 127 | max-height: 300px; 128 | overflow: auto; 129 | } 130 | 131 | #mocha .test pre { 132 | display: inline-block; 133 | font: 12px/1.5 monaco, monospace; 134 | margin: 5px; 135 | padding: 15px; 136 | border: 1px solid #eee; 137 | border-bottom-color: #ddd; 138 | -webkit-border-radius: 3px; 139 | -webkit-box-shadow: 0 1px 3px #eee; 140 | -moz-border-radius: 3px; 141 | -moz-box-shadow: 0 1px 3px #eee; 142 | } 143 | 144 | #mocha .test h2 { 145 | position: relative; 146 | } 147 | 148 | #mocha .test a.replay { 149 | position: absolute; 150 | top: 3px; 151 | right: -20px; 152 | text-decoration: none; 153 | vertical-align: middle; 154 | display: block; 155 | width: 15px; 156 | height: 15px; 157 | line-height: 15px; 158 | text-align: center; 159 | background: #eee; 160 | font-size: 15px; 161 | -moz-border-radius: 15px; 162 | border-radius: 15px; 163 | -webkit-transition: opacity 200ms; 164 | -moz-transition: opacity 200ms; 165 | transition: opacity 200ms; 166 | opacity: 0.2; 167 | color: #888; 168 | } 169 | 170 | #mocha .test:hover a.replay { 171 | opacity: 1; 172 | } 173 | 174 | #mocha-report.pass .test.fail { 175 | display: none; 176 | } 177 | 178 | #mocha-report.fail .test.pass { 179 | display: none; 180 | } 181 | 182 | #mocha-error { 183 | color: #c00; 184 | font-size: 1.5 em; 185 | font-weight: 100; 186 | letter-spacing: 1px; 187 | } 188 | 189 | #mocha-stats { 190 | position: fixed; 191 | top: 15px; 192 | right: 10px; 193 | font-size: 12px; 194 | margin: 0; 195 | color: #888; 196 | } 197 | 198 | #mocha-stats .progress { 199 | float: right; 200 | padding-top: 0; 201 | } 202 | 203 | #mocha-stats em { 204 | color: black; 205 | } 206 | 207 | #mocha-stats a { 208 | text-decoration: none; 209 | color: inherit; 210 | } 211 | 212 | #mocha-stats a:hover { 213 | border-bottom: 1px solid #eee; 214 | } 215 | 216 | #mocha-stats li { 217 | display: inline-block; 218 | margin: 0 5px; 219 | list-style: none; 220 | padding-top: 11px; 221 | } 222 | 223 | code .comment { color: #ddd } 224 | code .init { color: #2F6FAD } 225 | code .string { color: #5890AD } 226 | code .keyword { color: #8A6343 } 227 | code .number { color: #2F6FAD } 228 | -------------------------------------------------------------------------------- /bin/serve.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | var path = require('path'); 5 | var jserve = require('jserve'); 6 | var query = require('qs-middleware'); 7 | var yargs = require('yargs'); 8 | var fetch = require('node-fetch'); 9 | 10 | // Parse command-line arguments 11 | var args = yargs 12 | .options('p', { 13 | alias: 'port', 14 | nargs: 1, 15 | default: 5401, 16 | type: 'number', 17 | describe: 'Port number' 18 | }) 19 | .options('d', { 20 | alias: 'data', 21 | nargs: 1, 22 | default: './data', 23 | type: 'string', 24 | describe: 'The path to look for sample data in' 25 | }) 26 | .options('l', { 27 | alias: 'latency', 28 | nargs: 1, 29 | default: 0, 30 | type: 'number', 31 | describe: 'Add milliseconds of latency to the request' 32 | }) 33 | .options('i', { 34 | alias: 'index', 35 | default: false, 36 | type: 'boolean', 37 | describe: 'Serve static JSON at the index path' 38 | }) 39 | .options('q', { 40 | alias: 'query', 41 | default: false, 42 | type: 'boolean', 43 | describe: 'Handle query parameters in request path' 44 | }) 45 | .alias('h', 'help') 46 | .help() 47 | .argv; 48 | 49 | // Resolve the data directory against CWD if it's relative 50 | if (args.data && !/^[/~]/.test(args.data)) { 51 | args.data = path.resolve(process.cwd(), args.data); 52 | } 53 | 54 | // Create a JServe application 55 | // See https://github.com/rowanmanning/jserve 56 | var app = jserve({ 57 | contentType: 'application/x-shunter+json', 58 | log: { 59 | debug: console.log.bind(console), 60 | error: console.error.bind(console), 61 | info: console.log.bind(console) 62 | }, 63 | middleware: [ 64 | interceptIndex, 65 | query(), 66 | handleQueryParameters, 67 | addLatency, 68 | serveRemoteJson 69 | ], 70 | name: 'Shunter Serve', 71 | path: args.data, 72 | port: args.port, 73 | templatesPath: path.join(__dirname, '/../view/jserve') 74 | }); 75 | 76 | // Start the JServe application 77 | app.start(); 78 | 79 | // Middleware to serve JSON at the index route 80 | // eg. a request to / will return data/index.json 81 | function interceptIndex(request, response, next) { 82 | if (args.index === true && request.path === '/') { 83 | request.path = '/index.json'; 84 | } 85 | return next(); 86 | } 87 | 88 | // Middleware to handle query parameters in the request 89 | // eg. a request to /search?q=hello&count=10 will return data/search/q_count.json 90 | function handleQueryParameters(request, response, next) { 91 | if (args.query === true && Object.keys(request.query).length > 0) { 92 | request.path += '/' + Object.keys(request.query).join('_') + '.json'; 93 | } 94 | return next(); 95 | } 96 | 97 | // Middleware to add latency to a response 98 | function addLatency(request, response, next) { 99 | if (request.path === '/') { 100 | return next(); 101 | } 102 | setTimeout(next, args.latency); 103 | } 104 | 105 | // Middleware to serve remote JSON 106 | function serveRemoteJson(request, response, next) { 107 | if (request.path !== '/remote') { 108 | return next(); 109 | } 110 | var options = { 111 | url: request.query.url, 112 | headers: request.query.headers 113 | }; 114 | var error; 115 | 116 | if (!options.url || typeof options.url !== 'string') { 117 | error = new Error('Invalid query parameter: url'); 118 | error.status = 400; 119 | return next(error); 120 | } 121 | 122 | if (options.headers && typeof options.headers !== 'string') { 123 | error = new Error('Invalid query parameter: headers'); 124 | error.status = 400; 125 | return next(error); 126 | } 127 | 128 | options.headers = parseHeaders(options.headers); 129 | 130 | loadRemoteJson(options, function (error, json) { 131 | if (error) { 132 | return next(error); 133 | } 134 | response.writeHead(200, { 135 | 'Content-Type': 'application/x-shunter+json' 136 | }); 137 | response.end(JSON.stringify(json, null, 4)); 138 | }); 139 | } 140 | 141 | // Load remote JSON 142 | function loadRemoteJson(options, done) { 143 | function checkResponseStatus(response) { 144 | if (!response.ok) { // .ok = response.status >= 200 && response.status < 300 145 | var error = new Error('Remote JSON responded with ' + response.status + ' status'); 146 | error.status = response.statusCode; 147 | return done(error); 148 | } 149 | return response; 150 | } 151 | 152 | fetch(options.url) 153 | .then(checkResponseStatus) 154 | .then(function (response) { 155 | return response.json(); 156 | }) 157 | .then(function (response) { 158 | done(null, response); 159 | }) 160 | .catch(function (error) { 161 | return done(error); 162 | }); 163 | } 164 | 165 | // Parse a HTTP header string 166 | function parseHeaders(headerString) { 167 | var headers = {}; 168 | var headersArray = headerString.split(/[\r\n]+/); 169 | headersArray.forEach(function (headerString) { 170 | var headerChunks = headerString.split(':'); 171 | headers[headerChunks.shift().trim()] = headerChunks.join(':').trim(); 172 | }); 173 | return headers; 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Shunter](docs/shunter-logo.png) 2 | 3 | Shunter is a [Node.js][node] module built to read JSON and translate it into HTML. 4 | 5 | It helps you create a loosely-coupled front end application which can serve traffic from one or more back end applications — great for use in multi-language, multi-disciplinary teams, or just to make your project more flexible and future-proofed. 6 | 7 | Shunter does not contain an API client, or any Controller logic (in the [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) sense). Instead, Shunter simply proxies requests to a back end server, then: 8 | 9 | 1. If the back end wants Shunter to render the response, it returns the application state as JSON, served with a certain HTTP header. This initiates the templating process in Shunter. 10 | ![Diagram of Shunter intercepting a JSON backend reply](docs/img/shunter-json-intercept.png) 11 | 12 | 2. If the back end wishes to serve the response, it omits the header and Shunter proxies the request back to the client. 13 | ![Diagram of Shunter proxying a backend reply](docs/img/shunter-backend-proxy.png) 14 | 15 | 3. Shunter is also able to serve resources like CSS, JS, or images bundled with the templates as part of your application. 16 | ![Diagram of Shunter serving a bundled asset](docs/img/shunter-assets.png) 17 | 18 | [![NPM version][shield-npm]][info-npm] 19 | [![Node.js version support][shield-node]][info-node] 20 | ![GH Actions build status badge](https://github.com/springernature/shunter/actions/workflows/test-on-push-and-pull.yml/badge.svg) 21 | [![LGPL-3.0 licensed][shield-license]][info-license] 22 | 23 | ## Key features 24 | 25 | * Allows the creation of templates loosely coupled to the underlying back end applications. 26 | * Enables multiple back end applications to use the same unified front end. 27 | * Makes full site redesigns or swapping out back end applications very easy. 28 | * Completely technology-agnostic; if your application outputs JSON, it can work with Shunter. 29 | * Asset concatenation, minification, cache-busting, and other performance optimisations built-in. 30 | * Can output any type of content you like, e.g. HTML, RSS, RDF, etc. 31 | 32 | ## Getting started 33 | 34 | You can find all the details about how to use Shunter in our [documentation](docs/index.md). If you're new to Shunter, we recommend reading the [Getting Started Guide](docs/getting-started.md). This will teach you the basics, and how to create your first Shunter-based application. 35 | 36 | ## Requirements 37 | 38 | The latest version of Shunter requires [Node.js][node] v12-16. 39 | 40 | See the [Getting started documentation](docs/getting-started.md#prerequisites) for more information on Shunter's requirements. 41 | 42 | Instructions for [installing Node.js](https://nodejs.org/en/download) are available on their website. 43 | 44 | ## Support and migration 45 | 46 | Shunter supports various versions of Node.js: 47 | 48 | | Major Version | Last Feature Release | Node Versions Supported | 49 | | :------------ | :------------------- | :--------------- | 50 | | 5 | N/A | >=12 <=16 | 51 | | 4 | 4.13 | >=4 <=8 | 52 | | 3 | 3.8 | >=0.10 <=5 | 53 | 54 | _Versions 1 and 2 of Shunter were not public releases._ 55 | 56 | If you're migrating between major versions of Shunter, we maintain a [migration guide](docs/migration/index.md) to help you. 57 | 58 | If you'd like to know more about how we support our open source projects, including the full release process, check out our [support practices document][support]. 59 | 60 | ## Contributing 61 | 62 | We'd love for you to contribute to Shunter. We maintain a [guide to help developers](docs/developer-guide.md) get started with working on Shunter itself. It outlines the structure of the library and some of the development practices we uphold. 63 | 64 | We also label [issues that might be a good starting-point][starter-issues] for new developers to the project. 65 | 66 | ## License 67 | 68 | Shunter is licensed under the [Lesser General Public License (LGPL-3.0)][info-license]. 69 | 70 | Copyright © 2025, Springer Nature 71 | 72 | [brew]: http://mxcl.github.com/homebrew/ 73 | [node]: https://nodejs.org/ 74 | [npm]: https://www.npmjs.com/ 75 | [nvm]: https://github.com/nvm-sh/nvm 76 | [starter-issues]: https://github.com/springernature/shunter/labels/good-starter-issue 77 | [support]: https://github.com/springernature/frontend-playbook/blob/main/practices/open-source-support.md 78 | 79 | [info-coverage]: https://coveralls.io/github/springernature/shunter 80 | [info-dependencies]: https://gemnasium.com/springernature/shunter 81 | [info-license]: LICENSE 82 | [info-node]: package.json 83 | [info-npm]: https://www.npmjs.com/package/shunter 84 | [shield-coverage]: https://img.shields.io/coveralls/springernature/shunter.svg 85 | [shield-dependencies]: https://img.shields.io/gemnasium/springernature/shunter.svg 86 | [shield-license]: https://img.shields.io/badge/license-LGPL%203.0-blue.svg 87 | [shield-node]: https://img.shields.io/badge/node.js%20support-10–14-brightgreen.svg 88 | [shield-npm]: https://img.shields.io/npm/v/shunter.svg 89 | -------------------------------------------------------------------------------- /docs/routing.md: -------------------------------------------------------------------------------- 1 | # Routing 2 | 3 | * [Examples](#examples) 4 | * [Route Config Options](#route-config-options) 5 | * [Route Override](#route-override) 6 | * [Change Origin](#change-origin) 7 | 8 | When Shunter receives an incoming request it needs to know where the request should be proxied to. This is configured by setting a `routes` property when you create your shunter app, typically by requiring a config file: 9 | 10 | ```js 11 | var app = shunter({ 12 | routes: require('./config/routes.json'), 13 | ... 14 | }); 15 | ``` 16 | 17 | ## Examples 18 | 19 | The config is used to match the incoming hostname and request url and match it to a proxy target. 20 | 21 | ```json 22 | { 23 | "www.example.com": { 24 | "/^\\/blog/": { 25 | "host": "blog.example.com", 26 | "port": 80 27 | }, 28 | "/^\\/demo/": { 29 | "host": "demo.example.com" 30 | }, 31 | "/^\\/about/": { 32 | "host": "about.example.com", 33 | "port": 1337 34 | }, 35 | "default": { 36 | "host": "cms.example.com", 37 | "port": 8080 38 | } 39 | }, 40 | "test-www.example.com": { 41 | "/^\\/blog/": { 42 | "host": "test-blog.example.com", 43 | "port": 80 44 | }, 45 | "default": { 46 | "host": "test-cms.example.com", 47 | "port": 8080 48 | } 49 | }, 50 | "localhost": { 51 | "default": { 52 | "host": "127.0.0.1", 53 | "port": 5000 54 | } 55 | } 56 | } 57 | ``` 58 | 59 | The table shows how different requests would be mapped using this config: 60 | 61 | | Host Header | Request Url | Proxy Destination | 62 | | :--------------------- | :-------------------- | :--------------------------------------------- | 63 | | `www.example.com` | `/blog/article123` | `blog.example.com:80/blog/article123` | 64 | | `www.example.com` | `/demo/test` | `demo.example.com/demo/test` | 65 | | `www.example.com` | `/about/contact.html` | `about.example.com:1337/about/contact.html` | 66 | | `www.example.com` | `/foo` | `cms.example.com:8080/foo` | 67 | | `test-www.example.com` | `/blog/article123` | `test-blog.example.com:80/blog/article123` | 68 | | `test-www.example.com` | `/about/contact.html` | `test-cms.example.com:8080/about/contact.html` | 69 | | `test-www.example.com` | `/foo` | `test-cms.example.com:8080/foo` | 70 | | `foo.example.com` | `/foo/bar` | `127.0.0.1:5000/foo/bar` | 71 | 72 | ## Route Config Options 73 | 74 | By default if none of the regex patterns are matched Shunter will use the route under the `default` key. The name of the default key can be configured by providing the `route-config` option when you start your shunter app. So if you had the config: 75 | 76 | ```json 77 | { 78 | "www.example.com": { 79 | "custom": { 80 | "host": "127.0.0.1", 81 | "port": 1337 82 | }, 83 | "default": { 84 | "host": "127.0.0.1", 85 | "port": 5000 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | And ran your Shunter app with `--route-config=custom` requests would be routed to port 1337 instead of 5000. 92 | 93 | ## Route Override 94 | 95 | Routing can be overridden entirely by setting the `route-override` option. Running your Shunter app with `--route-override=http://www.example.com:1337` would route all requests to that destination. You could also route to an IPv4: `--route-override=8.8.8.8:80` 96 | 97 | If you do not specify a protocol (`http` or `https`) when setting a route via `route-override`, then it will default to `http`. Please also note that `https` is currently unsupported by Shunter. 98 | 99 | ```shell 100 | --route-override=$BACKEND_URL 101 | ``` 102 | 103 | | BACKEND_URL | Proxy Destination | 104 | | :------------------------ | :-------------------------- | 105 | | `http://www.example.com` | `http://www.example.com` | 106 | | `https://foo.example.com` | `https://foo.example.com` | 107 | | `www.example.com` | `http://www.example.com` | 108 | | `www.example.com:80` | `http://www.example.com:80` | 109 | | `localhost` | `http://localhost` | 110 | | `127.0.0.1:5000` | `http://127.0.0.1:5000` | 111 | | `8.8.8.8` | `http://8.8.8.8` | 112 | | `https://8.8.8.8:80` | `https://8.8.8.8:80` | 113 | 114 | You can use the `--origin-override` (`-g`) option in conjunction with the `route-override` option to set `changeOrigin: true` for the overriding route. See [Change Origin](#change-origin) for more details. 115 | 116 | This would be useful when deploying your Shunter app to a platform that makes use of environment variables. For example you could start up a Shunter app with: 117 | 118 | ```shell 119 | node app -p $PORT --route-override=$BACKEND_APP --origin-override 120 | ``` 121 | 122 | ## Change Origin 123 | 124 | In addition to setting the host and port for the proxy target there is an additional `changeOrigin` option that can be used. When this is set to true (it defaults to false) Shunter will update the host header to match the destination server. So with the following config: 125 | 126 | ```json 127 | { 128 | "www.example.com": { 129 | "default": { 130 | "host": "cms.example.com", 131 | "port": 8080, 132 | "changeOrigin": true 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | The application on cms.example.com would receive requests with a host header of `cms.example.com` instead of `www.example.com`. The original host header will be passed through to the backend in an `X-Orig-Host` header. 139 | -------------------------------------------------------------------------------- /tests/server/integration/lib/servers-under-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var spawn = require('child_process').spawn; 5 | var httpRequest = require('./http-request'); 6 | 7 | var processes = []; 8 | // debugMode logs to console, and does not close servers after test run 9 | // so you can check http://127.0.0.1:5400/home manually 10 | var debugMode = false; 11 | 12 | function allProcessesUp() { 13 | return processes.every(function (process) { 14 | return process.__isUp === true; 15 | }); 16 | } 17 | 18 | // handles events common for both server processes, on stderr stdout etc. 19 | var handleEventsForProcess = function (process, resolve, reject) { 20 | process.__isUp = false; 21 | 22 | // we cannot tell if a child process has finished spawing (no such event exists), 23 | // so we have to wait for both of them to output something. And pray they aren't 24 | // changed to start silently (unlikely, but if so, the tests will timeout). 25 | process.stdout.on('data', function (data) { 26 | if (allProcessesUp()) { 27 | if (debugMode) { 28 | console.log(`${process.pid} stdout: ${data}`); 29 | } 30 | return; 31 | } 32 | 33 | process.__isUp = true; 34 | 35 | if (processes.length > 1 && allProcessesUp()) { 36 | resolve(); 37 | } 38 | }); 39 | 40 | process.stderr.on('data', function (data) { 41 | console.log(`${process.pid} stderr: ${data}`); 42 | reject(); 43 | }); 44 | }; 45 | 46 | // starts the asset compilation process, run before the servers start. 47 | // returns a Promise which resolves when Uglification console logs its finished message 48 | var startCompilation = function () { 49 | return new Promise(function (resolve, reject) { 50 | // run asset compilation script (as if in a production env) 51 | // to make compiled assets available to tests 52 | var build = spawn('node', ['../../bin/compile.js'], { 53 | cwd: 'tests/mock-app/' 54 | }); 55 | 56 | build.stdout.on('data', function (data) { 57 | if (debugMode) { 58 | console.log(`${process.pid} stdout: ${data}`); 59 | } 60 | if (data.includes('Uglifying main.js took')) { 61 | resolve(); 62 | } 63 | }); 64 | 65 | build.stderr.on('data', function (data) { 66 | console.log(`${process.pid} stderr: ${data}`); 67 | reject(); 68 | }); 69 | }); 70 | }; 71 | 72 | // starts the fe and be servers 73 | // returns a Promise which resolves when both have output to their STDOUTs 74 | var startServers = function () { 75 | return new Promise(function (resolve, reject) { 76 | var backend = spawn('node', ['../../bin/serve.js'], { 77 | cwd: 'tests/mock-app/' 78 | }); 79 | processes.push(backend); 80 | handleEventsForProcess(backend, resolve, reject); 81 | 82 | // start the FE with one worker process (-c 1) in production mode, so it uses 83 | // the assets previously built by the build script 84 | var thisEnv = process.env; 85 | thisEnv.NODE_ENV = 'production'; 86 | var frontend = spawn('node', ['app', '-c', '1'], { 87 | cwd: 'tests/mock-app/', 88 | env: thisEnv 89 | }); 90 | processes.push(frontend); 91 | handleEventsForProcess(frontend, resolve, reject); 92 | }); 93 | }; 94 | 95 | // ping the FE server 96 | // returns a Promise which resolves once it recieves a pong response 97 | // or rejects on an unexpected error, or if maxTries exceeded 98 | var serversResponding = function () { 99 | return new Promise(function (resolve, reject) { 100 | var tries = 0; 101 | var maxTries = 1000; 102 | var doPingLoop = function () { 103 | var thisRequestPromise = httpRequest({ 104 | port: 5400, 105 | path: '/ping' 106 | }); 107 | thisRequestPromise 108 | .then(function (res) { 109 | if (res.text.includes('pong')) { 110 | resolve(tries); 111 | } 112 | }) 113 | .catch(function (err) { 114 | tries++; 115 | setTimeout(function () { 116 | if (err.message.includes('ECONNREFUSED') && tries < maxTries) { 117 | // server is still starting up 118 | doPingLoop(); 119 | } else { 120 | reject(err); 121 | } 122 | }, 100); // do not pound the CPU, it's busy 123 | }); 124 | }; 125 | doPingLoop(); 126 | }); 127 | }; 128 | 129 | var rmTestArtifacts = function () { 130 | // clean up resources from compilation stage 131 | try { 132 | // This will throw a DeprecationWarning in nodes 16+ 133 | fs.rmdirSync('./tests/mock-app/public/resources', {recursive: true}); 134 | } catch (error) { 135 | if (error.code !== 'ENOENT') { // no such file or dir 136 | console.error(error); 137 | } 138 | } 139 | }; 140 | 141 | var cleanup = function () { 142 | if (debugMode) { 143 | return; 144 | } 145 | rmTestArtifacts(); 146 | 147 | try { 148 | processes.forEach(function (process) { 149 | process.kill('SIGINT'); 150 | }); 151 | } catch (err) { 152 | console.error(err); 153 | } 154 | 155 | processes = []; 156 | }; 157 | 158 | module.exports = { 159 | // starts up the servers, and pings the FE server 160 | // returns a Promise that resolves once the FE pongs back 161 | readyForTest: function () { 162 | rmTestArtifacts(); 163 | 164 | return new Promise(function (resolve, reject) { 165 | startCompilation() 166 | .then(startServers) 167 | .then(serversResponding) 168 | .then(function () { 169 | resolve(); 170 | }) 171 | .catch(function (err) { 172 | console.error(err); 173 | reject(err); 174 | cleanup(); // tear down the test suite if we cant run smoke tests 175 | }); 176 | }); 177 | }, 178 | finish: function () { 179 | cleanup(); 180 | } 181 | }; 182 | -------------------------------------------------------------------------------- /bin/compile.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var async = require('async'); 7 | var glob = require('glob'); 8 | var uglify = require('uglify-js'); 9 | var yargs = require('yargs'); 10 | 11 | var argv = yargs 12 | .options('x', { 13 | alias: 'extra-js', 14 | type: 'string' 15 | }) 16 | .options('r', { 17 | alias: 'resource-module', 18 | type: 'string' 19 | }) 20 | .alias('h', 'help') 21 | .help() 22 | .describe({ 23 | x: 'Extra JS paths to minify', 24 | r: 'Name of modules with resources to include in the build, can specify several of these flags' 25 | }) 26 | .argv; 27 | 28 | var config = require('../lib/config')('production', null, {}); 29 | 30 | // All files named main.js will be minified, extra files to minify can be specified here 31 | var EXTRA_MINIFY_PATHS = []; 32 | if (argv['extra-js']) { 33 | EXTRA_MINIFY_PATHS = Array.isArray(argv['extra-js']) ? argv['extra-js'] : [argv['extra-js']]; 34 | } 35 | 36 | var resourceModules; 37 | if (argv['resource-module']) { 38 | resourceModules = ( 39 | Array.isArray(argv['resource-module']) ? 40 | argv['resource-module'] : 41 | [argv['resource-module']] 42 | ); 43 | config.modules = resourceModules; 44 | } 45 | 46 | var renderer = require('../lib/renderer')(config); 47 | 48 | var environment = renderer.environment; 49 | var manifest = renderer.manifest; 50 | 51 | environment.cssCompressor = 'csswring'; 52 | 53 | // Crude listener for errors, replace with domain once that is stable 54 | process.on('uncaughtException', function (err) { 55 | console.error('Caught exception: ' + err); 56 | process.exit(128); 57 | }); 58 | 59 | var compile = function (data, callback) { 60 | var findAssets = function () { 61 | var pattern = new RegExp('.(' + [].slice.call(arguments, 0).join('|') + ')$'); 62 | return Object.keys(data.assets).filter(function (key) { 63 | return key.match(pattern); 64 | }); 65 | }; 66 | 67 | var deleteAsset = function (name) { 68 | delete manifest.assets[name]; 69 | return null; 70 | }; 71 | 72 | var stylesheets = findAssets('css').map(function (name) { 73 | var asset = environment.findAsset(name); 74 | var content = asset ? asset.toString() : null; 75 | 76 | if (!content) { 77 | return deleteAsset(name); 78 | } 79 | 80 | return { 81 | path: config.path.publicResources + '/' + data.assets[name], 82 | content: content 83 | }; 84 | }).filter(function (stylesheet) { 85 | return stylesheet !== null; 86 | }); 87 | 88 | var jsToMinify = ['main'].concat(EXTRA_MINIFY_PATHS); 89 | 90 | var javascripts = findAssets('js').filter(function (name) { 91 | for (var i = 0; jsToMinify[i]; ++i) { 92 | if (name.includes(jsToMinify[i])) { 93 | return true; 94 | } 95 | } 96 | return false; 97 | }).map(function (name) { 98 | var asset = environment.findAsset(name); 99 | var content = asset ? asset.toString() : null; 100 | var start; 101 | var end; 102 | 103 | if (!content) { 104 | return deleteAsset(name); 105 | } 106 | start = new Date(); 107 | content = uglify.minify(content).code; 108 | end = new Date(); 109 | // Note: suspect this part of the process is timing out on build, extra logging to test 110 | console.log('Uglifying ' + name + ' took ' + (end - start) + 'ms'); 111 | 112 | return { 113 | path: config.path.publicResources + '/' + data.assets[name], 114 | content: content 115 | }; 116 | }).filter(function (script) { 117 | return script !== null; 118 | }); 119 | // Save the updated stylesheets and javascripts, then save the manifest 120 | async.map(stylesheets.concat(javascripts), function (resource, fn) { 121 | console.log('Writing resource to ' + resource.path); 122 | fs.writeFile(resource.path, resource.content, 'utf8', fn); 123 | }, function () { 124 | manifest.save(callback); 125 | }); 126 | }; 127 | 128 | var generate = function (callback) { 129 | async.waterfall([ 130 | function (fn) { 131 | fs.mkdir(config.path.publicResources, {recursive: true}, fn); 132 | }, 133 | function (dir, fn) { 134 | // Glob returns absolute path and we need to strip that out 135 | var readGlobDir = function (p, cb) { 136 | var pth = p.replace(/\\\?/g, '/'); // Glob must use / as path separator even on windows 137 | glob(pth + '/**/*.*', function (er, files) { 138 | if (er) { 139 | return cb(er); 140 | } 141 | return cb(null, files.map(function (f) { 142 | return path.relative(p, f); 143 | })); 144 | }); 145 | }; 146 | // Returns a flat array of files with relative paths 147 | async.concat(environment.paths, readGlobDir, fn); 148 | }, 149 | function (files) { 150 | var data = null; 151 | try { 152 | data = manifest.compile(files.filter(function (file) { 153 | return /(?:\.([^.]+))?$/.exec(file) === 'scss'; 154 | })); 155 | } catch (err) { 156 | callback(err, null); 157 | } 158 | if (data) { 159 | compile(data, callback); 160 | } 161 | }, 162 | function (files) { 163 | var data = null; 164 | try { 165 | data = manifest.compile(files.map(function (file) { 166 | return file.replace(/\.ejs$/, ''); 167 | })); 168 | } catch (err) { 169 | callback(err, null); 170 | } 171 | if (data) { 172 | compile(data, callback); 173 | } 174 | } 175 | ]); 176 | }; 177 | 178 | generate(function (err) { 179 | if (err) { 180 | console.error('Failed to generate manifest: ' + (err.message || err.toString())); 181 | process.exit(128); 182 | } else { 183 | console.log('Manifest compiled'); 184 | process.exit(0); 185 | } 186 | }); 187 | -------------------------------------------------------------------------------- /tests/server/core/dispatch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var assert = require('proclaim'); 5 | var sinon = require('sinon'); 6 | var mockery = require('mockery'); 7 | var newSpyResponse = require('../mocks/response'); 8 | 9 | var moduleName = '../../../lib/dispatch'; 10 | 11 | describe('Dispatching response', function () { 12 | var config; 13 | var createFilter; 14 | var filter; 15 | var req; 16 | var res; 17 | 18 | beforeEach(function () { 19 | mockery.enable({ 20 | useCleanCache: true, 21 | warnOnUnregistered: false, 22 | warnOnReplace: false 23 | }); 24 | 25 | filter = sinon.stub().returnsArg(0); 26 | createFilter = sinon.stub().returns(filter); 27 | 28 | mockery.registerMock('./output-filter', createFilter); 29 | req = require('../mocks/request'); 30 | req.url = '/hello'; 31 | res = newSpyResponse(); 32 | 33 | mockery.registerMock('mincer', require('../mocks/mincer')); 34 | mockery.registerMock('each-module', require('../mocks/each-module')); 35 | 36 | config = { 37 | argv: {}, 38 | log: require('../mocks/log'), 39 | timer: sinon.stub().returns(sinon.stub()), 40 | env: { 41 | isDevelopment: sinon.stub().returns(false), 42 | isProduction: sinon.stub().returns(true), 43 | tier: sinon.stub().returns('ci'), 44 | host: sinon.stub().returns('ci') 45 | }, 46 | path: { 47 | root: '/', 48 | shunterRoot: path.join(path.dirname(__dirname), '../../'), 49 | resources: '/resources', 50 | publicResources: '/public/resources', 51 | templates: '/view', 52 | dust: '/dust' 53 | }, 54 | modules: [ 55 | 'shunter' 56 | ], 57 | structure: { 58 | resources: 'resources', 59 | styles: 'css', 60 | images: 'img', 61 | scripts: 'js', 62 | fonts: 'fonts', 63 | templates: 'view', 64 | dust: 'dust', 65 | templateExt: '.dust', 66 | filters: 'filters', 67 | filtersInput: 'input', 68 | ejs: 'ejs', 69 | mincer: 'mincer' 70 | } 71 | }; 72 | }); 73 | afterEach(function () { 74 | mockery.deregisterAll(); 75 | mockery.disable(); 76 | }); 77 | 78 | it('Should set the content type header', function () { 79 | var dispatch = require(moduleName)(config); 80 | 81 | dispatch.send(null, 'output to send', req, res); 82 | assert.isTrue(res.setHeader.calledWith('Content-type', 'text/html; charset=utf-8')); 83 | }); 84 | 85 | it('Should set the correct content type if the json parameter is set', function () { 86 | var dispatch = require(moduleName)(config); 87 | req.isJson = true; 88 | 89 | dispatch.send(null, '{"foo": "bar"}', req, res, 200); 90 | assert.isTrue(res.setHeader.calledWith('Content-type', 'application/json; charset=utf-8')); 91 | assert.isTrue(res.writeHead.calledOnce); 92 | assert.isTrue(res.writeHead.calledWith(200)); 93 | assert.isTrue(res.end.calledOnce); 94 | }); 95 | 96 | it('Should take multibyte characaters into account when setting the content length header', function () { 97 | var dispatch = require(moduleName)(config); 98 | 99 | dispatch.send(null, 'hello¡', req, res); 100 | assert.isTrue(res.setHeader.calledWith('Content-length', 7)); 101 | }); 102 | 103 | it('Should default to a 200 status if there was no error', function () { 104 | var dispatch = require(moduleName)(config); 105 | 106 | dispatch.send(null, 'output to send', req, res); 107 | assert.isTrue(res.writeHead.calledOnce); 108 | assert.isTrue(res.writeHead.calledWith(200)); 109 | assert.isTrue(res.end.calledOnce); 110 | }); 111 | 112 | it('Should allow the status code to be passed to it', function () { 113 | var dispatch = require(moduleName)(config); 114 | 115 | dispatch.send(null, 'not allowed', req, res, 401); 116 | assert.isTrue(res.writeHead.calledOnce); 117 | assert.isTrue(res.writeHead.calledWith(401)); 118 | assert.isTrue(res.end.calledOnce); 119 | }); 120 | 121 | it('Should set a 500 status if there was an error', function () { 122 | var dispatch = require(moduleName)(config); 123 | 124 | dispatch.send({message: 'fail'}, 'output to send', req, res, 200); 125 | assert.isTrue(res.writeHead.calledOnce); 126 | assert.isTrue(res.writeHead.calledWith(500)); 127 | assert.isTrue(res.end.calledOnce); 128 | }); 129 | 130 | it('Should log.error something if using the default config and there was an error', function () { 131 | var dispatch = require(moduleName)(config); 132 | 133 | dispatch.send({message: 'fail'}, 'output to send', req, res); 134 | assert.isTrue(config.log.error.calledOnce); 135 | }); 136 | 137 | it('Should set "text/html" Content-type if returning an HTML error page regardless of req file extension', function () { 138 | var dispatch = require(moduleName)(config); 139 | req.url = '404s.txt'; 140 | dispatch.send({message: 'fail'}, 'output to send', req, res); 141 | 142 | assert.isTrue(res.setHeader.calledWith('Content-type', 'text/html; charset=utf-8')); 143 | }); 144 | 145 | it('Should not log.error something if using the default config and there was no error', function () { 146 | var dispatch = require(moduleName)(config); 147 | 148 | dispatch.send(null, 'output to send', req, res, 200); 149 | assert.isFalse(config.log.error.called); 150 | }); 151 | 152 | it('Should log.error something if using custom error pages and there was an error', function () { 153 | mockery.registerMock('./error-pages', require('../mocks/error-pages')); 154 | config.errorPages = { 155 | errorLayouts: { 156 | default: 'layout' 157 | } 158 | }; 159 | var dispatch = require(moduleName)(config); 160 | 161 | dispatch.send({message: 'fail'}, 'output to send', req, res, 200); 162 | assert.isTrue(config.log.error.calledOnce); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /tests/server/core/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | 5 | var moduleName = '../../../lib/router'; 6 | var config; 7 | 8 | describe('Proxy routing', function () { 9 | beforeEach(function () { 10 | config = {}; 11 | config.routes = { 12 | localhost: { 13 | '/\\/test\\/.*/': { 14 | host: 'test-www.nature.com', 15 | port: 80 16 | }, 17 | '/\\/test\\/foo/': { 18 | host: 'test-www.nature.com', 19 | port: 81 20 | }, 21 | '/\\/foo\\/bar/': { 22 | host: 'staging-www.nature.com', 23 | port: 82 24 | }, 25 | capybara: { 26 | host: 'test-capybara', 27 | port: 22789 28 | }, 29 | default: { 30 | host: '127.0.0.1', 31 | port: 5410 32 | } 33 | } 34 | }; 35 | config.argv = { 36 | 'route-config': '' 37 | }; 38 | config.log = require('../mocks/log'); 39 | }); 40 | 41 | afterEach(function () { 42 | config = {}; 43 | }); 44 | 45 | it('Should create a routes object from config objects\'s contents', function () { 46 | config.routes = { 47 | localhost: { 48 | '/foo/': 'bar' 49 | } 50 | }; 51 | var router = require(moduleName)(config); 52 | var route1 = router.map('localhost', 'foo'); 53 | assert.equal(route1, 'bar'); 54 | }); 55 | 56 | it('Should get the route from the given host if possible', function () { 57 | config.routes = { 58 | 'www.nature.com': { 59 | '/foo/': 'success' 60 | }, 61 | localhost: { 62 | '/foo/': 'fail' 63 | } 64 | }; 65 | var router = require(moduleName)(config); 66 | var route1 = router.map('www.nature.com', 'foo'); 67 | assert.equal(route1, 'success'); 68 | }); 69 | 70 | it('Should ignore the port 80', function () { 71 | config.routes = { 72 | 'www.nature.com': { 73 | '/foo/': 'success' 74 | }, 75 | localhost: { 76 | '/foo/': 'fail' 77 | } 78 | }; 79 | var router = require(moduleName)(config); 80 | var route1 = router.map('www.nature.com:80', 'foo'); 81 | assert.equal(route1, 'success'); 82 | }); 83 | 84 | it('Should fallback to localhost if the host doesn\'t match', function () { 85 | config.routes = { 86 | 'www.nature.com': { 87 | '/foo/': 'fail' 88 | }, 89 | localhost: { 90 | '/foo/': 'success' 91 | } 92 | }; 93 | var router = require(moduleName)(config); 94 | var route1 = router.map('test-www.nature.com', 'foo'); 95 | assert.equal(route1, 'success'); 96 | }); 97 | 98 | it('Should return null if the host doesn\'t match and no localhost routes are defined', function () { 99 | config.routes = { 100 | 'www.nature.com': { 101 | '/foo/': 'fail' 102 | } 103 | }; 104 | var router = require(moduleName)(config); 105 | var route = router.map('test-www.nature.com', 'foo'); 106 | assert.isNull(route); 107 | }); 108 | 109 | it('Should map a url to the first matched rule', function () { 110 | var router = require(moduleName)(config); 111 | var route1 = router.map('localhost', '/test/foo'); 112 | assert.equal(route1.host, 'test-www.nature.com'); 113 | assert.equal(route1.port, 80); 114 | var route2 = router.map('localhost', '/foo/bar'); 115 | assert.equal(route2.host, 'staging-www.nature.com'); 116 | assert.equal(route2.port, 82); 117 | }); 118 | 119 | it('Should map a url that doesn\'t match any of the rules to the default', function () { 120 | var route = require(moduleName)(config).map('localhost', '/'); 121 | assert.equal(route.host, '127.0.0.1'); 122 | assert.equal(route.port, 5410); 123 | }); 124 | 125 | it('Should allow the default route to be configured', function () { 126 | config.argv = { 127 | 'route-config': 'capybara' 128 | }; 129 | var route = require(moduleName)(config).map('localhost', '/'); 130 | assert.equal(route.host, 'test-capybara'); 131 | assert.equal(route.port, 22789); 132 | }); 133 | 134 | describe('Should set the default route from that specified in the config options', function () { 135 | it('Should set a route with a protocol, hostname and port', function () { 136 | config.argv = { 137 | 'route-override': 'https://foo.dev.bar-baz.com:80' 138 | }; 139 | var route = require(moduleName)(config).map('localhost', '/'); 140 | assert.equal(route.protocol, 'https:'); 141 | assert.equal(route.host, 'foo.dev.bar-baz.com'); 142 | assert.equal(route.port, 80); 143 | }); 144 | 145 | it('Should set a route with an IPv4 address and port, defaulting the protocol to http', function () { 146 | config.argv = { 147 | 'route-override': '127.0.0.1:9000' 148 | }; 149 | var route = require(moduleName)(config).map('localhost', '/'); 150 | assert.equal(route.protocol, 'http:'); 151 | assert.equal(route.host, '127.0.0.1'); 152 | assert.equal(route.port, 9000); 153 | }); 154 | 155 | it('Should set a route with a hostname defaulting the protocol to http', function () { 156 | config.argv = { 157 | 'route-override': 'localhost' 158 | }; 159 | var route = require(moduleName)(config).map('localhost', '/'); 160 | assert.equal(route.protocol, 'http:'); 161 | assert.equal(route.host, 'localhost'); 162 | assert.equal(route.port, null); 163 | }); 164 | }); 165 | 166 | it('Should set the default route and changeOrigin state if specified in the config options', function () { 167 | config.argv = { 168 | 'route-override': '127.0.0.1:9000', 169 | 'origin-override': true 170 | }; 171 | var route = require(moduleName)(config).map('localhost', '/'); 172 | assert.equal(route.host, '127.0.0.1'); 173 | assert.equal(route.port, 9000); 174 | assert.equal(route.changeOrigin, true); 175 | }); 176 | 177 | it('Should not match a named route against the url', function () { 178 | var route = require(moduleName)(config).map('localhost', '/capybara'); 179 | assert.equal(route.host, '127.0.0.1'); 180 | assert.equal(route.port, 5410); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (env, config, args) { 4 | config = config || {}; 5 | 6 | env = env || process.env.NODE_ENV || 'development'; 7 | 8 | var hostname = require('os').hostname(); 9 | var path = require('path'); 10 | var yargs = require('yargs'); 11 | var extend = require('extend'); 12 | var fs = require('fs'); 13 | var shunterRoot = path.dirname(__dirname); 14 | 15 | args = args || yargs 16 | .options('c', { 17 | alias: 'max-child-processes', 18 | default: 10, 19 | type: 'number' 20 | }) 21 | .options('d', { 22 | alias: 'source-directory', 23 | default: process.cwd(), 24 | type: 'string' 25 | }) 26 | .options('g', { 27 | alias: 'origin-override', 28 | type: 'boolean' 29 | }) 30 | .options('l', { 31 | alias: 'logging', 32 | default: 'info', 33 | type: 'string' 34 | }) 35 | .options('m', { 36 | alias: 'max-post-size', 37 | default: 204800, 38 | type: 'number' 39 | }) 40 | .options('o', { 41 | alias: 'route-override', 42 | type: 'string' 43 | }) 44 | .options('p', { 45 | alias: 'port', 46 | default: 5400, 47 | type: 'number' 48 | }) 49 | .options('r', { 50 | alias: 'route-config', 51 | default: 'default' 52 | }) 53 | .options('s', { 54 | alias: 'syslog', 55 | type: 'boolean' 56 | }) 57 | .options('w', { 58 | alias: 'preserve-whitespace', 59 | type: 'boolean' 60 | }) 61 | .options('compile-on-demand', { 62 | type: 'boolean' 63 | }) 64 | .options('mount-path', { 65 | type: 'string', 66 | default: '' 67 | }) 68 | .options('rewrite-protocol', { 69 | type: 'string' 70 | }) 71 | .options('rewrite-redirect', { 72 | type: 'boolean' 73 | }) 74 | .describe({ 75 | c: 'Shunter will create one worker process per cpu available up to this maximum', 76 | d: 'Specify the directory for the main app if you are not running it from its own directory', 77 | g: 'Requires --route-override. Sets changeOrigin to true for the route set up via --route-override', 78 | l: 'Set logging level', 79 | m: 'Maximum size for request body in bytes', 80 | o: 'Specify host and port to override or replace route config file', 81 | p: 'Port number', 82 | r: 'Specify the name of the default route from your route config file', 83 | s: 'Enable logging to syslog', 84 | w: 'Preserves whitespace in HTML output', 85 | 'compile-on-demand': 'Compile templates on demand instead of at application start up, only recommended in development mode', 86 | 'mount-path': 'defaults to "/", set it to "/bla" to serve requests from "/bla/path"', 87 | 'rewrite-protocol': 'Rewrite the location protocol on 301, 302, 307 & 308 redirects to http or https', 88 | 'rewrite-redirect': 'Rewrite the location host/port on 301, 302, 307 & 308 redirects based on requested host/port' 89 | }) 90 | .alias('h', 'help') 91 | .help() 92 | .alias('v', 'version') 93 | .version(function () { 94 | return require('../package').version; 95 | }) 96 | .check(function (argv, args) { 97 | var exclude = ['_', '$0']; 98 | 99 | Object.keys(argv).forEach(function (key) { 100 | if (!exclude.includes(key) && !Object.prototype.hasOwnProperty.call(args, key)) { 101 | throw new Error('Unknown argument error: `' + key + '` is not a valid argument'); 102 | } 103 | }); 104 | Object.keys(args).forEach(function (key) { 105 | if (Array.isArray(argv[key])) { 106 | throw new TypeError('Invalid argument error: `' + key + '` must only be specified once'); 107 | } 108 | }); 109 | return true; 110 | }) 111 | .argv; 112 | 113 | var appRoot = args['source-directory'] || process.cwd(); 114 | 115 | var defaultConfig = { 116 | argv: args, 117 | env: { 118 | host: function () { 119 | return hostname; 120 | }, 121 | isDevelopment: function () { 122 | return this.name === 'development'; 123 | }, 124 | isProduction: function () { 125 | return this.name === 'production'; 126 | }, 127 | name: env 128 | }, 129 | jsonViewParameter: null, 130 | log: null, 131 | middleware: [], 132 | modules: [], 133 | path: { 134 | dust: path.join(appRoot, 'dust'), 135 | public: path.join(appRoot, 'public'), 136 | publicResources: path.join(appRoot, 'public', 'resources'), 137 | resources: path.join(appRoot, 'resources'), 138 | root: appRoot, 139 | shunterRoot: shunterRoot, 140 | tests: path.join(appRoot, 'tests') 141 | }, 142 | statsd: { 143 | host: 'localhost', 144 | mock: env === 'development', 145 | prefix: 'shunter.' 146 | }, 147 | structure: { 148 | dust: 'dust', 149 | ejs: 'ejs', 150 | filters: 'filters', 151 | filtersInput: 'input', 152 | filtersOutput: 'output', 153 | fonts: 'fonts', 154 | images: 'img', 155 | logging: 'logging', 156 | loggingTransports: 'transports', 157 | mincer: 'mincer', 158 | resources: 'resources', 159 | scripts: 'js', 160 | styles: 'css', 161 | templateExt: '.dust', 162 | templates: 'view', 163 | tests: 'tests' 164 | }, 165 | trigger: { 166 | header: 'Content-type', 167 | matchExpression: 'application/x-shunter\\+json' 168 | }, 169 | timer: function () { 170 | var start = Date.now(); 171 | return function (msg) { 172 | var diff = Date.now() - start; 173 | config.log.debug(msg + ' - ' + diff + 'ms'); 174 | return diff; 175 | }; 176 | }, 177 | web: { 178 | public: '/public', 179 | publicResources: '/public/resources', 180 | resources: '/resources', 181 | tests: '/tests' 182 | } 183 | }; 184 | 185 | config = extend(true, {}, defaultConfig, config); 186 | var localConfig = path.join(appRoot, 'config', 'local.json'); 187 | if (fs.existsSync(localConfig)) { 188 | extend(true, config, require(localConfig)); 189 | } 190 | 191 | if (!config.log) { 192 | config.log = require('./logging')(config).getLogger(); 193 | } 194 | 195 | return config; 196 | }; 197 | -------------------------------------------------------------------------------- /docs/contributing-to-shunter.md: -------------------------------------------------------------------------------- 1 | # Contributing to Shunter 2 | 3 | This guide is here to help new developers get started on contributing to the development of Shunter itself. It will outline the structure of the library and some of the development practices we uphold. 4 | 5 | If you're looking for information on how to _use_ Shunter, please see the [documentation](index.md). 6 | 7 | - [Library structure](#library-structure) 8 | - [Issue tracking](#issue-tracking) 9 | - [Testing](#testing) 10 | - [Static analysis](#static-analysis) 11 | - [Versioning and releases](#versioning-and-releases) 12 | 13 | ## Library structure 14 | 15 | The main files that comprise Shunter live in the `lib` folder. Shunter has been broken up into several smaller modules which serve different purposes. We'll outline the basics here: 16 | 17 | * [`benchmark.js`](https://github.com/springernature/shunter/blob/main/lib/benchmark.js) exports a middleware which is used to benchmark request times. 18 | * [`config.js`](https://github.com/springernature/shunter/blob/main/lib/config.js) contains the default application configuration and code to merge defaults with the user config. 19 | * [`content-type.js`](https://github.com/springernature/shunter/blob/main/lib/content-type.js) is a small utility used to infer the content-type of a URL based on its file extension. 20 | * [`dispatch.js`](https://github.com/springernature/shunter/blob/main/lib/dispatch.js) applies output filters to the content and returns the response to the client. 21 | * [`dust.js`](https://github.com/springernature/shunter/blob/main/lib/dust.js) handles the application's Dust instance and registers some default helpers. 22 | * [`input-filter.js`](https://github.com/springernature/shunter/blob/main/lib/input-filter.js) handles the loading and application of input filters. 23 | * [`output-filter.js`](https://github.com/springernature/shunter/blob/main/lib/output-filter.js) handles the loading and application of output filters. 24 | * [`processor.js`](https://github.com/springernature/shunter/blob/main/lib/processor.js) exports the middlewares Shunter uses for interacting with the request/response cycle. 25 | * [`query.js`](https://github.com/springernature/shunter/blob/main/lib/query.js) exports a middleware which attaches a parsed query string object to the request. 26 | * [`renderer.js`](https://github.com/springernature/shunter/blob/main/lib/renderer.js) handles compilation and rendering of Dust templates. 27 | * [`router.js`](https://github.com/springernature/shunter/blob/main/lib/router.js) parses the route configuration and routes requests to the correct back end application. 28 | * [`server.js`](https://github.com/springernature/shunter/blob/main/lib/server.js) manages the lifecycle of the worker processes Shunter uses to serve requests. 29 | * [`shunter.js`](https://github.com/springernature/shunter/blob/main/lib/shunter.js) exports everything required for a Shunter application, and is the main entry-point. 30 | * [`statsd.js`](https://github.com/springernature/shunter/blob/main/lib/statsd.js) wraps a StatsD instance which is used to record application metrics. 31 | * [`watcher.js`](https://github.com/springernature/shunter/blob/main/lib/watcher.js) is a utility to watch a tree of files and reload them on change. 32 | * [`worker.js`](https://github.com/springernature/shunter/blob/main/lib/worker.js) creates a Connect app to handle requests with the Shunter middlewares added to the stack. Instances of this app are run in each process managed by `server.js`. 33 | 34 | ## Issue tracking 35 | 36 | We use [GitHub issues](https://github.com/springernature/shunter/issues) to log bugs and feature requests. This is a great place to look if you're interested in working on Shunter. 37 | 38 | If you're going to pick up a piece of work, check the comments to make sure nobody else has started on it. If you're going to do it, say so in the issue comments. 39 | 40 | We use labels extensively to categorise issues, so you should be able to find something that suits your mood. We also label [issues that might be a good starting-point](https://github.com/springernature/shunter/labels/good-starter-issue) for new developers to the project. 41 | 42 | If you're logging a new bug or feature request, please be as descriptive as possible. Include steps to reproduce and a reduced test case if applicable. 43 | 44 | ## Testing 45 | 46 | We maintain a fairly complete set of test suites for Shunter, and these get run on every pull-request and commit to the default branch. It's useful to also run these locally when you're working on Shunter. 47 | 48 | To run all the tests, you can use: 49 | 50 | ```shell 51 | make test 52 | ``` 53 | 54 | To run all the tests and linters together (exactly as we run on a [Continuous Integration (CI) system](https://en.wikipedia.org/wiki/Continuous_integration)), you can use: 55 | 56 | ```shell 57 | make ci 58 | ``` 59 | 60 | If you're developing new features or refactoring, make sure that your code is covered by unit tests. The `tests` directory mirrors the directory structure of the main application so that it's clear where each test belongs. 61 | 62 | ## Static analysis 63 | 64 | As well as unit testing, we also lint our JavaScript code with [JSHint](http://jshint.com/) and [JSCS](http://jscs.info/). This keeps everything consistent and readable. 65 | 66 | To run the linters, you can use: 67 | 68 | ```shell 69 | make lint 70 | ``` 71 | 72 | ## Versioning and releases 73 | 74 | Most of the time, one of the core developers will decide when a release is ready to go out. You shouldn't take this upon yourself without discussing with the team. 75 | 76 | Shunter is versioned with [semver](http://semver.org/). You should read through the [semver documentation](http://semver.org) if you're versioning Shunter. 77 | 78 | You can check the details of our release process in our [Open Source support guide](https://github.com/springernature/frontend-playbook/blob/main/practices/open-source-support.md#release-process) in the [Springer Nature frontend playbook](https://github.com/springernature/frontend-playbook). 79 | -------------------------------------------------------------------------------- /tests/server/core/dust.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var sinon = require('sinon'); 6 | 7 | describe('Template loading', function () { 8 | var dust = require('dustjs-helpers'); 9 | var mockConfig = { 10 | argv: {}, 11 | log: require('../mocks/log') 12 | }; 13 | var mockRenderer = { 14 | TEMPLATE_CACHE_KEY_PREFIX: 'root', 15 | compileOnDemand: sinon.stub() 16 | }; 17 | var options; 18 | var callback; 19 | 20 | var createMockCache = function (obj) { 21 | var cache = {}; 22 | Object.keys(obj).forEach(function (key) { 23 | cache[mockRenderer.TEMPLATE_CACHE_KEY_PREFIX + '__' + key] = obj[key]; 24 | }); 25 | return cache; 26 | }; 27 | 28 | require('../../../lib/dust')(dust, mockRenderer, mockConfig); 29 | 30 | beforeEach(function () { 31 | dust.cache = {}; 32 | options = {}; 33 | callback = sinon.stub(); 34 | }); 35 | afterEach(function () { 36 | mockConfig.log.warn.resetHistory(); 37 | mockRenderer.compileOnDemand.resetHistory(); 38 | }); 39 | 40 | it('Should log and gracefully handle missing templates', function () { 41 | dust.onLoad('nonexistent', options, callback); 42 | assert(mockConfig.log.warn.calledOnce); 43 | assert.match(mockConfig.log.warn.lastCall.args[0], /nonexistent$/i); 44 | assert(callback.calledOnce); 45 | assert.strictEqual(callback.lastCall.args[0], null); 46 | assert.strictEqual(callback.lastCall.args[1], ''); 47 | }); 48 | 49 | it('Should attempt to compile unknown templates if `compile-on-demand` is enabled', function () { 50 | mockConfig.argv['compile-on-demand'] = true; 51 | dust.onLoad('notloaded', options, callback); 52 | assert(mockRenderer.compileOnDemand.calledOnce); 53 | assert(mockRenderer.compileOnDemand.calledWith('notloaded')); 54 | }); 55 | 56 | it('Should load a given template from its keyname in the dust cache', function () { 57 | var templateStub = sinon.stub(); 58 | dust.cache = createMockCache({ 59 | foo: templateStub 60 | }); 61 | dust.onLoad('foo', options, callback); 62 | assert(callback.calledOnce); 63 | assert.strictEqual(callback.lastCall.args[0], null); 64 | assert.strictEqual(callback.lastCall.args[1], templateStub); 65 | }); 66 | 67 | it('Should use a nested template instead of the default one', function () { 68 | var templateStub = sinon.stub(); 69 | var parentStub = sinon.stub(); 70 | var rootStub = sinon.stub(); 71 | 72 | options = { 73 | namespace: 'bar__bash' 74 | }; 75 | dust.cache = createMockCache({ 76 | bar__bash__foo: templateStub, 77 | bar__foo: parentStub, 78 | foo: rootStub 79 | }); 80 | dust.onLoad('foo', options, callback); 81 | 82 | assert.equal(callback.lastCall.args[0], null); 83 | assert.equal(callback.lastCall.args[1], templateStub); 84 | }); 85 | 86 | it('Should use the parent-level (app-level) template if a nested (project-level) one cannot be found', function () { 87 | var parentStub = sinon.stub(); 88 | var rootStub = sinon.stub(); 89 | 90 | options = { 91 | namespace: 'bar__bash' 92 | }; 93 | dust.cache = createMockCache({ 94 | bar__foo: parentStub, 95 | foo: rootStub 96 | }); 97 | dust.onLoad('foo', options, callback); 98 | 99 | assert.equal(callback.lastCall.args[0], null); 100 | assert.equal(callback.lastCall.args[1], parentStub); 101 | }); 102 | 103 | it('Should use the default root-level template if no namespaced template can be found', function () { 104 | var rootStub = sinon.stub(); 105 | 106 | options = { 107 | namespace: 'bar__bash' 108 | }; 109 | dust.cache = createMockCache({ 110 | foo: rootStub 111 | }); 112 | dust.onLoad('foo', options, callback); 113 | 114 | assert.equal(callback.lastCall.args[0], null); 115 | assert.equal(callback.lastCall.args[1], rootStub); 116 | }); 117 | 118 | it('Should check the full template name in the local namespace', function () { 119 | var localStub = sinon.stub(); 120 | var parentStub = sinon.stub(); 121 | var rootStub = sinon.stub(); 122 | 123 | options = { 124 | namespace: 'foo__bar' 125 | }; 126 | dust.cache = createMockCache({ 127 | foo__bar__baz__quux: localStub, 128 | foo__baz__quux: parentStub, 129 | baz__quux: rootStub 130 | }); 131 | dust.onLoad('baz__quux', options, callback); 132 | 133 | assert.equal(callback.lastCall.args[0], null); 134 | assert.equal(callback.lastCall.args[1], localStub); 135 | }); 136 | 137 | it('Should check the full template name in the parent namespace', function () { 138 | var parentStub = sinon.stub(); 139 | var rootStub = sinon.stub(); 140 | 141 | options = { 142 | namespace: 'foo__bar' 143 | }; 144 | dust.cache = createMockCache({ 145 | foo__baz__quux: parentStub, 146 | baz__quux: rootStub 147 | }); 148 | dust.onLoad('baz__quux', options, callback); 149 | 150 | assert.equal(callback.lastCall.args[0], null); 151 | assert.equal(callback.lastCall.args[1], parentStub); 152 | }); 153 | 154 | it('Should check the full template name in the root namespace', function () { 155 | var rootStub = sinon.stub(); 156 | 157 | options = { 158 | namespace: 'foo__bar' 159 | }; 160 | dust.cache = createMockCache({ 161 | baz__quux: rootStub 162 | }); 163 | dust.onLoad('baz__quux', options, callback); 164 | 165 | assert.equal(callback.lastCall.args[0], null); 166 | assert.equal(callback.lastCall.args[1], rootStub); 167 | }); 168 | 169 | it('Should check the namespace contains the full path for the template', function () { 170 | var localStub = sinon.stub(); 171 | var parentStub = sinon.stub(); 172 | var rootStub = sinon.stub(); 173 | 174 | options = { 175 | namespace: 'foo__bar' 176 | }; 177 | dust.cache = createMockCache({ 178 | foo__bar__quux: localStub, 179 | foo__baz__quux: parentStub, 180 | baz__quux: rootStub 181 | }); 182 | dust.onLoad('baz__quux', options, callback); 183 | 184 | assert.equal(callback.lastCall.args[0], null); 185 | assert.equal(callback.lastCall.args[1], parentStub); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /tests/server/core/input-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var sinon = require('sinon'); 5 | 6 | describe('Input filtering', function () { 7 | var filter; 8 | var config; 9 | 10 | beforeEach(function () { 11 | config = { 12 | test: 'fake config', 13 | log: require('../mocks/log') 14 | }; 15 | filter = require('../../../lib/input-filter')(config); 16 | }); 17 | 18 | describe('Filter helpers', function () { 19 | it('Should find a whether a key exists in the article context', function () { 20 | var data = { 21 | article: { 22 | test: 'pass' 23 | }, 24 | test: 'fail' 25 | }; 26 | assert.strictEqual(filter.getBase(data, 'test').test, 'pass'); 27 | }); 28 | 29 | it('Should find whether a key exists in the global context', function () { 30 | var data = { 31 | article: { 32 | test2: 'fail' 33 | }, 34 | test: 'pass' 35 | }; 36 | assert.strictEqual(filter.getBase(data, 'test').test, 'pass'); 37 | }); 38 | 39 | it('Should return null if the key is not found', function () { 40 | var data = { 41 | article: { 42 | test2: 'fail' 43 | }, 44 | test: 'pass' 45 | }; 46 | assert.isNull(filter.getBase(data, 'test3')); 47 | }); 48 | }); 49 | 50 | describe('Creating filters', function () { 51 | it('Should create an empty list of filters', function () { 52 | assert.isArray(filter.filters); 53 | assert.strictEqual(filter.filters.length, 0); 54 | }); 55 | 56 | it('Should allow filters to be added', function () { 57 | filter.add(sinon.stub()); 58 | assert.strictEqual(filter.filters.length, 1); 59 | }); 60 | }); 61 | 62 | describe('Running filters', function () { 63 | it('Should apply all filters in the list in order', function () { 64 | var callback = sinon.stub(); 65 | 66 | var spy1 = sinon.spy(function (data) { 67 | return 'filter 1'; 68 | }); 69 | var spy2 = sinon.spy(function (data) { 70 | return 'filter 2'; 71 | }); 72 | 73 | filter.add(spy1); 74 | filter.add(spy2); 75 | 76 | filter.run({}, {}, 'init', callback); 77 | 78 | assert.isTrue(spy1.calledOnce); 79 | assert.isTrue(spy1.calledWith('init')); 80 | assert.isTrue(spy2.calledOnce); 81 | assert.isTrue(spy2.calledWith('filter 1')); 82 | assert.isTrue(callback.calledOnce); 83 | assert.isTrue(callback.calledWith('filter 2')); 84 | }); 85 | 86 | it('Should pass a callback if the filter specifies a second argument', function () { 87 | var callback = sinon.stub(); 88 | 89 | var spy = sinon.spy(function (data, fn) { 90 | fn('filter 1'); 91 | }); 92 | 93 | filter.add(spy); 94 | 95 | filter.run({}, {}, 'init', callback); 96 | 97 | assert.isTrue(spy.calledOnce); 98 | assert.isTrue(spy.calledWith('init')); 99 | assert.isFunction(spy.args[0][1]); 100 | assert.isTrue(callback.calledOnce); 101 | assert.isTrue(callback.calledWith('filter 1')); 102 | }); 103 | 104 | it('Should additionally provide config if the filter specifies three arguments', function () { 105 | var callback = sinon.stub(); 106 | 107 | var spy = sinon.spy(function (config, data, fn) { 108 | fn('filter 1'); 109 | }); 110 | 111 | filter.add(spy); 112 | 113 | filter.run({}, {}, 'init', callback); 114 | 115 | assert.isTrue(spy.calledOnce); 116 | assert.isTrue(spy.calledWith(config, 'init')); 117 | assert.isFunction(spy.args[0][2]); 118 | assert.isTrue(callback.calledOnce); 119 | assert.isTrue(callback.calledWith('filter 1')); 120 | }); 121 | 122 | it('Should additionally provide the request and response if the filter specifies five arguments', function () { 123 | var callback = sinon.stub(); 124 | 125 | var spy = sinon.spy(function (config, req, res, data, fn) { 126 | fn('filter 1'); 127 | }); 128 | 129 | filter.add(spy); 130 | 131 | filter.run('request', 'response', 'init', callback); 132 | 133 | assert.isTrue(spy.calledOnce); 134 | assert.isTrue(spy.calledWith(config, 'request', 'response', 'init')); 135 | assert.isFunction(spy.args[0][4]); 136 | assert.isTrue(callback.calledOnce); 137 | assert.isTrue(callback.calledWith('filter 1')); 138 | }); 139 | 140 | it('Should skip a filter that defines an unsupported number of arguments', function () { 141 | var callback = sinon.stub(); 142 | 143 | var spy1 = sinon.spy(function () { 144 | return 'filter 1'; 145 | }); 146 | var spy2 = sinon.spy(function (arg1, arg2, arg3, arg4) { 147 | return 'filter 2'; 148 | }); 149 | var spy3 = sinon.spy(function (arg1, arg2, arg3, arg4, arg5, arg6) { 150 | return 'filter 3'; 151 | }); 152 | 153 | filter.add(spy1); 154 | filter.add(spy2); 155 | filter.add(spy3); 156 | 157 | filter.run({}, {}, 'init', callback); 158 | 159 | assert.strictEqual(spy1.callCount, 0); 160 | assert.strictEqual(spy2.callCount, 0); 161 | assert.strictEqual(spy3.callCount, 0); 162 | assert.isTrue(callback.calledOnce); 163 | assert.isTrue(callback.calledWith('init')); 164 | }); 165 | 166 | it('Should run each filter with the input-filter as the caller object', function () { 167 | var callback = sinon.stub(); 168 | 169 | var spy1 = sinon.spy(function (data) { 170 | return 'filter 1'; 171 | }); 172 | var spy2 = sinon.spy(function (data, fn) { 173 | fn('filter 2'); 174 | }); 175 | var spy3 = sinon.spy(function (config, data, fn) { 176 | fn('filter 3'); 177 | }); 178 | var spy4 = sinon.spy(function (config, req, res, data, fn) { 179 | fn('filter 4'); 180 | }); 181 | 182 | filter.add(spy1); 183 | filter.add(spy2); 184 | filter.add(spy3); 185 | filter.add(spy4); 186 | 187 | filter.run({}, {}, 'init', callback); 188 | 189 | assert.isTrue(spy1.calledOnce); 190 | assert.isTrue(spy1.calledOn(filter)); 191 | assert.isTrue(spy2.calledOnce); 192 | assert.isTrue(spy2.calledOn(filter)); 193 | assert.isTrue(spy3.calledOnce); 194 | assert.isTrue(spy3.calledOn(filter)); 195 | assert.isTrue(spy4.calledOnce); 196 | assert.isTrue(spy4.calledOn(filter)); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /tests/server/core/logging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // to run just these tests: 4 | // ./node_modules/.bin/mocha --opts ./tests/mocha.opts ./tests/server/core/logging.js 5 | 6 | var assert = require('proclaim'); 7 | var winston = require('winston'); 8 | var Syslog = require('winston-syslog').Syslog; 9 | var defaultConfig = require('../../../lib/config')('development', {}, {}); 10 | 11 | Object.freeze(defaultConfig); 12 | 13 | describe('Logging config,', function () { 14 | var systemUnderTest = require('../../../lib/logging'); 15 | var testConfig; 16 | 17 | function getTransport(logger, type) { 18 | return logger.transports.find(function (element) { 19 | return element instanceof type; 20 | }); 21 | } 22 | 23 | beforeEach(function () { 24 | testConfig = { 25 | argv: { 26 | syslog: true, 27 | logging: 'info' 28 | }, 29 | env: { 30 | host: function () { 31 | return 'some.host.name'; 32 | } 33 | }, 34 | log: require('../mocks/log'), 35 | path: { 36 | root: '/location-of-userland-files', 37 | shunterRoot: './' 38 | }, 39 | structure: { 40 | logging: 'logging', 41 | loggingTransports: 'transports' 42 | }, 43 | syslogAppName: 'foo' 44 | }; 45 | }); 46 | 47 | describe('With default logging config,', function () { 48 | it('Should offer getLogger() in its API', function () { 49 | var loggingInstance = systemUnderTest(defaultConfig); 50 | assert.isFunction(loggingInstance.getLogger); 51 | }); 52 | 53 | it('Should load the winston console transport by default', function () { 54 | var logger = systemUnderTest(defaultConfig).getLogger(); 55 | var thisTransport = getTransport(logger, winston.transports.Console); 56 | assert.isTrue(thisTransport instanceof winston.transports.Console); 57 | }); 58 | 59 | it('Should not load the winston syslog transport by default', function () { 60 | var logger = systemUnderTest(defaultConfig).getLogger(); 61 | var thisTransport = getTransport(logger, Syslog); 62 | assert.isNotObject(thisTransport); 63 | }); 64 | }); 65 | 66 | describe('With an argv log level for console transport provided,', function () { 67 | it('Should respect the provided log level', function () { 68 | testConfig.argv.logging = 'someValueUniqueToThisTest'; 69 | var logger = systemUnderTest(testConfig).getLogger(); 70 | var thisTransport = getTransport(logger, winston.transports.Console); 71 | assert.strictEqual(thisTransport.level, 'someValueUniqueToThisTest'); 72 | }); 73 | }); 74 | 75 | describe('With syslog,', function () { 76 | it('Should load the winston syslog transport if requested', function () { 77 | var logger = systemUnderTest(testConfig).getLogger(); 78 | var thisTransport = getTransport(logger, Syslog); 79 | assert.isTrue(thisTransport instanceof Syslog); 80 | }); 81 | 82 | it('Should ensure the winston syslog level is "debug"', function () { 83 | var logger = systemUnderTest(testConfig).getLogger(); 84 | var thisTransport = getTransport(logger, Syslog); 85 | assert.strictEqual(thisTransport.level, 'debug'); 86 | }); 87 | 88 | it('Should not load syslog if !argv.syslog', function () { 89 | delete testConfig.argv.syslog; 90 | var logger = systemUnderTest(testConfig).getLogger(); 91 | var thisTransport = getTransport(logger, Syslog); 92 | assert.isNotObject(thisTransport); 93 | }); 94 | 95 | it('Should not load syslog if !syslogAppName', function () { 96 | delete testConfig.syslogAppName; 97 | var logger = systemUnderTest(testConfig).getLogger(); 98 | var thisTransport = getTransport(logger, Syslog); 99 | assert.isNotObject(thisTransport); 100 | }); 101 | }); 102 | 103 | // A user can provide their own completely custom logger instance when the app 104 | // is created. This instance is stored in the config object. 105 | describe('With user-provided logger instance,', function () { 106 | var format = winston.format; 107 | var userLoggerInstance = winston.createLogger({ 108 | transports: [ 109 | new (winston.transports.Console)({ 110 | format: format.combine( 111 | format.colorize(), 112 | format.timestamp() 113 | ), 114 | level: 'RUN_AROUND_SCREAMING' 115 | }) 116 | ] 117 | }); 118 | 119 | it('First confirms the Console transport level is the default "info"', function () { 120 | var logger = systemUnderTest(testConfig).getLogger(); 121 | var thisTransport = getTransport(logger, winston.transports.Console); 122 | assert.strictEqual(thisTransport.level, 'info'); 123 | }); 124 | 125 | it('Can override Console transport level via dynamic logger instance', function () { 126 | var thisConfig = testConfig; 127 | thisConfig.log = userLoggerInstance; 128 | 129 | var validatedConfigObject = require('../../../lib/config')(thisConfig.env, thisConfig, {}); 130 | var thisTransport = getTransport(validatedConfigObject.log, winston.transports.Console); 131 | assert.strictEqual(thisTransport.level, 'RUN_AROUND_SCREAMING'); 132 | }); 133 | }); 134 | 135 | describe('With file-based user-provided logging transports,', function () { 136 | var thisLogger; 137 | beforeEach(function () { 138 | var thisConfig = testConfig; 139 | thisConfig.path.root = './tests/server/mock-data'; 140 | // there should be two transport files in that^ dir, and only one should be valid 141 | thisLogger = systemUnderTest(thisConfig).getLogger(); 142 | }); 143 | 144 | it('Can override our default config via provided files', function () { 145 | var thisTransport = getTransport(thisLogger, winston.transports.Console); 146 | assert.strictEqual(thisTransport.level, 'THIS_IS_FINE'); 147 | }); 148 | 149 | it('Should not use user transport files that do not expose a function', function () { 150 | // a basic check for mangled transport files 151 | var thisTransport = getTransport(thisLogger, Syslog); 152 | assert.isNotObject(thisTransport); 153 | // One dropped transport out of two should leave one valid transport 154 | assert.strictEqual(Object.keys(thisLogger.transports).length, 1); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /tests/server/core/statsd.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | var assert = require('proclaim'); 5 | var sinon = require('sinon'); 6 | var mockery = require('mockery'); 7 | 8 | var modulePath = '../../../lib/statsd'; 9 | var mockConfig; 10 | 11 | describe('Logging to statsd', function () { 12 | var client; 13 | 14 | beforeEach(function () { 15 | mockConfig = { 16 | statsd: { 17 | mappings: [ 18 | { 19 | pattern: '^\\/foo\\/bar', 20 | name: 'foo_bar' 21 | }, 22 | { 23 | pattern: '^\\/foo', 24 | name: 'foo' 25 | }, 26 | { 27 | pattern: '^\\/test', 28 | name: 'test' 29 | } 30 | ], 31 | mock: false 32 | }, 33 | log: { 34 | error: sinon.stub() 35 | } 36 | }; 37 | 38 | client = { 39 | increment: sinon.stub(), 40 | decrement: sinon.stub(), 41 | timing: sinon.stub(), 42 | gauge: sinon.stub(), 43 | gaugeDelta: sinon.stub(), 44 | histogram: sinon.stub(), 45 | set: sinon.stub() 46 | }; 47 | 48 | mockery.enable({ 49 | useCleanCache: true, 50 | warnOnUnregistered: false, 51 | warnOnReplace: false 52 | }); 53 | 54 | mockery.registerMock('statsd-client', sinon.stub().returns(client)); 55 | }); 56 | afterEach(function () { 57 | mockery.deregisterAll(); 58 | mockery.disable(); 59 | mockConfig.log.error.resetHistory(); 60 | }); 61 | 62 | describe('Native methods', function () { 63 | it('Should proxy `increment` calls to the statsd client', function () { 64 | var statsd = require(modulePath)(mockConfig); 65 | statsd.increment('foo'); 66 | assert.isTrue(client.increment.calledOnce); 67 | assert.isTrue(client.increment.calledWith('foo')); 68 | }); 69 | 70 | it('Should proxy `decrement` calls to the statsd client', function () { 71 | var statsd = require(modulePath)(mockConfig); 72 | statsd.decrement('foo'); 73 | assert.isTrue(client.decrement.calledOnce); 74 | assert.isTrue(client.decrement.calledWith('foo')); 75 | }); 76 | 77 | it('Should proxy `timing` calls to the statsd client', function () { 78 | var statsd = require(modulePath)(mockConfig); 79 | statsd.timing('foo', 1); 80 | assert.isTrue(client.timing.calledOnce); 81 | assert.isTrue(client.timing.calledWith('foo', 1)); 82 | }); 83 | 84 | it('Should proxy `gauge` calls to the statsd client', function () { 85 | var statsd = require(modulePath)(mockConfig); 86 | statsd.gauge('foo', 1, 0.25); 87 | assert.isTrue(client.gauge.calledOnce); 88 | assert.isTrue(client.gauge.calledWith('foo', 1, 0.25)); 89 | }); 90 | 91 | it('Should proxy `gaugeDelta` calls to the statsd client', function () { 92 | var statsd = require(modulePath)(mockConfig); 93 | statsd.gaugeDelta('foo', 1); 94 | assert.isTrue(client.gaugeDelta.calledOnce); 95 | assert.isTrue(client.gaugeDelta.calledWith('foo', 1)); 96 | }); 97 | 98 | it('Should proxy `histogram` calls to the statsd client', function () { 99 | var statsd = require(modulePath)(mockConfig); 100 | statsd.histogram('foo', 1); 101 | assert.isTrue(client.histogram.calledOnce); 102 | assert.isTrue(client.histogram.calledWith('foo', 1)); 103 | }); 104 | 105 | it('Should proxy `set` calls to the statsd client', function () { 106 | var statsd = require(modulePath)(mockConfig); 107 | statsd.set('foo', 1); 108 | assert.isTrue(client.set.calledOnce); 109 | assert.isTrue(client.set.calledWith('foo', 1)); 110 | }); 111 | }); 112 | 113 | describe('Extended methods', function () { 114 | it('Should pass calls for `classifiedIncrement` to `increment`', function () { 115 | var statsd = require(modulePath)(mockConfig); 116 | statsd.classifiedIncrement('/test', 'foo'); 117 | assert.isTrue(client.increment.calledOnce); 118 | assert.isTrue(client.increment.calledWith('foo_test')); 119 | }); 120 | 121 | it('Should pass calls for `classifiedDecrement` to `decrement`', function () { 122 | var statsd = require(modulePath)(mockConfig); 123 | statsd.classifiedDecrement('/test', 'foo'); 124 | assert.isTrue(client.decrement.calledOnce); 125 | assert.isTrue(client.decrement.calledWith('foo_test')); 126 | }); 127 | 128 | it('Should pass calls for `classifiedTiming` to `timing`', function () { 129 | var statsd = require(modulePath)(mockConfig); 130 | statsd.classifiedTiming('/test', 'foo', 10); 131 | assert.isTrue(client.timing.calledOnce); 132 | assert.isTrue(client.timing.calledWith('foo_test', 10)); 133 | }); 134 | 135 | it('Should pass calls for `classifiedGauge` to `gauge`', function () { 136 | var statsd = require(modulePath)(mockConfig); 137 | statsd.classifiedGauge('/test', 'foo', 1); 138 | assert.isTrue(client.gauge.calledOnce); 139 | assert.isTrue(client.gauge.calledWith('foo_test', 1)); 140 | }); 141 | 142 | it('Should pass calls for `classifiedGaugeDelta` to `gaugeDelta`', function () { 143 | var statsd = require(modulePath)(mockConfig); 144 | statsd.classifiedGaugeDelta('/test', 'foo', 1); 145 | assert.isTrue(client.gaugeDelta.calledOnce); 146 | assert.isTrue(client.gaugeDelta.calledWith('foo_test', 1)); 147 | }); 148 | 149 | it('Should pass calls for `classifiedHistogram` to `histogram`', function () { 150 | var statsd = require(modulePath)(mockConfig); 151 | statsd.classifiedHistogram('/test', 'foo', 10); 152 | assert.isTrue(client.histogram.calledOnce); 153 | assert.isTrue(client.histogram.calledWith('foo_test', 10)); 154 | }); 155 | 156 | it('Should pass calls for `classifiedSet` to `set`', function () { 157 | var statsd = require(modulePath)(mockConfig); 158 | statsd.classifiedSet('/test', 'foo', 10); 159 | assert.isTrue(client.set.calledOnce); 160 | assert.isTrue(client.set.calledWith('foo_test', 10)); 161 | }); 162 | }); 163 | 164 | describe('Classifying metrics', function () { 165 | it('Should classify the metric based on the first config mapping that matches the url', function () { 166 | var statsd = require(modulePath)(mockConfig); 167 | statsd.classifiedIncrement('/foo', 'a'); 168 | assert.isTrue(client.increment.calledWith('a_foo')); 169 | statsd.classifiedIncrement('/foo/bar/baz', 'a'); 170 | assert.isTrue(client.increment.calledWith('a_foo_bar')); 171 | statsd.classifiedIncrement('/foo/baz', 'a'); 172 | assert.isTrue(client.increment.calledWith('a_foo')); 173 | }); 174 | 175 | it('Should just use the metric name if the url does not match any of the mappings', function () { 176 | var statsd = require(modulePath)(mockConfig); 177 | statsd.classifiedIncrement('/bar', 'a'); 178 | assert.isTrue(client.increment.calledWith('a')); 179 | }); 180 | 181 | it('Should just use the metric name if no mappings are defined', function () { 182 | var statsd = require(modulePath)({statsd: {mock: false}}); 183 | statsd.classifiedIncrement('/foo', 'a'); 184 | assert.isTrue(client.increment.calledWith('a')); 185 | }); 186 | }); 187 | 188 | describe('Mocking statsd', function () { 189 | it('Should not proxy calls to the statsd client when mocked', function () { 190 | mockConfig.statsd.mock = true; 191 | 192 | var statsd = require(modulePath)(mockConfig); 193 | statsd.increment('foo'); 194 | assert.isTrue(client.increment.notCalled); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /tests/server/core/error-pages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var assert = require('proclaim'); 4 | var sinon = require('sinon'); 5 | var mockery = require('mockery'); 6 | 7 | var newSpyResponse = require('../mocks/response'); 8 | 9 | var moduleName = '../../../lib/error-pages'; 10 | 11 | describe('Templating error pages', function () { 12 | var config; 13 | var contentType; 14 | var createFilter; 15 | var filter; 16 | var req; 17 | var res; 18 | var error; 19 | var renderer; 20 | 21 | beforeEach(function () { 22 | mockery.enable({ 23 | useCleanCache: true, 24 | warnOnUnregistered: false, 25 | warnOnReplace: false 26 | }); 27 | 28 | contentType = sinon.stub().returns('text/html; charset=utf-8'); 29 | 30 | filter = sinon.stub().returnsArg(0); 31 | createFilter = sinon.stub().returns(filter); 32 | var rendererLib = require('../mocks/renderer'); 33 | 34 | mockery.registerMock('./output-filter', createFilter); 35 | req = require('../mocks/request'); 36 | req.url = '/hello'; 37 | mockery.registerMock('./content-type', contentType); 38 | res = newSpyResponse(); 39 | 40 | mockery.registerMock('mincer', require('../mocks/mincer')); 41 | mockery.registerMock('each-module', require('../mocks/each-module')); 42 | mockery.registerMock('./renderer', rendererLib); 43 | 44 | renderer = rendererLib({}); 45 | 46 | config = { 47 | argv: {}, 48 | log: require('../mocks/log'), 49 | timer: sinon.stub().returns(sinon.stub()), 50 | env: { 51 | isDevelopment: sinon.stub().returns(false), 52 | isProduction: sinon.stub().returns(true), 53 | tier: sinon.stub().returns('ci'), 54 | host: sinon.stub().returns('ci') 55 | }, 56 | errorPages: { 57 | errorLayouts: { 58 | 404: 'layout-404', 59 | default: 'layout-default' 60 | }, 61 | staticData: { 62 | users: 'data' 63 | } 64 | }, 65 | path: { 66 | root: '/', 67 | resources: '/resources', 68 | publicResources: '/public/resources', 69 | templates: '/view', 70 | dust: '/dust' 71 | }, 72 | modules: [ 73 | 'shunter' 74 | ], 75 | structure: { 76 | resources: 'resources', 77 | styles: 'css', 78 | images: 'img', 79 | scripts: 'js', 80 | fonts: 'fonts', 81 | templates: 'view', 82 | dust: 'dust', 83 | templateExt: '.dust', 84 | filters: 'filters', 85 | filtersInput: 'input', 86 | ejs: 'ejs', 87 | mincer: 'mincer' 88 | } 89 | }; 90 | 91 | error = new Error('Some kind of error'); 92 | error.status = 418; 93 | }); 94 | afterEach(function () { 95 | mockery.deregisterAll(); 96 | mockery.disable(); 97 | }); 98 | 99 | it('Should callback with null if not configured to use templated pages', function () { 100 | var unconfiguredConfig = config; 101 | unconfiguredConfig.errorPages = {}; 102 | var errorPages = require(moduleName)(unconfiguredConfig); 103 | var result = false; 104 | errorPages.getPage(error, req, res, function (arg) { 105 | result = arg; 106 | }); 107 | 108 | assert.isTrue(renderer.render.notCalled); 109 | assert.strictEqual(result, null); 110 | }); 111 | 112 | it('Should try to render if configured to do so', function () { 113 | var errorPages = require(moduleName)(config); 114 | var result = false; 115 | errorPages.getPage(error, req, res, function (arg) { 116 | result = arg; 117 | }); 118 | 119 | renderer.render.firstCall.yield(null, 'my error page'); 120 | 121 | assert.strictEqual(renderer.render.callCount, 1); 122 | assert.strictEqual(result, 'my error page'); 123 | }); 124 | 125 | it('Should callback with null if there is an error rendering the error page', function () { 126 | var errorPages = require(moduleName)(config); 127 | var result = false; 128 | errorPages.getPage(error, req, res, function (arg) { 129 | result = arg; 130 | }); 131 | 132 | renderer.render.firstCall.yield(new Error('renderer.render threw some error')); 133 | 134 | assert.strictEqual(renderer.render.callCount, 1); 135 | assert.strictEqual(result, null); 136 | }); 137 | 138 | it('Should callback with null if error falsy', function () { 139 | var errorPages = require(moduleName)(config); 140 | var result = false; 141 | var error; 142 | errorPages.getPage(error, req, res, function (arg) { 143 | result = arg; 144 | }); 145 | 146 | assert.strictEqual(result, null); 147 | }); 148 | 149 | it('Should render if provided err.status falsy', function () { 150 | var errorPages = require(moduleName)(config); 151 | var result = false; 152 | error.status = undefined; 153 | errorPages.getPage(error, req, res, function (arg) { 154 | result = arg; 155 | }); 156 | 157 | renderer.render.firstCall.yield(null, 'my error page'); 158 | 159 | assert.strictEqual(renderer.render.callCount, 1); 160 | assert.strictEqual(result, 'my error page'); 161 | }); 162 | 163 | it('Should render the template with the users specified default layout', function () { 164 | var errorPages = require(moduleName)(config); 165 | errorPages.getPage(error, req, res, function () { }); 166 | assert.strictEqual(renderer.render.firstCall.args[2].layout.template, config.errorPages.errorLayouts.default); 167 | }); 168 | 169 | it('Should render the template with the users specified layout by error code', function () { 170 | var errorPages = require(moduleName)(config); 171 | var error = new Error('err'); 172 | error.status = 404; 173 | errorPages.getPage(error, req, res, function () { }); 174 | assert.strictEqual(renderer.render.firstCall.args[2].layout.template, config.errorPages.errorLayouts[error.status.toString()]); 175 | }); 176 | 177 | it('Should insert the error into the template context', function () { 178 | var errorPages = require(moduleName)(config); 179 | errorPages.getPage(error, req, res, function () { }); 180 | assert.strictEqual(renderer.render.firstCall.args[2].errorContext.error, error); 181 | }); 182 | 183 | it('Should populate the template context with the reqHost', function () { 184 | var errorPages = require(moduleName)(config); 185 | errorPages.getPage(error, req, res, function () { }); 186 | assert.strictEqual(renderer.render.firstCall.args[2].errorContext.reqHost, req.headers.host); 187 | }); 188 | 189 | it('Should populate the template context with the users static data', function () { 190 | var errorPages = require(moduleName)(config); 191 | errorPages.getPage(error, req, res, function () { }); 192 | assert.strictEqual(renderer.render.firstCall.args[2].users, config.errorPages.staticData.users); 193 | }); 194 | 195 | it('Should prevent the user from clobbering the required "layout" key', function () { 196 | config.errorPages.staticData.layout = {}; 197 | var errorPages = require(moduleName)(config); 198 | errorPages.getPage(error, req, res, function () { }); 199 | assert.strictEqual(renderer.render.firstCall.args[2].layout.template, config.errorPages.errorLayouts.default); 200 | }); 201 | 202 | it('Should prevent the user from clobbering the required "errorContext" key', function () { 203 | var badConfig = { 204 | user: 'error' 205 | }; 206 | config.errorPages.staticData.errorContext = badConfig; 207 | var errorPages = require(moduleName)(config); 208 | errorPages.getPage(error, req, res, function () { }); 209 | assert.notStrictEqual(renderer.render.firstCall.args[2].errorContext, badConfig); 210 | }); 211 | }); 212 | --------------------------------------------------------------------------------