1/**
2 * helpers for building applications with an HTTP server, with integrations with
3 * `@clo/lib/progress`
4 *
5 * @module
6 */
7
8export interface Server {
9 port: number;
10 url: string;
11 close(): Promise<void>;
12 [Symbol.asyncDispose](): Promise<void>;
13}
14
15export interface ServeOptions {
16 /** respond to a server request */
17 respond: (request: Request, opts: ServeContext) => Promise<Response>;
18 /** port 0 hosts on a random port */
19 port: number;
20
21 progress?: progress.Ref;
22}
23
24export interface ServeContext {
25 node: progress.Node;
26}
27
28/**
29 * this server is not optimized performance and likely includes bugs.
30 *
31 * more production ready alternatives: `srvx` and `@hono/node-server`.
32 */
33export async function serve(
34 { respond, port, progress: rootNode = progress.nullNode }: ServeOptions,
35): Promise<Server> {
36 const server = nodeHttp.createServer((req, res) => {
37 const headers = new Headers();
38 for (const key in req.headers) {
39 const value = req.headers[key];
40 if (value == null) continue;
41 if (Array.isArray(value)) {
42 for (const item of value) headers.append(key, item);
43 } else {
44 headers.append(key, value);
45 }
46 }
47 const method = UNWRAP(req.method).toUpperCase();
48 const hasNoBody = method === "GET" || method === "HEAD";
49 const request = new Request(
50 "http://" + (req.headers.host ?? "localhost") + UNWRAP(req.url),
51 {
52 method,
53 headers,
54 body: hasNoBody
55 ? undefined
56 : stream.Readable.toWeb(req) as ReadableStream,
57 // @ts-expect-error
58 duplex: hasNoBody ? undefined : "half",
59 },
60 );
61 const requestNode = rootNode.start(`${method} ${req.url}`);
62 respond(request, { node: requestNode })
63 .then((response) => {
64 res.writeHead(
65 response.status,
66 response.statusText,
67 Array.from(response.headers.entries()),
68 );
69 if (response.body) {
70 stream.Readable.fromWeb(response.body as import("node:stream/web").ReadableStream).pipe(res);
71 } else {
72 res.end();
73 }
74 })
75 .catch((err) => {
76 console.error(err);
77 })
78 .finally(() => {
79 requestNode.end();
80 });
81 });
82
83 const actualPort = await new Promise<number>((resolve, reject) => {
84 async function onError(err: unknown) {
85 if (exception.code(err) !== "EADDRINUSE") {
86 reject(err);
87 return;
88 }
89 const info = await subprocess.lookupProcessOnPort(port).catch(() => null);
90 if (!info) return void reject(err);
91
92 reject(
93 new Error(
94 `Port ${port} is in use by ${JSON.stringify(info.command)} (pid ${info.pid})`,
95 ),
96 );
97 }
98 server.on("error", onError);
99 server.listen(port, () => {
100 server.off("error", onError);
101 const addr = server.address();
102 ASSERT(addr && typeof addr === "object");
103 resolve(addr.port);
104 });
105 });
106
107 function close() {
108 return new Promise<void>((resolve, reject) => {
109 server.close((err) => {
110 if (err) reject(err);
111 else resolve();
112 });
113 });
114 }
115
116 return {
117 port: actualPort,
118 url: `http://localhost${actualPort === 80 ? "" : ":" + actualPort}/`,
119 close,
120 [Symbol.asyncDispose]: close,
121 };
122}
123
124/**
125 * parses the [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests)
126 * header, returning `null` if it is invalid. the `fileSize`
127 */
128export function parseRangeHeader(
129 rangeHeader: string,
130 fileSize: number,
131): Range[] | null {
132 const [unit, ranges] = rangeHeader.split("=");
133 if (!ranges) return null;
134 if (unit !== "bytes") return null;
135
136 const result: Range[] = [];
137 const rangeParts = ranges.split(",");
138
139 for (const range of rangeParts) {
140 const split = range.split("-");
141 const [start, end] = split;
142 if (!start || !end) return null;
143 if (start === "" && end === "") return null;
144 result.push({
145 start: start === "" ? fileSize - +end : +start,
146 end: end === "" ? fileSize - 1 : +end,
147 });
148 }
149
150 // validate that ranges do not intersect
151 result.sort((a, b) => a.start - b.start);
152 for (let i = 1; i < result.length; i++) {
153 if (UNWRAP(result[i]).start <= UNWRAP(result[i - 1]).end) {
154 return null;
155 }
156 }
157
158 return result;
159}
160
161export interface Range {
162 /** inclusive */
163 start: number;
164 /** inclusive */
165 end: number;
166}
167
168import * as nodeHttp from "node:http";
169import * as stream from "node:stream";
170import { ASSERT, UNWRAP } from "./assert.ts";
171import * as exception from "./exception.ts";
172import * as node from "./node.ts";
173import * as progress from "./progress.ts";
174import * as subprocess from "./subprocess.ts";