| 1 | /** |
| 2 | * helpers for building applications with an HTTP server, with integrations with |
| 3 | * `@clo/lib/progress` |
| 4 | * |
| 5 | * @module |
| 6 | */ |
| 7 | |
| 8 | export interface Server { |
| 9 | port: number; |
| 10 | url: string; |
| 11 | close(): Promise<void>; |
| 12 | [Symbol.asyncDispose](): Promise<void>; |
| 13 | } |
| 14 | |
| 15 | export 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 | |
| 24 | export 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 | */ |
| 33 | export 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 | */ |
| 128 | export 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 | |
| 161 | export interface Range { |
| 162 | /** inclusive */ |
| 163 | start: number; |
| 164 | /** inclusive */ |
| 165 | end: number; |
| 166 | } |
| 167 | |
| 168 | import * as nodeHttp from "node:http"; |
| 169 | import * as stream from "node:stream"; |
| 170 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 171 | import * as exception from "./exception.ts"; |
| 172 | import * as node from "./node.ts"; |
| 173 | import * as progress from "./progress.ts"; |
| 174 | import * as subprocess from "./subprocess.ts"; |