UNPKG

15.9 kBJavaScriptView Raw
1#!/usr/bin/env node
2
3// source/main.ts
4import path from "node:path";
5import chalk3 from "chalk";
6import boxen from "boxen";
7import clipboard from "clipboardy";
8import checkForUpdate from "update-check";
9
10// package.json
11var package_default = {
12 name: "serve",
13 version: "14.0.1",
14 description: "Static file serving and directory listing",
15 keywords: [
16 "vercel",
17 "serve",
18 "micro",
19 "http-server"
20 ],
21 repository: "vercel/serve",
22 license: "MIT",
23 type: "module",
24 bin: {
25 serve: "./build/main.js"
26 },
27 files: [
28 "build/"
29 ],
30 engines: {
31 node: ">= 14"
32 },
33 scripts: {
34 develop: "tsx watch ./source/main.ts",
35 start: "node ./build/main.js",
36 compile: "tsup ./source/main.ts",
37 "test:tsc": "tsc --project tsconfig.json",
38 test: "pnpm test:tsc",
39 "lint:code": "eslint --max-warnings 0 source/**/*.ts",
40 "lint:style": "prettier --check --ignore-path .gitignore .",
41 lint: "pnpm lint:code && pnpm lint:style",
42 format: "prettier --write --ignore-path .gitignore .",
43 prepare: "husky install config/husky && pnpm compile"
44 },
45 dependencies: {
46 "@zeit/schemas": "2.21.0",
47 ajv: "8.11.0",
48 arg: "5.0.2",
49 boxen: "7.0.0",
50 chalk: "5.0.1",
51 "chalk-template": "0.4.0",
52 clipboardy: "3.0.0",
53 compression: "1.7.4",
54 "is-port-reachable": "4.0.0",
55 "serve-handler": "6.1.3",
56 "update-check": "1.5.4"
57 },
58 devDependencies: {
59 "@types/compression": "1.7.2",
60 "@types/serve-handler": "6.1.1",
61 "@vercel/style-guide": "3.0.0",
62 eslint: "8.19.0",
63 husky: "8.0.1",
64 "lint-staged": "13.0.3",
65 prettier: "2.7.1",
66 tsup: "6.1.3",
67 tsx: "3.7.1",
68 typescript: "4.6.4"
69 },
70 tsup: {
71 target: "esnext",
72 format: [
73 "esm"
74 ],
75 outDir: "./build/"
76 },
77 prettier: "@vercel/style-guide/prettier",
78 eslintConfig: {
79 extends: [
80 "./node_modules/@vercel/style-guide/eslint/node.js",
81 "./node_modules/@vercel/style-guide/eslint/typescript.js"
82 ],
83 parserOptions: {
84 project: "tsconfig.json"
85 }
86 },
87 "lint-staged": {
88 "*": [
89 "prettier --ignore-unknown --write"
90 ],
91 "source/**/*.ts": [
92 "eslint --max-warnings 0 --fix"
93 ]
94 }
95};
96
97// source/utilities/promise.ts
98import { promisify } from "node:util";
99var resolve = (promise) => promise.then((data) => [void 0, data]).catch((error2) => [error2, void 0]);
100
101// source/utilities/server.ts
102import http from "node:http";
103import https from "node:https";
104import { readFile } from "node:fs/promises";
105import handler from "serve-handler";
106import compression from "compression";
107import isPortReachable from "is-port-reachable";
108
109// source/utilities/http.ts
110import { parse } from "node:url";
111import { networkInterfaces as getNetworkInterfaces } from "node:os";
112var networkInterfaces = getNetworkInterfaces();
113var parseEndpoint = (uriOrPort) => {
114 if (!isNaN(Number(uriOrPort)))
115 return [uriOrPort];
116 const endpoint = uriOrPort;
117 const url = parse(endpoint);
118 switch (url.protocol) {
119 case "pipe:": {
120 const pipe = endpoint.replace(/^pipe:/, "");
121 if (!pipe.startsWith("\\\\.\\"))
122 throw new Error(`Invalid Windows named pipe endpoint: ${endpoint}`);
123 return [pipe];
124 }
125 case "unix:":
126 if (!url.pathname)
127 throw new Error(`Invalid UNIX domain socket endpoint: ${endpoint}`);
128 return [url.pathname];
129 case "tcp:":
130 url.port = url.port ?? "3000";
131 url.hostname = url.hostname ?? "localhost";
132 return [parseInt(url.port, 10), url.hostname];
133 default:
134 throw new Error(`Unknown --listen endpoint scheme (protocol): ${url.protocol ?? "undefined"}`);
135 }
136};
137var registerCloseListener = (fn) => {
138 let run = false;
139 const wrapper = () => {
140 if (!run) {
141 run = true;
142 fn();
143 }
144 };
145 process.on("SIGINT", wrapper);
146 process.on("SIGTERM", wrapper);
147 process.on("exit", wrapper);
148};
149var getNetworkAddress = () => {
150 for (const interfaceDetails of Object.values(networkInterfaces)) {
151 if (!interfaceDetails)
152 continue;
153 for (const details of interfaceDetails) {
154 const { address, family, internal } = details;
155 if (family === "IPv4" && !internal)
156 return address;
157 }
158 }
159};
160
161// source/utilities/server.ts
162var compress = promisify(compression());
163var startServer = async (endpoint, config2, args2, previous) => {
164 const serverHandler = (request, response) => {
165 const run = async () => {
166 if (args2["--cors"])
167 response.setHeader("Access-Control-Allow-Origin", "*");
168 if (!args2["--no-compression"])
169 await compress(request, response);
170 await handler(request, response, config2);
171 };
172 run().catch((error2) => {
173 throw error2;
174 });
175 };
176 const useSsl = args2["--ssl-cert"] && args2["--ssl-key"];
177 const httpMode = useSsl ? "https" : "http";
178 const sslPass = args2["--ssl-pass"];
179 const serverConfig = httpMode === "https" && args2["--ssl-cert"] && args2["--ssl-key"] ? {
180 key: await readFile(args2["--ssl-key"]),
181 cert: await readFile(args2["--ssl-cert"]),
182 passphrase: sslPass ? await readFile(sslPass, "utf8") : ""
183 } : {};
184 const server = httpMode === "https" ? https.createServer(serverConfig, serverHandler) : http.createServer(serverHandler);
185 const getServerDetails = () => {
186 registerCloseListener(() => server.close());
187 const details = server.address();
188 let local;
189 let network;
190 if (typeof details === "string") {
191 local = details;
192 } else if (typeof details === "object" && details.port) {
193 let address;
194 if (details.address === "::")
195 address = "localhost";
196 else if (details.family === "IPv6")
197 address = `[${details.address}]`;
198 else
199 address = details.address;
200 const ip = getNetworkAddress();
201 local = `${httpMode}://${address}:${details.port}`;
202 network = ip ? `${httpMode}://${ip}:${details.port}` : void 0;
203 }
204 return {
205 local,
206 network,
207 previous
208 };
209 };
210 server.on("error", (error2) => {
211 throw new Error(`Failed to serve: ${error2.stack?.toString() ?? error2.message}`);
212 });
213 if (typeof endpoint[0] === "number" && !isNaN(endpoint[0]) && endpoint[0] !== 0) {
214 const port = endpoint[0];
215 const isClosed = await isPortReachable(port, {
216 host: endpoint[1] ?? "localhost"
217 });
218 if (isClosed)
219 return startServer([0], config2, args2, port);
220 }
221 return new Promise((resolve2, _reject) => {
222 if (endpoint.length === 1 && typeof endpoint[0] === "number")
223 server.listen(endpoint[0], () => resolve2(getServerDetails()));
224 else if (endpoint.length === 1 && typeof endpoint[0] === "string")
225 server.listen(endpoint[0], () => resolve2(getServerDetails()));
226 else if (endpoint.length === 2 && typeof endpoint[0] === "number" && typeof endpoint[1] === "string")
227 server.listen(endpoint[0], endpoint[1], () => resolve2(getServerDetails()));
228 });
229};
230
231// source/utilities/cli.ts
232import chalk from "chalk-template";
233import parseArgv from "arg";
234var options = {
235 "--help": Boolean,
236 "--version": Boolean,
237 "--listen": [parseEndpoint],
238 "--single": Boolean,
239 "--debug": Boolean,
240 "--config": String,
241 "--no-clipboard": Boolean,
242 "--no-compression": Boolean,
243 "--no-etag": Boolean,
244 "--symlinks": Boolean,
245 "--cors": Boolean,
246 "--no-port-switching": Boolean,
247 "--ssl-cert": String,
248 "--ssl-key": String,
249 "--ssl-pass": String,
250 "-h": "--help",
251 "-v": "--version",
252 "-l": "--listen",
253 "-s": "--single",
254 "-d": "--debug",
255 "-c": "--config",
256 "-n": "--no-clipboard",
257 "-u": "--no-compression",
258 "-S": "--symlinks",
259 "-C": "--cors",
260 "-p": "--listen"
261};
262var helpText = chalk`
263 {bold.cyan serve} - Static file serving and directory listing
264
265 {bold USAGE}
266
267 {bold $} {cyan serve} --help
268 {bold $} {cyan serve} --version
269 {bold $} {cyan serve} folder_name
270 {bold $} {cyan serve} [-l {underline listen_uri} [-l ...]] [{underline directory}]
271
272 By default, {cyan serve} will listen on {bold 0.0.0.0:3000} and serve the
273 current working directory on that address.
274
275 Specifying a single {bold --listen} argument will overwrite the default, not supplement it.
276
277 {bold OPTIONS}
278
279 --help Shows this help message
280
281 -v, --version Displays the current version of serve
282
283 -l, --listen {underline listen_uri} Specify a URI endpoint on which to listen (see below) -
284 more than one may be specified to listen in multiple places
285
286 -p Specify custom port
287
288 -d, --debug Show debugging information
289
290 -s, --single Rewrite all not-found requests to \`index.html\`
291
292 -c, --config Specify custom path to \`serve.json\`
293
294 -C, --cors Enable CORS, sets \`Access-Control-Allow-Origin\` to \`*\`
295
296 -n, --no-clipboard Do not copy the local address to the clipboard
297
298 -u, --no-compression Do not compress files
299
300 --no-etag Send \`Last-Modified\` header instead of \`ETag\`
301
302 -S, --symlinks Resolve symlinks instead of showing 404 errors
303
304 --ssl-cert Optional path to an SSL/TLS certificate to serve with HTTPS
305
306 --ssl-key Optional path to the SSL/TLS certificate\'s private key
307
308 --ssl-pass Optional path to the SSL/TLS certificate\'s passphrase
309
310 --no-port-switching Do not open a port other than the one specified when it\'s taken.
311
312 {bold ENDPOINTS}
313
314 Listen endpoints (specified by the {bold --listen} or {bold -l} options above) instruct {cyan serve}
315 to listen on one or more interfaces/ports, UNIX domain sockets, or Windows named pipes.
316
317 For TCP ports on hostname "localhost":
318
319 {bold $} {cyan serve} -l {underline 1234}
320
321 For TCP (traditional host/port) endpoints:
322
323 {bold $} {cyan serve} -l tcp://{underline hostname}:{underline 1234}
324
325 For UNIX domain socket endpoints:
326
327 {bold $} {cyan serve} -l unix:{underline /path/to/socket.sock}
328
329 For Windows named pipe endpoints:
330
331 {bold $} {cyan serve} -l pipe:\\\\.\\pipe\\{underline PipeName}
332`;
333var parseArguments = () => parseArgv(options);
334var getHelpText = () => helpText;
335
336// source/utilities/config.ts
337import {
338 resolve as resolvePath,
339 relative as resolveRelativePath
340} from "node:path";
341import { readFile as readFile2 } from "node:fs/promises";
342import Ajv from "ajv";
343import schema from "@zeit/schemas/deployment/config-static.js";
344var loadConfiguration = async (cwd2, entry2, args2) => {
345 const files = ["serve.json", "now.json", "package.json"];
346 if (args2["--config"])
347 files.unshift(args2["--config"]);
348 const config2 = {};
349 for (const file of files) {
350 const location = resolvePath(entry2, file);
351 const [error2, rawContents] = await resolve(readFile2(location, "utf8"));
352 if (error2) {
353 if (error2.code === "ENOENT" && file !== args2["--config"])
354 continue;
355 else
356 throw new Error(`Could not read configuration from file ${location}: ${error2.message}`);
357 }
358 let parsedJson;
359 try {
360 parsedJson = JSON.parse(rawContents);
361 if (typeof parsedJson !== "object")
362 throw new Error("configuration is not an object");
363 } catch (parserError) {
364 throw new Error(`Could not parse ${location} as JSON: ${parserError.message}`);
365 }
366 if (file === "now.json") {
367 parsedJson = parsedJson;
368 parsedJson = parsedJson.now.static;
369 } else if (file === "package.json") {
370 parsedJson = parsedJson;
371 parsedJson = parsedJson.static;
372 }
373 if (!parsedJson)
374 continue;
375 Object.assign(config2, parsedJson);
376 break;
377 }
378 if (entry2) {
379 const staticDirectory = config2.public;
380 config2.public = resolveRelativePath(cwd2, staticDirectory ? resolvePath(entry2, staticDirectory) : entry2);
381 }
382 if (Object.keys(config2).length !== 0) {
383 const ajv = new Ajv({ allowUnionTypes: true });
384 const validate = ajv.compile(schema);
385 if (!validate(config2) && validate.errors) {
386 const defaultMessage = "The configuration you provided is invalid:";
387 const error2 = validate.errors[0];
388 throw new Error(`${defaultMessage}
389${error2.message ?? ""}
390${JSON.stringify(error2.params)}`);
391 }
392 }
393 config2.etag = !args2["--no-etag"];
394 config2.symlinks = args2["--symlinks"] || config2.symlinks;
395 return config2;
396};
397
398// source/utilities/logger.ts
399import chalk2 from "chalk";
400var info = (...message) => console.error(chalk2.magenta("INFO:", ...message));
401var warn = (...message) => console.error(chalk2.yellow("WARNING:", ...message));
402var error = (...message) => console.error(chalk2.red("ERROR:", ...message));
403var log = console.log;
404var logger = { info, warn, error, log };
405
406// source/main.ts
407var printUpdateNotification = async (debugMode) => {
408 const [error2, update] = await resolve(checkForUpdate(package_default));
409 if (error2) {
410 const suffix = debugMode ? ":" : " (use `--debug` to see full error).";
411 logger.warn(`Checking for updates failed${suffix}`);
412 if (debugMode)
413 logger.error(error2.message);
414 }
415 if (!update)
416 return;
417 logger.log(chalk3.bgRed.white(" UPDATE "), `The latest version of \`serve\` is ${update.latest}.`);
418};
419var args;
420try {
421 args = parseArguments();
422} catch (error2) {
423 logger.error(error2.message);
424 process.exit(1);
425}
426if (process.env.NO_UPDATE_CHECK !== "1")
427 await printUpdateNotification(args["--debug"]);
428if (args["--version"]) {
429 logger.log(package_default.version);
430 process.exit(0);
431}
432if (args["--help"]) {
433 logger.log(getHelpText());
434 process.exit(0);
435}
436if (!args["--listen"])
437 args["--listen"] = [
438 [process.env.PORT ? parseInt(process.env.PORT, 10) : 3e3]
439 ];
440if (args._.length > 1) {
441 logger.error("Please provide one path argument at maximum");
442 process.exit(1);
443}
444if (args["--config"] === "now.json" || args["--config"] === "package.json")
445 logger.warn("The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.");
446var cwd = process.cwd();
447var entry = args._[0] ? path.resolve(args._[0]) : cwd;
448var config = await loadConfiguration(cwd, entry, args);
449if (args["--single"]) {
450 const { rewrites } = config;
451 const existingRewrites = Array.isArray(rewrites) ? rewrites : [];
452 config.rewrites = [
453 {
454 source: "**",
455 destination: "/index.html"
456 },
457 ...existingRewrites
458 ];
459}
460for (const endpoint of args["--listen"]) {
461 const { local, network, previous } = await startServer(endpoint, config, args);
462 const copyAddress = !args["--no-clipboard"];
463 if (!process.stdout.isTTY || process.env.NODE_ENV === "production") {
464 const suffix = local ? ` at ${local}.` : ".";
465 logger.info(`Accepting connections${suffix}`);
466 continue;
467 }
468 let message = chalk3.green("Serving!");
469 if (local) {
470 const prefix = network ? "- " : "";
471 const space = network ? " " : " ";
472 message += `
473
474${chalk3.bold(`${prefix}Local:`)}${space}${local}`;
475 }
476 if (network)
477 message += `
478${chalk3.bold("- On Your Network:")} ${network}`;
479 if (previous)
480 message += chalk3.red(`
481
482This port was picked because ${chalk3.underline(previous.toString())} is in use.`);
483 if (copyAddress && local) {
484 try {
485 await clipboard.write(local);
486 message += `
487
488${chalk3.grey("Copied local address to clipboard!")}`;
489 } catch (error2) {
490 logger.error(`Cannot copy server address to clipboard: ${error2.message}.`);
491 }
492 }
493 logger.log(boxen(message, {
494 padding: 1,
495 borderColor: "green",
496 margin: 1
497 }));
498}
499registerCloseListener(() => {
500 logger.log();
501 logger.info("Gracefully shutting down. Please wait...");
502 process.on("SIGINT", () => {
503 logger.log();
504 logger.warn("Force-closing all open sockets...");
505 process.exit(0);
506 });
507});