1 | #!/usr/bin/env node
|
2 |
|
3 |
|
4 | import path from "node:path";
|
5 | import chalk3 from "chalk";
|
6 | import boxen from "boxen";
|
7 | import clipboard from "clipboardy";
|
8 | import checkForUpdate from "update-check";
|
9 |
|
10 |
|
11 | var 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 |
|
98 | import { promisify } from "node:util";
|
99 | var resolve = (promise) => promise.then((data) => [void 0, data]).catch((error2) => [error2, void 0]);
|
100 |
|
101 |
|
102 | import http from "node:http";
|
103 | import https from "node:https";
|
104 | import { readFile } from "node:fs/promises";
|
105 | import handler from "serve-handler";
|
106 | import compression from "compression";
|
107 | import isPortReachable from "is-port-reachable";
|
108 |
|
109 |
|
110 | import { parse } from "node:url";
|
111 | import { networkInterfaces as getNetworkInterfaces } from "node:os";
|
112 | var networkInterfaces = getNetworkInterfaces();
|
113 | var 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 | };
|
137 | var 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 | };
|
149 | var 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 |
|
162 | var compress = promisify(compression());
|
163 | var 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 |
|
232 | import chalk from "chalk-template";
|
233 | import parseArgv from "arg";
|
234 | var 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 | };
|
262 | var 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 | `;
|
333 | var parseArguments = () => parseArgv(options);
|
334 | var getHelpText = () => helpText;
|
335 |
|
336 |
|
337 | import {
|
338 | resolve as resolvePath,
|
339 | relative as resolveRelativePath
|
340 | } from "node:path";
|
341 | import { readFile as readFile2 } from "node:fs/promises";
|
342 | import Ajv from "ajv";
|
343 | import schema from "@zeit/schemas/deployment/config-static.js";
|
344 | var 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 |
|
399 | import chalk2 from "chalk";
|
400 | var info = (...message) => console.error(chalk2.magenta("INFO:", ...message));
|
401 | var warn = (...message) => console.error(chalk2.yellow("WARNING:", ...message));
|
402 | var error = (...message) => console.error(chalk2.red("ERROR:", ...message));
|
403 | var log = console.log;
|
404 | var logger = { info, warn, error, log };
|
405 |
|
406 |
|
407 | var 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 | };
|
419 | var args;
|
420 | try {
|
421 | args = parseArguments();
|
422 | } catch (error2) {
|
423 | logger.error(error2.message);
|
424 | process.exit(1);
|
425 | }
|
426 | if (process.env.NO_UPDATE_CHECK !== "1")
|
427 | await printUpdateNotification(args["--debug"]);
|
428 | if (args["--version"]) {
|
429 | logger.log(package_default.version);
|
430 | process.exit(0);
|
431 | }
|
432 | if (args["--help"]) {
|
433 | logger.log(getHelpText());
|
434 | process.exit(0);
|
435 | }
|
436 | if (!args["--listen"])
|
437 | args["--listen"] = [
|
438 | [process.env.PORT ? parseInt(process.env.PORT, 10) : 3e3]
|
439 | ];
|
440 | if (args._.length > 1) {
|
441 | logger.error("Please provide one path argument at maximum");
|
442 | process.exit(1);
|
443 | }
|
444 | if (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`.");
|
446 | var cwd = process.cwd();
|
447 | var entry = args._[0] ? path.resolve(args._[0]) : cwd;
|
448 | var config = await loadConfiguration(cwd, entry, args);
|
449 | if (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 | }
|
460 | for (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 |
|
482 | This 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 | }
|
499 | registerCloseListener(() => {
|
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 | });
|