// SW tools -- Worker vor den Static Assets.
//
// Laeuft nur fuer die Pfade in wrangler.jsonc `assets.run_worker_first`:
// IT-Tools ganz, von CyberChef und BentoPDF nur die HTML-Seiten (Assets
// per Negativmuster ausgenommen). Alles andere liefert Cloudflare direkt
// aus build/site/, Header dort aus build/site/_headers (erzeugt aus
// src/headers.json).
//
// Was der Worker tut, ohne eine Datei einer Sammlung zu aendern:
// - IT-Tools: das Release hat ein absolutes `` und
// History-Routing; beides wuerde unter einem Unterpfad brechen.
// - Kleid: haengt in jede HTML-Seite einer Sammlung /kleid/basis.css und
// /kleid/.css ein (SW-labs-Aussehen, site/kleid/README.md);
// bei IT-Tools zusaetzlich /kleid/it-tools-dunkel.js (dunkler Modus).
// Abschaltbar ohne Code-Aenderung mit der Variable KLEID=aus.
import regeln from "./headers.json";
interface Env {
ASSETS: Fetcher;
// "aus" schaltet das Kleid ab (Messung vorher/nachher, Notbremse).
KLEID?: string;
}
type Direktiven = Record;
const IT_TOOLS = "/it-tools/";
const SAMMLUNGEN = [IT_TOOLS, "/cyberchef/", "/pdf/"] as const;
type Sammlung = (typeof SAMMLUNGEN)[number];
// Dateiname des Kleids je Praefix, unter /kleid/.
const KLEID: Record = {
"/it-tools/": "it-tools",
"/cyberchef/": "cyberchef",
"/pdf/": "pdf",
};
// CSP aus Basis plus Sammlungs-Ausnahmen; eine Direktive der Sammlung
// ersetzt die gleichnamige der Basis (keine Vereinigung, damit jede Quelle
// sichtbar in headers.json steht).
export function csp(prefix: string | null): string {
const basis = regeln.csp_basis as Direktiven;
const sammlung = prefix
? ((regeln.csp_sammlung as Record)[prefix] ?? {})
: {};
const alle: Direktiven = { ...basis, ...sammlung };
return Object.entries(alle)
.map(([name, d]) => `${name} ${d.werte.join(" ")}`)
.join("; ");
}
function mitHeadern(res: Response, prefix: string | null): Response {
const out = new Response(res.body, res);
for (const [name, h] of Object.entries(regeln.global)) {
out.headers.set(name, h.wert);
}
out.headers.set("Content-Security-Policy", csp(prefix));
return out;
}
// Praefix der Sammlung zu einem Pfad; "/pdf" ohne Schraegstrich zaehlt mit
// (Assets leiten auf "/pdf/" um, die Umleitung bekommt dieselben Header).
function sammlungZu(pathname: string): Sammlung | null {
for (const s of SAMMLUNGEN) {
if (pathname.startsWith(s) || pathname === s.slice(0, -1)) return s;
}
return null;
}
// Letztes Pfadsegment ohne Punkt = Route der Single-Page-App, kein Asset.
// So bekommt eine fehlende .js-Datei weiter ihr 404 statt index.html.
function istRoute(pathname: string): boolean {
const letztes = pathname.slice(pathname.lastIndexOf("/") + 1);
return !letztes.includes(".");
}
function istHtml(res: Response): boolean {
return (res.headers.get("Content-Type") ?? "").includes("text/html");
}
// Ein HTMLRewriter je Antwort: fuer IT-Tools, Kleid fuer alle.
// Das Kleid kommt ans ENDE von , damit es nach den Stylesheets der
// Sammlung steht und bei gleicher Spezifitaet gewinnt. Naive UI (IT-Tools)
// setzt seine Laufzeit-Styles an den ANFANG von , also davor.
// Das Dunkel-Skript von IT-Tools kommt an den Anfang von , vor das
// Inline-Skript des Releases, das den Modus beim ersten Zeichnen setzt.
function umschreiben(res: Response, sammlung: Sammlung, kleid: boolean): Response {
let rw = new HTMLRewriter();
if (sammlung === IT_TOOLS) {
// Das Release setzt ; relativ aufgeloeste Assets
// (./assets/...) und der Router brauchen den Unterpfad.
rw = rw.on("base", {
element(el) {
el.setAttribute("href", IT_TOOLS);
},
});
}
if (kleid) {
const name = KLEID[sammlung];
rw = rw.on("head", {
element(el) {
if (sammlung === IT_TOOLS) {
el.prepend('', { html: true });
}
el.append(
'' +
``,
{ html: true },
);
},
});
}
return rw.transform(res);
}
async function itTools(request: Request, env: Env): Promise {
const url = new URL(request.url);
if (url.pathname === "/it-tools") {
url.pathname = IT_TOOLS;
return mitHeadern(Response.redirect(url.toString(), 308), IT_TOOLS);
}
let res = await env.ASSETS.fetch(request);
// Deep Link (/it-tools/base64-string-converter): Datei gibt es nicht,
// Vue-Router braucht index.html. Nicht /it-tools/index.html anfragen --
// html_handling leitet das auf /it-tools/ um.
if (res.status === 404 && istRoute(url.pathname)) {
const index = new URL(IT_TOOLS, url);
res = await env.ASSETS.fetch(new Request(index.toString(), request));
}
if (res.ok && istHtml(res)) {
res = umschreiben(res, IT_TOOLS, env.KLEID !== "aus");
}
return mitHeadern(res, IT_TOOLS);
}
export default {
async fetch(request: Request, env: Env): Promise {
const { pathname } = new URL(request.url);
const sammlung = sammlungZu(pathname);
if (sammlung === IT_TOOLS) {
return itTools(request, env);
}
// CyberChef und BentoPDF: nur HTML-Seiten kommen hier an
// (run_worker_first). html_handling und not_found_handling gelten auch
// fuer env.ASSETS.fetch -- Umleitungen und /pdf/404.html bleiben, wie
// sie ohne Worker waeren. Die 404-Seite bekommt das Kleid mit.
let res = await env.ASSETS.fetch(request);
if (sammlung && env.KLEID !== "aus" && (res.ok || res.status === 404) && istHtml(res)) {
res = umschreiben(res, sammlung, true);
}
return mitHeadern(res, sammlung);
},
} satisfies ExportedHandler;