Skip to content

Vaynerov Technologies

Nous ne nous contentons pas de développer — nous façonnons chaque ligne de code et chaque pixel comme par enchantement.

Tous les articlesAnalyses techniques

Vingt-cinq outils, aucun envoi

La pastille du hub Toolbox annonce 100 % CÔTÉ CLIENT · RIEN N'EST ENVOYÉ. Cinq mots de communication et quelque trente mille lignes de conséquences — car dès lors qu'on refuse d'envoyer des octets à un serveur, c'est au navigateur de faire le travail système.

Vaynerov TechnologiesLe studio
Publié 13 min de lecture
Sur cette page
  1. Un contrat RPC, huit workers
  2. Mémoire plate, fichiers de taille arbitraire
  3. Un SHA-256 en flux, sans BigInt
  4. Rien ne persiste, sauf les préférences
  5. L'attaquant, c'est votre propre presse-papiers
  6. Des encodeurs que nous avons écrits nous-mêmes
  7. Vingt-cinq cartes qui ne respirent jamais à l'unisson

Promettre qu'un fichier ne quitte jamais l'appareil coûte peu. Le tenir, non. Un site d'outils adossé à un serveur peut hacher une image ISO de 3 Go en quatre lignes de Node, sur une machine dotée de vraie mémoire. La Toolbox n'a ni l'un ni l'autre : elle a un onglet, un tas que le navigateur peut tuer quand bon lui semble, et un thread principal qui doit aussi faire avancer votre curseur. Tout ce que le serveur aurait fait a dû être reconstruit dans cet onglet — compression en flux, hachage incrémental, gestion du cycle de vie des workers, un encodeur QR, une chaîne colorimétrique à correspondance de gamut — et cela devait survivre à des fichiers plus gros que la mémoire disponible pour les contenir.

Voici une visite des parties de ce chantier qui ont été réellement difficiles : le contrat de worker que partagent les huit tâches de fond, la discipline mémoire derrière le traitement de fichiers côté client, et le petit tas d'algorithmes que nous avons fini par écrire nous-mêmes parce que les solutions existantes présupposaient un backend.

25
outils, quatre catégories
8
Web Workers dédiés
39,246
lignes dans le commit de lancement
0
requête réseau dans la surface outils

La Toolbox est sortie le 25 juillet 2026 ; la surface outils est depuis vérifiée au grep pour tout appel réseau.

Un contrat RPC, huit workers

Huit outils font un travail assez lourd pour mériter leur propre thread : gzip, ZIP, conversion d'images, hachage de fichiers, diff de texte, Base64, testeur d'expressions régulières et extraction de palette. Huit conversations postMessage bricolées auraient donné huit fois les mêmes bugs, alors tous reposent sur un seul module de 149 lignes — un client RPC typé pour Web Worker, à la surface délibérément réduite.

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;
};

Tout le contrat, moins son corps. Les appelants n'écrivent jamais d'id : le client en injecte un et route les réponses grâce à lui.

Les règles de routage sont là où se trouve la valeur. Une réponse portant { id, progress } est remise à onProgress sans dénouer la promesse, si bien qu'une archive de deux minutes peut se signaler cent fois sur un seul appel. Une réponse portant { id, error } rejette cet appel-là. Une erreur dure du worker — thread mort, chunk non chargé — rejette tous les appels en attente et met le worker au rebut ; le call() suivant le fait discrètement renaître via la fabrique qu'on lui a donnée. Les workers sont aussi créés paresseusement, au premier usage, si bien qu'ouvrir l'outil ZIP ne coûte rien tant que vous n'y déposez pas de fichier.

Les trames de progression ne dénouent jamais la promesse. Un worker mort rejette tout ce qui est en vol et se voit remplacé au prochain usage — annulation et reprise après crash suivent le même chemin de code.

Cette dernière propriété donne l'annulation gratuitement. Il n'y a pas de drapeau d'abandon coopératif faufilé dans une boucle de compression ; terminate() tue le thread en plein chunk et la requête suivante en construit un neuf. Le testeur d'expressions régulières s'y appuie le plus fort, parce qu'un testeur de regex est une machine à exécuter des programmes fournis par l'utilisateur, au coût non borné. Chaque exécution reçoit un client jetable et une échéance de 600 ms ; à expiration, nous terminons, rapportons le résultat comme timeout, et un verrou settled avale le rejet qui arrive ensuite du cadavre. Un seul motif est en vol à la fois — une frappe plus récente écrase celle qui attend au lieu de faire la queue — et cancel() vide la file avant de terminer, sinon la tâche en attente créerait un worker pour un composant déjà démonté.

