jlc-dev-serve
Version:
A simple and efficient HTTP/S server for serving static files, meant to be used during development (with sane defaults).
488 lines (467 loc) • 17.1 kB
JavaScript
// it's a slippery slope from here on
import mime from 'mime'
import * as os from 'os'
import * as fs from 'node:fs'
import * as net from 'node:net'
import * as zlib from 'node:zlib'
import * as http from 'node:http'
import * as https from 'node:https'
import * as chokidar from 'chokidar'
import * as liveReload from './liveReloadServer.js'
import {sep, relative, basename} from 'node:path'
import {pipeline} from 'node:stream/promises'
import {createRequire} from 'node:module'
const require = createRequire(import.meta.url)
const {version} = require('./package.json')
const log = console.log
// so errors will look nicer (skips "double reporting")
process.on('uncaughtException', (error, origin) => {
if (error.code == 'ERR_STREAM_PREMATURE_CLOSE') {
log('🤯', 'ERR_STREAM_PREMATURE_CLOSE happened (but is impossible to catch), so we continue...')
} else {
log('🤯', error)
process.exit(1)
}
})
log(`💾 Starting jlc-dev-serve v${version}...`)
const config = {
host: process.env.PUBLIC ? '::' : process.env.HOST || 'localhost',
port: envNumber(process.env.PORT) ?? (process.env.HTTP ? 8080 : 4433),
cert: process.env.CERT,
key: process.env.KEY,
pass: process.env.PASS,
prefix: process.env.PREFIX || '',
http: envSwitch(process.env.HTTP),
redirect: envNumber(process.env.REDIRECT),
compression: envSwitch(process.env.COMPRESSION),
ignore_index: envSwitch(process.env.IGNORE_INDEX),
js_bootstrap: process.env.JS_BOOTSTRAP,
js_bootstrap_custom: process.env.JS_BOOTSTRAP_CUSTOM,
no_live_reload: envSwitch(process.env.NO_LIVE_RELOAD),
no_directory_listing: envSwitch(process.env.NO_DIRECTORY_LISTING)
}
// get rid of undefined values and print the config
log('⚙️ Current configuration:', JSON.parse(JSON.stringify(config))) // alt. 🪛
// check for insane config
if (process.env.PUBLIC && process.env.HOST) {
throw Error(`Can't set both PUBLIC and HOST, because PUBLIC will set the HOST for you.`)
}
if (config.http && config.cert) {
throw Error(`Can't set both HTTP and CERT (a certificate is only used with HTTPS servers).`)
}
if (config.http && config.redirect) {
throw Error(`Can't set both HTTP and REDIRECT (a redirect server is only used with HTTPS servers).`)
}
if (config.port < 0 || config.port > 65535) {
throw Error(`Port (${config.port}) is not in the valid range of 0-65535.`)
}
if (config.cert && !config.key) {
throw Error(`If using CERT you must also supply KEY.`)
}
if (!config.cert && config.pass) {
throw Error(`If using PASS you must also supply CERT and KEY.`)
}
if (config.prefix && (config.prefix.startsWith('/') || config.prefix.endsWith('/'))) {
throw Error(`A PREFIX must never start or end with /.`)
}
if (config.js_bootstrap) {
const scripts = []
let header, isModule = false
if (envSwitch(config.js_bootstrap)) { // if `true` or `1`
// all is good
} else {
const errorMsg = `If JS_BOOTSTRAP differs from "1" or "true" then it must follow a special syntax. Check the documentation at: https://github.com/JoakimCh/jlc-dev-serve`
const parts = config.js_bootstrap.split('|')
for (const part of parts) {
if (part == 'module') {
if (isModule) throw Error(errorMsg)
isModule = true
} else {
const url = new URL(part, 'http://whatever')
if (url.pathname.endsWith('.js')) {
scripts.push({isModule, src: part})
isModule = false
} else {
throw Error(errorMsg)
}
}
}
}
header = `<!doctype html>\n`
+`<meta charset="utf-8">\n`
+`<meta name="viewport" content="width=device-width, initial-scale=1.0">\n`
+`<title>index</title>\n`
for (const {src, isModule} of scripts) {
header += `<script ${isModule ? `type="module"` : ''} src="${src}"></script>\n`
}
config.js_bootstrap = {header, isModule}
}
if (config.js_bootstrap_custom) {
const parts = config.js_bootstrap_custom.split('|')
config.js_bootstrap_custom = new Map()
let isModule
for (const part of parts) {
if (part == 'module') {
isModule = true
} else {
let filePath = relative(process.cwd(), part)
if (!fs.existsSync(filePath)) {
throw Error(`Path doesn't exist: ${filePath}`)
}
if (process.platform == 'win32') {
filePath.replace('\\', '/')
}
filePath = '/' + filePath
config.js_bootstrap_custom.set(withoutExt(filePath), {filePath, isModule})
isModule = false
}
}
}
let isPublic
/** (doesn't allow tricks to escape further up in the filesystem) */
const filesServed = new Set()
const currentDirectory = process.cwd()
const server = await createServer()
server.once('listening', onceListening)
server.on('tlsClientError', (error, socket) => {
log(`${getClock()} ${socket.remoteAddress} failed setting up a secure connection, maybe it tried to connect using the HTTP protocol.`)
})
log(`🔍 Scanning for files to serve in the "current directory" which is:\n ${currentDirectory}`)
const watcher = chokidar.watch('.', {
ignored: (path) => {
if (path == '.') return false
return basename(path).startsWith('.')
}
})
watcher.once('ready', async () => {
log('🗃️', filesServed.size, `files found, starting the ${config.http ? 'HTTP' : 'HTTPS'} server...`)
await changePortIfNeeded()
server.listen(config.port, config.host)
if (!config.no_live_reload) {
log(`🔁 Starting the LiveReload server...`)
liveReload.start(config)
}
})
watcher.on('all', (event, path) => {
if (config.prefix) {
path = `/${config.prefix}/${path}`
} else {
path = '/'+path
}
switch (event) {
case 'add':
// when watching '.' (CWD) it returns a path relative to it
if (process.platform == 'win32') { // if on Windows we convert the path separators to the URL format
filesServed.add(path.replace('\\', '/'))
} else {
filesServed.add(path)
}
break
case 'change':
if (!config.no_live_reload) {
liveReload.reportChange(path)
}
break
case 'unlink':
filesServed.delete(path)
break
}
})
function onceListening() {
let address = server.address().address
if (server.address().family == 'IPv6') {
address = `[${address}]`
}
switch (address) {
case '[::1]':
case '127.0.0.1':
address = 'localhost'
}
const prefix = config.prefix ? config.prefix+'/' : ''
if (address == 'localhost') {
log(`✅ Ready for connections on: ${config.http ? 'http' : 'https'}://${address}:${server.address().port}/${prefix}`)
log(`🔒 (no access allowed outside of your computer) 🔒`)
} else {
isPublic = true
log(`✅ Ready for connections on:`)
console.group()
const listeningAddresses = getListeningAddresses(address)
for (const address of listeningAddresses) {
log(`${config.http ? 'http' : 'https'}://${address}:${server.address().port}/${prefix}`)
}
console.groupEnd()
log(`🔓🛑⚠️ WARNING: THE SERVED FILES ARE AVAILABLE TO ANY OTHER COMPUTER WHICH CAN ACCESS THOSE ADDRESSES! ⚠️🛑🔓`)
}
if (config.redirect) {
createRedirectServer(address)
}
}
async function createServer() {
if (config.http) {
return http.createServer(requestListener)
} else {
let key, cert, passphrase = config.pass
if (config.cert) {
log('🪪 Reading the certificate from: '+config.cert)
try {
key = envPEM('KEY', config.key)
cert = envPEM('CERT', config.cert)
} catch (error) {
throw Error(`Failure reading files from the CERT and KEY paths.`, {cause: error})
}
} else { // use devcert then
if (!['::1', '127.0.0.1', 'localhost'].includes(config.host.toLowerCase())) {
throw Error(`Please supply your own certificate (CERT and KEY) for ${config.host}, since a "localhost" certificate will not work with it.`)
}
log('🪪 Getting a certificate for "localhost" (might require admin-privileges on first run)...')
const devcert = await import('@expo/devcert')
const result = await devcert.certificateFor('localhost', {
skipCertutilInstall: false
})
key = result.key
cert = result.cert
}
return https.createServer({key, cert, passphrase}, requestListener)
}
}
function envPEM(env, value) {
if (value.includes('-----BEGIN')) {
return value
} else {
if (fs.existsSync(value)) {
value = fs.readFileSync(value)
}
if (!value.includes('-----BEGIN')) {
throw Error(`${env} must either contain the path to a PEM file or the text of a valid PEM file.`)
}
}
}
async function changePortIfNeeded() {
try {
if (await isPortTaken(config.port, config.host)) {
log(`🛑 Port ${config.port} is in use, trying another port...`)
config.port = 0 // then Node.js will pick one that is available
}
} catch (error) {
if (error.code == 'EACCES') {
error = Error(`No access to port ${config.port} (port 0 to 1023 require admin-privileges).`)
}
error.message = ''+error.message
throw error
}
}
async function requestListener(request, response) {
let urlPath
response.on('error', error => {
log('🤯 response error', error)
})
try { // so no failure can crash the server
const url = new URL(request.url, 'http:\\'+request.headers.host)
urlPath = decodeURIComponent(url.pathname)
if (!config.ignore_index && urlPath.endsWith('/') && filesServed.has(urlPath+'index.html')) {
urlPath += 'index.html'
} else if (config.js_bootstrap && urlPath.endsWith('/')
&& (filesServed.has(urlPath+'index.js') || filesServed.has(urlPath+'index.mjs'))) {
const extIsMjs = filesServed.has(urlPath+'index.mjs')
if (extIsMjs) {
urlPath += 'index.mjs'
} else {
urlPath += 'index.js'
}
let {header, isModule} = config.js_bootstrap
if (extIsMjs) isModule = true
const html = header + `<script ${isModule ? `type="module" ` : ''}src="${urlPath}"></script>`
// response.statusCode = 200
response.setHeader('Content-Type', 'text/html')
response.end(html)
urlPath = null // to not serve anything else
} else if (config.js_bootstrap_custom?.has(urlPath)) {
const {filePath, isModule} = config.js_bootstrap_custom.get(urlPath)
const html = `<!doctype html>\n`
+`<meta charset="utf-8">\n`
+`<meta name="viewport" content="width=device-width, initial-scale=1.0">\n`
+`<title>${urlPath}</title>\n`
+`<script ${isModule ? `type="module" ` : ''}src="${filePath}"></script>`
response.setHeader('Content-Type', 'text/html')
response.end(html)
urlPath = null // to not serve anything else
}
if (urlPath == null) {
// then do nothing more
} else if (urlPath.endsWith('/') && !config.NO_DIRECTORY_LISTING) {
directoryListing(urlPath, response)
} else if (filesServed.has(urlPath)) {
const filePath = currentDirectory + (!config.prefix ? urlPath : urlPath.slice(config.prefix.length+1))
await serveFile(request, response, filePath, config)
} else {
response.statusCode = 404
response.end()
}
log(`${getClock()} ${isPublic ? request.socket.remoteAddress : '127.0.0.1'} ${response.statusCode} ${request.method} ${decodeURIComponent(request.url)}`)
} catch (error) {
try {
response.statusCode = 500
response.setHeader('Content-Type', 'text/html')
response.end(''+error)
} catch (error) {
log('🤯 error response error', error)
}
log(`${getClock()} ${isPublic ? request.socket.remoteAddress : '127.0.0.1'} ${response.statusCode} ${request.method} ${decodeURIComponent(request.url)} (${error})`)
}
}
async function serveFile(request, response, filePath, config) {
const stat = fs.statSync(filePath)
const eTag = stat.mtimeMs.toString(36)
if (request.headers['if-none-match'] == eTag) {
response.statusCode = 304 // Not Modified
response.end() // browser cache is then used
} else {
// response.statusCode = 200
response.setHeader('Etag', eTag)
response.setHeader('Content-Type', mime.getType(filePath) || 'application/octet-stream')
if (request.method == 'HEAD') {
response.end()
} else {
let encoder, contentEncoding
const accept = request.headers['accept-encoding']
if (config.compression && accept) {
const accepts = accept.split(', ')
if (accepts.includes('br')) {
contentEncoding = 'br'
encoder = zlib.createBrotliCompress({params: {
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: stat.size
}})
} else if (accepts.includes('gzip')) {
contentEncoding = 'gzip'
encoder = zlib.createGzip({level: 9})
}
}
if (contentEncoding) { // if compression
response.setHeader('Content-Encoding', contentEncoding)
await pipeline(fs.createReadStream(filePath), encoder, response)
} else {
await pipeline(fs.createReadStream(filePath), response)
}
}
}
}
function isPortTaken(port, host) {
return new Promise((resolve, reject) => {
const dummyServer = net.createServer()
.once('error', error => {
error.code == 'EADDRINUSE' ? resolve(true) : reject(error)
})
.once('listening', () => {
dummyServer.close(() => resolve(false))
})
.listen(port, host)
})
}
function getClock() {
const date = new Date()
const hours = date.getHours().toString().padStart(2,'0')
const minutes = date.getMinutes().toString().padStart(2,'0')
const seconds = date.getSeconds().toString().padStart(2,'0')
return `${hours}:${minutes}:${seconds}`
}
async function createRedirectServer(address) {
let notAvailable
try {
notAvailable = await isPortTaken(config.redirect, config.host)
} catch {
notAvailable = true
}
if (notAvailable) {
log(`⚠️ Setting up a HTTP to HTTPS redirect server on port ${config.redirect} failed.`)
} else { // setup a redirect server
log(`🔀 Setting up a HTTP to HTTPS redirect server on port ${config.redirect}.`)
http.createServer((request, response) => {
const newLocation = `https://${address}:${server.address().port}${request.url}`
response.statusCode = 301 // Moved Permanently
response.setHeader('Location', newLocation)
response.end()
log(`${getClock()} ${isPublic ? request.socket.remoteAddress : '127.0.0.1'} Request to port ${config.redirect} (HTTP) redirected to: ${newLocation}`)
}).listen(config.redirect, config.host)
}
}
/** Return `undefined` if not set, else a number. */
function envNumber(value) {
if (typeof value == 'string') {
return Number(value)
}
return undefined
}
function envSwitch(value) {
if (typeof value == 'string') {
switch (value.toLowerCase()) {
case 'true': case '1':
return true
}
}
return undefined
}
function getListeningAddresses(host) {
let familyFilter
switch (host) {
case '0.0.0.0': familyFilter = ['IPv4']; break
case '::': case '[::]': familyFilter = ['IPv4', 'IPv6']; break
default: return [host]
}
const IPs = []
for (const networkInterface of Object.values(os.networkInterfaces())) {
for (const {address, family, internal, scopeid} of networkInterface) {
const IPv6 = (family == 'IPv6')
if (IPv6 && scopeid != 0) continue
if (internal || !familyFilter.includes(family)) continue
IPs.push(IPv6 ? `[${address}]` : address)
}
}
return IPs
}
function directoryListing(urlPath, response) {
// response.statusCode = 200
response.setHeader('Content-Type', 'text/html')
const subDirLevel = urlPath.split('/').length
const files = new Set()
const directories = new Set()
for (const filePath of filesServed.keys()) {
const fileSubDirLevel = filePath.split('/').length
if (filePath.startsWith(urlPath)) {
if (fileSubDirLevel == subDirLevel) {
files.add(filePath.split('/')[subDirLevel-1])
} else if (fileSubDirLevel >= subDirLevel) {
directories.add(filePath.split('/')[subDirLevel-1]+'/')
}
}
}
let html = `<!doctype html>\n`
+`<meta charset="utf-8">\n`
+`<meta name="viewport" content="width=device-width, initial-scale=1.0">\n`
+`<title>Directory listing</title>\n`
+`<style>:root {color-scheme: light dark}</style>\n`
+`<h3>Directory listing for: ${urlPath}</h3>\n`
+'<nav><ul>\n'
if (urlPath != '/') {
html += `<li>🔙<a href="..">[parent directory]</a></li>\n`
}
for (const dir of directories) {
html += `<li>📁<a href="${urlPath+dir}">${dir}</a></li>\n`
}
for (const dir of files) {
html += `<li>🗒️<a href="${urlPath+dir}">${dir}</a></li>\n`
}
html += '</ul></nav>\n'
response.end(html)
}
/** Returns the path without the extension part. */
function withoutExt(path = '') {
const lastSep = path.lastIndexOf(sep)
const lastDot = path.lastIndexOf('.')
if (lastDot > lastSep) {
return path.slice(0, lastDot)
} else {
return path
}
}