├── .gitignore ├── test ├── support │ ├── env.js │ └── rawagent.js ├── app.listen.js ├── fqdn.js ├── server.js └── mounting.js ├── .eslintrc ├── .babelrc ├── nuxt └── index.js ├── benchmark ├── connect.js └── metal.js ├── jest.config.js ├── package.json ├── README.md ├── src ├── handler.js ├── index.js └── utils.js └── indie-ws.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /test/support/env.js: -------------------------------------------------------------------------------- 1 | 2 | process.env.NODE_ENV = 'test'; 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ], 5 | "env": { 6 | "jest": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "env": { 4 | "test": { 5 | "plugins": [ 6 | "babel-plugin-dynamic-import-node" 7 | ] 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /nuxt/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | this.nuxt.hook('render:before', (renderer) => { 3 | const Metal = require('..') 4 | const app = Metal.createServer() 5 | renderer.app = app 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /benchmark/connect.js: -------------------------------------------------------------------------------- 1 | const connect = require('connect') 2 | const http = require('http') 3 | 4 | const app = connect() 5 | 6 | app.use((req, res, next) => { 7 | req.foobar = 1 8 | next() 9 | }); 10 | 11 | app.use((req, res) => { 12 | res.end(`Hello from Connect! ${++req.foobar}\n`) 13 | }) 14 | 15 | http.createServer(app).listen(3000) 16 | -------------------------------------------------------------------------------- /benchmark/metal.js: -------------------------------------------------------------------------------- 1 | const Metal = require('../dist/metal') 2 | const http = require('http') 3 | 4 | const app = Metal.createServer() 5 | 6 | app.use((req, res, next) => { 7 | req.foobar = 1 8 | next() 9 | }) 10 | 11 | app.use((req, res) => { 12 | res.end(`Hello from Connect! ${++req.foobar}\n`) 13 | }) 14 | 15 | http.createServer(app).listen(3000) 16 | -------------------------------------------------------------------------------- /test/app.listen.js: -------------------------------------------------------------------------------- 1 | 2 | var requireESM = require('esm')(module); 3 | var assert = require('assert') 4 | var connect = requireESM('../src').default.createServer; 5 | var request = require('supertest'); 6 | 7 | describe('app.listen()', function(){ 8 | it('should wrap in an http.Server', function(done){ 9 | var app = connect(); 10 | 11 | app.use(function(req, res){ 12 | res.end(); 13 | }); 14 | 15 | var server = app.listen(0, function () { 16 | assert.ok(server) 17 | request(server) 18 | .get('/') 19 | .expect(200, function (err) { 20 | server.close(function () { 21 | done(err) 22 | }) 23 | }) 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | expand: true, 4 | forceExit: true, 5 | 6 | // https://github.com/facebook/jest/pull/6747 fix warning here 7 | // But its performance overhead is pretty bad (30+%). 8 | // detectOpenHandles: true 9 | 10 | // setupTestFrameworkScriptFile: './test/utils/setup', 11 | // coverageDirectory: './coverage', 12 | // collectCoverageFrom: [], 13 | // coveragePathIgnorePatterns: [ 14 | // 'node_modules/(?!(@nuxt|nuxt))', 15 | // 'packages/webpack/src/config/plugins/vue' 16 | // ], 17 | testPathIgnorePatterns: [ 18 | 'bin/', 19 | 'node_modules/', 20 | 'test/fixtures/.*/.*?/' 21 | ], 22 | 23 | transform: { 24 | '^.+\\.js$': 'babel-jest' 25 | }, 26 | 27 | moduleFileExtensions: [ 28 | 'js', 29 | 'json' 30 | ], 31 | 32 | reporters: [ 33 | 'default' 34 | ].concat(process.env.JEST_JUNIT_OUTPUT ? ['jest-junit'] : []) 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nuxt/metal", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "build": "rollup -c build/rollup.config.js", 6 | "test": "mocha --require test/support/env --reporter spec --bail --check-leaks test/", 7 | "lint": "eslint --ext .js src test", 8 | "lint-fix": "yarn lint --fix", 9 | "docs:dev": "vuepress dev docs", 10 | "docs:build": "source deploy.sh", 11 | "prepublish": "yarn build" 12 | }, 13 | "main": "dist/metal.js", 14 | "files": [ 15 | "dist", 16 | "nuxt" 17 | ], 18 | "devDependencies": { 19 | "@nuxtjs/eslint-config": "^0.0.1", 20 | "babel-eslint": "^10.0.1", 21 | "babel-jest": "^23.6.0", 22 | "babel-plugin-dynamic-import-node": "^2.2.0", 23 | "babel-preset-env": "^1.7.0", 24 | "connect": "^3.6.6", 25 | "defu": "^0.0.1", 26 | "eslint": "^5.16.0", 27 | "eslint-config-standard": "^12.0.0", 28 | "eslint-plugin-import": "^2.17.2", 29 | "eslint-plugin-jest": "^22.5.1", 30 | "eslint-plugin-node": "^8.0.1", 31 | "eslint-plugin-promise": "^4.1.1", 32 | "eslint-plugin-standard": "^4.0.0", 33 | "eslint-plugin-vue": "^5.2.2", 34 | "esm": "^3.2.25", 35 | "jest": "^23.6.0", 36 | "mocha": "^7.0.1", 37 | "on-finished": "^2.3.0", 38 | "rollup": "^1.10.0", 39 | "rollup-plugin-alias": "^1.5.1", 40 | "rollup-plugin-babel": "^4.3.2", 41 | "rollup-plugin-commonjs": "^9.3.4", 42 | "rollup-plugin-json": "^4.0.0", 43 | "rollup-plugin-license": "^0.8.1", 44 | "rollup-plugin-node-resolve": "^4.2.3", 45 | "rollup-plugin-replace": "^2.2.0", 46 | "supertest": "^4.0.2", 47 | "vuepress": "^0.14.8" 48 | }, 49 | "dependencies": { 50 | "js-yaml": "^3.13.1", 51 | "mem": "^4.0.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |


2 | 3 | # @nuxt/metal 4 | 5 | Nuxt currently depends on [connect][cn], a lightweight middleware framework for 6 | Node. [connect][cn] currently has 10 dependencies, some of which haven't had 7 | updates in a long time. All are still written in ES5-style JavaScript and some 8 | still try to specifically address Node 0.8 shortcomings. 9 | 10 | [cn]: https://github.com/senchalabs/connect 11 | 12 | ``` 13 | - connect@3.6.6 14 | - debug@2.6.9 15 | - finalhandler@1.1.0 16 | - encodeurl@1.0.2 17 | - escape-html@1.0.3 18 | - on-finished@2.3.0 19 | - ee-first@1.1.1 20 | - statuses@1.4.0 21 | - unpipe@1.0.0 22 | - parseurl@1.3.2 23 | - utils-merge@1.0.1 24 | ``` 25 | 26 | **@nuxt/metal** is an attempt to provide a fully backwards-compatible rewrite 27 | of connect in modern JavaScript, with added support for async middleware and a 28 | restructured codebase with many simplifications, cleanups and idiomatic rewrites. 29 | All without compromising performance, if not improving it slightly. 30 | 31 | See http://hire.jonasgalvez.com.br/2019/apr/26/revamping-nuxts-http-server 32 | 33 | ## Benchmark 34 | 35 | - @nuxt/metal: **844k** requests in 40.1s, 103 MB read 36 | - connect: **814k** requests in 40.1s, 99.3 MB read 37 | 38 | ```sh 39 | autocannon -c 100 -d 40 -p 10 localhost:3000 40 | ``` 41 | 42 | ## Acknowledgement 43 | 44 | This module is largely based on the work of [TJ Holowaychuk][tj], [Douglas 45 | Christopher Wilson][dw], [Jonathan Ong][jo] and the awesome people at [Joyent][j]. 46 | This package is simply a massive restructuring of all original code, with only 47 | a few minor pieces removed and improved upon. 48 | 49 | [tj]: https://github.com/tj 50 | [dw]: https://github.com/dougwilson 51 | [jo]: https://github.com/jonathanong 52 | [j]: https://github.com/joyent 53 | -------------------------------------------------------------------------------- /test/support/rawagent.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var assert = require('assert') 4 | var http = require('http') 5 | 6 | module.exports = createRawAgent 7 | 8 | function createRawAgent (app) { 9 | return new RawAgent(app) 10 | } 11 | 12 | function RawAgent (app) { 13 | this.app = app 14 | 15 | this._open = 0 16 | this._port = null 17 | this._server = null 18 | } 19 | 20 | RawAgent.prototype.get = function get (path) { 21 | return new RawRequest(this, 'GET', path) 22 | } 23 | 24 | RawAgent.prototype._close = function _close (cb) { 25 | if (--this._open) { 26 | return process.nextTick(cb) 27 | } 28 | 29 | this._server.close(cb) 30 | } 31 | 32 | RawAgent.prototype._start = function _start (cb) { 33 | this._open++ 34 | 35 | if (this._port) { 36 | return process.nextTick(cb) 37 | } 38 | 39 | if (!this._server) { 40 | this._server = http.createServer(this.app).listen() 41 | } 42 | 43 | var agent = this 44 | this._server.on('listening', function onListening () { 45 | agent._port = this.address().port 46 | cb() 47 | }) 48 | } 49 | 50 | function RawRequest (agent, method, path) { 51 | this.agent = agent 52 | this.method = method 53 | this.path = path 54 | } 55 | 56 | RawRequest.prototype.expect = function expect (status, body, callback) { 57 | var request = this 58 | this.agent._start(function onStart () { 59 | var req = http.request({ 60 | host: '127.0.0.1', 61 | method: request.method, 62 | path: request.path, 63 | port: request.agent._port 64 | }) 65 | 66 | req.on('response', function (res) { 67 | var buf = '' 68 | 69 | res.setEncoding('utf8') 70 | res.on('data', function onData (s) { buf += s }) 71 | res.on('end', function onEnd () { 72 | var err = null 73 | 74 | try { 75 | assert.equal(res.statusCode, status, 'expected ' + status + ' status, got ' + res.statusCode) 76 | 77 | if (body instanceof RegExp) { 78 | assert.ok(body.test(buf), 'expected body ' + buf + ' to match ' + body) 79 | } else { 80 | assert.equal(buf, body, 'expected ' + body + ' response body, got ' + buf) 81 | } 82 | } catch (e) { 83 | err = e 84 | } 85 | 86 | request.agent._close(function onClose () { 87 | callback(err) 88 | }) 89 | }) 90 | }) 91 | 92 | req.end() 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /src/handler.js: -------------------------------------------------------------------------------- 1 | 2 | import { STATUS_CODES as statuses } from 'http' 3 | import { isFinished, 4 | encodeURL, 5 | getResponseStatusCode, 6 | getErrorHeaders, 7 | getErrorStatusCode, 8 | getErrorMessage, 9 | setHeaders 10 | } from './utils' 11 | 12 | // Create a function to handle the final response. 13 | export default function (req, res, options) { 14 | const opts = options || {} 15 | const env = opts.env || process.env.NODE_ENV || 'development' 16 | // get error callback 17 | const onerror = opts.onerror 18 | return function (err) { 19 | let headers 20 | let msg 21 | let status 22 | // ignore 404 on in-flight response 23 | if (!err && res.headersSent) { 24 | return 25 | } 26 | if (err) { 27 | status = getErrorStatusCode(err) 28 | if (status === undefined) { 29 | status = getResponseStatusCode(res) 30 | } else { 31 | headers = getErrorHeaders(err) 32 | } 33 | msg = getErrorMessage(err, status, env) 34 | } else { 35 | status = 404 36 | msg = `Cannot ${req.method} ${encodeURL(req.url || 'resource')}` 37 | } 38 | if (err && onerror) { 39 | setImmediate(onerror, err, req, res) 40 | } 41 | if (res.headersSent) { 42 | req.socket.destroy() 43 | return 44 | } 45 | send(req, res, status, headers, msg) 46 | } 47 | } 48 | 49 | function send(req, res, status, headers, message) { 50 | function write() { 51 | const body = JSON.stringify({ error: message }) 52 | res.statusCode = status 53 | res.statusMessage = statuses[status] 54 | setHeaders(res, headers) 55 | res.setHeader('Content-Security-Policy', "default-src 'none'") 56 | res.setHeader('X-Content-Type-Options', 'nosniff') 57 | res.setHeader('Content-Type', 'application/json; charset=utf-8') 58 | res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8')) 59 | if (req.method === 'HEAD') { 60 | res.end() 61 | return 62 | } 63 | res.end(body, 'utf8') 64 | } 65 | if (isFinished(req)) { 66 | write() 67 | return 68 | } 69 | req.unpipe() 70 | new Promise((resolve) => { 71 | function onFinished() { 72 | req.removeListener('end', onFinished) 73 | res.removeListener('finish', onFinished) 74 | res.removeListener('close', onFinished) 75 | write() 76 | resolve() 77 | } 78 | req.on('end', onFinished) 79 | res.on('finish', onFinished) 80 | res.on('close', onFinished) 81 | }) 82 | req.resume() 83 | } 84 | -------------------------------------------------------------------------------- /test/fqdn.js: -------------------------------------------------------------------------------- 1 | 2 | var requireESM = require('esm')(module); 3 | var assert = require('assert'); 4 | var connect = requireESM('../src').default.createServer; 5 | var http = require('http'); 6 | var rawrequest = require('./support/rawagent') 7 | 8 | describe('app.use()', function(){ 9 | var app; 10 | 11 | beforeEach(function(){ 12 | app = connect(); 13 | }); 14 | 15 | it('should not obscure FQDNs', function(done){ 16 | app.use(function(req, res){ 17 | res.end(req.url); 18 | }); 19 | 20 | rawrequest(app) 21 | .get('http://example.com/foo') 22 | .expect(200, 'http://example.com/foo', done) 23 | }); 24 | 25 | describe('with a connect app', function(){ 26 | it('should ignore FQDN in search', function (done) { 27 | app.use('/proxy', function (req, res) { 28 | res.end(req.url); 29 | }); 30 | 31 | rawrequest(app) 32 | .get('/proxy?url=http://example.com/blog/post/1') 33 | .expect(200, '/?url=http://example.com/blog/post/1', done) 34 | }); 35 | 36 | it('should ignore FQDN in path', function (done) { 37 | app.use('/proxy', function (req, res) { 38 | res.end(req.url); 39 | }); 40 | 41 | rawrequest(app) 42 | .get('/proxy/http://example.com/blog/post/1') 43 | .expect(200, '/http://example.com/blog/post/1', done) 44 | }); 45 | 46 | it('should adjust FQDN req.url', function(done){ 47 | app.use('/blog', function(req, res){ 48 | res.end(req.url); 49 | }); 50 | 51 | rawrequest(app) 52 | .get('http://example.com/blog/post/1') 53 | .expect(200, 'http://example.com/post/1', done) 54 | }); 55 | 56 | it('should adjust FQDN req.url with multiple handlers', function(done){ 57 | app.use(function(req,res,next) { 58 | next(); 59 | }); 60 | 61 | app.use('/blog', function(req, res){ 62 | res.end(req.url); 63 | }); 64 | 65 | rawrequest(app) 66 | .get('http://example.com/blog/post/1') 67 | .expect(200, 'http://example.com/post/1', done) 68 | }); 69 | 70 | // Don't think it's worth supporting this specific case: 71 | // 72 | // it('should adjust FQDN req.url with multiple routed handlers', function(done) { 73 | // app.use('/blog', function(req,res,next) { 74 | // next(); 75 | // }); 76 | // app.use('/blog', function(req, res) { 77 | // res.end(req.url); 78 | // }); 79 | 80 | // rawrequest(app) 81 | // .get('http://example.com/blog/post/1') 82 | // .expect(200, 'http://example.com/post/1', done) 83 | // }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { EventEmitter } from 'events' 3 | import { escapeRegExp } from './utils' 4 | import handler from './handler' 5 | 6 | const env = process.env.NODE_ENV || 'development' 7 | const metalStack = Symbol('metal:stack') 8 | 9 | export default class Metal extends EventEmitter { 10 | static createServer() { 11 | const app = new Metal() 12 | function appHandler(req, res, next) { 13 | return appHandler.handle(req, res, next) 14 | } 15 | appHandler.route = '/' 16 | appHandler[metalStack] = [] 17 | appHandler.use = app.use.bind(appHandler) 18 | appHandler.listen = app.listen.bind(appHandler) 19 | appHandler.handle = app.handle.bind(appHandler) 20 | for (const member in Metal.prototype) { 21 | appHandler[member] = Metal.prototype[member] 22 | } 23 | return appHandler 24 | } 25 | listen() { 26 | const server = http.createServer(this) 27 | return server.listen.apply(server, arguments) 28 | } 29 | use(route, handle) { 30 | // default route to '/' 31 | if (typeof route !== 'string' || route instanceof RegExp) { 32 | handle = route 33 | route = /\// 34 | } else if (!(route instanceof RegExp)) { 35 | route = new RegExp(escapeRegExp(route), 'i') 36 | } 37 | // wrap sub-apps 38 | if (typeof handle.handle === 'function') { 39 | const server = handle 40 | server.route = route 41 | handle = (req, res, next) => server.handle(req, res, next) 42 | } 43 | // wrap vanilla http.Servers 44 | if (handle instanceof http.Server) { 45 | handle = handle.listeners('request')[0] 46 | } 47 | this[metalStack].push({ route, handle }) 48 | return this 49 | } 50 | async handle(req, res, out) { 51 | let index = 0 52 | const stack = this[metalStack] 53 | req.originalUrl = req.originalUrl || req.url 54 | const done = out || handler(req, res, { env, onerror }) 55 | function next(err) { 56 | let url = req.url 57 | const { route, handle } = stack[index++] || {} 58 | if (!route) { 59 | return done(err) 60 | } 61 | // eslint-disable-next-line no-cond-assign 62 | if (req.match = route.exec(url)) { 63 | const match = req.match[0] 64 | if (match.length > 1 && match.match(/^(?:http:\/)?\//)) { 65 | url = url.replace(match, '') 66 | if (url.startsWith('?')) { 67 | url = '/' + url 68 | } 69 | if (req.url.startsWith('//')) { 70 | url = 'http:' + url 71 | } 72 | req.url = url 73 | } 74 | return call(handle, err, req, res, next) 75 | } else { 76 | return next() 77 | } 78 | } 79 | await next() 80 | } 81 | } 82 | 83 | // Invoke a route handle. 84 | function call(handle, err, req, res, next) { 85 | const arity = handle.length 86 | const hasError = Boolean(err) 87 | let error = err 88 | 89 | try { 90 | if (hasError && arity === 4) { 91 | // error-handling middleware 92 | return handle(err, req, res, next) 93 | } else if (!hasError && arity < 4) { 94 | // request-handling middleware 95 | return handle(req, res, next) 96 | } 97 | } catch (e) { 98 | // replace the error 99 | error = e 100 | } 101 | return next(error) 102 | } 103 | 104 | // Log error using console.error. 105 | function onerror(err) { 106 | if (env !== 'test') { 107 | console.error(err.stack || err.toString()) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 2 | import { STATUS_CODES as statuses } from 'http' 3 | 4 | export function escapeRegExp(str) { 5 | return str.replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1') 6 | } 7 | 8 | // RegExp to match non-URL code points, *after* encoding (i.e. not including 9 | // "%") and including invalid escape sequences. 10 | const ENCODE_CHARS_REGEXP = 11 | /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g 12 | 13 | // RegExp to match unmatched surrogate pair. 14 | const UNMATCHED_SURROGATE_PAIR_REGEXP = 15 | /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g 16 | 17 | // String to replace unmatched surrogate pair with. 18 | const UNMATCHED_SURROGATE_PAIR_REPLACE = '$1\uFFFD$2' 19 | 20 | // Encode a URL to a percent-encoded form, excluding already-encoded sequences. 21 | // 22 | // This function will take an already-encoded URL and encode all the non-URL 23 | // code points. This function will not encode the "%" character unless it is 24 | // not part of a valid sequence (`%20` will be left as-is, but `%foo` will 25 | // be encoded as `%25foo`). 26 | // 27 | // This encode is meant to be "safe" and does not throw errors. It will 28 | // try as hard as it can to properly encode the given URL, including replacing 29 | // any raw, unpaired surrogate pairs with the Unicode replacement character 30 | // prior to encoding. 31 | export function encodeURL(url) { 32 | return String(url) 33 | .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) 34 | .replace(ENCODE_CHARS_REGEXP, encodeURI) 35 | } 36 | 37 | // Determine if message is already finished 38 | export function isFinished(msg) { 39 | const socket = msg.socket 40 | if (typeof msg.finished === 'boolean') { // OutgoingMessage 41 | return Boolean(msg.finished || (socket && !socket.writable)) 42 | } 43 | if (typeof msg.complete === 'boolean') { // IncomingMessage 44 | return Boolean(msg.upgrade || !socket || !socket.readable || (msg.complete && !msg.readable)) 45 | } 46 | return undefined 47 | } 48 | 49 | // Get status code from response. 50 | export function getResponseStatusCode(res) { 51 | const status = res.statusCode 52 | // default status code to 500 if outside valid range 53 | if (typeof status !== 'number' || status < 400 || status > 599) { 54 | return 500 55 | } 56 | return status 57 | } 58 | 59 | // Get headers from Error object 60 | export function getErrorHeaders(err) { 61 | if (!err.headers || typeof err.headers !== 'object') { 62 | return undefined 63 | } 64 | const headers = Object.create(null) 65 | for (const key of Object.keys(err.headers)) { 66 | headers[key] = err.headers[key] 67 | } 68 | return headers 69 | } 70 | 71 | // Set response headers from an object 72 | export function setHeaders(res, headers) { 73 | if (!headers) { 74 | return 75 | } 76 | for (const key of Object.keys(headers)) { 77 | res.setHeader(key, headers[key]) 78 | } 79 | } 80 | 81 | // Get status code from Error object. 82 | export function getErrorStatusCode(err) { 83 | if (typeof err.status === 'number' && err.status >= 400 && err.status < 600) { 84 | return err.status 85 | } 86 | if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) { 87 | return err.statusCode 88 | } 89 | return undefined 90 | } 91 | 92 | // Get message from Error object, fallback to status message. 93 | export function getErrorMessage(err, status, env) { 94 | let msg 95 | if (env !== 'production') { 96 | msg = err.stack // typically includes err.message 97 | // fallback to err.toString() when possible 98 | if (!msg && typeof err.toString === 'function') { 99 | msg = err.toString() 100 | } 101 | } 102 | return msg || statuses[status] 103 | } 104 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | 2 | var requireESM = require('esm')(module); 3 | var assert = require('assert'); 4 | var connect = requireESM('../src').default.createServer; 5 | var http = require('http'); 6 | var rawrequest = require('./support/rawagent') 7 | var request = require('supertest'); 8 | 9 | describe('app', function(){ 10 | var app; 11 | 12 | beforeEach(function(){ 13 | app = connect(); 14 | }); 15 | 16 | it('should inherit from event emitter', function(done){ 17 | app.on('foo', done); 18 | app.emit('foo'); 19 | }); 20 | 21 | it('should work in http.createServer', function(done){ 22 | var app = connect(); 23 | 24 | app.use(function (req, res) { 25 | res.end('hello, world!'); 26 | }); 27 | 28 | var server = http.createServer(app); 29 | 30 | request(server) 31 | .get('/') 32 | .expect(200, 'hello, world!', done) 33 | }) 34 | 35 | it('should be a callable function', function(done){ 36 | var app = connect(); 37 | 38 | app.use(function (req, res) { 39 | res.end('hello, world!'); 40 | }); 41 | 42 | function handler(req, res) { 43 | res.write('oh, '); 44 | app(req, res); 45 | } 46 | 47 | var server = http.createServer(handler); 48 | 49 | request(server) 50 | .get('/') 51 | .expect(200, 'oh, hello, world!', done) 52 | }) 53 | 54 | it('should invoke callback if request not handled', function(done){ 55 | var app = connect(); 56 | 57 | app.use('/foo', function (req, res) { 58 | res.end('hello, world!'); 59 | }); 60 | 61 | function handler(req, res) { 62 | res.write('oh, '); 63 | app(req, res, function() { 64 | res.end('no!'); 65 | }); 66 | } 67 | 68 | var server = http.createServer(handler); 69 | 70 | request(server) 71 | .get('/') 72 | .expect(200, 'oh, no!', done) 73 | }) 74 | 75 | it('should invoke callback on error', function(done){ 76 | var app = connect(); 77 | 78 | app.use(function (req, res) { 79 | throw new Error('boom!'); 80 | }); 81 | 82 | function handler(req, res) { 83 | res.write('oh, '); 84 | app(req, res, function(err) { 85 | res.end(err.message); 86 | }); 87 | } 88 | 89 | var server = http.createServer(handler); 90 | 91 | request(server) 92 | .get('/') 93 | .expect(200, 'oh, boom!', done) 94 | }) 95 | 96 | it('should work as middleware', function(done){ 97 | // custom server handler array 98 | var handlers = [connect(), function(req, res, next){ 99 | res.writeHead(200, {'Content-Type': 'text/plain'}); 100 | res.end('Ok'); 101 | }]; 102 | 103 | // execute callbacks in sequence 104 | var n = 0; 105 | function run(req, res){ 106 | if (handlers[n]) { 107 | handlers[n++](req, res, function(){ 108 | run(req, res); 109 | }); 110 | } 111 | } 112 | 113 | // create a non-connect server 114 | var server = http.createServer(run); 115 | 116 | request(server) 117 | .get('/') 118 | .expect(200, 'Ok', done) 119 | }); 120 | 121 | it('should escape the 500 response body', function(done){ 122 | app.use(function(req, res, next){ 123 | next(new Error('error!')); 124 | }); 125 | request(app) 126 | .get('/') 127 | .expect(/Error: error!
/) 128 | .expect(/
   at/) 129 | .expect(500, done) 130 | }) 131 | 132 | describe('404 handler', function(){ 133 | it('should escape the 404 response body', function(done){ 134 | rawrequest(app) 135 | .get('/foo/') 136 | .expect(404, />Cannot GET \/foo\/%3Cscript%3Estuff'n%3C\/script%3Ealert()'); 170 | }) 171 | 172 | request(app) 173 | .get('/') 174 | .expect(500, /<script>alert\(\)<\/script>/, done) 175 | }) 176 | 177 | it('should use custom error code', function(done){ 178 | var app = connect(); 179 | 180 | app.use(function(req, res, next){ 181 | var err = new Error('ack!'); 182 | err.status = 503; 183 | throw err; 184 | }) 185 | 186 | request(app) 187 | .get('/') 188 | .expect(503, done) 189 | }) 190 | 191 | it('should keep error statusCode', function(done){ 192 | var app = connect(); 193 | 194 | app.use(function(req, res, next){ 195 | res.statusCode = 503; 196 | throw new Error('ack!'); 197 | }) 198 | 199 | request(app) 200 | .get('/') 201 | .expect(503, done) 202 | }) 203 | 204 | it('shoud not fire after headers sent', function(done){ 205 | var app = connect(); 206 | 207 | app.use(function(req, res, next){ 208 | res.write('body'); 209 | res.end(); 210 | process.nextTick(function() { 211 | next(new Error('ack!')); 212 | }); 213 | }) 214 | 215 | request(app) 216 | .get('/') 217 | .expect(200, done) 218 | }) 219 | 220 | it('shoud have no body for HEAD', function(done){ 221 | var app = connect(); 222 | 223 | app.use(function(req, res, next){ 224 | throw new Error('ack!'); 225 | }); 226 | 227 | request(app) 228 | .head('/') 229 | .expect(500) 230 | .expect(shouldHaveNoBody()) 231 | .end(done) 232 | }); 233 | }); 234 | }); 235 | 236 | function shouldHaveNoBody () { 237 | return function (res) { 238 | assert.ok(res.text === '' || res.text === undefined) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /test/mounting.js: -------------------------------------------------------------------------------- 1 | 2 | var requireESM = require('esm')(module); 3 | var assert = require('assert'); 4 | var connect = requireESM('../src').default.createServer; 5 | var http = require('http'); 6 | var request = require('supertest'); 7 | 8 | describe('app.use()', function(){ 9 | var app; 10 | 11 | beforeEach(function(){ 12 | app = connect(); 13 | }); 14 | 15 | it('should match all paths with "/"', function (done) { 16 | app.use('/', function (req, res) { 17 | res.end(req.url); 18 | }); 19 | 20 | request(app) 21 | .get('/blog') 22 | .expect(200, '/blog', done) 23 | }); 24 | 25 | it('should match full path', function (done) { 26 | app.use('/blog', function (req, res) { 27 | res.end(req.url); 28 | }); 29 | 30 | request(app) 31 | .get('/blog') 32 | .expect(200, '/', done) 33 | }); 34 | 35 | it('should match left-side of path', function (done) { 36 | app.use('/blog', function (req, res) { 37 | res.end(req.url); 38 | }); 39 | 40 | request(app) 41 | .get('/blog/article/1') 42 | .expect(200, '/article/1', done) 43 | }); 44 | 45 | it('should match up to dot', function (done) { 46 | app.use('/blog', function (req, res) { 47 | res.end(req.url) 48 | }) 49 | 50 | request(app) 51 | .get('/blog.json') 52 | .expect(200, done) 53 | }) 54 | 55 | it('should not match shorter path', function (done) { 56 | app.use('/blog-o-rama', function (req, res) { 57 | res.end(req.url); 58 | }); 59 | 60 | request(app) 61 | .get('/blog') 62 | .expect(404, done) 63 | }); 64 | 65 | it('should not end match in middle of component', function (done) { 66 | app.use('/blog', function (req, res) { 67 | res.end(req.url); 68 | }); 69 | 70 | request(app) 71 | .get('/blog-o-rama/article/1') 72 | .expect(404, done) 73 | }); 74 | 75 | it('should be case insensitive (lower-case route, mixed-case request)', function(done){ 76 | var blog = http.createServer(function(req, res){ 77 | assert.equal(req.url, '/'); 78 | res.end('blog'); 79 | }); 80 | 81 | app.use('/blog', blog); 82 | 83 | request(app) 84 | .get('/BLog') 85 | .expect('blog', done) 86 | }); 87 | 88 | it('should be case insensitive (mixed-case route, lower-case request)', function(done){ 89 | var blog = http.createServer(function(req, res){ 90 | assert.equal(req.url, '/'); 91 | res.end('blog'); 92 | }); 93 | 94 | app.use('/BLog', blog); 95 | 96 | request(app) 97 | .get('/blog') 98 | .expect('blog', done) 99 | }); 100 | 101 | it('should be case insensitive (mixed-case route, mixed-case request)', function(done){ 102 | var blog = http.createServer(function(req, res){ 103 | assert.equal(req.url, '/'); 104 | res.end('blog'); 105 | }); 106 | 107 | app.use('/BLog', blog); 108 | 109 | request(app) 110 | .get('/blOG') 111 | .expect('blog', done) 112 | }); 113 | 114 | it('should ignore fn.arity > 4', function(done){ 115 | var invoked = []; 116 | 117 | app.use(function(req, res, next, _a, _b){ 118 | invoked.push(0) 119 | next(); 120 | }); 121 | app.use(function(req, res, next){ 122 | invoked.push(1) 123 | next(new Error('err')); 124 | }); 125 | app.use(function(err, req, res, next){ 126 | invoked.push(2); 127 | res.end(invoked.join(',')); 128 | }); 129 | 130 | request(app) 131 | .get('/') 132 | .expect(200, '1,2', done) 133 | }); 134 | 135 | describe('with a connect app', function(){ 136 | it('should mount', function(done){ 137 | var blog = connect(); 138 | 139 | blog.use(function(req, res){ 140 | assert.equal(req.url, '/'); 141 | res.end('blog'); 142 | }); 143 | 144 | app.use('/blog', blog); 145 | 146 | request(app) 147 | .get('/blog') 148 | .expect(200, 'blog', done) 149 | }); 150 | 151 | it('should retain req.originalUrl', function(done){ 152 | var app = connect(); 153 | 154 | app.use('/blog', function(req, res){ 155 | res.end(req.originalUrl); 156 | }); 157 | 158 | request(app) 159 | .get('/blog/post/1') 160 | .expect(200, '/blog/post/1', done) 161 | }); 162 | 163 | it('should adjust req.url', function(done){ 164 | app.use('/blog', function(req, res){ 165 | res.end(req.url); 166 | }); 167 | 168 | request(app) 169 | .get('/blog/post/1') 170 | .expect(200, '/post/1', done) 171 | }); 172 | 173 | it('should strip trailing slash', function(done){ 174 | var blog = connect(); 175 | 176 | blog.use(function(req, res){ 177 | assert.equal(req.url, '/'); 178 | res.end('blog'); 179 | }); 180 | 181 | app.use('/blog/', blog); 182 | 183 | request(app) 184 | .get('/blog') 185 | .expect('blog', done) 186 | }); 187 | 188 | it('should set .route', function(){ 189 | var blog = connect(); 190 | var admin = connect(); 191 | app.use('/blog', blog); 192 | blog.use('/admin', admin); 193 | assert.equal(app.route, '/'); 194 | assert.equal(blog.route, '/blog'); 195 | assert.equal(admin.route, '/admin'); 196 | }); 197 | 198 | it('should not add trailing slash to req.url', function(done) { 199 | app.use('/admin', function(req, res, next) { 200 | next(); 201 | }); 202 | 203 | app.use(function(req, res, next) { 204 | res.end(req.url); 205 | }); 206 | 207 | request(app) 208 | .get('/admin') 209 | .expect('/admin', done) 210 | }) 211 | }) 212 | 213 | describe('with a node app', function(){ 214 | it('should mount', function(done){ 215 | var blog = http.createServer(function(req, res){ 216 | assert.equal(req.url, '/'); 217 | res.end('blog'); 218 | }); 219 | 220 | app.use('/blog', blog); 221 | 222 | request(app) 223 | .get('/blog') 224 | .expect('blog', done) 225 | }); 226 | }); 227 | 228 | describe('error handling', function(){ 229 | it('should send errors to airty 4 fns', function(done){ 230 | app.use(function(req, res, next){ 231 | next(new Error('msg')); 232 | }) 233 | app.use(function(err, req, res, next){ 234 | res.end('got error ' + err.message); 235 | }); 236 | 237 | request(app) 238 | .get('/') 239 | .expect('got error msg', done) 240 | }) 241 | 242 | it('should skip to non-error middleware', function(done){ 243 | var invoked = false; 244 | 245 | app.use(function(req, res, next){ 246 | next(new Error('msg')); 247 | }) 248 | app.use(function(req, res, next){ 249 | invoked = true; 250 | next(); 251 | }); 252 | app.use(function(err, req, res, next){ 253 | res.end(invoked ? 'invoked' : err.message); 254 | }); 255 | 256 | request(app) 257 | .get('/') 258 | .expect(200, 'msg', done) 259 | }) 260 | 261 | it('should start at error middleware declared after error', function(done){ 262 | var invoked = false; 263 | 264 | app.use(function(err, req, res, next){ 265 | res.end('fail: ' + err.message); 266 | }); 267 | app.use(function(req, res, next){ 268 | next(new Error('boom!')); 269 | }); 270 | app.use(function(err, req, res, next){ 271 | res.end('pass: ' + err.message); 272 | }); 273 | 274 | request(app) 275 | .get('/') 276 | .expect(200, 'pass: boom!', done) 277 | }) 278 | 279 | it('should stack error fns', function(done){ 280 | app.use(function(req, res, next){ 281 | next(new Error('msg')); 282 | }) 283 | app.use(function(err, req, res, next){ 284 | res.setHeader('X-Error', err.message); 285 | next(err); 286 | }); 287 | app.use(function(err, req, res, next){ 288 | res.end('got error ' + err.message); 289 | }); 290 | 291 | request(app) 292 | .get('/') 293 | .expect('X-Error', 'msg') 294 | .expect(200, 'got error msg', done) 295 | }) 296 | 297 | it('should invoke error stack even when headers sent', function(done){ 298 | app.use(function(req, res, next){ 299 | res.end('0'); 300 | next(new Error('msg')); 301 | }); 302 | app.use(function(err, req, res, next){ 303 | done(); 304 | }); 305 | 306 | request(app) 307 | .get('/') 308 | .end(function () {}) 309 | }) 310 | }) 311 | }); 312 | -------------------------------------------------------------------------------- /indie-ws.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const https = require('https') 3 | const fs = require('fs') 4 | const path = require('path') 5 | const os = require('os') 6 | const childProcess = require('child_process') 7 | 8 | const ansi = require('ansi-escape-sequences') 9 | 10 | const express = require('express') 11 | const helmet = require('helmet') 12 | const morgan = require('morgan') 13 | const AcmeTLS = require('@ind.ie/acme-tls') 14 | const redirectHTTPS = require('redirect-https') 15 | 16 | const nodecert = require('@ind.ie/nodecert') 17 | 18 | 19 | class WebServer { 20 | 21 | // Default error pages. 22 | static default404ErrorPage(missingPath) { 23 | return `Error 404: Not found

4🤭4

Could not find ${missingPath}

` 24 | } 25 | 26 | static default500ErrorPage(errorMessage) { 27 | return `Error 500: Internal Server Error

5🔥😱

Internal Server Error

${errorMessage}

` 28 | } 29 | 30 | // Returns a nicely-formatted version string based on 31 | // the version set in the package.json file. (Synchronous.) 32 | version () { 33 | const version = JSON.parse(fs.readFileSync(path.join(__dirname, './package.json'), 'utf-8')).version 34 | return `\n 💖 Indie Web Server v${version} ${clr(`(running on Node ${process.version})`, 'italic')}\n` 35 | } 36 | 37 | // Returns an https server instance – the same as you’d get with 38 | // require('https').createServer() – configured with your locally-trusted nodecert 39 | // certificates by default. If you pass in {global: true} in the options object, 40 | // globally-trusted TLS certificates are obtained from Let’s Encrypt. 41 | // 42 | // Note: if you pass in a key and cert in the options object, they will not be 43 | // ===== used and will be overwritten. 44 | createServer (options = {}, requestListener = undefined) { 45 | 46 | // Let’s be nice and not continue to pollute the options object. 47 | const requestsGlobalCertificateScope = options.global === true 48 | if (options.global !== undefined) { delete options.global } 49 | 50 | if (requestsGlobalCertificateScope) { 51 | return this._createTLSServerWithGloballyTrustedCertificate (options, requestListener) 52 | } else { 53 | // Default to using local certificates. 54 | return this._createTLSServerWithLocallyTrustedCertificate(options, requestListener) 55 | } 56 | } 57 | 58 | 59 | // Starts a static server. You can customise it by passing an options object with the 60 | // following properties (all optional): 61 | // 62 | // • path: (string) the path to serve (defaults to the current working directory). 63 | // • callback: (function) the callback to call once the server is ready (a default is provided). 64 | // • port: (integer) the port to bind to (between 0 - 49,151; the default is 443). 65 | // • global: (boolean) if true, automatically provision an use Let’s Encrypt TLS certificates. 66 | serve (options) { 67 | 68 | console.log(this.version()) 69 | 70 | // The options parameter object and all supported properties on the options parameter 71 | // object are optional. Check and populate the defaults. 72 | if (options === undefined) options = {} 73 | const pathToServe = typeof options.path === 'string' ? options.path : '.' 74 | const port = typeof options.port === 'number' ? options.port : 443 75 | const global = typeof options.global === 'boolean' ? options.global : false 76 | const callback = typeof options.callback === 'function' ? options.callback : function () { 77 | const serverPort = this.address().port 78 | let portSuffix = '' 79 | if (serverPort !== 443) { 80 | portSuffix = `:${serverPort}` 81 | } 82 | const location = global ? os.hostname() : `localhost${portSuffix}` 83 | console.log(`\n 🎉 Serving ${clr(pathToServe, 'cyan')} on ${clr(`https://${location}`, 'green')}\n`) 84 | } 85 | 86 | // Check if a 4042302 (404 → 302) redirect has been requested. 87 | // 88 | // What if links never died? What if we never broke the Web? What if it didn’t involve any extra work? 89 | // It’s possible. And easy. (And with Indie Web Server, it’s seamless.) 90 | // Just make your 404s into 302s. 91 | // 92 | // Find out more at https://4042302.org/ 93 | const _4042302Path = path.join(pathToServe, '4042302') 94 | 95 | // TODO: We should really be checking that this is a file, not that it 96 | // ===== exists, on the off-chance that someone might have a directory 97 | // with that name in their web root (that someone was me when I 98 | // erroneously ran web-server on the directory that I had the 99 | // actually 4042302 project folder in). 100 | const has4042302 = fs.existsSync(_4042302Path) 101 | let _4042302 = null 102 | if (has4042302) { 103 | _4042302 = fs.readFileSync(_4042302Path, 'utf-8').replace(/\s/g, '') 104 | } 105 | 106 | // Check if a custom 404 page exists at the conventional path. If it does, load it for use later. 107 | const custom404Path = path.join(pathToServe, '404', 'index.html') 108 | const hasCustom404 = fs.existsSync(custom404Path) 109 | let custom404 = null 110 | if (hasCustom404) { 111 | custom404 = fs.readFileSync(custom404Path, 'utf-8') 112 | } 113 | 114 | // Check if a custom 500 page exists at the conventional path. If it does, load it for use later. 115 | const custom500Path = path.join(pathToServe, '500', 'index.html') 116 | const hasCustom500 = fs.existsSync(custom500Path) 117 | let custom500 = null 118 | if (hasCustom500) { 119 | custom500 = fs.readFileSync(custom500Path, 'utf-8') 120 | } 121 | 122 | // Check for a valid port range 123 | // (port above 49,151 are ephemeral ports. See https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers#Dynamic,_private_or_ephemeral_ports) 124 | if (port < 0 || port > 49151) { 125 | throw new Error('Error: specified port must be between 0 and 49,151 inclusive.') 126 | } 127 | 128 | // On Linux, we need to get the Node process special access to so-called privileged 129 | // ports (<1,024). This is meaningless security theatre unless you’re living in 1968 130 | // and using a mainframe and hopefully Linux will join the rest of the modern world 131 | // in dropping this requirement soon (macOS just did in Mojave). 132 | this.ensureWeCanBindToPort(port) 133 | 134 | // Create an express server to serve the path using Morgan for logging. 135 | const app = express() 136 | app.use(helmet()) // Express.js security with HTTP headers. 137 | app.use(morgan('tiny')) // Logging. 138 | 139 | // To test a 500 error, hit /test-500-error 140 | app.use((request, response, next) => { 141 | if (request.path === '/test-500-error') { 142 | throw new Error('Bad things have happened.') 143 | } else { 144 | next() 145 | } 146 | }) 147 | 148 | app.use(express.static(pathToServe)) 149 | 150 | // 404 (Not Found) support. 151 | app.use((request, response, next) => { 152 | // If a 4042302 (404 → 302) redirect has been requested, honour that. 153 | // (See https://4042302.org/). Otherwise, if there is a custom 404 error page, 154 | // serve that. (The template variable THE_PATH, if present on the page, will be 155 | // replaced with the current request path before it is returned.) 156 | if (has4042302) { 157 | const forwardingURL = `${_4042302}${request.url}` 158 | console.log(`404 → 302: Forwarding to ${forwardingURL}`) 159 | response.redirect(forwardingURL) 160 | } else if (hasCustom404) { 161 | // Enable basic template support for including the missing path. 162 | const custom404WithPath = custom404.replace('THE_PATH', request.path) 163 | 164 | // Enable relative links to work in custom error pages. 165 | const custom404WithPathAndBase = custom404WithPath.replace('', '\n\t') 166 | 167 | response.status(404).send(custom404WithPathAndBase) 168 | } else { 169 | // Send default 404 page. 170 | response.status(404).send(WebServer.default404ErrorPage(request.path)) 171 | } 172 | }) 173 | 174 | // 500 (Server error) support. 175 | app.use((error, request, response, next) => { 176 | // Strip the Error: prefix from the message. 177 | const errorMessage = error.toString().replace('Error: ', '') 178 | 179 | // If there is a custom 500 path, serve that. The template variable 180 | // THE_ERROR, if present on the page, will be replaced with the error description. 181 | if (hasCustom500) { 182 | // Enable basic template support for including the error message. 183 | const custom500WithErrorMessage = custom500.replace('THE_ERROR', errorMessage) 184 | 185 | // Enable relative links to work in custom error pages. 186 | const custom500WithErrorMessageAndBase = custom500WithErrorMessage.replace('', '\n\t') 187 | 188 | response.status(500).send(custom500WithErrorMessageAndBase) 189 | } else { 190 | // Send default 500 page. 191 | response.status(500).send(WebServer.default500ErrorPage(errorMessage)) 192 | } 193 | }) 194 | 195 | // Create the server and start listening on the requested port. 196 | let server 197 | try { 198 | server = this.createServer({global}, app).listen(port, callback) 199 | } catch (error) { 200 | console.log('\nError: could not start server', error) 201 | throw error 202 | } 203 | 204 | return server 205 | } 206 | 207 | // 208 | // Private. 209 | // 210 | 211 | _createTLSServerWithLocallyTrustedCertificate (options, requestListener = undefined) { 212 | console.log(' 🚧 [Indie Web Server] Using locally-trusted certificates.') 213 | 214 | // Ensure that locally-trusted certificates exist. 215 | nodecert() 216 | 217 | const nodecertDirectory = path.join(os.homedir(), '.nodecert') 218 | 219 | const defaultOptions = { 220 | key: fs.readFileSync(path.join(nodecertDirectory, 'localhost-key.pem')), 221 | cert: fs.readFileSync(path.join(nodecertDirectory, 'localhost.pem')) 222 | } 223 | 224 | Object.assign(options, defaultOptions) 225 | 226 | return https.createServer(options, requestListener) 227 | } 228 | 229 | 230 | _createTLSServerWithGloballyTrustedCertificate (options, requestListener = undefined) { 231 | console.log(' 🌍 [Indie Web Server] Using globally-trusted certificates.') 232 | 233 | // Certificates are automatically obtained for the hostname and the www. subdomain of the hostname 234 | // for the machine that we are running on. 235 | const hostname = os.hostname() 236 | 237 | const acmeTLS = AcmeTLS.create({ 238 | // Note: while testing, you might want to use the staging server at: 239 | // ===== https://acme-staging-v02.api.letsencrypt.org/directory 240 | server: 'https://acme-v02.api.letsencrypt.org/directory', 241 | 242 | version: 'draft-11', 243 | 244 | // Certificates are stored in ~/.acme-tls/ 245 | configDir: `~/.acme-tls/${hostname}/`, 246 | 247 | approvedDomains: [hostname, `www.${hostname}`], 248 | agreeTos: true, 249 | 250 | // Instead of an email address, we pass the hostname. ACME TLS is based on 251 | // Greenlock.js and those folks decided to make email addresses a requirement 252 | // instead of an optional element as is the case with Let’s Encrypt. This has deep 253 | // architectural knock-ons including to the way certificates are stored in 254 | // the le-store-certbot storage strategy, etc. Instead of forking and gutting 255 | // multiple modules (I’ve already had to fork a number to remove the telemetry), 256 | // we are using the hostname in place of the email address as a local identifier. 257 | // Our fork of acme-v02 is aware of this and will simply disregard any email 258 | // addresses passed that match the hostname before making the call to the ACME 259 | // servers. (That module, as it reflects the ACME spec, does _not_ have the email 260 | // address as a required property.) 261 | email: os.hostname(), 262 | 263 | // These will be removed altogether soon. 264 | telemetry: false, 265 | communityMember: false, 266 | }) 267 | 268 | // Create an HTTP server to handle redirects for the Let’s Encrypt ACME HTTP-01 challenge method that we use. 269 | const httpsRedirectionMiddleware = redirectHTTPS() 270 | const httpServer = http.createServer(acmeTLS.middleware(httpsRedirectionMiddleware)) 271 | httpServer.listen(80, () => { 272 | console.log(' 👉 [Indie Web Server] HTTP → HTTPS redirection active.') 273 | }) 274 | 275 | // Add the TLS options from ACME TLS to any existing options that might have been passed in. 276 | Object.assign(options, acmeTLS.tlsOptions) 277 | 278 | // Create and return the HTTPS server. 279 | return https.createServer(options, requestListener) 280 | } 281 | 282 | 283 | // If we’re on Linux and the requested port is < 1024 ensure that we can bind to it. 284 | // (As of macOS Mojave, privileged ports are only an issue on Linux. Good riddance too, 285 | // as these so-called privileged ports are a relic from the days of mainframes and they 286 | // actually have a negative impact on security today: 287 | // https://www.staldal.nu/tech/2007/10/31/why-can-only-root-listen-to-ports-below-1024/ 288 | // 289 | // Note: this might cause issues if https-server is used as a library as it assumes that the 290 | // ===== current app is in index.js and that it can be forked. This might be an issue if a 291 | // process manager is already being used, etc. Worth keeping an eye on and possibly 292 | // making this method an optional part of server startup. 293 | ensureWeCanBindToPort (port) { 294 | if (port < 1024 && os.platform() === 'linux') { 295 | const options = {env: process.env} 296 | try { 297 | childProcess.execSync(`setcap -v 'cap_net_bind_service=+ep' $(which ${process.title})`, options) 298 | } catch (error) { 299 | try { 300 | // Allow Node.js to bind to ports < 1024. 301 | childProcess.execSync(`sudo setcap 'cap_net_bind_service=+ep' $(which ${process.title})`, options) 302 | 303 | console.log(' 😇 [Indie Web Server] First run on Linux: got privileges to bind to ports < 1024. Restarting…') 304 | 305 | // Fork a new instance of the server so that it is launched with the privileged Node.js. 306 | childProcess.fork(path.join(__dirname, 'bin', 'web-server.js'), process.argv.slice(2), {env: process.env}) 307 | 308 | // We’re done here. Go into an endless loop. Exiting (Ctrl+C) this will also exit the child process. 309 | while(1){} 310 | } catch (error) { 311 | console.log(`\n Error: could not get privileges for Node.js to bind to port ${port}.`, error) 312 | throw error 313 | } 314 | } 315 | } 316 | } 317 | } 318 | 319 | module.exports = new WebServer() 320 | 321 | // 322 | // Helpers. 323 | // 324 | 325 | // Format ansi strings. 326 | // Courtesy Bankai (https://github.com/choojs/bankai/blob/master/bin.js#L142) 327 | function clr (text, color) { 328 | return process.stdout.isTTY ? ansi.format(text, color) : text 329 | } --------------------------------------------------------------------------------