Veinticinco utilidades, cero subidas
La etiqueta del hub de utilidades dice 100% EN EL CLIENTE · NO SE SUBE NADA. Son cinco palabras de marketing y unas treinta mil líneas de consecuencias, porque en cuanto te niegas a enviar bytes a un servidor, el trabajo de sistemas le toca al navegador.
En esta página
Prometer que un archivo nunca sale del dispositivo es barato. Cumplirlo no lo es. Un sitio de utilidades con servidor detrás puede calcular el hash de una ISO de 3 GB con cuatro líneas de Node y una máquina con memoria de verdad. La caja de herramientas no tiene ni lo uno ni lo otro: tiene una pestaña, un montón de memoria que el navegador puede matar cuando quiera y un hilo principal que además debe mantener tu cursor en movimiento. Todo lo que habría hecho el servidor hubo que reconstruirlo dentro de esa pestaña — compresión en streaming, hashing incremental, gestión del ciclo de vida de los workers, un codificador de QR, una cadena de color con mapeo de gama — y tenía que sobrevivir a archivos más grandes que la memoria disponible para contenerlos.
Esto es un recorrido por las partes del proyecto que fueron genuinamente difíciles: el contrato de workers que comparten los ocho trabajos en segundo plano, la disciplina de memoria detrás del procesamiento de archivos en el cliente y el pequeño montón de algoritmos que acabamos escribiendo nosotros porque las alternativas daban por hecho un backend.
- 25
- utilidades, cuatro categorías
- 8
- Web Workers dedicados
- 39,246
- líneas en el commit de lanzamiento
- 0
- peticiones de red en la superficie de utilidades
La caja de herramientas se publicó el 2026-07-25; desde entonces, la ausencia de llamadas de red en la superficie de utilidades se verifica con grep.
Un contrato RPC, ocho workers
Ocho utilidades hacen un trabajo lo bastante pesado como para necesitar hilo propio: gzip, ZIP, conversión de imágenes, hashing de archivos, diff de texto, Base64, el probador de expresiones regulares y la extracción de paletas. Ocho conversaciones improvisadas con postMessage habrían sido ocho versiones de los mismos errores, así que todas se apoyan en un módulo de 149 líneas: un cliente RPC tipado para Web Workers con una superficie deliberadamente pequeña.
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;
};El contrato entero, sin su cuerpo. Quien llama nunca escribe un id: el cliente lo inyecta y enruta las respuestas por él.
El valor está en las reglas de enrutado. Una respuesta que lleva { id, progress } se entrega a onProgress sin resolver la promesa, así que un archivo comprimido de dos minutos puede informar de sí mismo cien veces en una sola llamada. Una respuesta que lleva { id, error } rechaza esa llamada concreta. Un error duro del worker — el hilo murió, el chunk no cargó — rechaza todas las llamadas pendientes y desecha el worker; la siguiente call() lo vuelve a crear sin ruido mediante la fábrica que recibió. Los workers también se crean de forma perezosa, en el primer uso, así que abrir la utilidad de ZIP no cuesta nada hasta que sueltas un archivo en ella.
Esa última propiedad regala la cancelación. No hay ninguna bandera de aborto cooperativo hilvanada por un bucle de compresión; terminate() mata el hilo a mitad de chunk y la petición siguiente construye uno nuevo. El probador de expresiones regulares es el que más se apoya en esto, porque un probador de regex es una máquina de ejecutar programas de coste ilimitado escritos por el usuario. Cada ejecución recibe un cliente desechable y un plazo de 600 ms; al vencer, terminamos el worker, informamos del resultado como timeout, y un pestillo settled se traga el rechazo que llega después desde el cadáver. Solo hay un patrón en vuelo en cada momento — una pulsación nueva sobrescribe la que estaba en cola en vez de ponerse a la fila — y cancel() descarta la cola antes de terminar, porque si no el trabajo encolado crearía un worker para un componente que ya se había desmontado.
Dos convenciones del mismo archivo han envejecido mejor de lo que esperábamos. Los errores cruzan la frontera como códigos estables (zip:*, failed), nunca como prosa, así que la interfaz los traduce a seis idiomas en vez de mostrarle una excepción en inglés a un lector portugués. Y las mitades de worker están tipadas a mano: nuestro tsconfig incluye la librería DOM, no webworker, así que dentro de un worker self es un Window. En vez de pelearnos con la configuración, cada worker escribe const scope = self as unknown as DedicatedWorkerScope y declara los dos miembros que realmente usa — un truco que estrenamos en el propagador del Orrery y que nunca hemos lamentado.
Memoria plana, archivos de cualquier tamaño
CompressionStream y DecompressionStream son la razón de que una utilidad de gzip pueda existir siquiera en una pestaña: gzip y deflate, nativos, en streaming, sin cargar WASM. La trampa está en qué haces con la salida. El movimiento obvio — recoger los chunks, concatenarlos en un solo Uint8Array y envolverlo en un Blob — te duplica el pico de memoria en el peor momento posible.
Descomprimir 2 GB necesitaría 2 GB dos veces. El constructor de Blob cose las partes sin ese pico.
Así que la función de drenaje compartida recoge BlobPart[] y está documentada, en el propio archivo, como que jamás los concatena. El escritor de ZIP va más lejos y vuelca las partes acumuladas cada 16 MiB durante el DEFLATE, porque el almacenamiento de los Blob lo gestiona el navegador y se pagina a disco: el montón se mantiene plano mientras el archivo crece más allá de lo que la pestaña podría contener. La extracción funciona en la dirección contraria: File.slice() da acceso aleatorio al directorio central, así que un archivo de 4.000 entradas se lista sin leer su volumen, y cada fila posee un almacén externo en lugar de un trozo de estado compartido de React, porque una lista que se vuelve a renderizar tres veces por entrada no es una lista que quieras en un móvil.
El progreso se limita en el origen y no en React — pasos del 1% para el hashing, 80 ms para el ZIP — y se mide con un TransformStream identidad que cuenta bytes del lado de la entrada de la cadena. La salida comprimida haría que la barra saltara y se estancara según lo comprimible que fuera el archivo; los bytes de entrada solo suben.
Por encima de 4 GiB el escritor de ZIP deja de intentarlo. DEFLATE sobre un archivo de ese tamaño aporta poco y arriesga todo, así que pasado MAX_DEFLATE_BYTES el escritor sigue calculando el CRC-32 — con una tabla de 256 entradas construida de forma perezosa, polinomio 0xEDB88320, incremental entre chunks — y emite una entrada STORE que reutiliza el File original sin copia alguna. También hay un suelo de navegador: Firefox 111 y 112 superan nuestra base de soporte pero son anteriores a CompressionStream (113), así que esos usuarios obtienen una utilidad de ZIP que funciona almacenando en vez de comprimiendo, con un aviso que explica por qué.
// 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. La otra mitad de la misma familia: crypto.subtle quiere Uint8Array<ArrayBuffer> en concreto — ArrayBufferLike no es asignable a BufferSource.
Un SHA-256 en streaming sin BigInt
crypto.subtle.digest es rápido, nativo y constitucionalmente de una sola pasada: toma un búfer, no un stream. Para los archivos que te puedes permitir sostener, eso está bien y es mejor que cualquier cosa que pudiéramos escribir. Por eso el calculador de resúmenes tiene dos modos con una línea dura entre ellos en los 256 MiB.
Por debajo de la línea carga el archivo una vez y calcula los cuatro resúmenes de golpe — SHA-1, SHA-256, SHA-384 y SHA-512 —, lo que convierte las pastillas de algoritmo de la interfaz en meros filtros de visualización. Pasar de SHA-256 a SHA-512 vuelve a renderizar una cadena de texto; no vuelve a leer tu archivo. Por encima de la línea, cargarlo entero es justamente lo que no podemos hacer, así que la utilidad cambia a un SHA-256 incremental escrito a mano y alimentado por File.stream(), con memoria plana y sin techo.
Escribir un SHA-256 es un rito de paso; escribir uno que sobreviva a una revisión da algo más de trabajo. El nuestro sigue el FIPS 180-4 al pie de la letra, incluida la parte que todo el mundo hace mal: la longitud del mensaje se añade como un entero big-endian de 64 bits, y los números de JavaScript no pueden contener uno con exactitud. En vez de meter BigInt en el camino caliente, la longitud se lleva como dos palabras de 32 bits y se incrementa con acarreo. Está probado por propiedades contra crypto.subtle en todas las fronteras de relleno que importan (0, 55, 56, 63, 64 y 65 bytes — los dos casos de 56 bytes son donde las implementaciones ingenuas pierden un bloque) y otra vez a 1 MB + 7, para demostrar que el camino en streaming y el de una sola pasada coinciden con entradas que cruzan los bordes de chunk.
No persiste nada salvo las preferencias
Que algo sea solo local no lo hace privado automáticamente. localStorage es local, y una utilidad que recordara tu última contraseña generada sería un riesgo peor que una que la enviara a alguna parte: el servidor, al menos, olvida. Así que el módulo de almacenamiento tiene un contrato de una línea escrito encima del formato de clave: solo opciones, nunca cargas útiles, nunca secretos generados.
// 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.randomEl generador de contraseñas guarda sus valores en el estado de React — no en localStorage, ni en la URL, ni en analítica — y los saca de crypto.getRandomValues con muestreo por rechazo.
Cada lectura y cada escritura van envueltas en try/catch, porque los modos de navegación privada lanzan excepciones al acceder en vez de fallar educadamente, y una capa de preferencias no tiene por qué tumbar una utilidad. El extractor de paletas renuncia por completo a la persistencia: su entrada es una de tus fotografías.
Sacar los resultados fuera tiene su propio folclore. Las Object URL se revocan diez segundos después del clic, no de forma síncrona: revocarlas de inmediato produce descargas silenciosas de cero bytes en Safari, que es la clase de error más exasperante porque nada da error. Las descargas de varios archivos se espacian 250 ms para que las heurísticas de ventanas emergentes del navegador no se coman de la cuarta en adelante, sin espera final después de la última.
El atacante es tu propio portapapeles
Renunciar a un servidor significa también renunciar a un saneador en el servidor. La previsualización de markdown renderiza texto pegado arbitrario directamente en la página que estás mirando, en cada pulsación, con tu sesión ahí mismo — así que está escrita como si la entrada fuera hostil, porque a veces lo es (un README de un repositorio que no has leído es entrada no confiable).
Todo fragmento de texto se escapa antes de concatenar y no después de ensamblar, lo que elimina toda una familia de errores en los que una pasada de escapado recorre marcado que ella misma acaba de generar. Las URL se vuelven a comprobar tras una ronda de decodificación de entidades HTML — javascript: es una URL javascript: con sombrero — y un enlace que falla la segunda comprobación se degrada a texto plano en vez de desaparecer, para que el usuario vea lo que escribió. La propia decodificación de entidades es una rama de último recurso del tokenizador, no un paso de preprocesado, precisamente porque una pasada de preprocesado le entregaría al analizador un texto que nunca validó.
Y luego está el coste. Todo lo que vive en el camino de una pulsación necesita un presupuesto, y la previsualización tiene cuatro.
| Presupuesto | Límite | Por qué existe |
|---|---|---|
| MAX_DELIMS | 5.000 caracteres | El trabajo con delimitadores de énfasis es superlineal; se cuenta en caracteres, no en nodos |
| MAX_LINK_SCAN | 200.000 caracteres | Reexploraciones por corchetes sin pareja, cobradas solo cuando una exploración falla |
| MAX_TABLE_CELLS | 20.000 celdas | Una hoja de cálculo pegada, un DOM acotado |
| MAX_BLOCK_DEPTH | 32 niveles | Las citas y listas anidadas no pueden alcanzar la pila |
Los cuatro presupuestos por pulsación de la previsualización de markdown. La batería de XSS que protege ese mismo archivo lleva 116 vectores.
Codificadores que escribimos nosotros
Dos utilidades incluyen algoritmos en vez de envoltorios, por la razón poco lucida de que las bibliotecas que habríamos usado pesan más que la utilidad.
El generador de códigos QR es un codificador ISO/IEC 18004 modelo 2 completo que cubre las versiones 1 a 11: corrección de errores Reed–Solomon sobre GF(256) con el polinomio primitivo estándar 0x11D, división en bloques e intercalado, la colocación en zigzag de los módulos que se salta la columna de temporización vertical en la columna 6, las ocho máscaras generadas y puntuadas con las cuatro reglas de penalización exactas de la especificación, la información de formato como BCH(15,5) sometida a XOR con 0x5412 y la información de versión como BCH(18,6) a partir de la versión 7. Nada de eso es negociable: un código QR casi correcto se escanea en tu móvil y falla en el de la caja. Verificamos las palabras de código contra el ejemplo publicado de HELLO WORLD en versión 1-Q, y luego escribimos un decodificador e hicimos ida y vuelta con las versiones 1–9 en tres modos de codificación y los cuatro niveles de corrección de errores, a capacidad exacta.
El extractor de paletas es corte por la mediana con una desviación deliberada. El corte por la mediana de manual divide una caja de color por el índice mediano de sus celdas ocupadas, y con fotografías eso funciona. Con logotipos es un desastre: dale una imagen que sea 90% blanca y 10% negra y el índice mediano cae dentro de la franja plana dominante, así que ambas mitades salen casi blancas y la paleta se pierde el negro por completo. La nuestra busca el canal más ancho y divide por el hueco más ancho entre celdas ocupadas de ese canal, deshaciendo los empates hacia la mediana de población. Muestrea a unos 100×100 e informa en OKLCH, que es también el idioma del conversor de color.
Un código QR casi correcto se escanea en tu móvil y falla en el de la caja.
Veinticinco tarjetas que nunca respiran al unísono
El hub tenía un problema de diseño que en realidad es un problema de renderizado: veinticinco tarjetas, cuatro categorías y sitio para cero ilustraciones. Cada categoría posee un tono HSL desnudo — archivos 35 92% 60%, texto 265 72% 70%, código 210 90% 62%, diseño 325 85% 66%, los cuatro superando el 4,5:1 contra nuestros dos tonos de fondo — que el anfitrión de la utilidad fija como --tool-hue en su contenedor, de modo que cabeceras, pastillas y la marca de agua del icono se tiñen solas sin una sola condicional.
El arte de las tarjetas son cuatro dioramas en CSS 3D, uno por categoría, sembrados por utilidad. Nada de WebGL — transformaciones y degradados — y la semilla importa más que la geometría: un hash FNV-1a de 32 bits del slug alimenta un generador xorshift que produce floats unitarios, así que Math.random y Date.now no aparecen por ninguna parte del camino de renderizado. Bajo las reglas de pureza de React, un componente que tira los dados mientras renderiza es un componente que parpadea; un componente que hashea su propio slug es una función pura que solo parece improvisada. Cada tarjeta recibe además un multiplicador de velocidad de ±15% y un desfase de la misma semilla, que es toda la razón de que la cuadrícula no lata como un corazón.
Otros dos contratos sostienen esa capa. El movimiento se pausa fuera de pantalla — useInView con un margen de 240 px pone data-paused, que congela las escenas mediante animation-play-state en vez de desmontarlas — y la inclinación de las tarjetas corre sobre valores de movimiento de framer motion con cero setState por cada movimiento del ratón, y nunca se activa en táctil. Y bajo prefers-reduced-motion, la transformación estática de cada elemento se escribe como su propia pose de reposo compuesta, para que el fotograma quieto se lea como un diorama deliberado y no como una animación rota a media zancada. Son 371 líneas de CSS para que cuatro escenas se queden quietas correctamente.
Lo interesante de la restricción de no subir nada es que nunca te deja aplazar un problema a la máquina de otro. Todos los techos de la caja de herramientas son techos reales, y preferimos nombrarlos a esconderlos.
No hay File System Access API, así que la salida sale por descargas en vez de escribirse de vuelta en tu disco. No hay WASM, así que vivimos dentro de lo que la plataforma da — por eso Firefox 111 solo obtiene ZIP en modo STORE y por eso un archivo de 5 GiB se almacena en vez de comprimirse. Los archivos siguen teniendo que caber por la pestaña, y la pestaña es del navegador. Un día después del lanzamiento apuntamos a esta misma superficie a ocho revisores adversarios que encontraron treinta defectos confirmados, que publicamos enteros en vez de parchear en silencio.