httpHandler

tcmts.cli.web.httpHandler · #xfy3fkib · term · runs on the server

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

used by