Deux conventions du même fichier ont mieux vieilli que prévu. Les erreurs traversent le réseau sous forme de codes stables (zip:*, failed), jamais de prose, pour que l'interface les traduise en six langues au lieu d'afficher une exception anglaise à un lecteur portugais. Et les moitiés worker sont typées à la main : notre tsconfig embarque la lib DOM, pas webworker, si bien qu'à l'intérieur d'un worker self est un Window. Plutôt que de nous battre avec la configuration, chaque worker écrit const scope = self as unknown as DedicatedWorkerScope et déclare les deux membres qu'il utilise vraiment — une astuce inaugurée dans le propagateur d'Orrery et jamais regrettée.

Mémoire plate, fichiers de taille arbitraire

CompressionStream et DecompressionStream sont la raison pour laquelle un outil gzip peut exister dans un onglet : gzip et deflate, natifs, en flux, sans charge WASM. Le piège, c'est ce qu'on fait de la sortie. Le geste évident — collecter les morceaux, concaténer en un seul Uint8Array, l'emballer dans un Blob — double votre pic mémoire au pire moment possible.

Une décompression de 2 Go réclamerait 2 Go deux fois. Le constructeur de Blob assemble les parts sans ce pic.

La fonction de vidage partagée collecte donc des BlobPart[] et il est écrit, dans le fichier même, qu'elle ne les concatène jamais. L'écrivain ZIP va plus loin et vide les parts accumulées tous les 16 Mio pendant le DEFLATE, parce que le stockage Blob est géré par le navigateur et pagine sur disque — le tas reste plat pendant que l'archive dépasse tout ce que l'onglet pourrait contenir. L'extraction travaille dans l'autre sens : File.slice() donne un accès aléatoire au répertoire central, si bien qu'une archive de 4 000 entrées se liste sans lire son gros, et chaque ligne possède un store externe plutôt qu'une tranche d'état React partagé, parce qu'une liste qui se rerend trois fois par entrée n'est pas une liste que l'on veut sur un téléphone.

La chaîne ne détient jamais le fichier entier. La progression est comptée du côté entrée, où le total est connu : elle est donc monotone par construction.

La progression est limitée à la source plutôt que dans React — par pas de 1 % pour le hachage, toutes les 80 ms pour le ZIP — et elle est mesurée par un TransformStream identité qui compte les octets du côté entrée de la chaîne. La sortie compressée ferait sauter et caler la barre selon la compressibilité du fichier ; les octets d'entrée, eux, ne font que monter.

Au-delà de 4 Gio, l'écrivain ZIP renonce. Le DEFLATE sur un fichier de cette taille rapporte peu et risque tout, alors passé MAX_DEFLATE_BYTES l'écrivain continue de calculer le CRC-32 — table de 256 entrées construite paresseusement, polynôme 0xEDB88320, incrémentale d'un chunk à l'autre — et émet une entrée STORE qui réutilise le File d'origine sans la moindre copie. Il y a aussi un plancher navigateur : Firefox 111 et 112 passent notre socle de support mais précèdent CompressionStream (113), donc ces utilisateurs obtiennent un outil ZIP fonctionnel qui stocke au lieu de compresser, et un avis qui explique pourquoi.

// 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. L'autre moitié de la même famille : crypto.subtle veut spécifiquement Uint8Array<ArrayBuffer> — ArrayBufferLike n'est pas assignable à BufferSource.

Un SHA-256 en flux, sans BigInt

crypto.subtle.digest est rapide, natif et constitutionnellement en un seul coup : il prend un tampon, pas un flux. Pour les fichiers que l'on peut se permettre de tenir en mémoire, c'est parfait, et meilleur que tout ce que nous pourrions écrire. Le calculateur d'empreintes a donc deux modes, séparés par une frontière nette à 256 Mio.

En dessous, il met en tampon une fois et calcule les quatre digests d'emblée — SHA-1, SHA-256, SHA-384, SHA-512 — ce qui transforme les pastilles d'algorithme de l'interface en simples filtres d'affichage. Passer de SHA-256 à SHA-512 rerend une chaîne de caractères ; cela ne relit pas votre fichier. Au-dessus, mettre en tampon est précisément ce que nous ne pouvons pas faire, alors l'outil bascule sur un SHA-256 incrémental écrit à la main et alimenté par File.stream(), à mémoire plate et sans plafond.

