Fünfundzwanzig Tools, null Uploads
Auf dem Hub der Toolbox steht ein Chip: 100% CLIENT-SIDE · NOTHING IS UPLOADED. Das sind fünf Wörter Marketing und rund dreißigtausend Zeilen Konsequenz — denn sobald man sich weigert, Bytes an einen Server zu schicken, muss der Browser die Systemarbeit erledigen.
Auf dieser Seite
Zu versprechen, dass eine Datei das Gerät nie verlässt, ist billig. Es einzuhalten nicht. Eine Tool-Seite mit Server im Rücken hasht ein 3 GB großes ISO mit vier Zeilen Node und einer Maschine mit echtem Arbeitsspeicher. Die Toolbox hat beides nicht: Sie hat einen Tab, einen Heap, den der Browser jederzeit einkassieren darf, und einen Haupt-Thread, der nebenbei Ihren Cursor bewegen muss. Alles, was sonst der Server erledigt hätte, musste in diesem Tab neu gebaut werden — streamende Kompression, inkrementelles Hashing, Lebenszyklus-Verwaltung für Worker, ein QR-Encoder, eine Farbpipeline mit Gamut-Mapping — und es musste Dateien überstehen, die größer sind als der Speicher, der sie halten könnte.
Das hier ist ein Gang durch die Teile dieses Baus, die wirklich schwer waren: den Worker-Vertrag, den sich alle acht Hintergrundjobs teilen, die Speicherdisziplin hinter client-seitiger Dateiverarbeitung und den kleinen Stapel Algorithmen, die wir am Ende selbst geschrieben haben, weil die Alternativen ein Backend voraussetzten.
- 25
- Tools, vier Kategorien
- 8
- dedizierte Web Worker
- 39,246
- Zeilen im Launch-Commit
- 0
- Netzwerkanfragen in der Tool-Oberfläche
Die Toolbox ging am 25.07.2026 live; die Tool-Oberfläche ist seither per grep auf Netzwerkaufrufe geprüft.
Ein RPC-Vertrag, acht Worker
Acht Tools leisten Arbeit, die schwer genug für einen eigenen Thread ist: Gzip, ZIP, Bildkonvertierung, Datei-Hashing, Text-Diff, Base64, der Regex-Tester und die Palettenextraktion. Acht selbstgestrickte postMessage-Dialoge wären acht Ausgaben derselben Fehler gewesen, also sitzen sie alle auf einem Modul mit 149 Zeilen — einem typisierten RPC-Client für Web Worker mit bewusst kleiner Oberfläche.
export function createWorkerClient<
Req extends object, // NOT { id?: number } — that bound would trip
Res extends { id: number }, // TypeScript's weak-type check for request unions
>(factory: () => Worker): {
call(request: Req, opts?: { transfer?: Transferable[]; onProgress?: OnProgress }): Promise<Res>;
terminate(): void;
};Der ganze Vertrag, ohne Rumpf. Aufrufer schreiben nie eine id — der Client injiziert sie und routet Antworten darüber.
Der Wert steckt in den Routing-Regeln. Eine Antwort mit { id, progress } geht an onProgress, ohne das Promise aufzulösen, ein zweiminütiges Archiv kann sich über einen einzigen Aufruf also hundertmal melden. Eine Antwort mit { id, error } lehnt genau diesen einen Aufruf ab. Ein harter Worker-Fehler — der Thread ist gestorben, der Chunk ließ sich nicht laden — lehnt jeden offenen Aufruf ab und entsorgt den Worker; der nächste call() lässt ihn über die mitgegebene Factory klanglos neu entstehen. Worker entstehen außerdem faul, beim ersten Gebrauch, das Öffnen des ZIP-Tools kostet also nichts, bis Sie eine Datei darauf fallen lassen.
Diese letzte Eigenschaft liefert den Abbruch gratis. Es gibt kein kooperatives Abbruch-Flag, das durch eine Kompressionsschleife gefädelt wird; terminate() tötet den Thread mitten im Chunk, und der nächste Request baut einen frischen. Der Regex-Tester lehnt sich am stärksten daran an, denn ein Regex-Tester ist eine Maschine zum Ausführen benutzergelieferter Programme von unbeschränkten Kosten. Jeder Lauf bekommt einen Wegwerf-Client und eine Frist von 600 ms; läuft sie ab, beenden wir, melden das Ergebnis als timeout, und eine settled-Sperre schluckt die Ablehnung, die danach von der Leiche eintrifft. Es ist immer nur ein Muster unterwegs — ein neuerer Tastendruck überschreibt das eingereihte, statt sich anzustellen —, und cancel() verwirft die Warteschlange vor dem Beenden, weil der eingereihte Job sonst einen Worker für eine Komponente starten würde, die längst unmountet ist.
Zwei Konventionen in derselben Datei sind besser gealtert, als wir erwartet hatten. Fehler überqueren die Leitung als stabile Codes (zip:*, failed), niemals als Prosa, damit die Oberfläche sie in sechs Sprachen übersetzt, statt einer portugiesischen Leserin eine englische Exception vorzusetzen. Und die Worker-Hälften sind von Hand typisiert: Unsere tsconfig liefert die DOM-Lib aus, nicht webworker, in einem Worker ist self also ein Window. Statt gegen die Konfiguration zu kämpfen, schreibt jeder Worker const scope = self as unknown as DedicatedWorkerScope und deklariert die zwei Member, die er tatsächlich benutzt — ein Kniff, den wir zuerst im Propagator des Orrery verwendet und nie bereut haben.
Flacher Speicher, beliebige Dateien
CompressionStream und DecompressionStream sind überhaupt der Grund, warum ein Gzip-Tool in einem Tab existieren kann: Gzip und Deflate, nativ, streamend, ohne WASM-Ballast. Die Falle ist, was man mit der Ausgabe macht. Der naheliegende Zug — Chunks sammeln, zu einem Uint8Array verketten, in einen Blob wickeln — verdoppelt den Speicherhöchststand im denkbar schlechtesten Moment.
Ein 2-GB-Dekomprimieren bräuchte 2 GB zweimal. Der Blob-Konstruktor näht die Teile ohne diesen Ausschlag zusammen.
Die gemeinsame Drain-Funktion sammelt daher BlobPart[] und ist in der Datei ausdrücklich dokumentiert als eine, die sie nie verkettet. Der ZIP-Writer geht weiter und schreibt die angesammelten Teile während DEFLATE alle 16 MiB weg, denn Blob-Speicher verwaltet der Browser und lagert auf die Platte aus — der Heap bleibt flach, während das Archiv über alles hinauswächst, was der Tab halten könnte. Das Entpacken läuft andersherum: File.slice() gibt wahlfreien Zugriff auf das zentrale Verzeichnis, ein Archiv mit 4000 Einträgen wird also aufgelistet, ohne seine Masse zu lesen, und jede Zeile besitzt einen externen Store statt eines Stücks geteilten React-States, denn eine Liste, die pro Eintrag dreimal neu rendert, will man auf einem Telefon nicht haben.
Der Fortschritt wird an der Quelle gedrosselt statt in React — 1-%-Schritte beim Hashen, 80 ms bei ZIP — und von einem identischen TransformStream gemessen, der die Bytes auf der Eingabeseite der Pipeline zählt. Komprimierte Ausgabe ließe den Balken springen und stocken, je nachdem, wie komprimierbar die Datei ist; Eingabebytes gehen immer nur nach oben.
Jenseits von 4 GiB hört der ZIP-Writer auf, es zu versuchen. DEFLATE auf einer Datei dieser Größe bringt wenig und riskiert alles, oberhalb von MAX_DEFLATE_BYTES berechnet der Writer deshalb weiter CRC-32 — mit einer faul aufgebauten Tabelle mit 256 Einträgen, Polynom 0xEDB88320, inkrementell über die Chunks hinweg — und gibt einen STORE-Eintrag aus, der die ursprüngliche File ganz ohne Kopie wiederverwendet. Es gibt auch eine Browser-Untergrenze: Firefox 111 und 112 erfüllen unsere Support-Basis, sind aber älter als CompressionStream (113), diese Nutzer bekommen also ein funktionierendes ZIP-Tool, das speichert statt zu deflaten, plus einen Hinweis, warum.
// TS 5.9 types the codec's writable as WritableStream<BufferSource>, which
// pipeThrough rejects against Uint8Array<ArrayBuffer> chunks even though a
// Uint8Array IS a BufferSource. Narrowing the pair is safe.gzip-core.ts. Die andere Hälfte derselben Familie: crypto.subtle will ausdrücklich Uint8Array<ArrayBuffer> — ArrayBufferLike ist nicht auf BufferSource zuweisbar.
Ein streamendes SHA-256 ohne BigInt
crypto.subtle.digest ist schnell, nativ und von Verfassung wegen einmalig: Es nimmt einen Puffer, keinen Stream. Für Dateien, die man sich leisten kann zu halten, ist das in Ordnung und besser als alles, was wir schreiben könnten. Der Datei-Hasher hat deshalb zwei Modi mit einer harten Linie dazwischen bei 256 MiB.
Unterhalb der Linie puffert er einmal und berechnet alle vier Digests im Voraus — SHA-1, SHA-256, SHA-384, SHA-512 —, was die Algorithmus-Chips in der Oberfläche zu reinen Anzeigefiltern macht. Von SHA-256 auf SHA-512 umzuschalten rendert eine Zeichenkette neu; es liest Ihre Datei nicht noch einmal. Oberhalb der Linie ist Puffern genau das, was wir nicht dürfen, also wechselt das Tool zu einem selbst geschriebenen inkrementellen SHA-256, gespeist von File.stream(), mit flachem Speicher und ohne Obergrenze.
SHA-256 zu schreiben ist ein Initiationsritus; eines zu schreiben, das ein Review übersteht, ist etwas mehr Arbeit. Unseres hält sich buchstabengetreu an FIPS 180-4, einschließlich der Stelle, die alle falsch machen — die Nachrichtenlänge wird als 64-Bit-Big-Endian-Ganzzahl angehängt, und JavaScript-Zahlen können eine solche nicht exakt halten. Statt BigInt auf den heißen Pfad zu holen, wird die Länge als zwei 32-Bit-Wörter geführt und mit Übertrag erhöht. Sie wird gegen crypto.subtle an jeder relevanten Padding-Grenze eigenschaftsbasiert getestet (0, 55, 56, 63, 64, 65 Bytes — die beiden 56-Byte-Fälle sind die, an denen naive Implementierungen einen Block verlieren) und noch einmal bei 1 MB + 7, um zu beweisen, dass Streaming-Pfad und Einmal-Pfad sich bei Eingaben einig sind, die über Chunk-Grenzen reichen.
Nichts bleibt liegen außer Einstellungen
Nur-lokal heißt nicht automatisch privat. localStorage ist lokal, und ein Tool, das sich Ihr zuletzt erzeugtes Passwort merkt, wäre eine schlimmere Haftung als eines, das es irgendwohin schickt — der Server vergisst wenigstens. Über dem Schlüsselformat des Storage-Moduls steht deshalb ein einzeiliger Vertrag: nur Optionen, niemals Nutzdaten, niemals erzeugte Geheimnisse.
// vaynerov:tools:<slug>:v1 — user OPTIONS only.
// Never persist user payloads or generated secrets.
// Uniform pick without modulo bias: reject the ragged tail of the range.
const limit = Math.floor(0x100000000 / alphabet.length) * alphabet.length;
// never a bare % n, never Math.randomDer Passwort-Generator hält seine Werte im React-State — nicht in localStorage, nicht in der URL, nicht in Analytics — und zieht sie mit Rejection Sampling aus crypto.getRandomValues.
Jedes Lesen und Schreiben steckt in try/catch, denn private Browser-Modi werfen beim Zugriff, statt höflich zu scheitern, und eine Einstellungsschicht hat kein Recht, ein Tool lahmzulegen. Der Paletten-Extraktor verzichtet ganz auf Persistenz: Seine Eingabe ist eines Ihrer Fotos.
Ergebnisse wieder heraus zu bekommen hat seine eigene Folklore. Object-URLs werden zehn Sekunden nach dem Klick freigegeben, nicht synchron — sofortiges Freigeben erzeugt in Safari stille Null-Byte-Downloads, die ärgerlichste Fehlerklasse überhaupt, weil nichts einen Fehler wirft. Mehrfach-Downloads liegen 250 ms auseinander, damit die Popup-Heuristik des Browsers nicht die Einträge ab dem vierten frisst — ohne nachgelagerte Pause nach dem letzten.
Der Angreifer ist Ihre eigene Zwischenablage
Auf einen Server zu verzichten heißt auch, auf einen serverseitigen Sanitizer zu verzichten. Die Markdown-Vorschau rendert beliebigen eingefügten Text direkt in die Seite, die Sie gerade ansehen, bei jedem Tastendruck, mit Ihrer Session direkt daneben — sie ist deshalb so geschrieben, als wäre die Eingabe feindlich, denn manchmal ist sie es (eine README aus einem Repository, das Sie nicht gelesen haben, ist nicht vertrauenswürdige Eingabe).
Jeder Textlauf wird vor dem Verketten maskiert und nicht nach dem Zusammenbau, was eine ganze Fehlerfamilie beseitigt, in der ein Maskierdurchgang über Markup läuft, das er gerade selbst erzeugt hat. URLs werden nach einer Runde HTML-Entity-Dekodierung erneut geprüft — javascript: ist eine javascript:-URL mit Hut — und ein Link, der die zweite Prüfung nicht besteht, fällt auf Klartext zurück, statt zu verschwinden, damit der Nutzer sieht, was er getippt hat. Die Entity-Dekodierung selbst ist ein Zweig des Tokenizers als letztes Mittel und kein Vorverarbeitungsschritt, genau deshalb, weil ein Vorverarbeitungsdurchgang dem Parser Text übergäbe, den er nie validiert hat.
Dann sind da die Kosten. Alles, was am Tastendruck hängt, braucht ein Budget, und die Vorschau hat vier.
| Budget | Grenze | Warum es existiert |
|---|---|---|
| MAX_DELIMS | 5000 Zeichen | Die Arbeit an Betonungs-Delimitern ist überlinear; gezählt in Zeichen, nicht in Knoten |
| MAX_LINK_SCAN | 200.000 Zeichen | Erneutes Scannen bei unpaarigen Klammern, nur bei fehlgeschlagenem Scan berechnet |
| MAX_TABLE_CELLS | 20.000 Zellen | Eine eingefügte Tabellenkalkulation, ein begrenztes DOM |
| MAX_BLOCK_DEPTH | 32 Ebenen | Verschachtelte Zitate und Listen kommen nicht an den Stack |
Vier Tastendruck-Budgets in der Markdown-Vorschau. Die XSS-Suite, die dieselbe Datei bewacht, trägt 116 Vektoren.
Encoder, die wir selbst geschrieben haben
Zwei Tools liefern Algorithmen statt Wrapper aus, aus dem unglamourösen Grund, dass die Bibliotheken, die wir genommen hätten, schwerer sind als das Tool selbst.
Der QR-Code-Generator ist ein vollständiger Encoder nach ISO/IEC 18004 Modell 2 für die Versionen 1 bis 11: Reed–Solomon-Fehlerkorrektur über GF(256) mit dem üblichen primitiven Polynom 0x11D, Blockaufteilung und Interleaving, die Zickzack-Platzierung der Module, die die vertikale Timing-Spalte bei Spalte 6 überspringt, alle acht Maskenmuster erzeugt und mit den exakten vier Strafregeln der Spezifikation bewertet, Formatinformation als BCH(15,5) mit 0x5412 XOR-verknüpft und Versionsinformation als BCH(18,6) ab Version 7. Nichts davon ist verhandelbar — ein QR-Code, der fast stimmt, scannt auf Ihrem Telefon und scheitert an dem an der Kasse. Wir haben die Codewörter gegen das veröffentlichte Beispiel HELLO WORLD in Version 1-Q verifiziert, dann einen Decoder geschrieben und die Versionen 1–9 über drei Kodierungsmodi und alle vier Fehlerkorrekturstufen bei exakter Kapazität hin und zurück geprüft.
Der Paletten-Extraktor ist Median Cut mit einer bewussten Abweichung. Lehrbuch-Median-Cut teilt eine Farbbox am Medianindex ihrer belegten Bins, und bei Fotografien ist das in Ordnung. Bei Logos ist es eine Katastrophe: Gibt man ihm ein Bild, das zu 90 % weiß und zu 10 % schwarz ist, landet der Medianindex mitten im dominanten flachen Lauf, beide Hälften kommen fast weiß zurück, und die Palette verfehlt das Schwarz komplett. Unserer sucht den breitesten Kanal und teilt dort an der größten Lücke zwischen belegten Bins, wobei Gleichstände zugunsten des Populationsmedians entschieden werden. Er tastet mit rund 100×100 ab und meldet in OKLCH — der Sprache, die auch der Farbkonverter spricht.
Ein QR-Code, der fast stimmt, scannt auf Ihrem Telefon und scheitert an dem an der Kasse.
Fünfundzwanzig Karten, die nie im Gleichtakt atmen
Der Hub hatte ein Designproblem, das in Wahrheit ein Rendering-Problem ist: fünfundzwanzig Karten, vier Kategorien und kein Platz für fünfundzwanzig Illustrationen. Jede Kategorie besitzt einen nackten HSL-Farbton — Dateien 35 92% 60%, Text 265 72% 70%, Code 210 90% 62%, Design 325 85% 66%, alle vier über 4,5:1 gegen unsere beiden Hintergrundtöne —, den der Tool-Host als --tool-hue auf seinen Container setzt, damit sich Kopfzeilen, Chips und das Icon-Wasserzeichen ohne eine einzige Bedingung selbst einfärben.
Die Kartengrafik besteht aus vier CSS-3D-Dioramen, eines je Kategorie, pro Tool geseedet. Kein WebGL — Transforms und Verläufe —, und das Seeding zählt mehr als die Geometrie: Ein FNV-1a-32-Bit-Hash des Slugs treibt einen Xorshift-Generator, der Einheitsfloats liefert, Math.random und Date.now tauchen im Renderpfad also nirgends auf. Unter Reacts Reinheitsregeln ist eine Komponente, die beim Rendern würfelt, eine Komponente, die flackert; eine Komponente, die ihren eigenen Slug hasht, ist eine reine Funktion, die zufällig improvisiert aussieht. Jede Karte bekommt aus demselben Seed außerdem einen Tempofaktor von ±15 % und einen Phasenversatz, und genau deshalb pulsiert das Raster nicht wie ein Herzschlag.
Zwei weitere Verträge halten diese Schicht zusammen. Bewegung pausiert außerhalb des Bildes — useInView mit 240 px Rand setzt data-paused, was die Szenen über animation-play-state einfriert, statt sie zu unmounten — und die Kartenneigung läuft über Motion Values ohne ein einziges setState pro Mausbewegung und greift auf Touch nie. Und unter prefers-reduced-motion ist der statische Transform jedes Elements als eigene komponierte Ruhepose geschrieben, damit das Standbild wie ein absichtliches Diorama wirkt und nicht wie eine mitten im Schritt eingefrorene kaputte Animation. Das sind 371 Zeilen CSS, damit vier Szenen richtig stillstehen.
Das Interessante an der Kein-Upload-Bedingung ist, dass sie einen nie ein Problem auf die Maschine von jemand anderem schieben lässt. Jede Obergrenze in der Toolbox ist eine echte, und wir benennen sie lieber, als sie zu verstecken.
Es gibt keine File System Access API, die Ausgabe geht also über Downloads statt zurück auf Ihre Platte. Es gibt kein WASM, wir leben also in dem, was die Plattform hergibt — deshalb bekommt Firefox 111 nur STORE-ZIPs und deshalb wird ein 5 GiB großes Archiv gespeichert statt komprimiert. Dateien müssen weiterhin durch den Tab passen, und der Tab gehört dem Browser, der ihn sich zurückholen darf. Einen Tag nach dem Launch haben wir acht kritische Prüfer auf genau diese Oberfläche angesetzt, die dreißig bestätigte Mängel fanden — die wir vollständig aufgeschrieben statt still gepatcht haben.