authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-27 22:05:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-27 22:30:26-07:00
log78996015dfc479cea728824a842a22cd9f1021cf
tree251816cc3c35f2c6fec71d4d12a7ff23acb584a7
parenta0b0784cd1344ffe9d30881135834a71122fb843
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: `http.serve`

this implements easy web server without a third party module. since this is a very simple function, clover library spices it up with `@clo/lib/progress` support. the library is feeling extremely unified, we even got a `@clo/lib/subprocess` API. implements #85

15 files changed, 263 insertions(+), 69 deletions(-)

framework/backend/entry-node.ts+8-25
......@@ -1,34 +1,19 @@
11const app = require(globalThis.CLOVER_SERVER_ENTRY ?? process.argv[2]);
22
3const protocol = "http";
4
5const server = serve({
6 fetch: app.default.fetch,
3const server = await http.serve({
4 respond: app.default.fetch,
75 port: Number(process.env.PORT ?? 3000),
8}, ({ address, port }) => {
9 if (address === "::" || address === "127.0.0.1") {
10 address = os.hostname().replace(/\.local$/, "");
11 }
12 console.info(
13 "live at " + url.format({
14 protocol,
15 hostname: address,
16 port,
17 }),
18 );
196});
7console.info(`live at ${server.url}`);
208
219process.on("SIGINT", () => {
22 server.close();
23 process.exit(0);
10 server.close().then(() => {
11 process.exit(0);
12 });
2413});
2514
2615process.on("SIGTERM", () => {
27 server.close((err) => {
28 if (err) {
29 console.error(err);
30 process.exit(1);
31 }
16 server.close().then(() => {
3217 process.exit(0);
3318 });
3419});
......@@ -38,8 +23,6 @@ declare global {
3823 var CLOVER_SERVER_ENTRY: string;
3924}
4025
41import * as os from "os";
42import * as url from "node:url";
43import { serve } from "@hono/node-server";
26import * as http from "@clo/lib/http";
4427import process from "node:process";
4528import * as console from "@clo/lib/log";
framework/lib/view.ts+1-1
......@@ -118,7 +118,7 @@ export function regenerate(tag: RegenKey) {
118118}
119119
120120import * as meta from "@clo/lib/render/meta";
121import type * as hono from "#hono";
121import type * as hono from "hono";
122122import * as render from "@clo/lib/render";
123123import * as sg from "./sitegen.ts";
124124import * as asset from "./assets.ts";
lib/examples/server.ts created+23
......@@ -0,0 +1,23 @@
1export async function main() {
2const server = await http.serve({
3 port: 3000,
4
5 async respond(request, { node }) {
6 node.total = 50;
7 for(let i = 0; i < 50; i += 1) {
8 await async.delay(5);
9 node.value += 1;
10 }
11 return new Response("hello!");
12 },
13
14 progress: progress.start('my web server'),
15});
16log.info(`up: ${server.url}`);
17
18}
19
20import * as async from "@clo/lib/async";
21import * as http from "@clo/lib/http";
22import * as log from "@clo/lib/log";
23import * as progress from "@clo/lib/progress";
lib/http.ts+135-2
......@@ -1,8 +1,137 @@
1export interface Server {
2 port: number;
3 url: string;
4 close(): Promise<void>;
5 [Symbol.asyncDispose](): Promise<void>;
6}
7
8export interface ServeOptions {
9 /** respond to a server request */
10 respond: (request: Request, opts: ServeContext) => Promise<Response>;
11 /** port 0 hosts on a random port */
12 port: number;
13
14 progress?: progress.Ref;
15}
16
17export interface ServeContext {
18 node: progress.Node;
19}
20
21/**
22 * this server is not optimized performance and likely includes bugs.
23 *
24 * more production ready alternatives: `srvx` and `@hono/node-server`.
25 */
26export async function serve(
27 { respond, port, progress: rootNode = progress.nullNode }: ServeOptions,
28): Promise<Server> {
29 const nodeHttp = node.builtin(
30 "http",
31 ) as unknown as typeof import("node:http");
32 const stream = node.builtin("stream");
33 ASSERT(
34 nodeHttp && stream,
35 "http.serve must be called in a node.js compatible runtime",
36 );
37
38 const server = nodeHttp.createServer((req, res) => {
39 const headers = new Headers();
40 for (const key in req.headers) {
41 const value = req.headers[key];
42 if (value == null) continue;
43 if (Array.isArray(value)) {
44 for (const item of value) headers.append(key, item);
45 } else {
46 headers.append(key, value);
47 }
48 }
49 const method = UNWRAP(req.method).toUpperCase();
50 const hasNoBody = method === "GET" || method === "HEAD";
51 const request = new Request(
52 "http://" + (req.headers.host ?? "localhost") + UNWRAP(req.url),
53 {
54 method,
55 headers,
56 body: hasNoBody
57 ? undefined
58 : stream.Readable.toWeb(req),
59 duplex: hasNoBody ? undefined : 'half',
60 },
61 );
62 const requestNode = rootNode.start(`${method} ${req.url}`);
63 respond(request, { node: requestNode })
64 .then((response) => {
65 res.writeHead(
66 response.status,
67 response.statusText,
68 Array.from(response.headers.entries()),
69 );
70 if (response.body) {
71 stream.Readable.fromWeb(response.body).pipe(res);
72 } else {
73 res.end();
74 }
75 })
76 .catch((err) => {
77 console.error(err);
78 })
79 .finally(() => {
80 requestNode.end();
81 });
82 });
83
84 const actualPort = await new Promise<number>((resolve, reject) => {
85 async function onError(err: unknown) {
86 if (error.code(err) !== "EADDRINUSE") {
87 reject(err);
88 return;
89 }
90 const info = await subprocess.lookupProcessOnPort(port).catch(() => null);
91 if (!info) return void reject(err);
92
93 reject(
94 new Error(
95 `Port ${port} is in use by ${
96 JSON.stringify(info.command)
97 } (pid ${info.pid})`,
98 ),
99 );
100 }
101 server.on("error", onError);
102 server.listen(port, () => {
103 server.off("error", onError);
104 const addr = server.address();
105 ASSERT(addr && typeof addr === "object");
106 resolve(addr.port);
107 });
108 });
109
110 function close() {
111 return new Promise<void>((resolve, reject) => {
112 server.close((err) => {
113 if (err) reject(err);
114 else resolve();
115 });
116 });
117 }
118
119 return {
120 port: actualPort,
121 url: `http://localhost${actualPort === 80 ? "" : ":" + actualPort}/`,
122 close,
123 [Symbol.asyncDispose]: close,
124 };
125}
126
1127/**
2128 * parses the [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests)
3129 * header, returning `null` if it is invalid. the `fileSize`
4130 */
5export function parseRangeHeader(rangeHeader: string, fileSize: number): Range[] | null {
131export function parseRangeHeader(
132 rangeHeader: string,
133 fileSize: number,
134): Range[] | null {
6135 const [unit, ranges] = rangeHeader.split("=");
7136 if (!ranges) return null;
8137 if (unit !== "bytes") return null;
......@@ -39,4 +168,8 @@ export interface Range {
39168 end: number;
40169}
41170
42import { UNWRAP } from "./assert.ts";
171import { ASSERT, UNWRAP } from "./assert.ts";
172import * as node from "./node.ts";
173import * as subprocess from "./subprocess.ts";
174import * as error from "./error.ts";
175import * as progress from "./progress.ts";
lib/node.ts+4
......@@ -62,6 +62,10 @@ interface Builtins {
6262 ): string;
6363 };
6464 "stream": {
65 Readable: {
66 toWeb(nodeStream: unknown): ReadableStream;
67 fromWeb(webStream: ReadableStream): { pipe: (other: unknown) => void};
68 };
6569 Writable: {
6670 prototype: {
6771 write: (value: string) => boolean;
lib/subprocess.ts+75
......@@ -86,8 +86,83 @@ export async function spawnAndWait(
8686// }
8787// });
8888
89
90async function findInodeForPort(port) {
91 const hexPort = port.toString(16).toUpperCase().padStart(4, '0');
92 for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
93 try {
94 const lines = (await fs.readFile(f, 'utf8')).split('\n').slice(1);
95 for (const line of lines) {
96 const parts = line.trim().split(/\s+/);
97 if (parts[1]?.split(':')[1] === hexPort) return parts[9];
98 }
99 } catch {}
100 }
101 return null;
102}
103
104async function findPidForInode(inode) {
105 const target = `socket:[${inode}]`;
106 for (const pid of await fs.readdir('/proc')) {
107 if (!/^\d+$/.test(pid)) continue;
108 try {
109 for (const fd of await fs.readdir(`/proc/${pid}/fd`)) {
110 try {
111 if (await fs.readlink(`/proc/${pid}/fd/${fd}`) === target) return pid;
112 } catch {}
113 }
114 } catch {}
115 }
116 return null;
117}
118
119async function commandFromPortLinux(port: number) {
120 const inode = await findInodeForPort(port);
121 if (!inode) throw new Error(`no process on port ${port}`);
122 const pid = await findPidForInode(inode);
123 if (!pid) throw new Error(`no pid for inode ${inode}`);
124 const raw = await fs.readFile(`/proc/${pid}/cmdline`, 'utf8');
125 return { pid: Number(pid), command: raw.split('\0').filter(Boolean).join(' ') };
126}
127
128async function commandFromPortMac(port: number) {
129 const { stdout: lsofOut } = await exec('lsof', ['-ti', `tcp:${port}`]);
130 const pid = lsofOut.trim().split('\n')[0];
131 if (!pid) throw new Error(`no process on port ${port}`);
132 const { stdout: psOut } = await exec('ps', ['-p', pid, '-o', 'args=']);
133 return { pid: Number(pid), command: psOut.trim() };
134}
135
136async function commandFromPortWindows(port: number) {
137 const { stdout } = await exec('netstat', ['-ano']);
138 const line = stdout.split('\n').find(l => {
139 const m = l.match(/\s+[\d.:]+:(\d+)\s+/);
140 return m && parseInt(m[1]!) === port && l.includes('LISTENING');
141 });
142 if (!line) throw new Error(`no process on port ${port}`);
143 const pid = line.trim().split(/\s+/).at(-1);
144 const { stdout: psOut } = await exec('powershell', [
145 '-NoProfile', '-Command',
146 `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
147 ]);
148 const cmdline = psOut.trim();
149 if (!cmdline) throw new Error(`no cmdline for pid ${pid}`);
150 return { pid: Number(pid), command: cmdline };
151}
152
153export async function lookupProcessOnPort(port: number) {
154 switch (os.platform()) {
155 case 'linux': return commandFromPortLinux(port);
156 case 'darwin': return commandFromPortMac(port);
157 case 'win32': return commandFromPortWindows(port);
158 default: throw new Error(`unsupported platform: ${os.platform()}`);
159 }
160}
161
89162// import * as process from "node:process";
90163import * as events from "node:events";
91164import * as util from "node:util";
92165import * as child_process from "node:child_process";
166import * as fs from 'node:fs/promises';
167import os from 'os';
93168import { Buffer } from "node:buffer";
package-lock.json+6-20
......@@ -6,10 +6,9 @@
66 "": {
77 "dependencies": {
88 "@clo/lib": "file:lib",
9 "@clo/markodown": "npm:@paperclover/markodown",
10 "@hono/node-server": "1.19.11",
9 "@clo/markodown": "npm:@paperclover/markodown@1.0.0",
1110 "@marko/compiler": "5.39.62",
12 "@types/node": "^24.12.0",
11 "@types/node": "24.x",
1312 "dashjs": "5.0.3",
1413 "esbuild": "0.25.5",
1514 "harfbuzzjs": "0.10.1",
......@@ -19,7 +18,7 @@
1918 "msgpackr": "1.11.5",
2019 "playwright": "1.58.2",
2120 "source-map-support": "0.5.21",
22 "typescript": "^5.9.3",
21 "typescript": "6.0.2",
2322 "unique-names-generator": "4.7.1",
2423 "vitest": "4.1.2",
2524 "vscode-oniguruma": "2.0.1",
......@@ -507,18 +506,6 @@
507506 "node": ">=18"
508507 }
509508 },
510 "node_modules/@hono/node-server": {
511 "version": "1.19.11",
512 "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
513 "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
514 "license": "MIT",
515 "engines": {
516 "node": ">=18.14.1"
517 },
518 "peerDependencies": {
519 "hono": "^4"
520 }
521 },
522509 "node_modules/@jridgewell/sourcemap-codec": {
523510 "version": "1.5.5",
524511 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
......@@ -1411,7 +1398,6 @@
14111398 "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
14121399 "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
14131400 "license": "MIT",
1414 "peer": true,
14151401 "engines": {
14161402 "node": ">=16.9.0"
14171403 }
......@@ -2205,9 +2191,9 @@
22052191 }
22062192 },
22072193 "node_modules/typescript": {
2208 "version": "5.9.3",
2209 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2210 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2194 "version": "6.0.2",
2195 "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
2196 "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
22112197 "license": "Apache-2.0",
22122198 "bin": {
22132199 "tsc": "bin/tsc",
package.json+4-12
......@@ -2,10 +2,9 @@
22 "type": "module",
33 "dependencies": {
44 "@clo/lib": "file:lib",
5 "@clo/markodown": "npm:@paperclover/markodown",
6 "@hono/node-server": "1.19.11",
5 "@clo/markodown": "npm:@paperclover/markodown@1.0.0",
76 "@marko/compiler": "5.39.62",
8 "@types/node": "^24.12.0",
7 "@types/node": "24.x",
98 "dashjs": "5.0.3",
109 "esbuild": "0.25.5",
1110 "harfbuzzjs": "0.10.1",
......@@ -15,7 +14,7 @@
1514 "msgpackr": "1.11.5",
1615 "playwright": "1.58.2",
1716 "source-map-support": "0.5.21",
18 "typescript": "^5.9.3",
17 "typescript": "6.0.2",
1918 "unique-names-generator": "4.7.1",
2019 "vitest": "4.1.2",
2120 "vscode-oniguruma": "2.0.1",
......@@ -27,13 +26,6 @@
2726 "#sitegen": "./framework/lib/sitegen.ts",
2827 "#sitegen/*": "./framework/lib/*.ts",
2928 "#jsx/jsx-runtime": "./lib/render/jsx-runtime.ts",
30 "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts",
31 "#hono": "hono",
32 "#hono/conninfo": {
33 "bun": "hono/bun",
34 "deno": "hono/deno",
35 "node": "@hono/node-server/conninfo",
36 "worker": "hono/cloudflare-workers"
37 }
29 "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts"
3830 }
3931}
src/backend.ts+1-1
......@@ -59,7 +59,7 @@ async function removeDuplicateSlashes(c: Context, next: Next) {
5959 await next();
6060}
6161
62import { type Context, Hono, type Next } from "#hono";
62import { type Context, Hono, type Next } from "hono";
6363import { logger } from "hono/logger";
6464import { trimTrailingSlash } from "hono/trailing-slash";
6565import * as assets from "#sitegen/assets";
src/blog/backend.ts+1-1
......@@ -16,5 +16,5 @@ app.get("/blog/community-translations", async (c) => {
1616
1717app.get("/blog/*", assets.notFound);
1818
19import { Hono } from "#hono";
19import { Hono } from "hono";
2020import * as assets from "#sitegen/assets";
src/blog/drafts/webdev/deranged-typescript.md+1-1
......@@ -66,7 +66,7 @@ export default app;
6666
6767...
6868
69import { type Context, Hono, type Next } from "#hono";
69import { type Context, Hono, type Next } from "hono";
7070import { logger } from "hono/logger";
7171import { trimTrailingSlash } from "hono/trailing-slash";
7272import * as assets from "#sitegen/assets";
src/friend-auth.ts+1-3
......@@ -66,8 +66,7 @@ function friendPage(route: assets.Key) {
6666 });
6767
6868 app.post(route, async (c) => {
69 const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ??
70 "unknown";
69 const ip = c.header("X-Forwarded-For") ?? "unknown";
7170 if (incorrectMap[ip]) {
7271 return serveAsset(c, "/friends/auth/fail", 403);
7372 }
......@@ -93,5 +92,4 @@ friendPage("/friends/oct25");
9392import { type Context, Hono } from "hono";
9493import { serveAsset } from "#sitegen/assets";
9594import { setTimeout } from "node:timers/promises";
96import { getConnInfo } from "#hono/conninfo";
9795import * as assets from "#sitegen/assets";
src/q+a/backend.ts+1-1
......@@ -214,7 +214,7 @@ async function questionFailure(
214214 });
215215}
216216
217import { type Context, Hono } from "#hono";
217import { type Context, Hono } from "hono";
218218import type { ContentfulStatusCode } from "hono/utils/http-status";
219219import {
220220 adjectives,
src/source-of-truth.ts+1-1
......@@ -103,7 +103,7 @@ const openFile = util.promisify(fsCallbacks.open);
103103const closeFile = util.promisify(fsCallbacks.close);
104104const fstat = util.promisify(fsCallbacks.fstat);
105105
106import { Hono } from "#hono";
106import { Hono } from "hono";
107107import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
108108import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
109109import * as path from "node:path";
tsconfig.json+1-1
......@@ -16,7 +16,7 @@
1616 "target": "esnext",
1717 "noUncheckedIndexedAccess": true,
1818 "resolveJsonModule": true,
19 "types": ["marko"]
19 "types": ["marko", "node"]
2020 },
2121 "include": ["./lib/**/*", "src/**/*", "framework/**/*"]
2222}