El árbol: crear, recorrer y borrar
Cómo se obtiene la raíz de OPFS y se manipula el árbol con la API asíncrona: crear carpetas y ficheros, iterar entradas, borrar en cascada y entender por qué un manejador no es una ruta.
La API del árbol de OPFS cabe en cinco métodos y no tiene ni una sola función que acepte una ruta. No hay mkdir -p, no hay stat, no hay rename, no hay glob. Esa pobreza aparente no es un descuido de la especificación: es la consecuencia de haber elegido manejadores en vez de rutas, y en cuanto entiendes qué es un manejador, la ausencia de todo lo demás pasa de carencia a decisión.
- Obtener la raíz y crear directorios y ficheros con la opción de creación explícita.
- Recorrer el árbol con iteración asíncrona y escribir un recorrido recursivo completo.
- Borrar entradas sueltas y subárboles enteros.
- Entender la identidad de los manejadores y por qué se pueden enviar a un worker.
La raíz y sus dos verbos
Todo empieza en navigator.storage.getDirectory(), que devuelve una promesa del FileSystemDirectoryHandle raíz. Un directorio expone exactamente dos formas de bajar: pedir un subdirectorio o pedir un fichero. Crear no es un verbo aparte, sino una opción de la búsqueda.
const raiz = await navigator.storage.getDirectory();
const datos = await raiz.getDirectoryHandle('datos', { create: true });
const wal = await datos.getFileHandle('journal.wal', { create: true });
// Sin la opción de creación, buscar algo que no existe es un error.
try {
await raiz.getFileHandle('fantasma.bin');
} catch (e) {
e.name; // 'NotFoundError'
}
flowchart TD R[raiz sin nombre] --> D1[datos] R --> D2[adjuntos] D1 --> F1[db.sqlite] D1 --> F2[journal.wal] D2 --> D3[2026] D3 --> F3[imagen-001.bin]
El detalle que más código rompe la primera vez es que los nombres son nombres, no rutas. Pasar un separador es un error de tipo, no una navegación implícita, y lo mismo ocurre con los nombres reservados del directorio actual y del padre:
await raiz.getFileHandle('datos/db.sqlite'); // TypeError
await raiz.getDirectoryHandle('..'); // TypeError
Si quieres el equivalente de crear una ruta profunda de una vez, lo escribes tú. Son seis líneas y conviene tenerlas en el proyecto desde el primer día:
async function asegurarRuta(raiz, ruta) {
let dir = raiz;
for (const segmento of ruta.split('/').filter(Boolean)) {
dir = await dir.getDirectoryHandle(segmento, { create: true });
}
return dir;
}
const destino = await asegurarRuta(raiz, 'adjuntos/2026/agosto');
Resolver una ruta de cinco niveles son cinco promesas encadenadas, y cada una cruza la frontera hacia el hilo de almacenamiento del navegador. En un bucle que toca miles de ficheros eso domina el tiempo total. La solución no es un truco de sintaxis: es cachear los manejadores de directorio en un Map y mantener el árbol lo más plano que tolere tu diseño.
Recorrer: iteración asíncrona
Un directorio es un iterable asíncrono. Tienes keys() para los nombres, values() para los manejadores y entries() para ambos, y siempre se consumen con for await. No hay ninguna llamada que te devuelva el listado completo de golpe, porque no hay ninguna garantía de que quepa en memoria.
for await (const [nombre, manejador] of datos.entries()) {
console.log(manejador.kind, nombre); // 'file' | 'directory'
}
El recorrido recursivo, que necesitarás para calcular el tamaño ocupado o para exportar, sale natural como generador asíncrono:
async function* recorrer(dir, prefijo = '') {
for await (const [nombre, manejador] of dir.entries()) {
const ruta = `${prefijo}/${nombre}`;
if (manejador.kind === 'file') {
const f = await manejador.getFile();
yield { ruta, bytes: f.size, modificado: f.lastModified };
} else {
yield* recorrer(manejador, ruta);
}
}
}
let total = 0;
for await (const entrada of recorrer(raiz)) {
total += entrada.bytes;
console.log(entrada.ruta, entrada.bytes);
}
Fíjate en que el tamaño de un fichero no está en su manejador: hay que pedir el objeto de fichero con getFile() para conocerlo. Un manejador no cachea metadatos, y esa es otra pista de que no es un stat, sino una referencia.
La especificación no promete nada sobre qué ve un iterador si otra pestaña crea o borra entradas durante el recorrido: puedes ver la entrada nueva, no verla, o verla a medias. Si el recorrido tiene que ser coherente, coordina con un cerrojo de la Web Locks API o haz que un único worker sea el dueño del árbol.
Borrar, y la identidad de los manejadores
El borrado se pide al directorio padre, nunca a la entrada, y un directorio con contenido no se va salvo que lo digas explícitamente:
await datos.removeEntry('journal.wal');
await raiz.removeEntry('adjuntos'); // InvalidModificationError
await raiz.removeEntry('adjuntos', { recursive: true }); // ahora sí
Y aquí llega la propiedad que más desconcierta: dos manejadores del mismo fichero no son el mismo objeto. Cada llamada crea uno nuevo, así que la comparación por identidad siempre miente. Para preguntar si dos manejadores apuntan a la misma entrada hay un método dedicado, y para saber dónde está uno respecto de otro hay otro:
const a = await datos.getFileHandle('db.sqlite', { create: true });
const b = await datos.getFileHandle('db.sqlite');
a === b; // false
await a.isSameEntry(b); // true
await raiz.resolve(a); // ['datos', 'db.sqlite']
await datos.resolve(a); // ['db.sqlite']
resolve() devuelve null si el manejador no desciende del directorio consultado, y es la única forma legítima de reconstruir algo parecido a una ruta: siempre relativa a un ancestro que ya tienes, nunca absoluta.
La diferencia entre una ruta y un manejador parece cosmética y gobierna todo el diseño de esta API. Una ruta es una cadena de texto que cualquiera puede fabricar y que hay que volver a resolver, y a volver a autorizar, en cada uso: por eso los sistemas de archivos tradicionales arrastran una familia entera de vulnerabilidades donde el nombre se comprueba en un instante y se usa en otro, con el objetivo cambiado en medio. Un manejador es lo contrario: una capacidad en el sentido clásico de los sistemas de seguridad, una referencia inforjable a una entrada concreta que ya lleva dentro el derecho a usarla. No puedes construirlo a partir de texto, solo obtenerlo de quien ya lo tenía, y por eso poseerlo es la autorización, sin ninguna comprobación posterior. De ahí se deduce todo lo demás. Se deduce que no exista una función que acepte rutas, porque reintroducirla devolvería el problema que la elección de capacidades acababa de eliminar. Se deduce que la delegación sea trivial: como los manejadores son clonables estructuralmente, pasarlos por postMessage a un worker o guardarlos en IndexedDB entre sesiones transmite el permiso junto con la referencia, sin ningún registro central de quién puede tocar qué. Y se deduce el patrón arquitectónico que dominará este track: un único worker propietario recibe al arrancar los manejadores del subárbol que le corresponde, y nadie más lo toca, no porque un guardia lo impida, sino porque nadie más tiene con qué nombrarlo. Cuando entiendes que estás repartiendo capacidades y no escribiendo rutas, el diseño de tu capa de persistencia deja de parecerse a un sistema de ficheros y empieza a parecerse a un sistema de actores.
// Delegar el subárbol a un worker: la capacidad viaja con el mensaje.
const propietario = new Worker('/almacen.js', { type: 'module' });
propietario.postMessage({ tipo: 'adoptar', dir: datos });
- Escribe
asegurarRuta,listaryborrarRutasobre los cinco métodos de esta lección, sin ninguna dependencia externa. - Crea cien ficheros repartidos en tres niveles y mide el recorrido completo con
performance.now(). Repítelo con los cien ficheros en un solo directorio plano y compara. - Comprueba que
getFileHandlecon un nombre que contiene un separador lanzaTypeError, y decide qué hace tu capa con los nombres que vienen del usuario. - Obtén dos manejadores del mismo fichero, verifica que la comparación por identidad falla y que
isSameEntryacierta. - Manda un manejador de directorio a un Worker con
postMessage, escribe desde allí y confirma que el hilo principal ve el resultado.