Gestión persistente de archivos con File System Access API
Guarda un handle nativo de archivo en IndexedDB para que un usuario que regrese pueda reabrir un archivo local sin volver a seleccionarlo. Esta demo de solo lectura incluye la página, el inicio, los botones y la recuperación de permisos. Lee archivos localmente; reanudar una subida requeriría, además, un estado de subida guardado y un protocolo de servidor compatible.
Compatibilidad de navegadores y mejora progresiva
Usa un navegador de escritorio que exponga showOpenFilePicker para la vía persistente.
La demo se probó en Linux con Chromium 145.0.7632.6, y su alternativa mediante un campo de selección
de archivos, con Firefox 146.0.1. Consulta la
tabla de compatibilidad del selector
para los navegadores que quieras admitir. La compatibilidad con IndexedDB o con el sistema de
archivos privado del origen (OPFS) no implica que se admita seleccionar archivos del disco del usuario.
La alternativa lee un File seleccionado durante esta visita. No puede
conservar un handle nativo ni reabrir el archivo tras recargar la página. La página muestra esta
diferencia y deshabilita el botón para reabrirlo.
Configura la demo local
Usa Node.js 26.8.1 y Corepack 0.34.5 para los comandos siguientes. Corepack es un requisito previo independiente en Node 26; sigue las instrucciones de instalación de Corepack si no está instalado. Este ejemplo fija las versiones Yarn 4.12.0 y Vite 8.3.2. Vite compila el TypeScript del navegador; no hay un receptor de subidas ni acceso a archivos del lado del servidor.
En Bash, verifica los requisitos previos antes de crear un proyecto. El subshell mantiene tu terminal en su directorio original. Si el nombre ya existe, elige otro; no sobrescribas esa carpeta.
(
node --version && corepack --version &&
mkdir file-handle-demo &&
cd file-handle-demo &&
touch yarn.lock
)
Abre una terminal en la nueva carpeta file-handle-demo y guarda allí los cuatro archivos
siguientes. El archivo local yarn.lock establece un proyecto de Yarn independiente,
incluso dentro de un espacio de trabajo existente. Si la configuración se detuvo tras crear la
carpeta, consérvala y crea allí el archivo de bloqueo vacío antes de continuar. Reemplaza solo los
archivos de esta nueva demo, no la configuración de una aplicación existente.
Guarda esto como package.json:
{
"name": "file-handle-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "vite build",
"start": "vite preview --host 127.0.0.1 --port 4173 --strictPort"
},
"dependencies": {
"idb-keyval": "6.3.0"
},
"devDependencies": {
"vite": "8.3.2"
}
}
Guarda esto como .yarnrc.yml para usar una instalación local de node_modules:
nodeLinker: node-modules
Guarda esto como index.html. Todos los controles de archivos comienzan deshabilitados
hasta que termina la inicialización.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Persistent file demo</title>
<style>
:root { color-scheme: light dark; font: 18px system-ui; }
main { max-width: 44rem; margin: 2rem auto; padding: 1rem; }
button, input { font: inherit; margin: .5rem 0; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<main>
<h1>Persistent file demo</h1>
<button id="choose" disabled>Choose file</button>
<button id="reopen" disabled>Reopen saved file</button>
<button id="forget" disabled>Forget saved file</button>
<p id="fallback" hidden>
<label for="file">Select file for this visit</label>
<input id="file" type="file" disabled />
</p>
<p id="status" role="status">Loading saved handle…</p>
<pre id="result" aria-label="File read result"></pre>
</main>
<script type="module" src="/main.ts"></script>
</body>
</html>
Guarda los dos bloques de TypeScript siguientes en main.ts, en orden. Las
declaraciones describen el selector nativo y los métodos de permisos para TypeScript; la detección
de funcionalidades sigue realizándose en tiempo de ejecución.
import { del, get, set } from 'idb-keyval'
type NativeFileHandle = FileSystemFileHandle & {
requestPermission(options: { mode: 'read' }): Promise<PermissionState>
}
declare global {
interface Window {
showOpenFilePicker?: (options: { multiple: false }) => Promise<NativeFileHandle[]>
}
}
function element<T extends HTMLElement>(id: string, kind: new () => T): T {
const value = document.getElementById(id)
if (!(value instanceof kind)) throw new Error(`Missing control: ${id}`)
return value
}
const choose = element('choose', HTMLButtonElement)
const reopen = element('reopen', HTMLButtonElement)
const forget = element('forget', HTMLButtonElement)
const input = element('file', HTMLInputElement)
const status = element('status', HTMLParagraphElement)
const result = element('result', HTMLPreElement)
const native = typeof window.showOpenFilePicker === 'function'
const key = 'selected-upload-file'
let savedHandle: NativeFileHandle | null = null
let ready = false
let busy = false
function controls(): void {
choose.hidden = !native
element('fallback', HTMLParagraphElement).hidden = native
choose.disabled = !ready || busy
input.disabled = !ready || busy
reopen.disabled = !ready || busy || !native || savedHandle === null
forget.disabled = !ready || busy || savedHandle === null
}
function failure(error: unknown): void {
const name = error instanceof Error ? error.name : ''
const messages: Record<string, string> = {
AbortError: 'Selection canceled. Previous saved file kept.',
NotAllowedError: 'Access not granted. Allow access or choose the file again.',
SecurityError: 'Use a secure page and click a file control to request access.',
NotFoundError: 'File unavailable. It may have moved or been deleted. Choose it again.',
}
status.textContent = messages[name] ?? 'Operation failed. Check storage access and try again.'
}
async function operation(work: () => Promise<void>): Promise<void> {
if (!ready || busy) return
busy = true
result.textContent = ''
status.textContent = 'Reading…'
controls()
try {
await work()
} catch (error) {
failure(error)
} finally {
busy = false
controls()
}
}
async function handleLargeFile(
fileHandle: Pick<FileSystemFileHandle, 'getFile'>,
consumeChunk: (chunk: Uint8Array) => Promise<void>,
): Promise<number> {
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
}
return totalSize
} catch (error) {
await reader.cancel(error).catch(() => {})
throw error
} finally {
reader.releaseLock()
}
}
async function readFile(handle: Pick<FileSystemFileHandle, 'getFile'>): Promise<string> {
let prefix: number[] = []
const bytes = await handleLargeFile(handle, async (chunk) => {
const remaining = 16 - prefix.length
if (remaining > 0) prefix = prefix.concat(Array.from(chunk.subarray(0, remaining)))
})
const hex = prefix.map((byte) => byte.toString(16).padStart(2, '0')).join(' ')
return `${bytes} bytes read. First 16 bytes (hex): ${hex || '(empty file)'}`
}
El manejador de reapertura llama a requestPermission de inmediato durante el clic, usando
el handle cargado al inicio. Nunca espera a IndexedDB antes de solicitar permiso. Un handle
almacenado puede perdurar más que su permiso, por lo que una respuesta que no lo conceda detiene
la lectura.
choose.addEventListener('click', () => {
void operation(async () => {
const picker = window.showOpenFilePicker
if (!picker) throw new Error('Native picker unavailable')
const [handle] = await picker.call(window, { multiple: false })
if (!handle) throw new Error('No file selected')
const summary = await readFile(handle)
status.textContent = 'Saving handle…'
await set(key, handle)
savedHandle = handle
result.textContent = summary
status.textContent = `Read ${handle.name}. Handle saved; reload and reopen it.`
})
})
reopen.addEventListener('click', () => {
void operation(async () => {
if (savedHandle === null) throw new Error('No saved handle')
const permission = await savedHandle.requestPermission({ mode: 'read' })
if (permission !== 'granted') {
throw new DOMException('Access not granted', 'NotAllowedError')
}
const summary = await readFile(savedHandle)
result.textContent = summary
status.textContent = `Reopened ${savedHandle.name}.`
})
})
forget.addEventListener('click', () => {
void operation(async () => {
await del(key)
savedHandle = null
status.textContent = 'Saved handle forgotten. Choose a file to start again.'
})
})
input.addEventListener('click', () => { input.value = '' })
input.addEventListener('cancel', () => {
result.textContent = ''
status.textContent = 'Selection canceled. Select a file when ready.'
})
input.addEventListener('change', () => {
const file = input.files?.[0]
if (!file) return
void operation(async () => {
const summary = await readFile({ getFile: async () => file })
result.textContent = summary
status.textContent = `Read ${file.name} for this visit. Select it again after reload.`
})
})
async function initialize(): Promise<void> {
try {
if (native) savedHandle = (await get<NativeFileHandle>(key)) ?? null
status.textContent = native
? savedHandle === null ? 'Choose a file to start.' : 'Saved handle loaded. Click Reopen saved file.'
: 'Native reopen unavailable. Select a file for this visit.'
} catch {
status.textContent = 'Saved handle could not be loaded. Choose a file to retry storage.'
} finally {
ready = true
controls()
}
}
void initialize()
Instala, compila e inicia la vista previa desde esa carpeta. La cadena &&
se detiene si falla la instalación o la compilación. Si falla la instalación, resuelve el problema
indicado y repite este comando; conserva tus archivos fuente y cualquier archivo de bloqueo
existente. Detén el servidor en primer plano con Ctrl+C.
corepack yarn install && corepack yarn build && corepack yarn start
Abre http://127.0.0.1:4173/ en el mismo perfil del navegador durante todo el ejercicio.
La configuración estricta del puerto informa de un conflicto en lugar de cambiar el origen sin
avisar. Detén el servicio en conflicto o elige otro puerto en package.json y vuelve
a compilar; ese nuevo origen tiene sus propios handles guardados.
Reabre un archivo y recupera el acceso
Crea un archivo de prueba hello.txt que contenga exactamente
hello sin un salto de línea final. Haz clic en
Choose file y selecciónalo en el selector del sistema.
El resultado debería decir 5 bytes read. First 16 bytes (hex): 68 65 6c 6c 6f.
Recarga la página y haz clic en Reopen saved file.
Concede acceso de lectura si el navegador lo solicita. El estado cambia a
Reopened hello.txt. solo cuando se completa la lectura.
Edita el archivo de prueba fuera del navegador y vuelve a abrirlo para leer su contenido actual. El handle identifica un archivo; no fija los bytes originales. Cerrar y reiniciar el navegador puede requerir que vuelvas a conceder permiso, y borrar el almacenamiento del sitio elimina el handle guardado. Las opciones de permisos persistentes de Chrome no hacen del acceso permanente un requisito de esta demo.
Para probar la recuperación, cierra y reinicia el navegador, haz clic en el botón para reabrir el
archivo y rechaza la solicitud de acceso de lectura. También puedes quitar el acceso a un archivo
en la configuración del sitio del navegador antes de recargar. Rechazar o descartar una solicitud
impide que la lectura se complete correctamente. En la versión de Chromium probada, una solicitud
de lectura rechazada puede devolver prompt en lugar de
denied; ambas respuestas detienen este ejemplo.
La página borra el resultado anterior y muestra
Access not granted. Allow access or choose the file again..
Concede acceso en un clic posterior o vuelve a seleccionar el archivo. Un handle guardado no elude
esa decisión.
Mover o eliminar el archivo de prueba puede invalidar su handle. Al reabrirlo, se muestra File unavailable. It may have moved or been deleted. Choose it again.. Cancelar el selector conserva el handle guardado anterior, pero borra el resultado de lectura visible. Forget saved file elimina la entrada de IndexedDB sin eliminar ni modificar el archivo del disco. Los controles de archivos permanecen deshabilitados durante cada operación para evitar que los clics repetidos inicien lecturas simultáneas o reemplacen su estado.
Consideraciones de seguridad
Los selectores nativos requieren un contexto seguro y un gesto del usuario. La URL de loopback funciona localmente; usa HTTPS al servir una aplicación desplegada. Un esquema, host o puerto diferente implica un origen diferente, así que vuelve a la URL exacta para reutilizar la entrada de IndexedDB de esta aplicación.
IndexedDB puede almacenar handles nativos mediante clonación estructurada. JSON y localStorage no pueden conservar sus capacidades. Esta demo solicita únicamente permiso de lectura y nunca escribe en el archivo seleccionado. Un handle guardado no es una copia de seguridad ni una prueba de que el archivo siga coincidiendo con una subida iniciada anteriormente; verifica esa identidad antes de reanudar cualquier transferencia.
Manejo de archivos grandes
La demo consume cada fragmento del flujo antes de solicitar otro y conserva solo una vista previa
de 16 bytes. No construye un Blob con el archivo completo ni usa arrayBuffer() para
el procesamiento. Esto limita la vista previa que conserva la demo, no el almacenamiento en búfer
interno del navegador ni la memoria total del proceso. Un archivo vacío produce una lectura
correcta de cero bytes. Para un consumidor que realice subidas, espera la confirmación real del
fragmento dentro de consumeChunk; un handle por sí solo no ofrece funciones de
reintento ni de reanudación.
El recorrido de directorios y la selección mediante arrastrar y soltar necesitan sus propios puntos de entrada y manejo de permisos. Quedan fuera de este ejemplo de reapertura de un único archivo. Conserva su comportamiento de inicio, de operación en curso y de fallo al añadir esas vías. Para transferir el archivo reabierto, el protocolo tus permite realizar subidas reanudables; guarda el estado de la subida por separado del handle local.
