├── .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%3E, done)
137 | });
138 |
139 | it('shoud not fire after headers sent', function(done){
140 | var app = connect();
141 |
142 | app.use(function(req, res, next){
143 | res.write('body');
144 | res.end();
145 | process.nextTick(next);
146 | })
147 |
148 | request(app)
149 | .get('/')
150 | .expect(200, done)
151 | })
152 |
153 | it('shoud have no body for HEAD', function(done){
154 | var app = connect();
155 |
156 | request(app)
157 | .head('/')
158 | .expect(404)
159 | .expect(shouldHaveNoBody())
160 | .end(done)
161 | })
162 | })
163 |
164 | describe('error handler', function(){
165 | it('should have escaped response body', function(done){
166 | var app = connect();
167 |
168 | app.use(function(req, res, next){
169 | throw new Error('');
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 found4🤭4
Could not find ${missingPath}
`
24 | }
25 |
26 | static default500ErrorPage(errorMessage) {
27 | return `Error 500: Internal Server Error5🔥😱
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 | }
--------------------------------------------------------------------------------