Manipulando arquivos no frontend com Origin Private File System (OPFS) nativamente

O que é a OPFS? Por que existe?

A OPFS é um conjunto de funções nativas do navegador para manipular arquivos. A melhor tradução livre seria “sistema de arquivos privados por origem”. Dissecaremos isso por um momento. Por sistema de arquivos, imagine a comum analogia de criar pastas, ler arquivos, coisas assim. E com privados por origem, entenda que cada domínio possui seu próprio sistema de arquivos.

Assim, um primeiro domínio foo.com.br tem seus diretórios, subdiretórios e arquivos na sua OPFS. E bar.com.br tem os seus em um escopo diferente. Esses conteúdos não são acessíveis entre si, um site não pode ler a OPFS do outro. E vai além: o browser garante também que a OPFS não acessa os arquivos normais do usuário, ou seja, essa API não consegue ver seus downloads, fotos e documentos (relaxe).

A vantagem desse isolamento todo, é que que você não precisa pedir permissão do usuário, porque não há nada de terrível possível. Então sem “popups” na cara da pessoa alarmando sobre uso de arquivos, sem perguntar “salvar como” e afins.

Outro LocalStorage então? 🤔

Se você desenvolve em frontend, já deve estar familiarizado com o clássico LocalStorage para persistências mais perenes. As duas ideias têm sua similaridade, mas os casos de uso e os níveis das APIs são muito diferentes. A começar que OPFS é uma API de abstração menor, ou seja, “código mais próximo da máquina”, mais verboso, mais detalhes a pensar. Use a OPFS se for realmente precisar de suas vantagens.

Na humilde opinião deste escritor, dois pontos são mais relevantes.

A primeira vantagem é quanto a volume. Cada browser tem sua política diferente, mas a OPFS é a que mais possui capacidade de armazenamento. Por exemplo, neste momento que vos escrevo, segundo a MDN, cada domínio consegue usar de 10% a 60% do tamanho total do disco da máquina inteira, enquanto LocalStorage é limitado a 5 MiB.

O segundo diferencial é que OPFS tem mais potencial de otimização, escalando mais — uma característica rara de gerar preocupação para client side. Um LocalStorage é, para quem programa, um grande documento guardando dados em chave-valor, sem índices configuráveis, então sua leitura e escrita são (com alguma sorte) otimizadas arbitrariamente pelo browser. Já a OPFS já está naturalmente em sharding, isto é, os dados estão separados e organizados, afinal é assim que a gente usa qualquer sistema de arquivos intuitivamente, você lê e escreve diretamente apenas no que quer. É como se os nomes de arquivos fossem os índices únicos dentro de uma árvore de pastas.

Mas e o IndexedDB?

Uma menção de honra é que até agora a melhor alternativa estava sendo o IndexedDB, bem popular para suporte offline-first. Porém embora sua ideia seja boa… na prática há tantos problemas que seria necessário um novo artigo inteiro para discutir. 🤐

Mas, resumidamente: desde sempre temos problemas de algumas sintaxes JS sem implementação nos browsers, regras CSS diferentes, APIs com polyfill, imagine então um gerenciador inteiro de banco de dados?! Embora OFPS seja muitíssimo mais “grosseira”, tem bem menos margem pra erros, é quase 1:1 com o sistema de arquivos do sistema operacional já ali disponível.

Crie pastas e arquivos pelo frontend 🧑🏽‍💻

Sim, foi muito debate arquitetural até agora. Mas mãos à obra. Criaremos uma pasta e escrevemos um arquivo JSON nela.

A base de tudo é a raiz do sistema de arquivos privado de seu domínio:

const opfsRoot = await navigator.storage.getDirectory();

A partir dessa root, criamos quantas subpastas quisermos (no exemplo, um subdiretório chamado "people/"):

const directoryHandle = await opfsRoot.getDirectoryHandle(
  "people",
  { create: true },
);

E dentro desse subdiretório, instanciaremos um proxy para um arquivo chamado "mazuh.json" para finalmente manipularmos seu conteúdo:

