Manipulação persistente de arquivos com a File System Access API
Salve um handle de arquivo nativo no IndexedDB para permitir que um usuário que retorna reabra um arquivo local sem selecioná-lo novamente. Esta demonstração somente de leitura inclui a página, a inicialização, os botões e a recuperação de permissões. Ela lê arquivos localmente; para retomar um upload, também seria necessário salvar o estado do upload e usar um protocolo de servidor compatível.
Compatibilidade com navegadores e melhoria progressiva
Use um navegador desktop que exponha showOpenFilePicker para o fluxo com persistência.
A demonstração foi testada no Linux com Chromium 145.0.7632.6, e sua alternativa com um campo de
seleção de arquivo, com Firefox 146.0.1. Consulte a
tabela de compatibilidade do seletor
para os navegadores que você pretende atender. O suporte ao IndexedDB ou ao sistema de arquivos
privado da origem (OPFS) não implica suporte à seleção de arquivos no disco do usuário.
A alternativa lê um File selecionado durante esta visita. Ela não pode
persistir um handle nativo nem reabrir o arquivo após recarregar a página. A página deixa essa
diferença visível e desabilita o botão de reabertura.
Configurar a demonstração local
Use Node.js 26.8.1 e Corepack 0.34.5 para os comandos abaixo. O Corepack é um pré-requisito separado no Node 26; siga as instruções de instalação do Corepack se ele não estiver instalado. Este exemplo fixa as versões Yarn 4.12.0 e Vite 8.3.2. O Vite compila o TypeScript do navegador; não há receptor de uploads nem acesso a arquivos no lado do servidor.
No Bash, verifique os pré-requisitos antes de criar um projeto. O subshell mantém seu terminal no diretório original. Se o nome já existir, escolha outro nome; não sobrescreva essa pasta.
(
node --version && corepack --version &&
mkdir file-handle-demo &&
cd file-handle-demo &&
touch yarn.lock
)
Abra um terminal na nova pasta file-handle-demo e salve nela os quatro arquivos a seguir.
O yarn.lock local estabelece um projeto Yarn separado, inclusive dentro de um
workspace existente. Se a configuração parou após criar a pasta, preserve-a e crie nela o arquivo de
lock vazio antes de continuar. Substitua apenas os arquivos desta nova demonstração, não a
configuração de uma aplicação existente.
Salve este conteúdo 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"
}
}
Salve este conteúdo como .yarnrc.yml para usar uma instalação local de
node_modules:
nodeLinker: node-modules
Salve este conteúdo como index.html. Todos os controles de arquivo começam
desabilitados até a inicialização terminar.
<!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>
Salve os dois blocos TypeScript abaixo em main.ts, na ordem apresentada.
As declarações descrevem o seletor nativo e os métodos de permissão para o TypeScript; a detecção de
recursos continua ocorrendo em tempo de execução.
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)'}`
}
O manipulador de reabertura chama requestPermission imediatamente durante o clique,
usando o handle carregado na inicialização. Ele nunca espera pelo IndexedDB antes de solicitar
permissão. Um handle armazenado pode durar mais que sua permissão, então uma resposta que não
conceda acesso interrompe a leitura.
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()
Instale, compile e inicie a prévia a partir dessa pasta. O encadeamento com
&& para se a instalação ou a compilação falhar. Se a instalação falhar,
corrija o problema informado e repita este comando; mantenha seus arquivos de código-fonte e qualquer
arquivo de lock existente. Pare o servidor em primeiro plano com Ctrl+C.
corepack yarn install && corepack yarn build && corepack yarn start
Abra http://127.0.0.1:4173/ no mesmo perfil de navegador durante todo o exercício. A
configuração de porta estrita informa um conflito em vez de alterar a origem silenciosamente.
Pare o serviço conflitante ou escolha outra porta em package.json e compile
novamente; essa nova origem tem seus próprios handles salvos.
Reabrir um arquivo e recuperar o acesso
Crie um hello.txt descartável contendo exatamente
hello, sem uma quebra de linha no final. Clique em
Choose file e selecione-o no seletor do sistema.
O resultado deve mostrar 5 bytes read. First 16 bytes (hex): 68 65 6c 6c 6f.
Recarregue a página e clique em Reopen saved file.
Conceda acesso de leitura se o navegador solicitar. O status passa a ser
Reopened hello.txt. somente após a conclusão da leitura.
Edite o arquivo descartável fora do navegador e reabra-o para ler seu conteúdo atual. O handle identifica um arquivo; ele não congela os bytes originais. Fechar e reiniciar o navegador pode exigir uma nova concessão de acesso, e limpar o armazenamento do site remove o handle salvo. As opções de permissão persistente do Chrome não tornam o acesso permanente um requisito desta demonstração.
Para testar a recuperação, feche e reinicie o navegador, clique no botão de reabertura e recuse a
solicitação de acesso de leitura. Você também pode remover o acesso a um arquivo nas configurações
do site no navegador antes de recarregar a página. Recusar ou dispensar uma solicitação impede que a
leitura seja concluída com sucesso. Na versão do Chromium testada, uma solicitação de leitura
recusada pode retornar prompt em vez de denied;
ambos interrompem este exemplo.
A página limpa o resultado anterior e mostra
Access not granted. Allow access or choose the file again..
Conceda acesso em um clique posterior ou selecione o arquivo novamente. Um handle salvo não contorna
essa escolha.
Mover ou excluir o arquivo descartável pode invalidar seu handle. Nesse caso, a reabertura mostra File unavailable. It may have moved or been deleted. Choose it again.. Cancelar o seletor mantém o handle salvo anteriormente, mas limpa o resultado de leitura exibido. Forget saved file remove a entrada do IndexedDB sem excluir nem modificar o arquivo no disco. Os controles de arquivo permanecem desabilitados durante cada operação, impedindo que cliques repetidos iniciem leituras simultâneas ou substituam seu status.
Considerações de segurança
Seletores nativos exigem um contexto seguro e um gesto do usuário. A URL de loopback funciona localmente; use HTTPS ao servir uma aplicação em produção. Um esquema, host ou porta diferente corresponde a outra origem, então retorne à URL exata para reutilizar a entrada desta aplicação no IndexedDB.
O IndexedDB pode armazenar handles nativos por meio de clonagem estruturada. JSON e localStorage não conseguem preservar suas capacidades. Esta demonstração solicita apenas permissão de leitura e nunca grava no arquivo selecionado. Um handle salvo não é um backup nem uma evidência de que o arquivo ainda corresponde a um upload iniciado anteriormente; verifique essa identidade antes de retomar qualquer transferência.
Manipulação de arquivos grandes
A demonstração consome cada bloco do fluxo antes de solicitar outro e mantém apenas uma prévia de
16 bytes. Ela não monta um Blob do arquivo inteiro nem usa arrayBuffer() para o
processamento. Isso limita a prévia mantida pela demonstração, não o buffer interno do navegador nem
a memória total do processo. Um arquivo vazio resulta em uma leitura bem-sucedida de zero bytes.
Para um consumidor de upload, aguarde a confirmação efetiva do bloco dentro de
consumeChunk; um handle por si só não oferece novas tentativas nem retomada.
A travessia de diretórios e a seleção por arrastar e soltar precisam de seus próprios pontos de entrada e tratamento de permissões. Elas estão fora do escopo deste exemplo de reabertura de um único arquivo. Mantenha o comportamento de inicialização, de operação em andamento e de falha ao adicionar esses fluxos. Para transferir o arquivo reaberto, o tus oferece um protocolo de upload retomável; persista o estado do upload separadamente do handle local.
