forward

tcmts.cli.sync.forward · #cyt23klb · term · runs on the server

uses node:https

Pass a request on to the server to names, as it came (method, path, headers, body), streaming both ways, and its answer back: an app served beside the codebase, under a path of the site. Saying who asked (x-forwarded-*). With several servers (an app's version and its next, on two ports while one replaces the other), the one that answered last is asked first, and one that refuses the connection passes the request to the next: a request's body is read first then, so it can go to either.

import http from "node:http";
import https from "node:https";
import zlib from "node:zlib";

export function forward(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  to: string | string[],
  last: { at: number } = { at: 0 },
) {
  const servers = typeof to === "string" ? [to] : to;
  const order = servers.map((_, i) => (last.at + i) % servers.length);
  // not answering (starting, or being built again): a page asked for says so and tries again by itself; anything else
  // is told when to try (Retry-After)
  const unavailable = () => {
    if (res.headersSent) return res.end();
    const page = /text\/html/.test(String(req.headers.accept ?? ""));
    res.writeHead(503, {
      "content-type": page ? "text/html; charset=utf-8" : "text/plain; charset=utf-8",
      "retry-after": "5",
      "cache-control": "no-store",
    });
    res.end(
      page
        ? `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="refresh" content="5"><title>Starting…</title><style>body{font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;color:#555}</style></head><body><p>This app is starting (or being built again after a deploy). The page will load by itself in a moment.</p></body></html>`
        : "the app isn't answering yet: try again in a few seconds",
    );
  };
  const noBody = req.method === "GET" || req.method === "HEAD";
  const send = (k: number, body?: Buffer) => {
    const at = order[k]!;
    const target = new URL(req.url ?? "/", servers[at]);
    const client = target.protocol === "https:" ? https : http;
    const out = client.request(
      target,
      {
        method: req.method,
        // (the Host the browser asked: the app's pages and its checks of where a call came from see the site's)
        headers: {
          ...req.headers,
          "x-forwarded-host": req.headers.host ?? "",
          "x-forwarded-proto": (req.socket as { encrypted?: boolean }).encrypted
            ? "https"
            : String(req.headers["x-forwarded-proto"] ?? "http"),
          "x-forwarded-for": [req.headers["x-forwarded-for"], req.socket.remoteAddress]
            .filter(Boolean)
            .join(", "),
        },
      },
      (answer) => {
        last.at = at;
        // compressed on the way, if the app didn't and the browser takes it: pages, their trees, scripts and styles are
        // text that shrinks tenfold (each piece flushed as it comes, so a page that streams still does)
        const type = String(answer.headers["content-type"] ?? "");
        const zip =
          !answer.headers["content-encoding"] &&
          /\bgzip\b/.test(String(req.headers["accept-encoding"] ?? "")) &&
          /^(text\/(?!event-stream)|application\/(json|javascript)|image\/svg)/.test(type) &&
          req.method !== "HEAD" &&
          answer.statusCode !== 204 &&
          answer.statusCode !== 304;
        if (!zip) {
          res.writeHead(answer.statusCode ?? 502, answer.headers);
          answer.pipe(res);
          return;
        }
        const { "content-length": _length, ...headers } = answer.headers;
        res.writeHead(answer.statusCode ?? 502, {
          ...headers,
          "content-encoding": "gzip",
          vary: [answer.headers.vary, "accept-encoding"].filter(Boolean).join(", "),
        });
        answer.pipe(zlib.createGzip({ flush: zlib.constants.Z_SYNC_FLUSH })).pipe(res);
      },
    );
    out.on("error", (e) => {
      // refused: nothing reached it, so the next server can have it
      const refused = (e as NodeJS.ErrnoException).code === "ECONNREFUSED";
      if (refused && !res.headersSent && k + 1 < order.length && (noBody || body))
        return send(k + 1, body);
      unavailable();
    });
    // (the caller gone: the app's request too, so a stream it's sending stops)
    res.once("close", () => out.destroy());
    if (noBody) out.end();
    else if (body) out.end(body);
    else req.pipe(out);
  };
  if (noBody || servers.length === 1) return send(0);
  const chunks: Buffer[] = [];
  req.on("data", (c: Buffer) => chunks.push(c));
  req.on("end", () => send(0, Buffer.concat(chunks)));
}

used by