Skip to the content

Capsid menu

workers/app.ts

205 lines and 11 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of app.ts
FunctionLinesComplexityCoveredCRAPMutation
<anonymous:31>31 to 311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
applyEdgePolicy41 to 473No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
cacheDimensions53 to 684No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
applySecurityHeaders71 to 731No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
applyDocumentHeaders79 to 914No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
fetch97 to 1464No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
redirectTo154 to 1591No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
withHeader162 to 1752No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
fetch184 to 1933No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
queue195 to 1982No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
gateway202 to 24111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/lib/context.ts, app/lib/https-redirect.mjs, app/lib/negotiate.mjs, app/lib/publications/pdf-redirect.mjs, app/lib/seo.ts, app/lib/theme.ts, app/lib/timing.ts, app/lib/webmention/queue.ts, app/lib/wordpress-redirects.mjs, app/lib/worker-preview.ts, workers/ask-budget.ts, workers/csp.mjs, workers/feed-types.mjs, workers/media-events.ts, workers/security-headers.mjs, workers/webmention-verify.ts. No module of this repo imports it.

Source

// aislop-ignore-next-line ai-slop/hallucinated-import -- a Workers built-in, not an npm package
import { WorkerEntrypoint } from "cloudflare:workers";
import { createRequestHandler, RouterContextProvider } from "react-router";

import { cloudflareContext, documentHandlerContext, nonceContext } from "~/lib/context";
import { httpsRedirectStatus, httpsRedirectTarget } from "~/lib/https-redirect.mjs";
import { negotiatesAwayFromHtml } from "~/lib/negotiate.mjs";
import { EDGE_CACHE_CONTROL, EDGE_CACHE_HEADER, SHARED_CACHE_CONTROL } from "~/lib/seo";
import { isApexHost, wordpressDisposition, wwwRedirectTarget } from "~/lib/wordpress-redirects.mjs";
import { paperSlashTarget } from "~/lib/publications/pdf-redirect.mjs";
import { themeFromRequest } from "~/lib/theme";
import { serverTiming, timingsContext, type Timings } from "~/lib/timing";
import {
  CSP_ENDPOINT_NAME,
  CSP_REPORT_PATH,
  contentSecurityPolicy,
  isPrivatePath,
} from "./csp.mjs";
import { isUnpolicedType } from "./feed-types.mjs";
import { applyHeaderSet } from "@dustinedwards/site-runtime/headers";
import { reportingEndpoints } from "@dustinedwards/site-runtime/csp";
import { SECURITY_HEADERS } from "./security-headers.mjs";
import { handleMediaEvents } from "./media-events";
import { handleWebmentionVerify } from "./webmention-verify";
import { WEBMENTION_VERIFY_QUEUE } from "~/lib/webmention/queue";
import { isWorkerPreview } from "~/lib/worker-preview";

export { AskBudget } from "./ask-budget";

const requestHandler = createRequestHandler(
  () => import("virtual:react-router/server-build"),
  import.meta.env.MODE,
);

// Omitting Cache-Control is not opting out: the platform caches it heuristically and cookies are not
// in the key, so an authenticated /admin render would be served to anyone.
const UNCACHED = "private, no-store";

// `Cloudflare-CDN-Cache-Control` outranks `Cache-Control` at the edge, so it is stripped from any
// response not marked shared, or a private one would become storable.
function applyEdgePolicy(headers: Headers): void {
  if (headers.get("cache-control") !== SHARED_CACHE_CONTROL) {
    headers.delete(EDGE_CACHE_HEADER);
    return;
  }
  if (!headers.has(EDGE_CACHE_HEADER)) headers.set(EDGE_CACHE_HEADER, EDGE_CACHE_CONTROL);
}

// The path is written into the key because a custom `cf.cacheKey` replaces the platform's path and
// query rather than extending them. The platform cache is the only cache; do not add `caches.default`.
export type RendererProps = { theme?: string };

export function cacheDimensions(
  url: URL,
  request: Request,
  theme: string,
): { cacheKey: string; props: RendererProps } {
  const themed =
    request.method === "GET" &&
    !isPrivatePath(url.pathname) &&
    !negotiatesAwayFromHtml(request);

  if (!themed) return { cacheKey: `${url.pathname}${url.search}`, props: {} };

  const keyUrl = new URL(url);
  keyUrl.searchParams.set("theme", theme);
  return { cacheKey: `${keyUrl.pathname}${keyUrl.search}`, props: { theme } };
}

// The shared package's apply, authoritative: this site's set wins over anything a route set.
function applySecurityHeaders(headers: Headers) {
  applyHeaderSet(headers, SECURITY_HEADERS);
}

/**
 * Every header a rendered response leaves with, on both exits of the Renderer: timing, the security
 * set, the report endpoint, the policy (feeds excepted) and the uncached default.
 */
function applyDocumentHeaders(
  headers: Headers,
  { timings, reportTo, csp }: { timings: Timings | undefined; reportTo: string; csp: string },
) {
  if (timings) headers.set("Server-Timing", serverTiming(timings));
  applySecurityHeaders(headers);
  headers.set("Reporting-Endpoints", reportTo);
  if (!isUnpolicedType(headers.get("content-type"))) {
    headers.set("Content-Security-Policy", csp);
  }
  if (!headers.has("cache-control")) headers.set("cache-control", UNCACHED);
  applyEdgePolicy(headers);
}

/**
 * The cacheable entrypoint; `default` has cache disabled. Document headers belong here, or they are absent from every hit.
 */
