Manejo persistente de archivos con la File System Access API
Los mecanismos tradicionales de subida de archivos llevan mucho tiempo limitados por el sandbox
del navegador. El enfoque típico con <input type="file"> te obliga a volver a
seleccionar los archivos tras actualizar la página y no expone rutas directas del sistema de
archivos. La File System Access API ofrece una solución moderna al habilitar handles de archivo
persistentes que las aplicaciones pueden guardar en IndexedDB. Una vez concedido el permiso, un
usuario que regresa puede volver a abrir el mismo archivo local sin seleccionarlo de nuevo.
Reanudar una subida requiere además un protocolo de servidor reanudable y el estado de subida
guardado; un handle de archivo por sí solo no proporciona eso. Como el soporte de los navegadores
varía, mantén un mecanismo de respaldo estándar de selección de archivos.
Compatibilidad con navegadores y mejora progresiva
La biblioteca browser-fs-access recurre a un input de archivos cuando los selectores nativos no están disponibles. Comprueba cada método de selección en lugar de asumir que todas las API del sistema de archivos son compatibles en conjunto. Consulta la tabla de compatibilidad de selectores para los navegadores a los que te diriges. La selección de archivos de respaldo no proporciona un handle nativo reutilizable.
En un proyecto de JavaScript con un bundler, instala browser-fs-access y idb-keyval. Este último se
usa más abajo para el almacenamiento de handles en IndexedDB.
yarn add browser-fs-access idb-keyval
import { fileOpen, supported } from 'browser-fs-access'
async function handleFileAccess() {
if (supported) {
console.log('Using native File System Access API')
} else {
console.log('Using legacy fallback via browser-fs-access')
}
try {
const blob = await fileOpen({
mimeTypes: ['image/*'],
multiple: false,
})
return blob
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled file selection')
} else {
console.error('Error accessing file:', err)
}
return null
}
}
Consideraciones de seguridad
La File System Access API aplica varias medidas de seguridad para proteger los datos del usuario:
- Los permisos están vinculados al origen. Los handles guardados pueden sobrevivir a la concesión de un permiso, así que vuelve a comprobar el acceso.
- Un contexto seguro (HTTPS) es obligatorio para el uso en producción.
- Las operaciones de escritura solicitan el consentimiento explícito del usuario antes de modificar archivos.
- El acceso se limita a los archivos o directorios aprobados por el usuario; algunas ubicaciones sensibles están bloqueadas.
Llama a las solicitudes de permisos desde una acción del usuario, como un clic en un botón. Si se
requiere acceso de escritura, usa { mode: 'readwrite' } en lugar de { mode: 'read' }.
async function verifyPermissions(handle) {
const options = { mode: 'read' }
try {
if ((await handle.queryPermission(options)) === 'granted') {
return true
}
const permission = await handle.requestPermission(options)
return permission === 'granted'
} catch (err) {
console.error('Permission error:', err)
return false
}
}
Para persistir un handle nativo, guárdalo con el soporte de clonación estructurada de IndexedDB, no
con JSON ni localStorage. Carga el handle guardado antes de habilitar el botón de reapertura para
que las solicitudes de permisos puedan ejecutarse directamente desde el clic. Conecta las
siguientes funciones a los botones de tu página después de importar en el mismo módulo el helper
verifyPermissions anterior:
import { get, set } from 'idb-keyval'
let savedHandle = null
async function loadSavedHandle() {
savedHandle = await get('selected-upload-file') ?? null
return savedHandle !== null
}
async function choosePersistentFile() {
if (typeof window.showOpenFilePicker !== 'function') {
return handleFileAccess()
}
const [handle] = await window.showOpenFilePicker({ multiple: false })
await set('selected-upload-file', handle)
savedHandle = handle
return handle.getFile()
}
async function reopenSavedFile() {
if (!savedHandle) return null
if (!(await verifyPermissions(savedHandle))) return null
return savedHandle.getFile()
}
Al cargar la página, espera a loadSavedHandle() y habilita un botón de reapertura solo cuando devuelva true.
Llama a choosePersistentFile() o reopenSavedFile() desde sus respectivos manejadores de clic. Captura en esos
manejadores los errores de almacenamiento, de cancelación del selector y de acceso a archivos, y
muestra un estado útil. Los usuarios pueden borrar el almacenamiento del navegador, revocar el
permiso o mover el archivo, así que conserva siempre el botón de elección. Antes de reanudar una
subida, verifica que el archivo reabierto siga coincidiendo con la subida original.
Manejo de directorios
Habilitar las subidas de directorios te permite procesar jerarquías completas de archivos. El siguiente ejemplo recopila de forma recursiva los archivos de un directorio seleccionado por el usuario:
async function handleDirectoryUpload() {
if (typeof window.showDirectoryPicker !== 'function') {
throw new Error('Directory selection is unavailable in this browser')
}
try {
const dirHandle = await window.showDirectoryPicker()
const files = []
async function* getFilesRecursively(entry) {
for await (const handle of entry.values()) {
if (handle.kind === 'file') {
const file = await handle.getFile()
if (file) yield file
} else if (handle.kind === 'directory') {
yield* getFilesRecursively(handle)
}
}
}
for await (const file of getFilesRecursively(dirHandle)) {
files.push(file)
}
return files
} catch (err) {
if (err.name === 'AbortError') {
console.log('User cancelled directory selection')
} else if (err.name === 'SecurityError') {
console.error('Permission denied')
} else {
console.error('Error accessing directory:', err)
}
return []
}
}
Integración de arrastrar y soltar
Integra arrastrar y soltar para mejorar la experiencia de selección de archivos. El ejemplo de abajo muestra la comprobación de tipos de los elementos soltados y procesa tanto handles de archivo como objetos de archivo estándar:
function setupDragAndDrop(dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
dropZone.classList.add('drag-active')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-active')
})
dropZone.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('drag-active')
const items = Array.from(e.dataTransfer.items).filter((item) => item.kind === 'file')
try {
const handles = await Promise.all(
items.map((item) => item.getAsFileSystemHandle?.() ?? item.getAsFile()),
)
for (const handle of handles) {
if (!handle) continue
if (handle instanceof File) {
// Process a regular file dropped
console.log('Processing dropped file:', handle.name)
} else if (handle.kind === 'file') {
const file = await handle.getFile()
console.log('Processing file handle:', file.name)
} else if (handle.kind === 'directory') {
console.log('Processing directory:', handle.name)
}
}
} catch (err) {
console.error('Error processing dropped items:', err)
}
})
}
Manejo de archivos grandes
Para mantener acotada la memoria, consume cada fragmento antes de leer otro; no acumules los
fragmentos en un nuevo Blob. Proporciona un callback asíncrono consumeChunk que termine de procesar o
enviar cada fragmento antes de resolverse. Esto controla el búfer de la aplicación, no los búferes
internos del navegador.
async function handleLargeFile(fileHandle, consumeChunk) {
const file = await fileHandle.getFile()
const reader = file.stream().getReader()
let totalSize = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await consumeChunk(value)
totalSize += value.length
// Report progress
const progress = (totalSize / file.size) * 100
console.log(`Processing: ${progress.toFixed(2)}%`)
}
return totalSize
} catch (err) {
await reader.cancel(err).catch(() => {})
throw err
} finally {
reader.releaseLock()
}
}
Estrategias de manejo de errores
Asegúrate de que tus operaciones con archivos sean robustas mediante un manejo de errores completo. La estrategia de abajo comprueba los permisos, valida el acceso a los archivos y aborda los casos de error más comunes:
async function safeFileOperation(handle) {
if (!handle) {
throw new Error('No file handle provided')
}
try {
const permissionGranted = await verifyPermissions(handle)
if (!permissionGranted) {
throw new DOMException('Permission denied', 'NotAllowedError')
}
const file = await handle.getFile()
if (!file) {
throw new Error('Could not access file')
}
return file
} catch (err) {
switch (err.name) {
case 'NotFoundError':
console.error('File no longer exists')
break
case 'SecurityError':
console.error('Permission denied')
break
case 'NotAllowedError':
console.error('User denied permission')
break
default:
console.error('Unexpected error:', err)
}
throw err
}
}
La File System Access API transforma el manejo de archivos en la web con una integración directa con el sistema de archivos local. Aunque el soporte de los navegadores se limita a ciertos entornos, implementar la mejora progresiva garantiza que tus usuarios tengan una experiencia fiable en todas las plataformas.
Para implementaciones avanzadas de subida de archivos, considera explorar herramientas como Uppy y el protocolo tus para habilitar subidas resilientes y reanudables.
