|
1 | | -import { RequestListener, Server, createServer } from "node:http"; |
2 | | -import { |
3 | | - Server as HTTPServer, |
4 | | - createServer as createHTTPSServer, |
5 | | -} from "node:https"; |
6 | | -import { promisify } from "node:util"; |
7 | | -import { resolve } from "node:path"; |
8 | | -import { promises as fs, watch } from "node:fs"; |
9 | | -import { networkInterfaces } from "node:os"; |
10 | | -import type { AddressInfo } from "node:net"; |
11 | | -import { fileURLToPath } from "mlly"; |
12 | | -import { cyan, gray, underline, bold } from "colorette"; |
13 | | -import { getPort, GetPortInput } from "get-port-please"; |
14 | | -import addShutdown from "http-shutdown"; |
15 | | -import { defu } from "defu"; |
16 | | -import { open } from "./lib/open"; |
17 | | - |
18 | | -export interface Certificate { |
19 | | - key: string; |
20 | | - cert: string; |
21 | | -} |
22 | | - |
23 | | -export interface HTTPSOptions { |
24 | | - cert: string; |
25 | | - key: string; |
26 | | - domains?: string[]; |
27 | | - validityDays?: number; |
28 | | -} |
29 | | - |
30 | | -export interface ListenOptions { |
31 | | - name: string; |
32 | | - port?: GetPortInput; |
33 | | - hostname: string; |
34 | | - showURL: boolean; |
35 | | - baseURL: string; |
36 | | - open: boolean; |
37 | | - https: boolean | HTTPSOptions; |
38 | | - clipboard: boolean; |
39 | | - isTest: boolean; |
40 | | - isProd: boolean; |
41 | | - autoClose: boolean; |
42 | | - autoCloseSignals: string[]; |
43 | | -} |
44 | | - |
45 | | -export interface WatchOptions { |
46 | | - cwd: string; |
47 | | - entry: string; |
48 | | -} |
49 | | - |
50 | | -export interface ShowURLOptions { |
51 | | - baseURL: string; |
52 | | - name?: string; |
53 | | -} |
54 | | - |
55 | | -export interface Listener { |
56 | | - url: string; |
57 | | - address: any; |
58 | | - server: Server | HTTPServer; |
59 | | - https: false | Certificate; |
60 | | - close: () => Promise<void>; |
61 | | - open: () => Promise<void>; |
62 | | - showURL: (options?: Pick<ListenOptions, "baseURL">) => void; |
63 | | -} |
64 | | - |
65 | | -export async function listen( |
66 | | - handle: RequestListener, |
67 | | - options_: Partial<ListenOptions> = {}, |
68 | | -): Promise<Listener> { |
69 | | - options_ = defu(options_, { |
70 | | - port: process.env.PORT || 3000, |
71 | | - hostname: process.env.HOST || "", |
72 | | - showURL: true, |
73 | | - baseURL: "/", |
74 | | - open: false, |
75 | | - clipboard: false, |
76 | | - isTest: process.env.NODE_ENV === "test", |
77 | | - isProd: process.env.NODE_ENV === "production", |
78 | | - autoClose: true, |
79 | | - }); |
80 | | - |
81 | | - if (options_.isTest) { |
82 | | - options_.showURL = false; |
83 | | - } |
84 | | - |
85 | | - if (options_.isProd || options_.isTest) { |
86 | | - options_.open = false; |
87 | | - options_.clipboard = false; |
88 | | - } |
89 | | - |
90 | | - const port = await getPort({ |
91 | | - port: Number(options_.port), |
92 | | - verbose: !options_.isTest, |
93 | | - host: options_.hostname, |
94 | | - alternativePortRange: [3000, 3100], |
95 | | - ...(typeof options_.port === "object" && options_.port), |
96 | | - }); |
97 | | - |
98 | | - let server: Server | HTTPServer; |
99 | | - |
100 | | - let addr: { proto: "http" | "https"; addr: string; port: number } | null; |
101 | | - const getURL = (host?: string, baseURL?: string) => { |
102 | | - const anyV4 = addr?.addr === "0.0.0.0"; |
103 | | - const anyV6 = addr?.addr === "[::]"; |
104 | | - |
105 | | - return `${addr!.proto}://${ |
106 | | - host || options_.hostname || (anyV4 || anyV6 ? "localhost" : addr!.addr) |
107 | | - }:${addr!.port}${baseURL || options_.baseURL}`; |
108 | | - }; |
109 | | - |
110 | | - let https: Listener["https"] = false; |
111 | | - if (options_.https) { |
112 | | - const { key, cert } = await resolveCert( |
113 | | - { ...(options_.https as any) }, |
114 | | - options_.hostname, |
115 | | - ); |
116 | | - https = { key, cert }; |
117 | | - server = createHTTPSServer({ key, cert }, handle); |
118 | | - addShutdown(server); |
119 | | - // @ts-ignore |
120 | | - await promisify(server.listen.bind(server))(port, options_.hostname); |
121 | | - const _addr = server.address() as AddressInfo; |
122 | | - addr = { proto: "https", addr: formatAddress(_addr), port: _addr.port }; |
123 | | - } else { |
124 | | - server = createServer(handle); |
125 | | - addShutdown(server); |
126 | | - // @ts-ignore |
127 | | - await promisify(server.listen.bind(server))(port, options_.hostname); |
128 | | - const _addr = server.address() as AddressInfo; |
129 | | - addr = { proto: "http", addr: formatAddress(_addr), port: _addr.port }; |
130 | | - } |
131 | | - |
132 | | - let _closed = false; |
133 | | - const close = () => { |
134 | | - if (_closed) { |
135 | | - return Promise.resolve(); |
136 | | - } |
137 | | - _closed = true; |
138 | | - return promisify((server as any).shutdown)(); |
139 | | - }; |
140 | | - |
141 | | - if (options_.clipboard) { |
142 | | - const clipboardy = await import("clipboardy").then((r) => r.default || r); |
143 | | - await clipboardy.write(getURL()).catch(() => { |
144 | | - options_.clipboard = false; |
145 | | - }); |
146 | | - } |
147 | | - |
148 | | - const showURL = (options?: ShowURLOptions) => { |
149 | | - const add = options_.clipboard ? gray("(copied to clipboard)") : ""; |
150 | | - const lines = []; |
151 | | - const baseURL = options?.baseURL || options_.baseURL || ""; |
152 | | - const name = options?.name ? ` (${options.name})` : ""; |
153 | | - |
154 | | - const anyV4 = addr?.addr === "0.0.0.0"; |
155 | | - const anyV6 = addr?.addr === "[::]"; |
156 | | - if (anyV4 || anyV6) { |
157 | | - lines.push( |
158 | | - ` > Local${name}: ${formatURL( |
159 | | - getURL("localhost", baseURL), |
160 | | - )} ${add}`, |
161 | | - ); |
162 | | - for (const addr of getNetworkInterfaces(anyV4)) { |
163 | | - lines.push(` > Network${name}: ${formatURL(getURL(addr, baseURL))}`); |
164 | | - } |
165 | | - } else { |
166 | | - lines.push( |
167 | | - ` > Listening${name}: ${formatURL( |
168 | | - getURL(undefined, baseURL), |
169 | | - )} ${add}`, |
170 | | - ); |
171 | | - } |
172 | | - // eslint-disable-next-line no-console |
173 | | - console.log("\n" + lines.join("\n") + "\n"); |
174 | | - }; |
175 | | - |
176 | | - if (options_.showURL) { |
177 | | - showURL(); |
178 | | - } |
179 | | - |
180 | | - const _open = async () => { |
181 | | - await open(getURL()).catch(() => {}); |
182 | | - }; |
183 | | - if (options_.open) { |
184 | | - await _open(); |
185 | | - } |
186 | | - |
187 | | - if (options_.autoClose) { |
188 | | - process.on("exit", () => close()); |
189 | | - } |
190 | | - |
191 | | - return <Listener>{ |
192 | | - url: getURL(), |
193 | | - https, |
194 | | - server, |
195 | | - open: _open, |
196 | | - showURL, |
197 | | - close, |
198 | | - }; |
199 | | -} |
200 | | - |
201 | | -export async function listenAndWatch( |
202 | | - input: string, |
203 | | - options: Partial<ListenOptions & WatchOptions> = {}, |
204 | | -): Promise<Listener> { |
205 | | - const cwd = resolve(options.cwd ? fileURLToPath(options.cwd) : "."); |
206 | | - |
207 | | - const jiti = await import("jiti").then((r) => r.default || r); |
208 | | - const _jitiRequire = jiti(cwd, { |
209 | | - esmResolve: true, |
210 | | - requireCache: false, |
211 | | - interopDefault: true, |
212 | | - }); |
213 | | - |
214 | | - const entry = _jitiRequire.resolve(input); |
215 | | - |
216 | | - let handle: RequestListener; |
217 | | - |
218 | | - const resolveHandle = () => { |
219 | | - const imported = _jitiRequire(entry); |
220 | | - handle = imported.default || imported; |
221 | | - }; |
222 | | - |
223 | | - resolveHandle(); |
224 | | - |
225 | | - const watcher = await watch(entry, () => { |
226 | | - resolveHandle(); |
227 | | - }); |
228 | | - |
229 | | - const listenter = await listen((...args) => { |
230 | | - return handle(...args); |
231 | | - }, options); |
232 | | - |
233 | | - const _close = listenter.close; |
234 | | - listenter.close = async () => { |
235 | | - watcher.close(); |
236 | | - await _close(); |
237 | | - }; |
238 | | - |
239 | | - return listenter; |
240 | | -} |
241 | | - |
242 | | -async function resolveCert( |
243 | | - options: HTTPSOptions, |
244 | | - host?: string, |
245 | | -): Promise<Certificate> { |
246 | | - // Use cert if provided |
247 | | - if (options.key && options.cert) { |
248 | | - const isInline = (s = "") => s.startsWith("--"); |
249 | | - const r = (s: string) => (isInline(s) ? s : fs.readFile(s, "utf8")); |
250 | | - return { |
251 | | - key: await r(options.key), |
252 | | - cert: await r(options.cert), |
253 | | - }; |
254 | | - } |
255 | | - |
256 | | - // Use auto generated cert |
257 | | - const { generateCA, generateSSLCert } = await import("./cert"); |
258 | | - const ca = await generateCA(); |
259 | | - const cert = await generateSSLCert({ |
260 | | - caCert: ca.cert, |
261 | | - caKey: ca.key, |
262 | | - domains: |
263 | | - options.domains || |
264 | | - (["localhost", "127.0.0.1", "::1", host].filter(Boolean) as string[]), |
265 | | - validityDays: options.validityDays || 1, |
266 | | - }); |
267 | | - return cert; |
268 | | -} |
269 | | - |
270 | | -function getNetworkInterfaces(v4Only = true): string[] { |
271 | | - const addrs = new Set<string>(); |
272 | | - for (const details of Object.values(networkInterfaces())) { |
273 | | - if (details) { |
274 | | - for (const d of details) { |
275 | | - if ( |
276 | | - !d.internal && |
277 | | - !(d.mac === "00:00:00:00:00:00") && |
278 | | - !d.address.startsWith("fe80::") && |
279 | | - !(v4Only && (d.family === "IPv6" || +d.family === 6)) |
280 | | - ) { |
281 | | - addrs.add(formatAddress(d)); |
282 | | - } |
283 | | - } |
284 | | - } |
285 | | - } |
286 | | - return [...addrs].sort(); |
287 | | -} |
288 | | - |
289 | | -function formatAddress(addr: { family: string | number; address: string }) { |
290 | | - return addr.family === "IPv6" || addr.family === 6 |
291 | | - ? `[${addr.address}]` |
292 | | - : addr.address; |
293 | | -} |
294 | | - |
295 | | -function formatURL(url: string) { |
296 | | - return cyan( |
297 | | - underline(decodeURI(url).replace(/:(\d+)\//g, `:${bold("$1")}/`)), |
298 | | - ); |
299 | | -} |
| 1 | +export * from "./listen"; |
| 2 | +export * from "./types"; |
| 3 | +export * from "./watch"; |
0 commit comments