diff --git a/framework/backend/entry-node.ts b/framework/backend/entry-node.ts index e55cb9c4b96e5696a847be2c3d12fc30ad137c07..3a9a65469785f512a0a772fc2e5214ae6dbb7d41 100644 --- a/framework/backend/entry-node.ts +++ b/framework/backend/entry-node.ts @@ -1,34 +1,19 @@ const app = require(globalThis.CLOVER_SERVER_ENTRY ?? process.argv[2]); -const protocol = "http"; - -const server = serve({ - fetch: app.default.fetch, +const server = await http.serve({ + respond: app.default.fetch, port: Number(process.env.PORT ?? 3000), -}, ({ address, port }) => { - if (address === "::" || address === "127.0.0.1") { - address = os.hostname().replace(/\.local$/, ""); - } - console.info( - "live at " + url.format({ - protocol, - hostname: address, - port, - }), - ); }); +console.info(`live at ${server.url}`); process.on("SIGINT", () => { - server.close(); - process.exit(0); + server.close().then(() => { + process.exit(0); + }); }); process.on("SIGTERM", () => { - server.close((err) => { - if (err) { - console.error(err); - process.exit(1); - } + server.close().then(() => { process.exit(0); }); }); @@ -38,8 +23,6 @@ declare global { var CLOVER_SERVER_ENTRY: string; } -import * as os from "os"; -import * as url from "node:url"; -import { serve } from "@hono/node-server"; +import * as http from "@clo/lib/http"; import process from "node:process"; import * as console from "@clo/lib/log"; diff --git a/framework/lib/view.ts b/framework/lib/view.ts index 8a75cb60f3499fed9efeb03550472eadb8c4f762..29fd59d8874f911920d68794f8f53ca9ded804c3 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -118,7 +118,7 @@ export function regenerate(tag: RegenKey) { } import * as meta from "@clo/lib/render/meta"; -import type * as hono from "#hono"; +import type * as hono from "hono"; import * as render from "@clo/lib/render"; import * as sg from "./sitegen.ts"; import * as asset from "./assets.ts"; diff --git a/lib/examples/server.ts b/lib/examples/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6d42994dc7139ac176cf021bc41e1d0551cbcb2 --- /dev/null +++ b/lib/examples/server.ts @@ -0,0 +1,23 @@ +export async function main() { +const server = await http.serve({ + port: 3000, + + async respond(request, { node }) { + node.total = 50; + for(let i = 0; i < 50; i += 1) { + await async.delay(5); + node.value += 1; + } + return new Response("hello!"); + }, + + progress: progress.start('my web server'), +}); +log.info(`up: ${server.url}`); + +} + +import * as async from "@clo/lib/async"; +import * as http from "@clo/lib/http"; +import * as log from "@clo/lib/log"; +import * as progress from "@clo/lib/progress"; diff --git a/lib/http.ts b/lib/http.ts index 1802a440af89e9d1a3de68bff553467210fc46bd..db5c4c90c0c2e8375ec4196be4c56d080feb0156 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -1,8 +1,137 @@ +export interface Server { + port: number; + url: string; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +export interface ServeOptions { + /** respond to a server request */ + respond: (request: Request, opts: ServeContext) => Promise; + /** port 0 hosts on a random port */ + port: number; + + progress?: progress.Ref; +} + +export interface ServeContext { + node: progress.Node; +} + +/** + * this server is not optimized performance and likely includes bugs. + * + * more production ready alternatives: `srvx` and `@hono/node-server`. + */ +export async function serve( + { respond, port, progress: rootNode = progress.nullNode }: ServeOptions, +): Promise { + const nodeHttp = node.builtin( + "http", + ) as unknown as typeof import("node:http"); + const stream = node.builtin("stream"); + ASSERT( + nodeHttp && stream, + "http.serve must be called in a node.js compatible runtime", + ); + + const server = nodeHttp.createServer((req, res) => { + const headers = new Headers(); + for (const key in req.headers) { + const value = req.headers[key]; + if (value == null) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.append(key, value); + } + } + const method = UNWRAP(req.method).toUpperCase(); + const hasNoBody = method === "GET" || method === "HEAD"; + const request = new Request( + "http://" + (req.headers.host ?? "localhost") + UNWRAP(req.url), + { + method, + headers, + body: hasNoBody + ? undefined + : stream.Readable.toWeb(req), + duplex: hasNoBody ? undefined : 'half', + }, + ); + const requestNode = rootNode.start(`${method} ${req.url}`); + respond(request, { node: requestNode }) + .then((response) => { + res.writeHead( + response.status, + response.statusText, + Array.from(response.headers.entries()), + ); + if (response.body) { + stream.Readable.fromWeb(response.body).pipe(res); + } else { + res.end(); + } + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + requestNode.end(); + }); + }); + + const actualPort = await new Promise((resolve, reject) => { + async function onError(err: unknown) { + if (error.code(err) !== "EADDRINUSE") { + reject(err); + return; + } + const info = await subprocess.lookupProcessOnPort(port).catch(() => null); + if (!info) return void reject(err); + + reject( + new Error( + `Port ${port} is in use by ${ + JSON.stringify(info.command) + } (pid ${info.pid})`, + ), + ); + } + server.on("error", onError); + server.listen(port, () => { + server.off("error", onError); + const addr = server.address(); + ASSERT(addr && typeof addr === "object"); + resolve(addr.port); + }); + }); + + function close() { + return new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + return { + port: actualPort, + url: `http://localhost${actualPort === 80 ? "" : ":" + actualPort}/`, + close, + [Symbol.asyncDispose]: close, + }; +} + /** * parses the [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) * header, returning `null` if it is invalid. the `fileSize` */ -export function parseRangeHeader(rangeHeader: string, fileSize: number): Range[] | null { +export function parseRangeHeader( + rangeHeader: string, + fileSize: number, +): Range[] | null { const [unit, ranges] = rangeHeader.split("="); if (!ranges) return null; if (unit !== "bytes") return null; @@ -39,4 +168,8 @@ export interface Range { end: number; } -import { UNWRAP } from "./assert.ts"; +import { ASSERT, UNWRAP } from "./assert.ts"; +import * as node from "./node.ts"; +import * as subprocess from "./subprocess.ts"; +import * as error from "./error.ts"; +import * as progress from "./progress.ts"; diff --git a/lib/node.ts b/lib/node.ts index 5a4055e47f0accc465737241a75026be6219739b..e3d1026addea59cb642030a060eae32df8947745 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -62,6 +62,10 @@ interface Builtins { ): string; }; "stream": { + Readable: { + toWeb(nodeStream: unknown): ReadableStream; + fromWeb(webStream: ReadableStream): { pipe: (other: unknown) => void}; + }; Writable: { prototype: { write: (value: string) => boolean; diff --git a/lib/subprocess.ts b/lib/subprocess.ts index d1e605e609e9b1e52da9dc4533575b74696f3c87..de443a4b6176ab06574096bc9bb71a904b825363 100644 --- a/lib/subprocess.ts +++ b/lib/subprocess.ts @@ -86,8 +86,83 @@ export async function spawnAndWait( // } // }); + +async function findInodeForPort(port) { + const hexPort = port.toString(16).toUpperCase().padStart(4, '0'); + for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) { + try { + const lines = (await fs.readFile(f, 'utf8')).split('\n').slice(1); + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts[1]?.split(':')[1] === hexPort) return parts[9]; + } + } catch {} + } + return null; +} + +async function findPidForInode(inode) { + const target = `socket:[${inode}]`; + for (const pid of await fs.readdir('/proc')) { + if (!/^\d+$/.test(pid)) continue; + try { + for (const fd of await fs.readdir(`/proc/${pid}/fd`)) { + try { + if (await fs.readlink(`/proc/${pid}/fd/${fd}`) === target) return pid; + } catch {} + } + } catch {} + } + return null; +} + +async function commandFromPortLinux(port: number) { + const inode = await findInodeForPort(port); + if (!inode) throw new Error(`no process on port ${port}`); + const pid = await findPidForInode(inode); + if (!pid) throw new Error(`no pid for inode ${inode}`); + const raw = await fs.readFile(`/proc/${pid}/cmdline`, 'utf8'); + return { pid: Number(pid), command: raw.split('\0').filter(Boolean).join(' ') }; +} + +async function commandFromPortMac(port: number) { + const { stdout: lsofOut } = await exec('lsof', ['-ti', `tcp:${port}`]); + const pid = lsofOut.trim().split('\n')[0]; + if (!pid) throw new Error(`no process on port ${port}`); + const { stdout: psOut } = await exec('ps', ['-p', pid, '-o', 'args=']); + return { pid: Number(pid), command: psOut.trim() }; +} + +async function commandFromPortWindows(port: number) { + const { stdout } = await exec('netstat', ['-ano']); + const line = stdout.split('\n').find(l => { + const m = l.match(/\s+[\d.:]+:(\d+)\s+/); + return m && parseInt(m[1]!) === port && l.includes('LISTENING'); + }); + if (!line) throw new Error(`no process on port ${port}`); + const pid = line.trim().split(/\s+/).at(-1); + const { stdout: psOut } = await exec('powershell', [ + '-NoProfile', '-Command', + `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine` + ]); + const cmdline = psOut.trim(); + if (!cmdline) throw new Error(`no cmdline for pid ${pid}`); + return { pid: Number(pid), command: cmdline }; +} + +export async function lookupProcessOnPort(port: number) { + switch (os.platform()) { + case 'linux': return commandFromPortLinux(port); + case 'darwin': return commandFromPortMac(port); + case 'win32': return commandFromPortWindows(port); + default: throw new Error(`unsupported platform: ${os.platform()}`); + } +} + // import * as process from "node:process"; import * as events from "node:events"; import * as util from "node:util"; import * as child_process from "node:child_process"; +import * as fs from 'node:fs/promises'; +import os from 'os'; import { Buffer } from "node:buffer"; diff --git a/package-lock.json b/package-lock.json index 205b45cf4a32d12e66c3a1417a9eac99b0248ffb..330eacdcc8368642666066c6c05fc27e0660ee0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,9 @@ "": { "dependencies": { "@clo/lib": "file:lib", - "@clo/markodown": "npm:@paperclover/markodown", - "@hono/node-server": "1.19.11", + "@clo/markodown": "npm:@paperclover/markodown@1.0.0", "@marko/compiler": "5.39.62", - "@types/node": "^24.12.0", + "@types/node": "24.x", "dashjs": "5.0.3", "esbuild": "0.25.5", "harfbuzzjs": "0.10.1", @@ -19,7 +18,7 @@ "msgpackr": "1.11.5", "playwright": "1.58.2", "source-map-support": "0.5.21", - "typescript": "^5.9.3", + "typescript": "6.0.2", "unique-names-generator": "4.7.1", "vitest": "4.1.2", "vscode-oniguruma": "2.0.1", @@ -507,18 +506,6 @@ "node": ">=18" } }, - "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1411,7 +1398,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -2205,9 +2191,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 7d37dc1883e43562d386e1292f585bf4dd358993..aabd2f74bafd9e812bdae040fe9366697d7c20db 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,9 @@ "type": "module", "dependencies": { "@clo/lib": "file:lib", - "@clo/markodown": "npm:@paperclover/markodown", - "@hono/node-server": "1.19.11", + "@clo/markodown": "npm:@paperclover/markodown@1.0.0", "@marko/compiler": "5.39.62", - "@types/node": "^24.12.0", + "@types/node": "24.x", "dashjs": "5.0.3", "esbuild": "0.25.5", "harfbuzzjs": "0.10.1", @@ -15,7 +14,7 @@ "msgpackr": "1.11.5", "playwright": "1.58.2", "source-map-support": "0.5.21", - "typescript": "^5.9.3", + "typescript": "6.0.2", "unique-names-generator": "4.7.1", "vitest": "4.1.2", "vscode-oniguruma": "2.0.1", @@ -27,13 +26,6 @@ "#sitegen": "./framework/lib/sitegen.ts", "#sitegen/*": "./framework/lib/*.ts", "#jsx/jsx-runtime": "./lib/render/jsx-runtime.ts", - "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts", - "#hono": "hono", - "#hono/conninfo": { - "bun": "hono/bun", - "deno": "hono/deno", - "node": "@hono/node-server/conninfo", - "worker": "hono/cloudflare-workers" - } + "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts" } } diff --git a/src/backend.ts b/src/backend.ts index dd8050ac18a7d45f0953e6622d69cd2af33b53a1..1524314bc168281008a4d3421c0ffa142998fa96 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -59,7 +59,7 @@ async function removeDuplicateSlashes(c: Context, next: Next) { await next(); } -import { type Context, Hono, type Next } from "#hono"; +import { type Context, Hono, type Next } from "hono"; import { logger } from "hono/logger"; import { trimTrailingSlash } from "hono/trailing-slash"; import * as assets from "#sitegen/assets"; diff --git a/src/blog/backend.ts b/src/blog/backend.ts index 9ac595e5caa51772c636d8f59e90731a2b28af95..de552ccf602b66ffc85181a8852cd539b16814cf 100644 --- a/src/blog/backend.ts +++ b/src/blog/backend.ts @@ -16,5 +16,5 @@ app.get("/blog/community-translations", async (c) => { app.get("/blog/*", assets.notFound); -import { Hono } from "#hono"; +import { Hono } from "hono"; import * as assets from "#sitegen/assets"; diff --git a/src/blog/drafts/webdev/deranged-typescript.md b/src/blog/drafts/webdev/deranged-typescript.md index a6f4a450915a4612b7c3cc7aabdfdef0cd034061..3fe1aa0065a1c14e336a8e9269f0e1b551856f33 100644 --- a/src/blog/drafts/webdev/deranged-typescript.md +++ b/src/blog/drafts/webdev/deranged-typescript.md @@ -66,7 +66,7 @@ export default app; ... -import { type Context, Hono, type Next } from "#hono"; +import { type Context, Hono, type Next } from "hono"; import { logger } from "hono/logger"; import { trimTrailingSlash } from "hono/trailing-slash"; import * as assets from "#sitegen/assets"; diff --git a/src/friend-auth.ts b/src/friend-auth.ts index 8d173c0e95060f99ac794b40ef073a1c9a4dfb84..f8ceb1e4a01b2e91a682f4d7dbe5d64805701e15 100644 --- a/src/friend-auth.ts +++ b/src/friend-auth.ts @@ -66,8 +66,7 @@ function friendPage(route: assets.Key) { }); app.post(route, async (c) => { - const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ?? - "unknown"; + const ip = c.header("X-Forwarded-For") ?? "unknown"; if (incorrectMap[ip]) { return serveAsset(c, "/friends/auth/fail", 403); } @@ -93,5 +92,4 @@ friendPage("/friends/oct25"); import { type Context, Hono } from "hono"; import { serveAsset } from "#sitegen/assets"; import { setTimeout } from "node:timers/promises"; -import { getConnInfo } from "#hono/conninfo"; import * as assets from "#sitegen/assets"; diff --git a/src/q+a/backend.ts b/src/q+a/backend.ts index a22e9a100a56226c71734f58ac0e0f6df007a8e1..b99f25968e7bfa349b1d167298834d0d1c634405 100644 --- a/src/q+a/backend.ts +++ b/src/q+a/backend.ts @@ -214,7 +214,7 @@ async function questionFailure( }); } -import { type Context, Hono } from "#hono"; +import { type Context, Hono } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { adjectives, diff --git a/src/source-of-truth.ts b/src/source-of-truth.ts index 7d158fe2ca71e0c5a543ab970c267a59664a77dd..6ff4e0e8a24f7c0b9b2d714cea6449f250f8a448 100644 --- a/src/source-of-truth.ts +++ b/src/source-of-truth.ts @@ -103,7 +103,7 @@ const openFile = util.promisify(fsCallbacks.open); const closeFile = util.promisify(fsCallbacks.close); const fstat = util.promisify(fsCallbacks.fstat); -import { Hono } from "#hono"; +import { Hono } from "hono"; import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts"; import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts"; import * as path from "node:path"; diff --git a/tsconfig.json b/tsconfig.json index 7810b1ee4725da3579165b8650a0541c11ccdc04..3efa1acc8f81abf71c3bab3f0feadedc86665fdd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "target": "esnext", "noUncheckedIndexedAccess": true, "resolveJsonModule": true, - "types": ["marko"] + "types": ["marko", "node"] }, "include": ["./lib/**/*", "src/**/*", "framework/**/*"] }