Écrire SHA-256 est un rite de passage ; en écrire un qui survive à une relecture demande un peu plus de travail. Le nôtre suit FIPS 180-4 à la lettre, y compris la partie que tout le monde rate : la longueur du message est ajoutée en entier gros-boutiste de 64 bits, et les nombres JavaScript ne peuvent pas en contenir un exactement. Plutôt que de tirer BigInt sur le chemin chaud, la longueur est portée par deux mots de 32 bits et incrémentée avec retenue. Elle est testée par propriétés contre crypto.subtle à chaque frontière de remplissage qui compte (0, 55, 56, 63, 64, 65 octets — les deux cas à 56 octets sont là où les implémentations naïves perdent un bloc) et à nouveau à 1 Mo + 7, pour prouver que le chemin en flux et le chemin en un coup s'accordent sur des entrées à cheval sur les bords de chunk.

Rien ne persiste, sauf les préférences

Local ne veut pas automatiquement dire privé. localStorage est local, et un outil qui se souviendrait de votre dernier mot de passe généré serait un passif pire qu'un outil qui l'aurait posté quelque part — le serveur, au moins, oublie. Le module de stockage a donc un contrat d'une ligne écrit au-dessus du format de clé : des options uniquement, jamais de contenus, jamais de secrets générés.

// 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.random

Le générateur de mots de passe garde ses valeurs dans l'état React — pas dans localStorage, pas dans l'URL, pas dans les statistiques — et les tire de crypto.getRandomValues par échantillonnage avec rejet.

Chaque lecture et chaque écriture est enveloppée dans un try/catch, parce que les modes de navigation privée lèvent une exception à l'accès plutôt que d'échouer poliment, et parce qu'une couche de préférences n'a pas à faire tomber un outil. L'extracteur de palette renonce entièrement à la persistance : son entrée est une de vos photographies.

Faire sortir les résultats a son propre folklore. Les URL d'objet sont révoquées dix secondes après le clic, et non de façon synchrone — révoquer immédiatement produit des téléchargements silencieux de zéro octet dans Safari, la classe de bug la plus exaspérante qui soit puisque rien n'échoue visiblement. Les téléchargements multiples sont espacés de 250 ms pour que l'heuristique anti-popup du navigateur n'avale pas les entrées à partir de la quatrième, sans temporisation superflue après la dernière.

L'attaquant, c'est votre propre presse-papiers

Refuser un serveur, c'est aussi refuser un assainisseur côté serveur. L'aperçu markdown rend du texte collé arbitraire directement dans la page que vous regardez, à chaque frappe, avec votre session juste là — il est donc écrit comme si l'entrée était hostile, parce qu'elle l'est parfois (un README venu d'un dépôt que vous n'avez pas lu est une entrée non fiable).

Chaque passage de texte est échappé avant concaténation plutôt qu'après assemblage, ce qui élimine toute une famille de bugs où une passe d'échappement repasse sur du balisage qu'elle vient de produire. Les URL sont revérifiées après un tour de décodage d'entités HTML — &#106;avascript: est une URL javascript: déguisée — et un lien qui échoue à la seconde vérification se rétrograde en texte brut plutôt que de disparaître, pour que l'utilisateur voie ce qu'il a tapé. Le décodage d'entités est lui-même une branche de dernier recours du tokeniseur, et non une étape de prétraitement, précisément parce qu'une passe de prétraitement remettrait à l'analyseur un texte qu'il n'a jamais validé.

Vient ensuite le coût. Tout ce qui se trouve sur le chemin d'une frappe a besoin d'un budget, et l'aperçu en a quatre.

BudgetLimiteRaison d'être
MAX_DELIMS5 000 caractèresLe travail sur les délimiteurs d'emphase est superlinéaire ; compté en caractères, pas en nœuds
MAX_LINK_SCAN200 000 caractèresRebalayages de crochets non appariés, facturés seulement quand un balayage échoue
MAX_TABLE_CELLS20 000 cellulesUn tableur collé, un DOM borné
MAX_BLOCK_DEPTH32 niveauxCitations et listes imbriquées ne peuvent pas atteindre la pile

Quatre budgets de frappe dans l'aperçu markdown. La suite XSS qui garde ce même fichier porte 116 vecteurs.

Des encodeurs que nous avons écrits nous-mêmes

Deux outils livrent des algorithmes plutôt que des enveloppes, pour la raison peu glorieuse que les bibliothèques que nous aurions employées sont plus lourdes que l'outil.

