httpHandler
uses node:url
Serve plain HTTP endpoints, for webhooks and API clients outside the app. A handler takes a standard Request
and returns a Response, plain data (sent as JSON) or nothing (204). The name is resolved on every request,
so moving a name moves its URL. There are no CSRF checks here: these are meant to be called from elsewhere.
import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { dim, green, red } from "#codebase/tcmts/cli/style"; import { dataDir, errorReply, isHttpError, routeName, serverApiHost } from "#codebase/tcmts/cli/web"; import type { HttpRoutes, Middleware, RpcOptions } from "#codebase/tcmts/cli/web"; import { exportId } from "#codebase/tcmts/core/hash"; import { materialize } from "#codebase/tcmts/loader"; import { pathToFileURL } from "node:url"; export function httpHandler(cb: Codebase, routes: HttpRoutes, opts: RpcOptions): Middleware { const host = serverApiHost(cb); const data = dataDir(cb); return async (req, res, next) => { const url = new URL( req.url ?? "/", `${opts.secure ? "https" : "http"}://${req.headers.host ?? "localhost"}`, ); const name = routeName(routes, url.pathname); if (name === undefined) return next(); const started = performance.now(); const reply = ( status: number, body: unknown, headers: Record<string, string | string[]> = {}, ) => { res.writeHead(status, { "content-type": "application/json", ...headers }); res.end(JSON.stringify(body)); const mark = status < 400 ? green(String(status)) : red(String(status)); console.log( ` ${dim("→")} ${req.method} ${url.pathname} ${dim(name || "?")} ${mark} ${dim(`${Math.round(performance.now() - started)}ms`)}`, ); }; let hash: string; try { hash = cb.lookupOne(name, "value"); } catch { return reply(404, { error: `no endpoint at ${url.pathname}` }); } const rec = cb.store.getDef(hash)!; if (rec.kind !== "term") return reply(404, { error: `no endpoint at ${url.pathname}` }); const chunks: Buffer[] = []; let size = 0; for await (const c of req) { size += (c as Buffer).length; if (size > 16 * 1024 * 1024) return reply(413, { error: "request body too large" }); chunks.push(c as Buffer); } materialize([hash], cb.source(), cb.lay); const fn = ( (await import(pathToFileURL(cb.modulePath(rec.componentHash)).href)) as Record< string, unknown > )[exportId(hash)]; if (typeof fn !== "function") return reply(404, { error: `${name} isn't a function` }); const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) for (const x of Array.isArray(v) ? v : v === undefined ? [] : [v]) headers.append(k, x); const method = req.method ?? "GET"; // the handler's request is the address the caller used: under the app's base, over HTTPS and at the host a proxy // in front says (`x-forwarded-proto`, `x-forwarded-host`: a proxy that terminates TLS, Railway's say) const forwarded = (h: string) => String(req.headers[h] ?? "") .split(",")[0]! .trim(); const proto = forwarded("x-forwarded-proto"); const scheme = proto === "https" || proto === "http" ? proto : url.protocol.slice(0, -1); const at = forwarded("x-forwarded-host"); const asked = new URL( `${opts.base ?? ""}${url.pathname}${url.search}`, `${scheme}://${/^([a-z0-9-]+\.)*[a-z0-9-]+(:\d+)?$|^\[[0-9a-f:.]+\](:\d+)?$/i.test(at) ? at : url.host}`, ); const request = new Request(asked, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks), }); const abort = new AbortController(); res.on("close", () => { if (!res.writableFinished) abort.abort(new Error("the client disconnected")); }); const { withCall } = await host(); const call = { method, url: asked.href, headers: req.headers, ip: req.socket.remoteAddress, signal: abort.signal, }; const r = await withCall( call, { secure: opts.secure, dataDir: data, cookiePath: opts.base }, async () => (fn as (r: Request) => unknown)(request), ); const cookies: Record<string, string | string[]> = r.setCookies.length ? { "set-cookie": r.setCookies } : {}; if (!r.ok) { const err = errorReply( r.error as Error & { status?: number; retryAfter?: number }, isHttpError(r.error), name, opts.production, ); if (err.retryAfter !== undefined) cookies["retry-after"] = String(err.retryAfter); return reply(err.status, { error: (err.body as { message: string }).message }, cookies); } if (r.value instanceof Response) { const out: Record<string, string | string[]> = { ...cookies }; r.value.headers.forEach( (v, k) => (out[k] = k === "set-cookie" ? [...((out[k] as string[] | undefined) ?? []), v] : v), ); res.writeHead(r.value.status, out); res.end(Buffer.from(await r.value.arrayBuffer())); console.log( ` ${dim("→")} ${method} ${url.pathname} ${dim(name)} ${r.value.status < 400 ? green(String(r.value.status)) : red(String(r.value.status))} ${dim(`${Math.round(performance.now() - started)}ms`)}`, ); return; } if (r.value === undefined) { res.writeHead(204, cookies); return res.end(); } reply(200, r.value, cookies); }; }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.style.dim
- tcmts.cli.style.green
- tcmts.cli.style.red
- tcmts.cli.web.HttpRoutes
- tcmts.cli.web.Middleware
- tcmts.cli.web.RpcOptions
- tcmts.cli.web.dataDir
- tcmts.cli.web.errorReply
- tcmts.cli.web.isHttpError
- tcmts.cli.web.routeName
- tcmts.cli.web.serverApiHost
- tcmts.core.hash.exportId
- tcmts.loader.materialize