const filename = "mazuh.json";

const fileHandle = await directoryHandle.getFileHandle(filename, {
  create: true,
});

Até agora foi só boilerplate. O máximo de alteração que houve foi a criação de uma nova pasta e novo arquivo caso estes não existam, por causa das flags de create: true.

Implementeremos então uma função para cada operação básica, respectivamente ler, escrever e apagar o arquivo:

const readJsonFile = async () => {
  const file = await fileHandle.getFile();
  const text = await file.text();
  return text ? JSON.parse(text) : null;
};

const writeJsonFile = async (obj) => {
  const writableFileStream = await fileHandle.createWritable();

  try {
    await writableFileStream.write(JSON.stringify(obj));
  } finally {
    await writableFileStream.close();
  }
};

const removeFile = async () => {
  return directoryHandle.removeEntry(filename);
};
Ilustração de uso das funções criadas acima de ler, escrever e remover. Não é muito necessária para a compreensão do texto.

Essencialmente é tudo async, pois como o código JS estará numa única thread lidando com coisas de UI, não queremos que I/O de disco trave o comportamento geral da aplicação web — ou seja, não queremos seu React lento porque o disco rígido da pessoa é lento.

Porém, há versões sync (ou, ao menos, que retornam o valor direto e não Promise) disso tudo, disponíveis para web workers em thread separada. Inclusive, o suporte à OPFS nesse contexto “síncrono” é até maior, há mais funções. Por exemplo, hoje no Safari, o código de exemplo acima não funciona completamente, pois não implementaram ainda a escrita por streams assincronamente.

E por falar em stream de escrita, note que essa operação em particular precisará de “fechar” manualmente o arquivo ao fim com método close, sinalizando ao sistema operacional que aquele arquivo já está disponível para uso.

E é isso. 💅🏽 Usando o Chrome 122 em diante, se copiar e colar esses snippets no console do navegador, as funções já estarão disponíveis pra uso.

Pessoalmente, acho muito “estranho” de sair copiando e colando esses códigos tão delicados de recursos compartilhados, sem falar das diferenças cruciais entre as operações (como a escrita precisar do close() , mas a leitura não). Por isso, é uma boa decisão arquitetural acessar a OPFS apenas usando funções customizadas como essas, impedindo que vazem esses detalhes tão delicados de comunicação entre máquinas para ambientes onde a lógica está focada na interação com humanos. 🧑🏻‍🔬

Eu iria além e faria um adaptador específico para cada tipo de recurso/arquivo, no nosso exemplo seria um módulo "people-opfs-service.js" talvez. Pois uma entidade pode permitir, por exemplo, só leitura e criação de arquivos como um backup para atividades analíticas, já outra entidade tem operações transacionais mais complexas ou até em massa para vários arquivos ao mesmo tempo. Enfim, se a aplicação é offline-first, ela precisa do “próprio backend interno” centralizando e focalizando a manutenção de lógicas importantes assim.

Listando conteúdos de um subdiretório

Já vimos como remover um arquivo a partir de um diretório, mas faltou dizer como listar quais arquivos estão dentro dele, afinal ninguém decora nomes hardcoded na vida real. Criaremos uma nova função customizada:

const listFilenames = async () => {
  const entries = directoryHandle.entries();

  const filenames = [];
  for await (const [filename] of entries) {
    filenames.push(filename);
  }

  return filenames;
};
Ilustração de uso da função criada para listagem. Não é muito necessária para a compreensão do texto.

Pode ser que alguém considere haver muito “karatê” aí nesse código.

É que esse método entries() retorna um “iterável prometido”, os elementos da lista não existem ainda e vão sendo carregados pelo await a cada passo do loop.

Cada elemento resolvido é uma tupla fazendo atribuição via desestruturação (destructuring assignment), como em [filename, fileHandler] = arr , porém a gente só quer o primeiro valor e ficou [filename] = arr mesmo. O segundo valor, que decidimos ignorar, seria o file handler para já ir ler e escrevendo aí durante o loop se fosse necessário.