export class Renderer extends WorkerEntrypoint<Env, RendererProps> {
  override async fetch(request: Request): Promise<Response> {
    const env = this.env;
    const ctx = this.ctx;
    const url = new URL(request.url);

    const context = new RouterContextProvider();
    context.set(cloudflareContext, { env, ctx });
    // For the Carrel preview, which renders a page through the same routes without a second fetch.
    context.set(documentHandlerContext, requestHandler);

    // The private planes only (/admin, /lab): a public page is edge-cached with its header, so a nonce there would be shared
    // by every reader; its one inline script is allowed by hash instead (workers/csp.mjs). A static
    // or derived nonce looks exactly like success: every page renders and the policy is worth
    // nothing.
    const adminNonce = isPrivatePath(url.pathname) ? crypto.randomUUID() : undefined;
    context.set(nonceContext, adminNonce);

    // Stamped here because loaders run concurrently on one shared `timings` array; only after the
    // handler returns is it complete.
    const handlerStart = performance.now();
    const response = await requestHandler(request, context);
    const timings = context.get(timingsContext).timings;
    if (timings) {
      timings.push({ name: "worker_total", ms: performance.now() - handlerStart });
    }

    // Absolute https (the gateway has already redirected plaintext): `Reporting-Endpoints` ignores anything else.
    const reportTo = reportingEndpoints(CSP_ENDPOINT_NAME, new URL(CSP_REPORT_PATH, url.origin));
    // The same pathname React Router reads off this request, so the hashed rules are the rendered ones.
    const csp = await contentSecurityPolicy(url.pathname, adminNonce);


    const document = { timings, reportTo, csp };

    // `Response.redirect()` and friends return immutable headers, so a `set` throws; the catch
    // rebuilds and applies the same set.
    try {
      applyDocumentHeaders(response.headers, document);
    } catch {
      const headers = new Headers(response.headers);
      applyDocumentHeaders(headers, document);
      return new Response(response.body, {
        status: response.status,
        statusText: response.statusText,
        headers,
      });
    }

    return response;
  }
}

/**
 * A gateway redirect: 301 for GET and HEAD, 308 otherwise so a form body survives. `no-store` is
 * load-bearing: the scheme is not in the cache key, so a stored HTTPS redirect would loop HTTPS
 * readers, and a stored rename would outlive a change to the map.
 */
function redirectTo(request: Request, location: string) {
  return new Response(null, {
    status: httpsRedirectStatus(request.method),
    headers: { Location: location, "Cache-Control": "no-store" },
  });
}

/** A fetched response's headers can be immutable, so a refused set rebuilds it around a copy. */
function withHeader(response: Response, name: string, value: string): Response {
  try {
    response.headers.set(name, value);
    return response;
  } catch {
    const headers = new Headers(response.headers);
    headers.set(name, value);
    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers,
    });
  }
}

type FetchArgs = Parameters<NonNullable<ExportedHandler<Env>["fetch"]>>;

/*
 * THE GATEWAY. Cache disabled, so it runs on every request (check:urls reads this phrase).
 * Order is load-bearing: the HTTPS redirect first, because the cache key ignores the scheme.
 */
export default {
  async fetch(request, env, ctx) {
    const response = await gateway(request, env, ctx);
    // Every exit, redirects included, which never pass through applyDocumentHeaders. Static assets
    // are served before the Worker runs, so robots.txt's Disallow is what covers those.
    // The workers.dev address keeps answering after the cutover (the watchdog, the browser tests and
    // the sign-in fallback use it), so it is kept out of the index here rather than redirected away.
    // robots.txt must not disallow it, or a crawler never reads this header.
    const unindexed = isWorkerPreview(env) || new URL(request.url).hostname.endsWith(".workers.dev");
    return unindexed ? withHeader(response, "X-Robots-Tag", "noindex") : response;
  },

  async queue(batch, env) {
    if (batch.queue === WEBMENTION_VERIFY_QUEUE) await handleWebmentionVerify(batch, env);
    else await handleMediaEvents(batch, env);
  },

} satisfies ExportedHandler<Env>;

async function gateway(...[request, , ctx]: FetchArgs): Promise<Response> {
  // First, so `http://www/x` is one hop to `https://apex/x` and not two.
  const requested = new URL(request.url);
  const apex = wwwRedirectTarget(requested.hostname, requested.pathname, requested.search);
  if (apex !== null) return redirectTo(request, apex);

  const secure = httpsRedirectTarget(request.url);
  if (secure !== null) return redirectTo(request, secure);

  const url = new URL(request.url);

  // The old WordPress addresses, on the apex host only: the new site's own paths never reach this map.
  const wordpress = isApexHost(url.hostname) ? wordpressDisposition(url.pathname) : null;
  if (wordpress?.status === 301) {
    return redirectTo(request, new URL(wordpress.location, url).toString());
  }

  // The slash form is canonical: Scholar honors `citation_pdf_url` only in the page's subdirectory.
  const slashed = paperSlashTarget(url.pathname);
  if (slashed !== null) return redirectTo(request, new URL(`${slashed}${url.search}`, url).toString());

  // A removed WordPress address renders the Gone page in its place: the address bar keeps the old URL
  // and the status is 410. Every redirect above was decided first; none of them names a removed path.
  const gone = wordpress?.status === 410 ? new URL("/gone", url) : null;

  const theme = themeFromRequest(request);
  const { cacheKey, props } = cacheDimensions(gone ?? url, request, theme);

  const response = await ctx.exports.Renderer({ props }).fetch(gone ? new Request(gone, request) : request, {
    cf: { cacheKey },
  });

  // Tells browsers and proxies not to hold a per-reader document. Not in the Renderer, whose
  // response is the one the platform stores. Any cookie counts, failing closed.
  const shared = response.headers.get("cache-control") === SHARED_CACHE_CONTROL;
  const out =
    shared && request.headers.has("cookie") ? withHeader(response, "cache-control", UNCACHED) : response;

  return out;
}