Comparing version 0.4.4 to 0.4.5
@@ -10,3 +10,3 @@ import { createServer } from 'node:http'; | ||
import { RequestHandler } from './handler.js'; | ||
import { color, logger, requestLogLine } from './logger.js'; | ||
import { color, Logger, requestLogLine } from './logger.js'; | ||
import { serverOptions } from './options.js'; | ||
@@ -16,2 +16,3 @@ import { FileResolver } from './resolver.js'; | ||
export async function run() { | ||
const logger = new Logger(process.stdout, process.stderr); | ||
const args = new CLIArgs(argv.slice(2)); | ||
@@ -38,3 +39,3 @@ if (args.has('--version')) { | ||
} | ||
const cliServer = new CLIServer(options); | ||
const cliServer = new CLIServer(options, logger); | ||
cliServer.start(); | ||
@@ -48,5 +49,5 @@ } | ||
#server; | ||
#resolver; | ||
#shuttingDown = false; | ||
constructor(options) { | ||
#logger; | ||
constructor(options, logger) { | ||
this.#logger = logger; | ||
this.#options = options; | ||
@@ -61,25 +62,26 @@ this.#portIterator = new Set(options.ports).values(); | ||
res.on('close', () => { | ||
logger.write('request', requestLogLine(handler.data())); | ||
this.#logger.write('request', requestLogLine(handler.data())); | ||
}); | ||
await handler.process(); | ||
}); | ||
server.on('error', (error) => this.#onServerError(error)); | ||
server.on('error', (error) => this.onError(error)); | ||
server.on('listening', () => { | ||
const info = this.headerInfo(); | ||
if (info) logger.write('header', info, { top: 1, bottom: 1 }); | ||
if (info) { | ||
this.#logger.write('header', info, { top: 1, bottom: 1 }); | ||
} | ||
}); | ||
this.#resolver = resolver; | ||
this.#server = server; | ||
} | ||
nextPort() { | ||
this.#port = this.#portIterator.next().value; | ||
return this.#port; | ||
} | ||
start() { | ||
const port = this.nextPort(); | ||
if (!port) throw new Error('Port not specified'); | ||
this.handleSignals(); | ||
this.#server.listen( | ||
{ | ||
host: this.#options.host, | ||
port: this.#portIterator.next().value, | ||
}, | ||
() => { | ||
this.handleKeyboardInput(); | ||
}, | ||
); | ||
this.#server.listen({ host: this.#options.host, port }, () => { | ||
this.handleKeyboardInput(); | ||
}); | ||
} | ||
@@ -120,3 +122,3 @@ headerInfo() { | ||
helpShown = true; | ||
logger.write('info', 'Hit Control+C or Escape to stop the server.'); | ||
this.#logger.write('info', 'Hit Control+C or Escape to stop the server.'); | ||
} | ||
@@ -126,7 +128,11 @@ }); | ||
} | ||
#attached = false; | ||
handleSignals() { | ||
if (this.#attached) return; | ||
process.on('SIGBREAK', this.shutdown); | ||
process.on('SIGINT', this.shutdown); | ||
process.on('SIGTERM', this.shutdown); | ||
this.#attached = true; | ||
} | ||
#shuttingDown = false; | ||
shutdown = async () => { | ||
@@ -136,3 +142,3 @@ if (this.#shuttingDown) return; | ||
process.exitCode = 0; | ||
const promise = logger.write('info', 'Gracefully shutting down...'); | ||
const promise = this.#logger.write('info', 'Gracefully shutting down...'); | ||
this.#server.closeAllConnections(); | ||
@@ -143,17 +149,13 @@ this.#server.close(); | ||
}; | ||
#onServerError(error) { | ||
onError(error) { | ||
if (error.code === 'EADDRINUSE') { | ||
const { value: nextPort } = this.#portIterator.next(); | ||
const { ports } = this.#options; | ||
this.#server.closeAllConnections(); | ||
this.#server.close(() => { | ||
if (nextPort) { | ||
this.#port = nextPort; | ||
this.#server.listen({ | ||
host: this.#options.host, | ||
port: this.#port, | ||
}); | ||
const port = this.nextPort(); | ||
if (port) { | ||
this.#server.listen({ host: this.#options.host, port }); | ||
} else { | ||
const { ports } = this.#options; | ||
const msg = `${ports.length > 1 ? 'ports' : 'port'} already in use: ${ports.join(', ')}`; | ||
logger.error(msg); | ||
this.#logger.error(msg); | ||
exit(1); | ||
@@ -165,5 +167,5 @@ } | ||
if (error.code === 'ENOTFOUND') { | ||
logger.error(`host not found: '${error.hostname}'`); | ||
this.#logger.error(`host not found: '${error.hostname}'`); | ||
} else { | ||
logger.error(error); | ||
this.#logger.error(error); | ||
} | ||
@@ -170,0 +172,0 @@ exit(1); |
import { release } from 'node:os'; | ||
import { platform } from 'node:process'; | ||
import { stderr, stdout } from 'node:process'; | ||
import { inspect } from 'node:util'; | ||
@@ -28,5 +27,9 @@ import { clamp, fwdSlash, getEnv, getRuntime, trimSlash, withResolvers } from './utils.js'; | ||
} | ||
class Logger { | ||
#lastout; | ||
#lasterr; | ||
export class Logger { | ||
#out; | ||
#err; | ||
constructor(out, err) { | ||
this.#out = { stream: out }; | ||
this.#err = { stream: err ?? out }; | ||
} | ||
async write(group, data = '', padding = { top: 0, bottom: 0 }) { | ||
@@ -42,13 +45,9 @@ const item = { | ||
const { promise, resolve, reject } = withResolvers(); | ||
const writeCallback = (err) => { | ||
const dest = group === 'error' ? this.#err : this.#out; | ||
const text = this.#withPadding(dest.last, item); | ||
dest.last = item; | ||
dest.stream.write(text, (err) => { | ||
if (err) reject(err); | ||
else resolve(); | ||
}; | ||
if (group === 'error') { | ||
stderr.write(this.#withPadding(this.#lasterr, item), writeCallback); | ||
this.#lasterr = item; | ||
} else { | ||
stdout.write(this.#withPadding(this.#lastout, item), writeCallback); | ||
this.#lastout = item; | ||
} | ||
}); | ||
return promise; | ||
@@ -151,2 +150,1 @@ } | ||
export const color = new ColorUtils(supportsColor()); | ||
export const logger = new Logger(); |
import { isAbsolute, resolve } from 'node:path'; | ||
import { DEFAULT_OPTIONS, PORTS_CONFIG } from './constants.js'; | ||
export function serverOptions(options, context) { | ||
const validator = new OptionsValidator(context.onError); | ||
const checked = { | ||
ports: validator.ports(options.ports), | ||
gzip: validator.gzip(options.gzip), | ||
host: validator.host(options.host), | ||
cors: validator.cors(options.cors), | ||
headers: validator.headers(options.headers), | ||
dirFile: validator.dirFile(options.dirFile), | ||
dirList: validator.dirList(options.dirList), | ||
ext: validator.ext(options.ext), | ||
exclude: validator.exclude(options.exclude), | ||
}; | ||
const final = structuredClone({ | ||
root: validator.root(options.root), | ||
...DEFAULT_OPTIONS, | ||
}); | ||
for (const [key, value] of Object.entries(checked)) { | ||
const valid = typeof value !== 'undefined'; | ||
if (typeof value !== 'undefined') { | ||
final[key] = value; | ||
} | ||
} | ||
return final; | ||
} | ||
export class OptionsValidator { | ||
onError; | ||
#errorCb; | ||
constructor(onError) { | ||
this.onError = onError; | ||
this.#errorCb = onError; | ||
} | ||
#array(input, filterFn) { | ||
if (!Array.isArray(input)) return; | ||
if (input.length === 0) return input; | ||
const value = input.filter(filterFn); | ||
if (value.length) return value; | ||
#error(msg, input) { | ||
let dbg = input; | ||
if (typeof input === 'object') { | ||
dbg = JSON.stringify(input); | ||
} else if (typeof input === 'string') { | ||
dbg = `'${dbg.replaceAll("'", "\\'")}'`; | ||
} | ||
this.#errorCb(`${msg}: ${dbg}`); | ||
} | ||
#bool(optName, input) { | ||
#arr(input, msg, validFn) { | ||
if (typeof input === 'undefined') return; | ||
if (Array.isArray(input)) { | ||
if (input.length === 0) return input; | ||
const valid = input.filter((item) => { | ||
if (validFn(item)) return true; | ||
this.#error(msg, item); | ||
}); | ||
if (valid.length) { | ||
return valid; | ||
} | ||
} else { | ||
this.#error(msg, input); | ||
} | ||
} | ||
#bool(input, msg) { | ||
if (typeof input === 'undefined') return; | ||
if (typeof input === 'boolean') return input; | ||
else this.#error(`invalid ${optName} value: '${input}'`); | ||
else this.#error(msg, input); | ||
} | ||
#error(msg) { | ||
this.onError?.(msg); | ||
#str(input, msg, isValid) { | ||
if (typeof input === 'undefined') return; | ||
if (typeof input === 'string' && isValid(input)) return input; | ||
else this.#error(msg, input); | ||
} | ||
cors(input) { | ||
return this.#bool('cors', input); | ||
return this.#bool(input, 'invalid cors value'); | ||
} | ||
dirFile(input) { | ||
return this.#array(input, (item) => { | ||
const ok = isValidPattern(item); | ||
if (!ok) this.#error(`invalid dirFile value: '${item}'`); | ||
return ok; | ||
}); | ||
return this.#arr(input, 'invalid dirFile value', isValidPattern); | ||
} | ||
dirList(input) { | ||
return this.#bool('dirList', input); | ||
return this.#bool(input, 'invalid dirList value'); | ||
} | ||
exclude(input) { | ||
return this.#array(input, (item) => { | ||
const ok = isValidPattern(item); | ||
if (!ok) this.#error(`invalid exclude pattern: '${item}'`); | ||
return ok; | ||
}); | ||
return this.#arr(input, 'invalid exclude pattern', isValidPattern); | ||
} | ||
ext(input) { | ||
return this.#array(input, (item) => { | ||
const ok = isValidExt(item); | ||
if (!ok) this.#error(`invalid ext value: '${item}'`); | ||
return ok; | ||
}); | ||
return this.#arr(input, 'invalid ext value', isValidExt); | ||
} | ||
gzip(input) { | ||
return this.#bool('gzip', input); | ||
return this.#bool(input, 'invalid gzip value'); | ||
} | ||
headers(input) { | ||
return this.#array(input, (rule) => { | ||
const ok = isValidHeaderRule(rule); | ||
if (!ok) this.#error(`invalid header value: ${JSON.stringify(rule)}`); | ||
return ok; | ||
}); | ||
return this.#arr(input, 'invalid header value', isValidHeaderRule); | ||
} | ||
host(input) { | ||
if (typeof input !== 'string') return; | ||
if (isValidHost(input)) return input; | ||
else this.#error(`invalid host value: '${input}'`); | ||
return this.#str(input, 'invalid host value', isValidHost); | ||
} | ||
ports(input) { | ||
if (!Array.isArray(input) || input.length === 0) return; | ||
if (typeof input === 'undefined') return; | ||
if (!Array.isArray(input)) { | ||
this.#error('invalid port value', input); | ||
return; | ||
} | ||
if (input.length === 0) return; | ||
const value = input.slice(0, PORTS_CONFIG.maxCount); | ||
const invalid = value.find((num) => !isValidPort(num)); | ||
if (typeof invalid === 'undefined') return value; | ||
else this.#error(`invalid port number: '${invalid}'`); | ||
if (typeof invalid === 'undefined') { | ||
return value; | ||
} else { | ||
this.#error('invalid port number', invalid); | ||
} | ||
} | ||
@@ -111,3 +146,5 @@ root(input) { | ||
export function isValidPattern(value) { | ||
return typeof value === 'string' && value.length > 0 && !/[\\\/\:]/.test(value); | ||
if (typeof value !== 'string') return false; | ||
if (value.length < (value.startsWith('!') ? 2 : 1)) return false; | ||
return !/[\\\/\:]/.test(value); | ||
} | ||
@@ -117,25 +154,1 @@ export function isValidPort(num) { | ||
} | ||
export function serverOptions(options, context) { | ||
const validator = new OptionsValidator(context?.onError); | ||
const checked = { | ||
ports: validator.ports(options.ports), | ||
gzip: validator.gzip(options.gzip), | ||
host: validator.host(options.host), | ||
cors: validator.cors(options.cors), | ||
headers: validator.headers(options.headers), | ||
dirFile: validator.dirFile(options.dirFile), | ||
dirList: validator.dirList(options.dirList), | ||
ext: validator.ext(options.ext), | ||
exclude: validator.exclude(options.exclude), | ||
}; | ||
const final = structuredClone({ | ||
root: validator.root(options.root), | ||
...DEFAULT_OPTIONS, | ||
}); | ||
for (const [key, value] of Object.entries(checked)) { | ||
if (typeof value !== 'undefined') { | ||
final[key] = value; | ||
} | ||
} | ||
return final; | ||
} |
{ | ||
"name": "servitsy", | ||
"version": "0.4.4", | ||
"version": "0.4.5", | ||
"license": "MIT", | ||
@@ -5,0 +5,0 @@ "description": "Small, local HTTP server for static files", |
@@ -67,3 +67,3 @@ # servitsy | ||
| ------------- | ------- | ------------ | --------------- | | ||
| [servitsy] | 0.4.4 | 0 | 108 kB | | ||
| [servitsy] | 0.4.5 | 0 | 108 kB | | ||
| [servor] | 4.0.2 | 0 | 144 kB | | ||
@@ -70,0 +70,0 @@ | [sirv-cli] | 3.0.0 | 12 | 396 kB | |
66404
2031