Tudo isso vai alimentando a coleção de filenames retornada ao fim, que são strings normais.

😇 Em outras palavras, as entradas da OPFS são lazy loaded e nós queremos prover algo mais eager loaded que o resto do código consuma mais confortavelmente. Antes era uma “promessa” de que haveria valores, e então forçamos ela a se concretizar para a saída.

Cuidado para não esgulepar a OPFS 👩🏽‍🚒

Concentramos os exemplos em: listagem de arquivos e leitura/escrita/remoção de cada. E isso é praticamente todo o suporte útil hoje. Se você já usou a “mãe espiritual” da OPFS, a FS do Node, você deve ter sentido falta de outras operações como renomear arquivos, copiar e colar, fazer append e coisas assim. Tá faltando mesmo, essa API é nova. Mas, na prática, isso põe em risco a atomicidade de certas operações, porque por exemplo para renomear um arquivo temos que remover o antigo, adicionar seu conteúdo em um novo e rezar para que nada dê errado no meio do caminho deixando o estado inconsistente.

Naturalmente você pode fazer mais do que rezar, sim, mas na volta eu te conto. 👀

E por falar em transações, o uso concorrente pode ser problemático. Imagine múltiplas abas usando o mesmo arquivo, como gerenciar essas prioridades e manter a interface acurada para o usuário? Arquivos por si só não possuem esquema de transações nem supervisões como hooks avisando qual arquivo mudou. Uma solução até bem ok de começo é polling, checando arquivos de interesse de tempos em tempos para sincronizar. Outra mais dramática é apenas permitir que a pessoa use uma única aba por vez. Ou até só aceitar que certas inconsistências possam existir. Em geral quanto mais sua aplicação dependa do “modo offline”, mais rígidas serão suas decisões aqui.

Um cuidado bacana é evitar leaks. Vazamento de memória (e recursos de forma geral) é um risco quando se manipula uma API de baixa abstração assim. Os gatilhos mais comuns são esquecer de dar close na streams de escrita mesmo após erros e não implementar direito funções de cleanup de componentes de UI (como a saída de useEffect em React). O usuário vai usando, esses probleminhas vão acumulando, e quando você percebe tá tudo travando e só resolve fechando e abrindo de novo.

E por fim, lembre que há um motivo para dizermos que a memória RAM é a memória principal em estudos sobre organização e arquitetura de computadores. Ou seja, não trate levianamente o disco do usuário como se fosse uma “variável global”. Então imagine que queremos atualizar o conteúdo de um arquivo em tempo real a medida que o usuário digita as alterações num input: prefira proteger a função de edição com um debounce, para que 50 teclas digitadas não se tornem sempre 50 combos de leitura e escrita. Dá uma segurada aí.

Prova de conceito: Postmaiden ✨

Como já deve ter notado, o melhor caso de uso da OPFS é algo que dê um peso enorme ao requisito de ser offline-first, ou seja, prioritariamente funcione só com o lado cliente sem guardar dados em servidor externo, a parte online (se houver) sendo algo completamente opcional.

Dito isso, tudo o que foi explicado aqui foi aplicado no projeto Postmaiden, uma alternativa ao clássico Postman para testar APIs HTTP. Está sendo construído em público e gerando conteúdos técnicos como este. A ideia é projetar todas as funcionalidades para funcionar sem servidores intermediários, provendo mais “privacidade industrial” para quem constrói e testa esses endpoints.

Demonstração do Postmaiden funcionando. Há um teste de requisição HTTP para uma API gratuita que gera fatos aleatórios sobre gatos. Mostra um 200 OK, com o corpo "gatos possuem audição supersônica" num JSON, dados básicos de performance indicando que o tempo de resposta foi de 98ms, método GET usado e cabeçalhos.

Bora contribuir também?

Ainda há desafios a serem resolvidos, não só com a OPFS. O código é tá aberto e você pode ver isso tudo sendo posto em prática. Então é só me procurar lá no X (ex-Twitter) por @marcell_mz se quiser ajudar ou abrir uma issue no GitHub do Postmaiden.

Deixe um comentário