457 lines
12 KiB
JavaScript
Executable File
457 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
// Native
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const {promisify} = require('util');
|
|
const {parse} = require('url');
|
|
const os = require('os');
|
|
|
|
// Packages
|
|
const Ajv = require('ajv');
|
|
const checkForUpdate = require('update-check');
|
|
const chalk = require('chalk');
|
|
const arg = require('arg');
|
|
const {write: copy} = require('clipboardy');
|
|
const handler = require('serve-handler');
|
|
const schema = require('@zeit/schemas/deployment/config-static');
|
|
const boxen = require('boxen');
|
|
const compression = require('compression');
|
|
|
|
// Utilities
|
|
const pkg = require('../package');
|
|
|
|
const readFile = promisify(fs.readFile);
|
|
const compressionHandler = promisify(compression());
|
|
|
|
const interfaces = os.networkInterfaces();
|
|
|
|
const warning = (message) => chalk`{yellow WARNING:} ${message}`;
|
|
const info = (message) => chalk`{magenta INFO:} ${message}`;
|
|
const error = (message) => chalk`{red ERROR:} ${message}`;
|
|
|
|
const updateCheck = async (isDebugging) => {
|
|
let update = null;
|
|
|
|
try {
|
|
update = await checkForUpdate(pkg);
|
|
} catch (err) {
|
|
const suffix = isDebugging ? ':' : ' (use `--debug` to see full error)';
|
|
console.error(warning(`Checking for updates failed${suffix}`));
|
|
|
|
if (isDebugging) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
if (!update) {
|
|
return;
|
|
}
|
|
|
|
console.log(`${chalk.bgRed('UPDATE AVAILABLE')} The latest version of \`serve\` is ${update.latest}`);
|
|
};
|
|
|
|
const getHelp = () => chalk`
|
|
{bold.cyan serve} - Static file serving and directory listing
|
|
|
|
{bold USAGE}
|
|
|
|
{bold $} {cyan serve} --help
|
|
{bold $} {cyan serve} --version
|
|
{bold $} {cyan serve} folder_name
|
|
{bold $} {cyan serve} [-l {underline listen_uri} [-l ...]] [{underline directory}]
|
|
|
|
By default, {cyan serve} will listen on {bold 0.0.0.0:5000} and serve the
|
|
current working directory on that address.
|
|
|
|
Specifying a single {bold --listen} argument will overwrite the default, not supplement it.
|
|
|
|
{bold OPTIONS}
|
|
|
|
--help Shows this help message
|
|
|
|
-v, --version Displays the current version of serve
|
|
|
|
-l, --listen {underline listen_uri} Specify a URI endpoint on which to listen (see below) -
|
|
more than one may be specified to listen in multiple places
|
|
|
|
-p Specify custom port
|
|
|
|
-d, --debug Show debugging information
|
|
|
|
-s, --single Rewrite all not-found requests to \`index.html\`
|
|
|
|
-c, --config Specify custom path to \`serve.json\`
|
|
|
|
-C, --cors Enable CORS, sets \`Access-Control-Allow-Origin\` to \`*\`
|
|
|
|
-n, --no-clipboard Do not copy the local address to the clipboard
|
|
|
|
-u, --no-compression Do not compress files
|
|
|
|
--no-etag Send \`Last-Modified\` header instead of \`ETag\`
|
|
|
|
-S, --symlinks Resolve symlinks instead of showing 404 errors
|
|
|
|
--ssl-cert Optional path to an SSL/TLS certificate to serve with HTTPS
|
|
|
|
--ssl-key Optional path to the SSL/TLS certificate\'s private key
|
|
|
|
--no-port-switching Do not open a port other than the one specified when it\'s taken.
|
|
|
|
{bold ENDPOINTS}
|
|
|
|
Listen endpoints (specified by the {bold --listen} or {bold -l} options above) instruct {cyan serve}
|
|
to listen on one or more interfaces/ports, UNIX domain sockets, or Windows named pipes.
|
|
|
|
For TCP ports on hostname "localhost":
|
|
|
|
{bold $} {cyan serve} -l {underline 1234}
|
|
|
|
For TCP (traditional host/port) endpoints:
|
|
|
|
{bold $} {cyan serve} -l tcp://{underline hostname}:{underline 1234}
|
|
|
|
For UNIX domain socket endpoints:
|
|
|
|
{bold $} {cyan serve} -l unix:{underline /path/to/socket.sock}
|
|
|
|
For Windows named pipe endpoints:
|
|
|
|
{bold $} {cyan serve} -l pipe:\\\\.\\pipe\\{underline PipeName}
|
|
`;
|
|
|
|
const parseEndpoint = (str) => {
|
|
if (!isNaN(str)) {
|
|
return [str];
|
|
}
|
|
|
|
// We cannot use `new URL` here, otherwise it will not
|
|
// parse the host properly and it would drop support for IPv6.
|
|
const url = parse(str);
|
|
|
|
switch (url.protocol) {
|
|
case 'pipe:': {
|
|
// some special handling
|
|
const cutStr = str.replace(/^pipe:/, '');
|
|
|
|
if (cutStr.slice(0, 4) !== '\\\\.\\') {
|
|
throw new Error(`Invalid Windows named pipe endpoint: ${str}`);
|
|
}
|
|
|
|
return [cutStr];
|
|
}
|
|
case 'unix:':
|
|
if (!url.pathname) {
|
|
throw new Error(`Invalid UNIX domain socket endpoint: ${str}`);
|
|
}
|
|
|
|
return [url.pathname];
|
|
case 'tcp:':
|
|
url.port = url.port || '5000';
|
|
return [parseInt(url.port, 10), url.hostname];
|
|
default:
|
|
throw new Error(`Unknown --listen endpoint scheme (protocol): ${url.protocol}`);
|
|
}
|
|
};
|
|
|
|
const registerShutdown = (fn) => {
|
|
let run = false;
|
|
|
|
const wrapper = () => {
|
|
if (!run) {
|
|
run = true;
|
|
fn();
|
|
}
|
|
};
|
|
|
|
process.on('SIGINT', wrapper);
|
|
process.on('SIGTERM', wrapper);
|
|
process.on('exit', wrapper);
|
|
};
|
|
|
|
const getNetworkAddress = () => {
|
|
for (const name of Object.keys(interfaces)) {
|
|
for (const interface of interfaces[name]) {
|
|
const {address, family, internal} = interface;
|
|
if (family === 'IPv4' && !internal) {
|
|
return address;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const startEndpoint = (endpoint, config, args, previous) => {
|
|
const {isTTY} = process.stdout;
|
|
const clipboard = args['--no-clipboard'] !== true;
|
|
const compress = args['--no-compression'] !== true;
|
|
const httpMode = args['--ssl-cert'] && args['--ssl-key'] ? 'https' : 'http';
|
|
|
|
const serverHandler = async (request, response) => {
|
|
if (args['--cors']) {
|
|
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
}
|
|
if (compress) {
|
|
await compressionHandler(request, response);
|
|
}
|
|
|
|
return handler(request, response, config);
|
|
};
|
|
|
|
const server = httpMode === 'https'
|
|
? https.createServer({
|
|
key: fs.readFileSync(args['--ssl-key']),
|
|
cert: fs.readFileSync(args['--ssl-cert'])
|
|
}, serverHandler)
|
|
: http.createServer(serverHandler);
|
|
|
|
server.on('error', (err) => {
|
|
if (err.code === 'EADDRINUSE' && endpoint.length === 1 && !isNaN(endpoint[0]) && args['--no-port-switching'] !== true) {
|
|
startEndpoint([0], config, args, endpoint[0]);
|
|
return;
|
|
}
|
|
|
|
console.error(error(`Failed to serve: ${err.stack}`));
|
|
process.exit(1);
|
|
});
|
|
|
|
server.listen(...endpoint, async () => {
|
|
const details = server.address();
|
|
registerShutdown(() => server.close());
|
|
|
|
let localAddress = null;
|
|
let networkAddress = null;
|
|
|
|
if (typeof details === 'string') {
|
|
localAddress = details;
|
|
} else if (typeof details === 'object' && details.port) {
|
|
const address = details.address === '::' ? 'localhost' : details.address;
|
|
const ip = getNetworkAddress();
|
|
|
|
localAddress = `${httpMode}://${address}:${details.port}`;
|
|
networkAddress = networkAddress ? `${httpMode}://${ip}:${details.port}` : null;
|
|
}
|
|
|
|
if (isTTY && process.env.NODE_ENV !== 'production') {
|
|
let message = chalk.green('Serving!');
|
|
|
|
if (localAddress) {
|
|
const prefix = networkAddress ? '- ' : '';
|
|
const space = networkAddress ? ' ' : ' ';
|
|
|
|
message += `\n\n${chalk.bold(`${prefix}Local:`)}${space}${localAddress}`;
|
|
}
|
|
|
|
if (networkAddress) {
|
|
message += `\n${chalk.bold('- On Your Network:')} ${networkAddress}`;
|
|
}
|
|
|
|
if (previous) {
|
|
message += chalk.red(`\n\nThis port was picked because ${chalk.underline(previous)} is in use.`);
|
|
}
|
|
|
|
if (clipboard) {
|
|
try {
|
|
await copy(localAddress);
|
|
message += `\n\n${chalk.grey('Copied local address to clipboard!')}`;
|
|
} catch (err) {
|
|
console.error(error(`Cannot copy to clipboard: ${err.message}`));
|
|
}
|
|
}
|
|
|
|
console.log(boxen(message, {
|
|
padding: 1,
|
|
borderColor: 'green',
|
|
margin: 1
|
|
}));
|
|
} else {
|
|
const suffix = localAddress ? ` at ${localAddress}` : '';
|
|
console.log(info(`Accepting connections${suffix}`));
|
|
}
|
|
});
|
|
};
|
|
|
|
const loadConfig = async (cwd, entry, args) => {
|
|
const files = [
|
|
'serve.json',
|
|
'now.json',
|
|
'package.json'
|
|
];
|
|
|
|
if (args['--config']) {
|
|
files.unshift(args['--config']);
|
|
}
|
|
|
|
const config = {};
|
|
|
|
for (const file of files) {
|
|
const location = path.resolve(entry, file);
|
|
let content = null;
|
|
|
|
try {
|
|
content = await readFile(location, 'utf8');
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
continue;
|
|
}
|
|
|
|
console.error(error(`Not able to read ${location}: ${err.message}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
content = JSON.parse(content);
|
|
} catch (err) {
|
|
console.error(error(`Could not parse ${location} as JSON: ${err.message}`));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (typeof content !== 'object') {
|
|
console.error(warning(`Didn't find a valid object in ${location}. Skipping...`));
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
switch (file) {
|
|
case 'now.json':
|
|
content = content.static;
|
|
break;
|
|
case 'package.json':
|
|
content = content.now.static;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
continue;
|
|
}
|
|
|
|
Object.assign(config, content);
|
|
console.log(info(`Discovered configuration in \`${file}\``));
|
|
|
|
if (file === 'now.json' || file === 'package.json') {
|
|
console.error(warning('The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.'));
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (entry) {
|
|
const {public} = config;
|
|
config.public = path.relative(cwd, (public ? path.resolve(entry, public) : entry));
|
|
}
|
|
|
|
if (Object.keys(config).length !== 0) {
|
|
const ajv = new Ajv();
|
|
const validateSchema = ajv.compile(schema);
|
|
|
|
if (!validateSchema(config)) {
|
|
const defaultMessage = error('The configuration you provided is wrong:');
|
|
const {message, params} = validateSchema.errors[0];
|
|
|
|
console.error(`${defaultMessage}\n${message}\n${JSON.stringify(params)}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// "ETag" headers are enabled by default unless `--no-etag` is provided
|
|
config.etag = !args['--no-etag'];
|
|
|
|
return config;
|
|
};
|
|
|
|
(async () => {
|
|
let args = null;
|
|
|
|
try {
|
|
args = arg({
|
|
'--help': Boolean,
|
|
'--version': Boolean,
|
|
'--listen': [parseEndpoint],
|
|
'--single': Boolean,
|
|
'--debug': Boolean,
|
|
'--config': String,
|
|
'--no-clipboard': Boolean,
|
|
'--no-compression': Boolean,
|
|
'--no-etag': Boolean,
|
|
'--symlinks': Boolean,
|
|
'--cors': Boolean,
|
|
'--no-port-switching': Boolean,
|
|
'--ssl-cert': String,
|
|
'--ssl-key': String,
|
|
'-h': '--help',
|
|
'-v': '--version',
|
|
'-l': '--listen',
|
|
'-s': '--single',
|
|
'-d': '--debug',
|
|
'-c': '--config',
|
|
'-n': '--no-clipboard',
|
|
'-u': '--no-compression',
|
|
'-S': '--symlinks',
|
|
'-C': '--cors',
|
|
// This is deprecated and only for backwards-compatibility.
|
|
'-p': '--listen'
|
|
});
|
|
} catch (err) {
|
|
console.error(error(err.message));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (process.env.NO_UPDATE_CHECK !== '1') {
|
|
await updateCheck(args['--debug']);
|
|
}
|
|
|
|
if (args['--version']) {
|
|
console.log(pkg.version);
|
|
return;
|
|
}
|
|
|
|
if (args['--help']) {
|
|
console.log(getHelp());
|
|
return;
|
|
}
|
|
|
|
if (!args['--listen']) {
|
|
// Default endpoint
|
|
args['--listen'] = [[process.env.PORT || 5000]];
|
|
}
|
|
|
|
if (args._.length > 1) {
|
|
console.error(error('Please provide one path argument at maximum'));
|
|
process.exit(1);
|
|
}
|
|
|
|
const cwd = process.cwd();
|
|
const entry = args._.length > 0 ? path.resolve(args._[0]) : cwd;
|
|
|
|
const config = await loadConfig(cwd, entry, args);
|
|
|
|
if (args['--single']) {
|
|
const {rewrites} = config;
|
|
const existingRewrites = Array.isArray(rewrites) ? rewrites : [];
|
|
|
|
// As the first rewrite rule, make `--single` work
|
|
config.rewrites = [{
|
|
source: '**',
|
|
destination: '/index.html'
|
|
}, ...existingRewrites];
|
|
}
|
|
|
|
if (args['--symlinks']) {
|
|
config.symlinks = true;
|
|
}
|
|
|
|
for (const endpoint of args['--listen']) {
|
|
startEndpoint(endpoint, config, args);
|
|
}
|
|
|
|
registerShutdown(() => {
|
|
console.log(`\n${info('Gracefully shutting down. Please wait...')}`);
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log(`\n${warning('Force-closing all open sockets...')}`);
|
|
process.exit(0);
|
|
});
|
|
});
|
|
})();
|