From c6fd01e30f1d16da0ebdef2872069b89ca11c7d1 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 17 Jul 2023 17:24:58 +0200 Subject: [PATCH] refactor: split code --- src/_utils.ts | 66 +++++++++++ src/index.ts | 302 +------------------------------------------------- src/listen.ts | 156 ++++++++++++++++++++++++++ src/types.ts | 48 ++++++++ src/watch.ts | 48 ++++++++ 5 files changed, 321 insertions(+), 299 deletions(-) create mode 100644 src/_utils.ts create mode 100644 src/listen.ts create mode 100644 src/types.ts create mode 100644 src/watch.ts diff --git a/src/_utils.ts b/src/_utils.ts new file mode 100644 index 0000000..5e12ccf --- /dev/null +++ b/src/_utils.ts @@ -0,0 +1,66 @@ +import { promises as fs } from "node:fs"; +import { networkInterfaces } from "node:os"; +import { cyan, underline, bold } from "colorette"; +import type { Certificate, HTTPSOptions } from "./types"; + +export async function resolveCert( + options: HTTPSOptions, + host?: string, +): Promise { + // Use cert if provided + if (options.key && options.cert) { + const isInline = (s = "") => s.startsWith("--"); + const r = (s: string) => (isInline(s) ? s : fs.readFile(s, "utf8")); + return { + key: await r(options.key), + cert: await r(options.cert), + }; + } + + // Use auto generated cert + const { generateCA, generateSSLCert } = await import("./cert"); + const ca = await generateCA(); + const cert = await generateSSLCert({ + caCert: ca.cert, + caKey: ca.key, + domains: + options.domains || + (["localhost", "127.0.0.1", "::1", host].filter(Boolean) as string[]), + validityDays: options.validityDays || 1, + }); + return cert; +} + +export function getNetworkInterfaces(v4Only = true): string[] { + const addrs = new Set(); + for (const details of Object.values(networkInterfaces())) { + if (details) { + for (const d of details) { + if ( + !d.internal && + !(d.mac === "00:00:00:00:00:00") && + !d.address.startsWith("fe80::") && + !(v4Only && (d.family === "IPv6" || +d.family === 6)) + ) { + addrs.add(formatAddress(d)); + } + } + } + } + return [...addrs].sort(); +} + +export function formatAddress(addr: { + family: string | number; + address: string; +}) { + return addr.family === "IPv6" || addr.family === 6 + ? `[${addr.address}]` + : addr.address; +} + +export function formatURL(url: string) { + return cyan( + underline(decodeURI(url).replace(/:(\d+)\//g, `:${bold("$1")}/`)), + ); +} diff --git a/src/index.ts b/src/index.ts index 738a24a..fe23809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,299 +1,3 @@ -import { RequestListener, Server, createServer } from "node:http"; -import { - Server as HTTPServer, - createServer as createHTTPSServer, -} from "node:https"; -import { promisify } from "node:util"; -import { resolve } from "node:path"; -import { promises as fs, watch } from "node:fs"; -import { networkInterfaces } from "node:os"; -import type { AddressInfo } from "node:net"; -import { fileURLToPath } from "mlly"; -import { cyan, gray, underline, bold } from "colorette"; -import { getPort, GetPortInput } from "get-port-please"; -import addShutdown from "http-shutdown"; -import { defu } from "defu"; -import { open } from "./lib/open"; - -export interface Certificate { - key: string; - cert: string; -} - -export interface HTTPSOptions { - cert: string; - key: string; - domains?: string[]; - validityDays?: number; -} - -export interface ListenOptions { - name: string; - port?: GetPortInput; - hostname: string; - showURL: boolean; - baseURL: string; - open: boolean; - https: boolean | HTTPSOptions; - clipboard: boolean; - isTest: boolean; - isProd: boolean; - autoClose: boolean; - autoCloseSignals: string[]; -} - -export interface WatchOptions { - cwd: string; - entry: string; -} - -export interface ShowURLOptions { - baseURL: string; - name?: string; -} - -export interface Listener { - url: string; - address: any; - server: Server | HTTPServer; - https: false | Certificate; - close: () => Promise; - open: () => Promise; - showURL: (options?: Pick) => void; -} - -export async function listen( - handle: RequestListener, - options_: Partial = {}, -): Promise { - options_ = defu(options_, { - port: process.env.PORT || 3000, - hostname: process.env.HOST || "", - showURL: true, - baseURL: "/", - open: false, - clipboard: false, - isTest: process.env.NODE_ENV === "test", - isProd: process.env.NODE_ENV === "production", - autoClose: true, - }); - - if (options_.isTest) { - options_.showURL = false; - } - - if (options_.isProd || options_.isTest) { - options_.open = false; - options_.clipboard = false; - } - - const port = await getPort({ - port: Number(options_.port), - verbose: !options_.isTest, - host: options_.hostname, - alternativePortRange: [3000, 3100], - ...(typeof options_.port === "object" && options_.port), - }); - - let server: Server | HTTPServer; - - let addr: { proto: "http" | "https"; addr: string; port: number } | null; - const getURL = (host?: string, baseURL?: string) => { - const anyV4 = addr?.addr === "0.0.0.0"; - const anyV6 = addr?.addr === "[::]"; - - return `${addr!.proto}://${ - host || options_.hostname || (anyV4 || anyV6 ? "localhost" : addr!.addr) - }:${addr!.port}${baseURL || options_.baseURL}`; - }; - - let https: Listener["https"] = false; - if (options_.https) { - const { key, cert } = await resolveCert( - { ...(options_.https as any) }, - options_.hostname, - ); - https = { key, cert }; - server = createHTTPSServer({ key, cert }, handle); - addShutdown(server); - // @ts-ignore - await promisify(server.listen.bind(server))(port, options_.hostname); - const _addr = server.address() as AddressInfo; - addr = { proto: "https", addr: formatAddress(_addr), port: _addr.port }; - } else { - server = createServer(handle); - addShutdown(server); - // @ts-ignore - await promisify(server.listen.bind(server))(port, options_.hostname); - const _addr = server.address() as AddressInfo; - addr = { proto: "http", addr: formatAddress(_addr), port: _addr.port }; - } - - let _closed = false; - const close = () => { - if (_closed) { - return Promise.resolve(); - } - _closed = true; - return promisify((server as any).shutdown)(); - }; - - if (options_.clipboard) { - const clipboardy = await import("clipboardy").then((r) => r.default || r); - await clipboardy.write(getURL()).catch(() => { - options_.clipboard = false; - }); - } - - const showURL = (options?: ShowURLOptions) => { - const add = options_.clipboard ? gray("(copied to clipboard)") : ""; - const lines = []; - const baseURL = options?.baseURL || options_.baseURL || ""; - const name = options?.name ? ` (${options.name})` : ""; - - const anyV4 = addr?.addr === "0.0.0.0"; - const anyV6 = addr?.addr === "[::]"; - if (anyV4 || anyV6) { - lines.push( - ` > Local${name}: ${formatURL( - getURL("localhost", baseURL), - )} ${add}`, - ); - for (const addr of getNetworkInterfaces(anyV4)) { - lines.push(` > Network${name}: ${formatURL(getURL(addr, baseURL))}`); - } - } else { - lines.push( - ` > Listening${name}: ${formatURL( - getURL(undefined, baseURL), - )} ${add}`, - ); - } - // eslint-disable-next-line no-console - console.log("\n" + lines.join("\n") + "\n"); - }; - - if (options_.showURL) { - showURL(); - } - - const _open = async () => { - await open(getURL()).catch(() => {}); - }; - if (options_.open) { - await _open(); - } - - if (options_.autoClose) { - process.on("exit", () => close()); - } - - return { - url: getURL(), - https, - server, - open: _open, - showURL, - close, - }; -} - -export async function listenAndWatch( - input: string, - options: Partial = {}, -): Promise { - const cwd = resolve(options.cwd ? fileURLToPath(options.cwd) : "."); - - const jiti = await import("jiti").then((r) => r.default || r); - const _jitiRequire = jiti(cwd, { - esmResolve: true, - requireCache: false, - interopDefault: true, - }); - - const entry = _jitiRequire.resolve(input); - - let handle: RequestListener; - - const resolveHandle = () => { - const imported = _jitiRequire(entry); - handle = imported.default || imported; - }; - - resolveHandle(); - - const watcher = await watch(entry, () => { - resolveHandle(); - }); - - const listenter = await listen((...args) => { - return handle(...args); - }, options); - - const _close = listenter.close; - listenter.close = async () => { - watcher.close(); - await _close(); - }; - - return listenter; -} - -async function resolveCert( - options: HTTPSOptions, - host?: string, -): Promise { - // Use cert if provided - if (options.key && options.cert) { - const isInline = (s = "") => s.startsWith("--"); - const r = (s: string) => (isInline(s) ? s : fs.readFile(s, "utf8")); - return { - key: await r(options.key), - cert: await r(options.cert), - }; - } - - // Use auto generated cert - const { generateCA, generateSSLCert } = await import("./cert"); - const ca = await generateCA(); - const cert = await generateSSLCert({ - caCert: ca.cert, - caKey: ca.key, - domains: - options.domains || - (["localhost", "127.0.0.1", "::1", host].filter(Boolean) as string[]), - validityDays: options.validityDays || 1, - }); - return cert; -} - -function getNetworkInterfaces(v4Only = true): string[] { - const addrs = new Set(); - for (const details of Object.values(networkInterfaces())) { - if (details) { - for (const d of details) { - if ( - !d.internal && - !(d.mac === "00:00:00:00:00:00") && - !d.address.startsWith("fe80::") && - !(v4Only && (d.family === "IPv6" || +d.family === 6)) - ) { - addrs.add(formatAddress(d)); - } - } - } - } - return [...addrs].sort(); -} - -function formatAddress(addr: { family: string | number; address: string }) { - return addr.family === "IPv6" || addr.family === 6 - ? `[${addr.address}]` - : addr.address; -} - -function formatURL(url: string) { - return cyan( - underline(decodeURI(url).replace(/:(\d+)\//g, `:${bold("$1")}/`)), - ); -} +export * from "./listen"; +export * from "./types"; +export * from "./watch"; diff --git a/src/listen.ts b/src/listen.ts new file mode 100644 index 0000000..33d8f31 --- /dev/null +++ b/src/listen.ts @@ -0,0 +1,156 @@ +import { createServer } from "node:http"; +import { + Server as HTTPServer, + createServer as createHTTPSServer, +} from "node:https"; +import { promisify } from "node:util"; +import type { RequestListener, Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { getPort } from "get-port-please"; +import addShutdown from "http-shutdown"; +import { defu } from "defu"; +import { gray } from "colorette"; +import { open } from "./lib/open"; +import type { ListenOptions, Listener, ShowURLOptions } from "./types"; +import { + resolveCert, + formatAddress, + formatURL, + getNetworkInterfaces, +} from "./_utils"; + +export async function listen( + handle: RequestListener, + options_: Partial = {}, +): Promise { + options_ = defu(options_, { + port: process.env.PORT || 3000, + hostname: process.env.HOST || "", + showURL: true, + baseURL: "/", + open: false, + clipboard: false, + isTest: process.env.NODE_ENV === "test", + isProd: process.env.NODE_ENV === "production", + autoClose: true, + }); + + if (options_.isTest) { + options_.showURL = false; + } + + if (options_.isProd || options_.isTest) { + options_.open = false; + options_.clipboard = false; + } + + const port = await getPort({ + port: Number(options_.port), + verbose: !options_.isTest, + host: options_.hostname, + alternativePortRange: [3000, 3100], + ...(typeof options_.port === "object" && options_.port), + }); + + let server: Server | HTTPServer; + + let addr: { proto: "http" | "https"; addr: string; port: number } | null; + const getURL = (host?: string, baseURL?: string) => { + const anyV4 = addr?.addr === "0.0.0.0"; + const anyV6 = addr?.addr === "[::]"; + + return `${addr!.proto}://${ + host || options_.hostname || (anyV4 || anyV6 ? "localhost" : addr!.addr) + }:${addr!.port}${baseURL || options_.baseURL}`; + }; + + let https: Listener["https"] = false; + if (options_.https) { + const { key, cert } = await resolveCert( + { ...(options_.https as any) }, + options_.hostname, + ); + https = { key, cert }; + server = createHTTPSServer({ key, cert }, handle); + addShutdown(server); + // @ts-ignore + await promisify(server.listen.bind(server))(port, options_.hostname); + const _addr = server.address() as AddressInfo; + addr = { proto: "https", addr: formatAddress(_addr), port: _addr.port }; + } else { + server = createServer(handle); + addShutdown(server); + // @ts-ignore + await promisify(server.listen.bind(server))(port, options_.hostname); + const _addr = server.address() as AddressInfo; + addr = { proto: "http", addr: formatAddress(_addr), port: _addr.port }; + } + + let _closed = false; + const close = () => { + if (_closed) { + return Promise.resolve(); + } + _closed = true; + return promisify((server as any).shutdown)(); + }; + + if (options_.clipboard) { + const clipboardy = await import("clipboardy").then((r) => r.default || r); + await clipboardy.write(getURL()).catch(() => { + options_.clipboard = false; + }); + } + + const showURL = (options?: ShowURLOptions) => { + const add = options_.clipboard ? gray("(copied to clipboard)") : ""; + const lines = []; + const baseURL = options?.baseURL || options_.baseURL || ""; + const name = options?.name ? ` (${options.name})` : ""; + + const anyV4 = addr?.addr === "0.0.0.0"; + const anyV6 = addr?.addr === "[::]"; + if (anyV4 || anyV6) { + lines.push( + ` > Local${name}: ${formatURL( + getURL("localhost", baseURL), + )} ${add}`, + ); + for (const addr of getNetworkInterfaces(anyV4)) { + lines.push(` > Network${name}: ${formatURL(getURL(addr, baseURL))}`); + } + } else { + lines.push( + ` > Listening${name}: ${formatURL( + getURL(undefined, baseURL), + )} ${add}`, + ); + } + // eslint-disable-next-line no-console + console.log("\n" + lines.join("\n") + "\n"); + }; + + if (options_.showURL) { + showURL(); + } + + const _open = async () => { + await open(getURL()).catch(() => {}); + }; + if (options_.open) { + await _open(); + } + + if (options_.autoClose) { + process.on("exit", () => close()); + } + + return { + url: getURL(), + https, + server, + open: _open, + showURL, + close, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..42337e8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,48 @@ +import type { GetPortInput } from "get-port-please"; + +export interface Certificate { + key: string; + cert: string; +} + +export interface HTTPSOptions { + cert: string; + key: string; + domains?: string[]; + validityDays?: number; +} + +export interface ListenOptions { + name: string; + port?: GetPortInput; + hostname: string; + showURL: boolean; + baseURL: string; + open: boolean; + https: boolean | HTTPSOptions; + clipboard: boolean; + isTest: boolean; + isProd: boolean; + autoClose: boolean; + autoCloseSignals: string[]; +} + +export interface WatchOptions { + cwd: string; + entry: string; +} + +export interface ShowURLOptions { + baseURL: string; + name?: string; +} + +export interface Listener { + url: string; + address: any; + server: Server | HTTPServer; + https: false | Certificate; + close: () => Promise; + open: () => Promise; + showURL: (options?: Pick) => void; +} diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..5c0ecfa --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,48 @@ +import type { ListenOptions } from "node:net"; +import type { RequestListener } from "node:http"; +import { resolve } from "node:path"; +import { watch } from "node:fs"; +import { fileURLToPath } from "mlly"; +import type { Listener, WatchOptions } from "./types"; +import { listen } from "./listen"; + +export async function listenAndWatch( + input: string, + options: Partial = {}, +): Promise { + const cwd = resolve(options.cwd ? fileURLToPath(options.cwd) : "."); + + const jiti = await import("jiti").then((r) => r.default || r); + const _jitiRequire = jiti(cwd, { + esmResolve: true, + requireCache: false, + interopDefault: true, + }); + + const entry = _jitiRequire.resolve(input); + + let handle: RequestListener; + + const resolveHandle = () => { + const imported = _jitiRequire(entry); + handle = imported.default || imported; + }; + + resolveHandle(); + + const watcher = await watch(entry, () => { + resolveHandle(); + }); + + const listenter = await listen((...args) => { + return handle(...args); + }, options); + + const _close = listenter.close; + listenter.close = async () => { + watcher.close(); + await _close(); + }; + + return listenter; +}