Le générateur de QR est un encodeur ISO/IEC 18004 modèle 2 complet couvrant les versions 1 à 11 : correction d'erreurs Reed–Solomon sur GF(256) avec le polynôme primitif standard 0x11D, découpage et entrelacement des blocs, le placement de modules en zigzag qui saute la colonne de synchronisation verticale en colonne 6, les huit motifs de masque générés et notés selon les quatre règles de pénalité exactes de la spécification, l'information de format en BCH(15,5) XOR-ée avec 0x5412, et l'information de version en BCH(18,6) à partir de la version 7. Rien de tout cela n'est négociable — un QR code presque juste se scanne sur votre téléphone et échoue sur celui de la caisse. Nous avons vérifié les mots de code contre l'exemple publié HELLO WORLD version 1-Q, puis écrit un décodeur et fait l'aller-retour sur les versions 1 à 9, dans trois modes d'encodage et aux quatre niveaux de correction d'erreurs, à capacité exacte.

L'extracteur de palette est une coupe médiane avec un écart délibéré. La coupe médiane du manuel scinde une boîte de couleurs à l'indice médian de ses casiers occupés, et sur des photographies c'est très bien. Sur des logos, c'est un désastre : donnez-lui une image blanche à 90 % et noire à 10 % et l'indice médian tombe au milieu de la plage plate dominante, si bien que les deux moitiés reviennent presque blanches et que la palette rate entièrement le noir. La nôtre trouve le canal le plus large, puis coupe au plus grand intervalle entre casiers occupés sur ce canal, en départageant vers la médiane de population. Elle échantillonne à environ 100×100 et rapporte en OKLCH, la langue que parle aussi le convertisseur de couleurs.

Un QR code presque juste se scanne sur votre téléphone et échoue sur celui de la caisse.

Vingt-cinq cartes qui ne respirent jamais à l'unisson

Le hub avait un problème de design qui est en réalité un problème de rendu : vingt-cinq cartes, quatre catégories, et pas la place pour vingt-cinq illustrations. Chaque catégorie possède une teinte en HSL nue — fichiers 35 92% 60%, texte 265 72% 70%, code 210 90% 62%, design 325 85% 66%, toutes quatre au-dessus de 4,5:1 face à nos deux fonds — que l'hôte d'outil pose en --tool-hue sur son conteneur, si bien qu'en-têtes, pastilles et filigrane d'icône se teintent sans une seule condition.

Le décor des cartes, ce sont quatre dioramas en CSS 3D, un par catégorie, amorcés par outil. Pas de WebGL — des transformations et des dégradés — et l'amorçage compte davantage que la géométrie : un hachage FNV-1a 32 bits du slug pilote un générateur xorshift qui produit des flottants unitaires, si bien que Math.random et Date.now n'apparaissent nulle part sur le chemin de rendu. Sous les règles de pureté de React, un composant qui lance les dés pendant le rendu est un composant qui scintille ; un composant qui hache son propre slug est une fonction pure qui a seulement l'air improvisée. Chaque carte reçoit aussi un multiplicateur de vitesse de ±15 % et un décalage de phase issus de la même graine, et c'est toute la raison pour laquelle la grille ne bat pas comme un cœur.

Deux autres contrats tiennent cette couche ensemble. Le mouvement se met en pause hors écran — useInView avec une marge de 240 px pose data-paused, qui fige les scènes par animation-play-state plutôt que de les démonter — et l'inclinaison des cartes tourne sur des valeurs de mouvement framer sans un seul setState par mouvement de souris, et ne s'engage jamais au tactile. Enfin, sous prefers-reduced-motion, la transformation statique de chaque élément est écrite comme sa propre pose de repos composée, si bien que l'image fixe se lit comme un diorama voulu et non comme une animation cassée figée en plein pas. Cela fait 371 lignes de CSS pour que quatre scènes tiennent correctement immobiles.

Ce qu'il y a d'intéressant dans la contrainte du zéro envoi, c'est qu'elle ne vous laisse jamais renvoyer un problème sur la machine de quelqu'un d'autre. Chaque plafond de la Toolbox est un vrai plafond, et nous préférons les nommer que les cacher.

Il n'y a pas d'API File System Access, donc la sortie passe par des téléchargements plutôt que par une écriture sur votre disque. Il n'y a pas de WASM, donc nous vivons dans ce que la plateforme donne — c'est pourquoi Firefox 111 n'obtient que des ZIP en STORE, et pourquoi une archive de 5 Gio est stockée plutôt que compressée. Les fichiers doivent encore passer par l'onglet, et l'onglet appartient au navigateur, qui peut le reprendre. Le lendemain du lancement, nous avons lancé huit relecteurs adverses sur cette même surface et ils ont trouvé trente défauts confirmés, que nous avons publiés intégralement plutôt que corrigés en silence.