serve
Version:
Static file serving and directory listing
508 lines (463 loc) • 15.9 kB
JavaScript
// source/main.ts
import path from "node:path";
import chalk3 from "chalk";
import boxen from "boxen";
import clipboard from "clipboardy";
import checkForUpdate from "update-check";
// package.json
var package_default = {
name: "serve",
version: "14.0.1",
description: "Static file serving and directory listing",
keywords: [
"vercel",
"serve",
"micro",
"http-server"
],
repository: "vercel/serve",
license: "MIT",
type: "module",
bin: {
serve: "./build/main.js"
},
files: [
"build/"
],
engines: {
node: ">= 14"
},
scripts: {
develop: "tsx watch ./source/main.ts",
start: "node ./build/main.js",
compile: "tsup ./source/main.ts",
"test:tsc": "tsc --project tsconfig.json",
test: "pnpm test:tsc",
"lint:code": "eslint --max-warnings 0 source/**/*.ts",
"lint:style": "prettier --check --ignore-path .gitignore .",
lint: "pnpm lint:code && pnpm lint:style",
format: "prettier --write --ignore-path .gitignore .",
prepare: "husky install config/husky && pnpm compile"
},
dependencies: {
"@zeit/schemas": "2.21.0",
ajv: "8.11.0",
arg: "5.0.2",
boxen: "7.0.0",
chalk: "5.0.1",
"chalk-template": "0.4.0",
clipboardy: "3.0.0",
compression: "1.7.4",
"is-port-reachable": "4.0.0",
"serve-handler": "6.1.3",
"update-check": "1.5.4"
},
devDependencies: {
"@types/compression": "1.7.2",
"@types/serve-handler": "6.1.1",
"@vercel/style-guide": "3.0.0",
eslint: "8.19.0",
husky: "8.0.1",
"lint-staged": "13.0.3",
prettier: "2.7.1",
tsup: "6.1.3",
tsx: "3.7.1",
typescript: "4.6.4"
},
tsup: {
target: "esnext",
format: [
"esm"
],
outDir: "./build/"
},
prettier: "@vercel/style-guide/prettier",
eslintConfig: {
extends: [
"./node_modules/@vercel/style-guide/eslint/node.js",
"./node_modules/@vercel/style-guide/eslint/typescript.js"
],
parserOptions: {
project: "tsconfig.json"
}
},
"lint-staged": {
"*": [
"prettier --ignore-unknown --write"
],
"source/**/*.ts": [
"eslint --max-warnings 0 --fix"
]
}
};
// source/utilities/promise.ts
import { promisify } from "node:util";
var resolve = (promise) => promise.then((data) => [void 0, data]).catch((error2) => [error2, void 0]);
// source/utilities/server.ts
import http from "node:http";
import https from "node:https";
import { readFile } from "node:fs/promises";
import handler from "serve-handler";
import compression from "compression";
import isPortReachable from "is-port-reachable";
// source/utilities/http.ts
import { parse } from "node:url";
import { networkInterfaces as getNetworkInterfaces } from "node:os";
var networkInterfaces = getNetworkInterfaces();
var parseEndpoint = (uriOrPort) => {
if (!isNaN(Number(uriOrPort)))
return [uriOrPort];
const endpoint = uriOrPort;
const url = parse(endpoint);
switch (url.protocol) {
case "pipe:": {
const pipe = endpoint.replace(/^pipe:/, "");
if (!pipe.startsWith("\\\\.\\"))
throw new Error(`Invalid Windows named pipe endpoint: ${endpoint}`);
return [pipe];
}
case "unix:":
if (!url.pathname)
throw new Error(`Invalid UNIX domain socket endpoint: ${endpoint}`);
return [url.pathname];
case "tcp:":
url.port = url.port ?? "3000";
url.hostname = url.hostname ?? "localhost";
return [parseInt(url.port, 10), url.hostname];
default:
throw new Error(`Unknown --listen endpoint scheme (protocol): ${url.protocol ?? "undefined"}`);
}
};
var registerCloseListener = (fn) => {
let run = false;
const wrapper = () => {
if (!run) {
run = true;
fn();
}
};
process.on("SIGINT", wrapper);
process.on("SIGTERM", wrapper);
process.on("exit", wrapper);
};
var getNetworkAddress = () => {
for (const interfaceDetails of Object.values(networkInterfaces)) {
if (!interfaceDetails)
continue;
for (const details of interfaceDetails) {
const { address, family, internal } = details;
if (family === "IPv4" && !internal)
return address;
}
}
};
// source/utilities/server.ts
var compress = promisify(compression());
var startServer = async (endpoint, config2, args2, previous) => {
const serverHandler = (request, response) => {
const run = async () => {
if (args2["--cors"])
response.setHeader("Access-Control-Allow-Origin", "*");
if (!args2["--no-compression"])
await compress(request, response);
await handler(request, response, config2);
};
run().catch((error2) => {
throw error2;
});
};
const useSsl = args2["--ssl-cert"] && args2["--ssl-key"];
const httpMode = useSsl ? "https" : "http";
const sslPass = args2["--ssl-pass"];
const serverConfig = httpMode === "https" && args2["--ssl-cert"] && args2["--ssl-key"] ? {
key: await readFile(args2["--ssl-key"]),
cert: await readFile(args2["--ssl-cert"]),
passphrase: sslPass ? await readFile(sslPass, "utf8") : ""
} : {};
const server = httpMode === "https" ? https.createServer(serverConfig, serverHandler) : http.createServer(serverHandler);
const getServerDetails = () => {
registerCloseListener(() => server.close());
const details = server.address();
let local;
let network;
if (typeof details === "string") {
local = details;
} else if (typeof details === "object" && details.port) {
let address;
if (details.address === "::")
address = "localhost";
else if (details.family === "IPv6")
address = `[${details.address}]`;
else
address = details.address;
const ip = getNetworkAddress();
local = `${httpMode}://${address}:${details.port}`;
network = ip ? `${httpMode}://${ip}:${details.port}` : void 0;
}
return {
local,
network,
previous
};
};
server.on("error", (error2) => {
throw new Error(`Failed to serve: ${error2.stack?.toString() ?? error2.message}`);
});
if (typeof endpoint[0] === "number" && !isNaN(endpoint[0]) && endpoint[0] !== 0) {
const port = endpoint[0];
const isClosed = await isPortReachable(port, {
host: endpoint[1] ?? "localhost"
});
if (isClosed)
return startServer([0], config2, args2, port);
}
return new Promise((resolve2, _reject) => {
if (endpoint.length === 1 && typeof endpoint[0] === "number")
server.listen(endpoint[0], () => resolve2(getServerDetails()));
else if (endpoint.length === 1 && typeof endpoint[0] === "string")
server.listen(endpoint[0], () => resolve2(getServerDetails()));
else if (endpoint.length === 2 && typeof endpoint[0] === "number" && typeof endpoint[1] === "string")
server.listen(endpoint[0], endpoint[1], () => resolve2(getServerDetails()));
});
};
// source/utilities/cli.ts
import chalk from "chalk-template";
import parseArgv from "arg";
var options = {
"--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,
"--ssl-pass": String,
"-h": "--help",
"-v": "--version",
"-l": "--listen",
"-s": "--single",
"-d": "--debug",
"-c": "--config",
"-n": "--no-clipboard",
"-u": "--no-compression",
"-S": "--symlinks",
"-C": "--cors",
"-p": "--listen"
};
var helpText = 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:3000} 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
--ssl-pass Optional path to the SSL/TLS certificate\'s passphrase
--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}
`;
var parseArguments = () => parseArgv(options);
var getHelpText = () => helpText;
// source/utilities/config.ts
import {
resolve as resolvePath,
relative as resolveRelativePath
} from "node:path";
import { readFile as readFile2 } from "node:fs/promises";
import Ajv from "ajv";
import schema from "@zeit/schemas/deployment/config-static.js";
var loadConfiguration = async (cwd2, entry2, args2) => {
const files = ["serve.json", "now.json", "package.json"];
if (args2["--config"])
files.unshift(args2["--config"]);
const config2 = {};
for (const file of files) {
const location = resolvePath(entry2, file);
const [error2, rawContents] = await resolve(readFile2(location, "utf8"));
if (error2) {
if (error2.code === "ENOENT" && file !== args2["--config"])
continue;
else
throw new Error(`Could not read configuration from file ${location}: ${error2.message}`);
}
let parsedJson;
try {
parsedJson = JSON.parse(rawContents);
if (typeof parsedJson !== "object")
throw new Error("configuration is not an object");
} catch (parserError) {
throw new Error(`Could not parse ${location} as JSON: ${parserError.message}`);
}
if (file === "now.json") {
parsedJson = parsedJson;
parsedJson = parsedJson.now.static;
} else if (file === "package.json") {
parsedJson = parsedJson;
parsedJson = parsedJson.static;
}
if (!parsedJson)
continue;
Object.assign(config2, parsedJson);
break;
}
if (entry2) {
const staticDirectory = config2.public;
config2.public = resolveRelativePath(cwd2, staticDirectory ? resolvePath(entry2, staticDirectory) : entry2);
}
if (Object.keys(config2).length !== 0) {
const ajv = new Ajv({ allowUnionTypes: true });
const validate = ajv.compile(schema);
if (!validate(config2) && validate.errors) {
const defaultMessage = "The configuration you provided is invalid:";
const error2 = validate.errors[0];
throw new Error(`${defaultMessage}
${error2.message ?? ""}
${JSON.stringify(error2.params)}`);
}
}
config2.etag = !args2["--no-etag"];
config2.symlinks = args2["--symlinks"] || config2.symlinks;
return config2;
};
// source/utilities/logger.ts
import chalk2 from "chalk";
var info = (...message) => console.error(chalk2.magenta("INFO:", ...message));
var warn = (...message) => console.error(chalk2.yellow("WARNING:", ...message));
var error = (...message) => console.error(chalk2.red("ERROR:", ...message));
var log = console.log;
var logger = { info, warn, error, log };
// source/main.ts
var printUpdateNotification = async (debugMode) => {
const [error2, update] = await resolve(checkForUpdate(package_default));
if (error2) {
const suffix = debugMode ? ":" : " (use `--debug` to see full error).";
logger.warn(`Checking for updates failed${suffix}`);
if (debugMode)
logger.error(error2.message);
}
if (!update)
return;
logger.log(chalk3.bgRed.white(" UPDATE "), `The latest version of \`serve\` is ${update.latest}.`);
};
var args;
try {
args = parseArguments();
} catch (error2) {
logger.error(error2.message);
process.exit(1);
}
if (process.env.NO_UPDATE_CHECK !== "1")
await printUpdateNotification(args["--debug"]);
if (args["--version"]) {
logger.log(package_default.version);
process.exit(0);
}
if (args["--help"]) {
logger.log(getHelpText());
process.exit(0);
}
if (!args["--listen"])
args["--listen"] = [
[process.env.PORT ? parseInt(process.env.PORT, 10) : 3e3]
];
if (args._.length > 1) {
logger.error("Please provide one path argument at maximum");
process.exit(1);
}
if (args["--config"] === "now.json" || args["--config"] === "package.json")
logger.warn("The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.");
var cwd = process.cwd();
var entry = args._[0] ? path.resolve(args._[0]) : cwd;
var config = await loadConfiguration(cwd, entry, args);
if (args["--single"]) {
const { rewrites } = config;
const existingRewrites = Array.isArray(rewrites) ? rewrites : [];
config.rewrites = [
{
source: "**",
destination: "/index.html"
},
...existingRewrites
];
}
for (const endpoint of args["--listen"]) {
const { local, network, previous } = await startServer(endpoint, config, args);
const copyAddress = !args["--no-clipboard"];
if (!process.stdout.isTTY || process.env.NODE_ENV === "production") {
const suffix = local ? ` at ${local}.` : ".";
logger.info(`Accepting connections${suffix}`);
continue;
}
let message = chalk3.green("Serving!");
if (local) {
const prefix = network ? "- " : "";
const space = network ? " " : " ";
message += `
${chalk3.bold(`${prefix}Local:`)}${space}${local}`;
}
if (network)
message += `
${chalk3.bold("- On Your Network:")} ${network}`;
if (previous)
message += chalk3.red(`
This port was picked because ${chalk3.underline(previous.toString())} is in use.`);
if (copyAddress && local) {
try {
await clipboard.write(local);
message += `
${chalk3.grey("Copied local address to clipboard!")}`;
} catch (error2) {
logger.error(`Cannot copy server address to clipboard: ${error2.message}.`);
}
}
logger.log(boxen(message, {
padding: 1,
borderColor: "green",
margin: 1
}));
}
registerCloseListener(() => {
logger.log();
logger.info("Gracefully shutting down. Please wait...");
process.on("SIGINT", () => {
logger.log();
logger.warn("Force-closing all open sockets...");
process.exit(0);
});
});