// Der einzige Server, der von aussen erreichbar ist. // // Er macht drei Dinge unter einer Adresse, weil ein zweiter oeffentlicher // Port ein zweiter Eintrag im Reverse-Proxy und ein zweites Zertifikat waere: // // / Uebersicht mit den Adressen fuer OBS (Passwort) // /setup Einrichtung: Twitch-Apps, Tokens, Discord (Passwort) // /callback OAuth-Rueckruf von Twitch (offen, state schuetzt) // /alerts ... die Browser-Sources (Schluessel) // /events der SSE-Strom dahinter (Schluessel) // // Zum Binden: hinter dem Reverse-Proxy bindet der Dienst auf 0.0.0.0, damit // der Proxy aus einem anderen Container drankommt. Das ist der Bruch mit // ADR-0005, und der Schutz wandert dafuer von "nur localhost" auf Passwort // und Schluessel. Ohne PUBLIC_URL bleibt es bei 127.0.0.1. import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { logger } from '#core/logger.ts'; import { setupRoutes, type ServerDeps } from '../../ingest/src/server.ts'; import { createZugang, type Zugang } from './auth.ts'; import { createBus, type Bus, type OverlayEvent } from './bus.ts'; import { startDemo } from './demo.ts'; import { TEST_KNOEPFE, testEvent, type TestArt } from './test-events.ts'; const log = logger('overlay'); const WEB_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'overlay-web', 'public'); /** * Herzschlag. OBS' Browser-Source haelt eine tote Verbindung sonst fuer * lebendig, bis die naechste Chatnachricht kommt - und wenn der Chat still * ist, ist das eine Stunde. */ const HEARTBEAT_MS = 15_000; const TYPES: Record = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', }; /** Die Seiten, die als Browser-Source in OBS eingetragen werden. */ export const PAGES = [ { path: '/alerts', file: 'alerts.html', w: 1920, h: 1080, title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' }, { path: '/chat', file: 'chat.html', w: 460, h: 900, title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' }, { path: '/kamera', file: 'kamera.html', w: 520, h: 380, title: 'Kamerarahmen', hint: 'Rahmen um die Facecam mit Zuschauern und Laufzeit. Position 0,0, über der Kamera.' }, { path: '/status', file: 'status.html', w: 1920, h: 160, title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' }, { path: '/effects', file: 'effects.html', w: 1920, h: 1080, title: 'Effekte', hint: 'Emote-Regen bei Chat-Spitzen, Konfetti bei Abos und Raids.' }, { path: '/starting', file: 'starting.html', w: 1920, h: 1080, title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' }, { path: '/tts', file: 'tts.html', w: 600, h: 200, title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' }, { path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200, title: 'Liegestütze', hint: 'Offene Liegestütze aus Kanalpunkten. Zählt bei Einlösung hoch, korrigieren unten auf dieser Seite.' }, ] as const; export type OverlayServer = { bus: Bus; zugang: Zugang; close: () => void; port: number; }; export type OverlayOptions = { port: number; channel: string; /** Oeffentliche Adresse ohne Schraegstrich, z. B. https://abdu.dimension47.de */ publicUrl: string; bus?: Bus; /** Wird eingehaengt, damit Einrichtung und Status unter derselben Adresse liegen. */ setup?: ServerDeps; /** * Die Freigabe wartender Vorlese-Nachrichten. Der Hub reicht sie herein; * ohne sie zeigt die Seite den Abschnitt gar nicht erst an. */ tts?: { wartend: () => Array<{ id: string; name: string; text: string; seit: string }>; freigeben: (id: string) => boolean; ablehnen: (id: string) => boolean; alleAblehnen: () => number; still: () => void; /** Spricht einen Text sofort, ohne Warteschlange. Fuer den Testknopf. */ probe: (name: string, text: string) => void; /** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */ audio: (id: string) => Buffer | undefined; }; /** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */ liegestuetze?: { stand: () => number; aendern: (n: number) => number; setzen: (n: number) => number; }; }; export function startOverlayServer(opts: OverlayOptions): OverlayServer { const bus = opts.bus ?? createBus(); const zugang = createZugang(); // Laeuft die Vorfuehrung, steht hier ihr Abbruch. Ein zweiter Start soll // den ersten ersetzen, nicht danebenlaufen. const vorfuehrung = { abbruch: null as AbortController | null }; // Hinter einem Proxy muss der Dienst von aussen erreichbar sein. Ohne // oeffentliche Adresse bleibt alles wie vorher an der Loopback haengen. const host = process.env.BIND_HOST || (opts.publicUrl ? '0.0.0.0' : '127.0.0.1'); const server = http.createServer((req, res) => { void handle(req, res, bus, zugang, opts, vorfuehrung).catch((err: Error) => { log.error(`Anfrage fehlgeschlagen: ${err.message}`); if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Interner Fehler'); }); }); server.listen(opts.port, host, () => { log.info(`Overlay-Server laeuft auf ${host}:${opts.port}` + (opts.publicUrl ? ` (oeffentlich: ${opts.publicUrl})` : '')); if (!zugang.hatPasswort() && opts.publicUrl) { log.warn('Es ist kein Passwort gesetzt - die Einrichtungsseite steht offen. ' + 'Setzen mit: abdulive web:passwort'); } }); server.on('error', (err) => { log.error(`Overlay-Server nicht gestartet: ${err.message}` + ((err as NodeJS.ErrnoException).code === 'EADDRINUSE' ? ` - Port ${opts.port} ist belegt. Anderer Port: overlay.port in config.yaml.` : '')); }); return { bus, zugang, close: () => server.close(), port: opts.port }; } type Vorfuehrung = { abbruch: AbortController | null }; async function handle( req: http.IncomingMessage, res: http.ServerResponse, bus: Bus, zugang: Zugang, opts: OverlayOptions, vorfuehrung: Vorfuehrung ): Promise { const url = new URL(req.url ?? '/', opts.publicUrl || `http://127.0.0.1:${opts.port}`); const p = url.pathname; // ---- Anmeldung. Bewusst vor allem anderen. if (p === '/login' && req.method === 'POST') { const cookie = zugang.anmelden((await readForm(req)).get('passwort') ?? ''); if (!cookie) return redirect(res, '/?fehler=1'); res.writeHead(303, { location: '/', 'set-cookie': cookie }); res.end(); return; } if (p === '/logout') { res.writeHead(303, { location: '/', 'set-cookie': zugang.abmelden() }); res.end(); return; } // ---- Der Rueckruf von Twitch. Kein Passwort: Twitch schickt den Browser // hierher zurueck, und der `state`-Wert ist der Schutz. if (p === '/callback' && opts.setup) { if (await setupRoutes(req, res, opts.setup, url)) return; } // ---- Alles Statische ausser den Seiten selbst. Enthaelt keine Daten, // also auch keinen Schluessel - sonst muesste ihn jede Seite weiterreichen. if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, '')); // ---- Die Browser-Sources und ihr Datenstrom. // Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der // Uebersicht (POST, mit Anmeldung). Ohne die Einschraenkung bekamen die // statt einer Antwort die Overlay-Seite - oder ohne Schluessel ein 403. const page = req.method === 'GET' || req.method === 'HEAD' ? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined; const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1]; if (page || p === '/events' || audioId) { if (!zugang.darfOverlay(req, url)) { return send(res, 403, 'text/plain; charset=utf-8', 'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' + 'Sie steht fertig auf der Uebersichtsseite.'); } if (p === '/events') return sse(req, res, bus); if (audioId) { const wav = opts.tts?.audio(audioId); if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.'); res.writeHead(200, { 'content-type': 'audio/wav', 'content-length': wav.length, 'cache-control': 'no-store', }); res.end(wav); return; } return sendFile(res, page!.file); } // ---- Ab hier nur mit Anmeldung. if (!zugang.istAngemeldet(req)) { return send(res, 401, 'text/html; charset=utf-8', loginPage(opts.channel, url.searchParams.has('fehler'))); } // ---- Testknoepfe. Die Ereignisse gehen nur an die Browser-Sources: nichts // davon landet in der Datenbank, auf Discord oder bei Twitch. if (p === '/test' && req.method === 'POST') { const form = await readForm(req); const art = (form.get('art') ?? '') as TestArt | 'demo-an' | 'demo-aus'; const laeuftStream = zustand(bus).live; const antwort = (text: string, code = 200): void => send(res, code, 'application/json; charset=utf-8', JSON.stringify({ text, demo: vorfuehrung.abbruch !== null })); if (art === 'demo-aus') { vorfuehrung.abbruch?.abort(); vorfuehrung.abbruch = null; // Der letzte erfundene Stand bleibt sonst im Bild stehen. bus.publish({ kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null }); return antwort('Vorführung gestoppt.'); } if (art === 'demo-an') { if (laeuftStream) { return antwort('Nicht während eines Streams: die Vorführung überschreibt ' + 'Titel, Kategorie und Zuschauerzahl in der Statusleiste.', 409); } vorfuehrung.abbruch?.abort(); vorfuehrung.abbruch = new AbortController(); startDemo(bus, vorfuehrung.abbruch.signal); return antwort('Vorführung läuft.'); } const knopf = TEST_KNOEPFE.find((k) => k.art === art); if (!knopf) return antwort('Unbekannter Knopf.', 400); if (knopf.nurOffline && laeuftStream) { return antwort('Gesperrt, solange der Stream läuft - das würde in der ' + 'Statusleiste stehen bleiben.', 409); } // Die neueste Nachricht aus dem Rueckblick, damit "letzte löschen" etwas // trifft, das auch wirklich im Bild steht. const chat = zustand(bus).chat; const ev = testEvent(knopf.art, chat[chat.length - 1]?.id); if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409); // Vorlesen geht durch den Hub, damit der Knopf die echte Stimme prueft // und nicht nur die Anzeige. if (ev.kind === 'tts' && opts.tts) { opts.tts.probe(ev.name, ev.text); log.info('Testereignis ausgeloest: tts (mit Stimme)'); return antwort(`${knopf.label} ausgelöst.`); } bus.publish(ev); log.info(`Testereignis ausgeloest: ${knopf.art}`); return antwort(`${knopf.label} ausgelöst.`); } // ---- Vorlese-Warteschlange if (p === '/tts' && req.method === 'POST' && opts.tts) { const form = await readForm(req); const was = form.get('was') ?? ''; const id = form.get('id') ?? ''; const antwort = (text: string, code = 200): void => send(res, code, 'application/json; charset=utf-8', JSON.stringify({ text, wartend: opts.tts!.wartend() })); if (was === 'still') { opts.tts.still(); return antwort('Alles still.'); } if (was === 'alle-ablehnen') { const n = opts.tts.alleAblehnen(); return antwort(`${n} Nachricht(en) abgelehnt.`); } if (was === 'freigeben') { return antwort(opts.tts.freigeben(id) ? 'Wird vorgelesen.' : 'Nicht mehr da.'); } if (was === 'ablehnen') { return antwort(opts.tts.ablehnen(id) ? 'Abgelehnt.' : 'Nicht mehr da.'); } return antwort('Unbekannt.', 400); } if (p === '/tts/wartend' && opts.tts) { return send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ wartend: opts.tts.wartend() })); } // ---- Liegestuetze von Hand korrigieren if (p === '/liegestuetze' && req.method === 'POST' && opts.liegestuetze) { const form = await readForm(req); const was = form.get('was') ?? ''; const n = zahlAus(form.get('wert')); const antwort = (text: string, code = 200): void => send(res, code, 'application/json; charset=utf-8', JSON.stringify({ text, offen: opts.liegestuetze!.stand() })); if (n === null) return antwort('Keine ganze Zahl.', 400); if (was === 'aendern') { const offen = opts.liegestuetze.aendern(n); return antwort(`${n > 0 ? '+' : ''}${n}, jetzt ${offen} offen.`); } if (was === 'setzen') { if (n < 0) return antwort('Weniger als 0 geht nicht.', 400); return antwort(`Auf ${opts.liegestuetze.setzen(n)} gesetzt.`); } return antwort('Unbekannt.', 400); } if (p === '/schluessel/neu' && req.method === 'POST') { zugang.neuerOverlayKey(); return redirect(res, '/'); } if (p === '/' || p === '/index.html') { return send(res, 200, 'text/html; charset=utf-8', indexPage(opts, zugang.overlayKey(), bus, vorfuehrung.abbruch !== null)); } if (opts.setup && await setupRoutes(req, res, opts.setup, url)) return; send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden. Die Uebersicht steht auf /'); } // ---------------------------------------------------------------- SSE function sse(req: http.IncomingMessage, res: http.ServerResponse, bus: Bus): void { res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', // Ohne das puffert so mancher Proxy den Strom, und die Einblendung kommt // gesammelt nach zwei Minuten. Nginx beachtet genau diesen Kopf. 'x-accel-buffering': 'no', }); const write = (ev: OverlayEvent): void => { // `retry` sagt dem Browser, wie schnell er neu verbinden soll. Vorgabe // waeren drei Sekunden; nach einem Neustart mitten im Stream ist eine // Sekunde besser, und mehr kostet es hier nicht. res.write(`retry: 1000\ndata: ${JSON.stringify(ev)}\n\n`); }; // Erst anmelden, dann den Schnappschuss: andersherum wuerde ein Ereignis, // das genau dazwischen faellt, verloren gehen. const unsubscribe = bus.subscribe(write); write(bus.snapshot()); // Als echtes Ereignis, nicht als SSE-Kommentar: ein Kommentar haelt die // Verbindung zwar offen, ist im Browser aber unsichtbar. Die Seite soll // merken koennen, dass laengst nichts mehr kam, und selbst neu verbinden. const beat = setInterval( () => res.write(`data: ${JSON.stringify({ kind: 'beat', t: Date.now() })}\n\n`), HEARTBEAT_MS); beat.unref(); const end = (): void => { clearInterval(beat); unsubscribe(); }; req.on('close', end); res.on('error', end); } /** Der aktuelle Stand aus dem Bus, ohne den Typ an jeder Stelle aufzudroeseln. */ function zustand(bus: Bus) { const s = bus.snapshot(); if (s.kind !== 'state') throw new Error('Schnappschuss hat die falsche Form'); return { live: s.stream.live, chat: s.chat }; } // ---------------------------------------------------------------- Werkzeug function readForm(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let size = 0; req.on('data', (c: Buffer) => { size += c.length; if (size > 16 * 1024) { reject(new Error('Anfrage zu gross')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8')))); req.on('error', reject); }); } /** Eingabe von der Seite in eine ganze Zahl. Alles andere ist `null`. */ export function zahlAus(roh: string | null | undefined): number | null { if (roh === null || roh === undefined) return null; const s = roh.trim(); return /^[+-]?\d{1,6}$/.test(s) ? Number(s) : null; } function redirect(res: http.ServerResponse, to: string): void { res.writeHead(303, { location: to }); res.end(); } function send(res: http.ServerResponse, code: number, type: string, body: string | Buffer): void { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', // Der Schluessel steht in der Adresse. Ohne diesen Kopf traegt ihn jeder // Bildaufruf zu Twitchs Emote-CDN im Referer mit hinaus. }); res.end(body); } function sendFile(res: http.ServerResponse, rel: string): void { // Aufloesen und danach pruefen, ob das Ergebnis noch unter WEB_ROOT liegt. // `..` im Pfad ist der Klassiker, und ein Server, der beliebige Dateien // ausliefert, liefert irgendwann secrets.json aus. const file = path.resolve(WEB_ROOT, rel); if (!file.startsWith(WEB_ROOT + path.sep)) { return send(res, 403, 'text/plain; charset=utf-8', 'Nicht erlaubt'); } let data: Buffer; try { data = fs.readFileSync(file); } catch { return send(res, 404, 'text/plain; charset=utf-8', 'Nicht gefunden'); } send(res, 200, TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', data); } // ---------------------------------------------------------------- Seiten const SEITEN_CSS = ` :root { color-scheme: dark; } body { margin: 0; padding: 2.5rem clamp(1rem, 5vw, 4rem); background: #0d0b14; color: #e9e6f3; font: 16px/1.6 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; } h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h1 span { color: #a970ff; } p.lead { color: #9b94b3; margin: 0 0 2rem; max-width: 62ch; } table { border-collapse: collapse; width: 100%; max-width: 74rem; } td { padding: .9rem 1rem; border-top: 1px solid #241f35; vertical-align: top; } tr:first-child td { border-top: 0; } .hint { color: #8b84a3; font-size: .875rem; } .size { color: #8b84a3; white-space: nowrap; font-variant-numeric: tabular-nums; } code { background: #171327; padding: .25rem .5rem; border-radius: .4rem; word-break: break-all; font-family: "Cascadia Mono", Consolas, monospace; font-size: .8rem; } button, a.knopf { font: inherit; font-size: .85rem; margin-left: .5rem; background: none; border: 1px solid #3a3352; color: #c9c2e0; border-radius: .4rem; padding: .2rem .6rem; cursor: pointer; text-decoration: none; } button:hover, a.knopf:hover { border-color: #a970ff; color: #fff; } input { font: inherit; background: #171327; border: 1px solid #3a3352; color: #e9e6f3; border-radius: .4rem; padding: .5rem .7rem; } .status { margin-top: 2rem; color: #8b84a3; font-size: .9rem; } .dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; background: #ef4444; margin-right: .4rem; } .dot.on { background: #22c55e; } ol { max-width: 62ch; color: #9b94b3; } .warn { color: #ffc53d; } h2 { font-size: 1.1rem; margin: 2.5rem 0 .4rem; } .gruppe { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-bottom: .6rem; } .gruppenname { display: inline-block; min-width: 9rem; color: #8b84a3; font-size: .85rem; } button.test { margin-left: 0; } button.test:disabled { opacity: .35; cursor: not-allowed; border-color: #241f35; } .warnbox { border: 1px solid #ffc53d; color: #ffc53d; border-radius: .5rem; padding: .6rem .9rem; margin: .8rem 0; max-width: 62ch; } .meldung { min-height: 1.5rem; font-size: .9rem; color: #8b84a3; margin-top: .8rem; } .meldung.ok { color: #4ade80; } .meldung.fehler { color: #ffc53d; } .wartend { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; } .wartend:first-child { border-top: 0; } .wartend .hint { max-width: 60ch; word-break: break-word; } .lz-stand { font: 700 3rem/1 "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif; font-variant-numeric: tabular-nums; margin: .4rem 0 1rem; } .lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin-left: .5rem; } button.lz { margin-left: 0; min-width: 3.2rem; } input.lz { width: 6.5rem; padding: .2rem .6rem; font-size: .85rem; } button.tts { margin-left: .4rem; } `; export function loginPage(channel: string, fehler: boolean): string { return ` Anmelden

