Comparing version 0.3.2 to 0.3.3
141
lib/cli.js
import { homedir, networkInterfaces } from 'node:os'; | ||
import { sep as dirSep } from 'node:path'; | ||
import { default as process, argv, env, exit, stdin } from 'node:process'; | ||
import process, { argv, exit, platform, stdin } from 'node:process'; | ||
import { emitKeypressEvents } from 'node:readline'; | ||
@@ -12,3 +12,3 @@ | ||
import { staticServer } from './server.js'; | ||
import { color, clamp, isPrivateIPv4 } from './utils.js'; | ||
import { clamp, color, getRuntime, isPrivateIPv4 } from './utils.js'; | ||
@@ -22,2 +22,4 @@ /** | ||
const runtime = getRuntime(); | ||
/** | ||
@@ -65,2 +67,5 @@ * Run servitsy with configuration from command line arguments. | ||
/** @type {import('node:os').NetworkInterfaceInfo | undefined} */ | ||
#localNetworkInfo; | ||
/** @type {import('node:http').Server} */ | ||
@@ -75,2 +80,5 @@ #server; | ||
this.#portIterator = new Set(options.ports).values(); | ||
this.#localNetworkInfo = Object.values(networkInterfaces()) | ||
.flat() | ||
.find((c) => c?.family === 'IPv4' && isPrivateIPv4(c?.address)); | ||
@@ -82,5 +90,5 @@ this.#server = staticServer(options, { | ||
}); | ||
this.#server.on('error', (error) => this.#handleServerError(error)); | ||
this.#server.on('error', (error) => this.#onServerError(error)); | ||
this.#server.on('listening', () => { | ||
logger.write('header', this.#headerInfo(), { top: 1, bottom: 1 }); | ||
logger.write('header', this.headerInfo(), { top: 1, bottom: 1 }); | ||
}); | ||
@@ -90,11 +98,17 @@ } | ||
start() { | ||
this.#handleSignals(); | ||
this.#handleKeyboardInput(); | ||
this.#server.listen({ | ||
host: this.#options.host, | ||
port: this.#portIterator.next().value, | ||
}); | ||
this.handleSignals(); | ||
this.#server.listen( | ||
{ | ||
host: this.#options.host, | ||
port: this.#portIterator.next().value, | ||
}, | ||
// Wait until the server started listening — and hopefully all Deno | ||
// permission requests are done — before we can take over stdin inputs. | ||
() => { | ||
this.handleKeyboardInput(); | ||
}, | ||
); | ||
} | ||
#headerInfo() { | ||
headerInfo() { | ||
const { host, root } = this.#options; | ||
@@ -106,2 +120,3 @@ const address = this.#server.address(); | ||
currentHost: address.address, | ||
networkAddress: this.#localNetworkInfo?.address, | ||
}); | ||
@@ -125,42 +140,6 @@ const data = [ | ||
/** | ||
* @param {NodeJS.ErrnoException & {hostname?: string}} error | ||
*/ | ||
#handleServerError(error) { | ||
// Try restarting with the next port | ||
if (error.syscall === 'listen' && 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, | ||
}); | ||
} else { | ||
logger.writeErrors({ | ||
error: `${ports.length > 1 ? 'ports' : 'port'} already in use: ${ports.join(', ')}`, | ||
}); | ||
exit(1); | ||
} | ||
}); | ||
return; | ||
} | ||
// Handle other errors | ||
if (error.syscall === 'getaddrinfo' && error.code === 'ENOTFOUND') { | ||
logger.writeErrors({ error: `host not found: '${error.hostname}'` }); | ||
} else { | ||
logger.writeErrorObj(error); | ||
} | ||
exit(1); | ||
} | ||
#handleKeyboardInput() { | ||
handleKeyboardInput() { | ||
if (!stdin.isTTY) return; | ||
let helpShown = false; | ||
emitKeypressEvents(stdin); | ||
stdin.setRawMode(true); | ||
stdin.on('keypress', (_str, key) => { | ||
@@ -179,5 +158,6 @@ if ( | ||
}); | ||
stdin.setRawMode(true); | ||
} | ||
#handleSignals() { | ||
handleSignals() { | ||
process.on('SIGBREAK', this.shutdown); | ||
@@ -201,2 +181,37 @@ process.on('SIGINT', this.shutdown); | ||
}; | ||
/** | ||
* @param {NodeJS.ErrnoException & {hostname?: string}} error | ||
*/ | ||
#onServerError(error) { | ||
// Try restarting with the next port | ||
if (error.syscall === 'listen' && 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, | ||
}); | ||
} else { | ||
logger.writeErrors({ | ||
error: `${ports.length > 1 ? 'ports' : 'port'} already in use: ${ports.join(', ')}`, | ||
}); | ||
exit(1); | ||
} | ||
}); | ||
return; | ||
} | ||
// Handle other errors | ||
if (error.syscall === 'getaddrinfo' && error.code === 'ENOTFOUND') { | ||
logger.writeErrors({ error: `host not found: '${error.hostname}'` }); | ||
} else { | ||
logger.writeErrorObj(error); | ||
} | ||
exit(1); | ||
} | ||
} | ||
@@ -265,15 +280,9 @@ | ||
/** | ||
* @param {{ configuredHost: string; currentHost: string }} address | ||
* @param {{ configuredHost: string; currentHost: string; networkAddress?: string }} address | ||
* @returns {{ local: string; network?: string }} | ||
*/ | ||
function displayHosts({ configuredHost, currentHost }) { | ||
function displayHosts({ configuredHost, currentHost, networkAddress }) { | ||
const isLocalhost = (value = '') => HOSTS_LOCAL.includes(value); | ||
const isWildcard = (value = '') => Object.values(HOSTS_WILDCARD).includes(value); | ||
const isWebcontainers = () => env['SHELL']?.endsWith('/jsh'); | ||
const isWildcard = (value = '') => HOSTS_WILDCARD.v4 === value || HOSTS_WILDCARD.v6 === value; | ||
const networkAddress = () => { | ||
const configs = Object.values(networkInterfaces()).flat(); | ||
return configs.find((c) => c?.family === 'IPv4' && isPrivateIPv4(c?.address))?.address; | ||
}; | ||
if (!isWildcard(configuredHost) && !isLocalhost(configuredHost)) { | ||
@@ -285,3 +294,3 @@ return { local: configuredHost }; | ||
local: isWildcard(currentHost) || isLocalhost(currentHost) ? 'localhost' : currentHost, | ||
network: isWildcard(configuredHost) && !isWebcontainers() ? networkAddress() : undefined, | ||
network: isWildcard(configuredHost) && runtime !== 'webcontainer' ? networkAddress : undefined, | ||
}; | ||
@@ -291,2 +300,3 @@ } | ||
/** | ||
* Replace the home dir with '~' in path | ||
* @param {string} root | ||
@@ -296,7 +306,14 @@ * @returns {string} | ||
function displayRoot(root) { | ||
const prefix = homedir() + dirSep; | ||
if (root.startsWith(prefix)) { | ||
return root.replace(prefix, '~' + dirSep); | ||
if ( | ||
// skip: not a common windows convention | ||
platform !== 'win32' && | ||
// skip: requires --allow-sys=homedir in Deno | ||
runtime !== 'deno' | ||
) { | ||
const prefix = homedir() + dirSep; | ||
if (root.startsWith(prefix)) { | ||
return root.replace(prefix, '~' + dirSep); | ||
} | ||
} | ||
return root; | ||
} |
@@ -1,4 +0,3 @@ | ||
import { accessSync, statSync, constants as fsConstants } from 'node:fs'; | ||
import { accessSync, constants as fsConstants, statSync } from 'node:fs'; | ||
import { resolve } from 'node:path'; | ||
import { cwd } from 'node:process'; | ||
@@ -26,16 +25,2 @@ import { CLIArgs } from './args.js'; | ||
/** | ||
* @param {CLIArgs} args | ||
* @param {ValidationContext} context | ||
* @returns | ||
*/ | ||
export function validateArgPresence(args, { warn }) { | ||
const knownArgs = Object.values(CLI_OPTIONS).flatMap((spec) => spec.args); | ||
for (const name of args.keys()) { | ||
if (!knownArgs.includes(name)) { | ||
warn(`unknown option '${name}'`); | ||
} | ||
} | ||
} | ||
/** | ||
* @param {Partial<ListenOptions & ServerOptions> & {args?: CLIArgs}} options | ||
@@ -88,2 +73,16 @@ * @returns {{errors: ErrorMessage[]; options: ListenOptions & ServerOptions}} | ||
/** | ||
* @param {CLIArgs} args | ||
* @param {ValidationContext} context | ||
* @returns | ||
*/ | ||
export function validateArgPresence(args, { warn }) { | ||
const knownArgs = Object.values(CLI_OPTIONS).flatMap((spec) => spec.args); | ||
for (const name of args.keys()) { | ||
if (!knownArgs.includes(name)) { | ||
warn(`unknown option '${name}'`); | ||
} | ||
} | ||
} | ||
/** | ||
* @param {string | boolean | undefined} input | ||
@@ -372,3 +371,3 @@ * @param {ValidationContext & { optName: string; defaultValue: boolean; emptyValue: boolean }} context | ||
const root = resolve(cwd(), typeof input === 'string' ? input : ''); | ||
const root = resolve(input ?? ''); | ||
@@ -375,0 +374,0 @@ try { |
117
lib/utils.js
@@ -1,5 +0,5 @@ | ||
import { ColorUtils } from './color.js'; | ||
import { release } from 'node:os'; | ||
import { env, platform, versions } from 'node:process'; | ||
import { inspect } from 'node:util'; | ||
export const color = new ColorUtils(); | ||
/** | ||
@@ -13,2 +13,36 @@ * @type {(value: number, min: number, max: number) => number} | ||
export class ColorUtils { | ||
/** @param {boolean} [colorEnabled] */ | ||
constructor(colorEnabled) { | ||
this.enabled = typeof colorEnabled === 'boolean' ? colorEnabled : true; | ||
} | ||
/** @type {(text: string, format?: string) => string} */ | ||
style = (text, format = '') => { | ||
if (!this.enabled) return text; | ||
return styleText(format.trim().split(/\s+/g), text); | ||
}; | ||
/** @type {(text: string, format?: string, chars?: [string, string]) => string} */ | ||
brackets = (text, format = 'dim,,dim', chars = ['[', ']']) => { | ||
return this.sequence([chars[0], text, chars[1]], format); | ||
}; | ||
/** @type {(parts: string[], format?: string) => string} */ | ||
sequence = (parts, format = '') => { | ||
if (!format || !this.enabled) { | ||
return parts.join(''); | ||
} | ||
const formats = format.split(','); | ||
return parts | ||
.map((part, index) => (formats[index] ? this.style(part, formats[index]) : part)) | ||
.join(''); | ||
}; | ||
/** @type {(input: string) => string} */ | ||
strip = stripStyle; | ||
} | ||
export const color = new ColorUtils(supportsColor()); | ||
/** | ||
@@ -54,2 +88,19 @@ * @type {(input: string, context?: 'text' | 'attr') => string} | ||
/** | ||
* @type {(key: string) => string} | ||
*/ | ||
export function getEnv(key) { | ||
return env[key] ?? ''; | ||
} | ||
/** | ||
* @returns {'bun' | 'deno' | 'node' | 'webcontainer'} | ||
*/ | ||
export function getRuntime() { | ||
if (versions.bun && globalThis.Bun) return 'bun'; | ||
if (versions.deno && globalThis.Deno) return 'deno'; | ||
if (versions.webcontainer && getEnv('SHELL').endsWith('/jsh')) return 'webcontainer'; | ||
return 'node'; | ||
} | ||
/** | ||
* @param {string} name | ||
@@ -101,2 +152,62 @@ * @returns {string} | ||
/** | ||
* @param {string} input | ||
* @returns {string} | ||
*/ | ||
export function stripStyle(input) { | ||
if (typeof input === 'string' && input.includes('\x1b[')) { | ||
return input.replace(/\x1b\[\d+m/g, ''); | ||
} | ||
return input; | ||
} | ||
/** | ||
* Basic implementation of 'node:util' styleText to support Node 18 + Deno. | ||
* @param {string | string[]} format | ||
* @param {string} text | ||
* @returns {string} | ||
*/ | ||
export function styleText(format, text) { | ||
let before = ''; | ||
let after = ''; | ||
for (const style of Array.isArray(format) ? format : [format]) { | ||
const codes = inspect.colors[style.trim()]; | ||
if (!codes) continue; | ||
before = `${before}\x1b[${codes[0]}m`; | ||
after = `\x1b[${codes[1]}m${after}`; | ||
} | ||
return `${before}${text}${after}`; | ||
} | ||
/** | ||
* @returns {boolean} | ||
*/ | ||
function supportsColor() { | ||
if (typeof globalThis.Deno?.noColor === 'boolean') { | ||
return !globalThis.Deno.noColor; | ||
} | ||
if (getEnv('NO_COLOR')) { | ||
const forceColor = getEnv('FORCE_COLOR'); | ||
return forceColor === 'true' || /^\d$/.test(forceColor); | ||
} | ||
// Logic borrowed from supports-color. | ||
// Windows 10 build 10586 is the first release that supports 256 colors. | ||
if (platform === 'win32') { | ||
const [major, _, build] = release().split('.'); | ||
return Number(major) >= 10 && Number(build) >= 10_586; | ||
} | ||
// Should work in *nix terminals. | ||
const term = getEnv('TERM'); | ||
const colorterm = getEnv('COLORTERM'); | ||
return ( | ||
colorterm === 'truecolor' || | ||
term === 'xterm-256color' || | ||
term === 'xterm-16color' || | ||
term === 'xterm-color' | ||
); | ||
} | ||
/** | ||
* @type {(input: string, options?: { start?: boolean; end?: boolean }) => string} | ||
@@ -103,0 +214,0 @@ */ |
{ | ||
"name": "servitsy", | ||
"version": "0.3.2", | ||
"version": "0.3.3", | ||
"license": "MIT", | ||
@@ -5,0 +5,0 @@ "description": "Small, local HTTP server for static files", |
@@ -18,6 +18,14 @@ # servitsy | ||
> [!NOTE] | ||
> servitsy is a command-line tool, published as a npm package. It requires Node.js version 18 (or higher). | ||
> servitsy is a command-line tool, published as a npm package. It requires [Node.js] version 18 or higher, or a compatible runtime like [Deno] or [Bun]. | ||
Calling servitsy without any option will: | ||
```sh | ||
# Running with Bun | ||
bunx servitsy | ||
# Running with Deno | ||
deno run --allow-net --allow-read --allow-sys npm:servitsy | ||
``` | ||
Calling servitsy without options will: | ||
- serve the current directory at `http://localhost:8080` (listening on hostname `0.0.0.0`); | ||
@@ -33,6 +41,6 @@ - try the next port numbers if `8080` is not available; | ||
```sh | ||
# serve current folder on port 3000, with CORS headers | ||
# Serve current folder on port 3000, with CORS headers | ||
npx servitsy -p 3000 --cors | ||
# serve 'dist' folder and disable directory listings | ||
# Serve 'dist' folder and disable directory listings | ||
npx servitsy dist --dir-list false | ||
@@ -55,3 +63,3 @@ ``` | ||
> [!WARNING] | ||
> **servitsy is not designed for production.** There are safer and faster tools to serve a folder of static HTML to the public. See Apache, Nginx, [@fastify/static], etc. | ||
> **servitsy is not designed for production.** There are safer and faster tools to serve a folder of static HTML to the public. See Apache, Nginx, `@fastify/static`, etc. | ||
@@ -62,3 +70,3 @@ For local testing, here are a few established alternatives you may prefer, with their respective size: | ||
| ------------- | ------- | ------------ | --------------- | | ||
| [servitsy] | 0.3.1 | 0 | 124 kB | | ||
| [servitsy] | 0.3.3 | 0 | 128 kB | | ||
| [servor] | 4.0.2 | 0 | 144 kB | | ||
@@ -75,3 +83,5 @@ | [sirv-cli] | 3.0.0 | 12 | 396 kB | | ||
[@fastify/static]: https://www.npmjs.com/package/@fastify/static | ||
[Bun]: https://bun.sh/ | ||
[Deno]: https://deno.com/ | ||
[Node.js]: https://nodejs.org/ | ||
[http-server]: https://www.npmjs.com/package/http-server | ||
@@ -78,0 +88,0 @@ [serve]: https://www.npmjs.com/package/serve |
85186
2655
87
22