abdulive ${escape(channel)}

${fehler ? 'Passwort stimmt nicht.' : 'Passwort eingeben.'}

`; } /** * Die Seite, die man aufmacht, bevor man OBS einrichtet. Sie ist bewusst hier * und nicht in public/: sie kennt die oeffentliche Adresse, den Schluessel und * die Seitenliste, und das soll nicht an zwei Stellen stehen. */ export function indexPage( opts: OverlayOptions, key: string, bus: Bus, demoLaeuft = false ): string { // Der Abschnitt erscheint nur, wenn der Hub eine Warteschlange einhaengt - // im Alleinbetrieb des Overlay-Servers gibt es nichts freizugeben. const ttsFeld = opts.tts ? `

Vorlesen: wartet auf Freigabe

Nichts wird vorgelesen, bevor du hier klickst. Ablehnen gibt die Kanalpunkte zurück, sofern das verbundene Konto das darf.

wird geladen …
„Sofort still" bricht auch ab, was gerade gesprochen wird.

 

` : ''; const lzFeld = opts.liegestuetze ? `

Liegestütze

Jede Einlösung zählt hoch. Hier herunterzählen, wenn sie gemacht sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.

${opts.liegestuetze.stand()}offen

 

` : ''; const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`; const rows = PAGES.map((p) => { const id = `u${p.path.slice(1)}`; return ` ${p.title}
${p.hint}
${p.w} × ${p.h} ${base}${p.path}?key=${key} ansehen `; }).join(''); const gruppen = ['Einblendungen', 'Kanalpunkte', 'Chat', 'Effekte', 'Stand'] as const; const testfeld = gruppen.map((g) => `
${g} ${TEST_KNOEPFE.filter((k) => k.gruppe === g).map((k) => `` ).join('')}
`).join(''); return ` Overlay - ${escape(opts.channel)}

Overlay ${escape(opts.channel)}

In OBS je eine Browser-Source anlegen, Adresse eintragen, Breite und Höhe setzen. "Quelle beim Unsichtbarwerden deaktivieren" ausschalten, sonst verpasst das Overlay alles, was in einer anderen Szene passiert.

${rows}

verbinde ...  ·  ${bus.clients()} verbundene Quelle(n) ${opts.setup ? ' ·  Einrichtung' + 'Status' : ''} Abmelden

Ausprobieren

Löst ein erfundenes Ereignis aus. Es geht nur an die Browser-Sources - nichts davon landet in der Datenbank, auf Discord oder bei Twitch. Am besten die Seiten in einem zweiten Fenster offen haben („ansehen“ oben).

${testfeld}
Dauerbetrieb Erfundener Chat und alle paar Sekunden eine Einblendung, bis du wieder stoppst.

 

${ttsFeld} ${lzFeld}

Schlüssel

Danach müssen alle Adressen in OBS neu eingetragen werden.
  1. Alle Seiten haben einen durchsichtigen Hintergrund. In OBS nichts weiter einstellen.
  2. Jede Seite verbindet sich von allein neu - ein Neustart des Dienstes kostet eine Sekunde.
  3. Der Schlüssel in der Adresse darf nur lesen. Er landet in jedem OBS-Profil, also nicht weitergeben.
`; } function escape(s: string): string { return s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]!)); }