Gestion persistante des fichiers avec la File System Access API
Enregistrez un handle de fichier natif dans IndexedDB pour permettre à un utilisateur qui revient de rouvrir un fichier local sans le sélectionner à nouveau. Cette démo en lecture seule comprend la page, le démarrage, les boutons et le rétablissement des autorisations. Elle lit les fichiers localement ; reprendre un téléversement nécessiterait aussi de sauvegarder son état et d’utiliser un protocole serveur compatible.
Prise en charge par les navigateurs et amélioration progressive
Utilisez un navigateur de bureau exposant showOpenFilePicker pour le fonctionnement
avec persistance. La démo a été testée sous Linux avec Chromium 145.0.7632.6, et sa solution de
repli utilisant un champ de sélection de fichier avec Firefox 146.0.1. Consultez le
tableau de compatibilité du sélecteur
pour vos navigateurs cibles. La prise en charge d’IndexedDB ou du système de fichiers privé de
l’origine (OPFS) ne prouve pas que la sélection de fichiers sur le disque de l’utilisateur est
prise en charge.
La solution de repli lit un File sélectionné pour cette visite. Elle ne
peut ni conserver un handle natif ni rouvrir le fichier après rechargement. La page rend cette
différence visible et désactive son bouton de réouverture.
Configurer la démo locale
Utilisez Node.js 26.8.1 et Corepack 0.34.5 pour les commandes ci-dessous. Corepack est un prérequis à installer séparément avec Node 26 ; suivez les instructions d’installation de Corepack s’il est absent. Cet exemple fixe les versions de Yarn à 4.12.0 et de Vite à 8.3.2. Vite compile le TypeScript exécuté dans le navigateur ; aucun composant ne reçoit de téléversements et aucun accès aux fichiers ne se fait côté serveur.
Dans Bash, vérifiez les prérequis avant de créer un projet. Le sous-shell laisse votre terminal dans son répertoire d’origine. Si le nom existe déjà, choisissez-en un autre ; n’écrasez pas ce dossier.
(
node --version && corepack --version &&
mkdir file-handle-demo &&
cd file-handle-demo &&
touch yarn.lock
)
Ouvrez un terminal dans le nouveau dossier file-handle-demo et enregistrez-y les
quatre fichiers suivants. Le fichier local yarn.lock crée un projet Yarn
séparé, y compris au sein d’un espace de travail existant. Si la configuration s’est arrêtée
après la création du dossier, conservez-le et créez-y le fichier de verrouillage vide avant de
continuer. Remplacez uniquement les fichiers de cette nouvelle démo, et non la configuration
d’une application existante.
Enregistrez ceci sous 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"
}
}
Enregistrez ceci sous .yarnrc.yml pour utiliser une installation locale de
node_modules :
nodeLinker: node-modules
Enregistrez ceci sous index.html. Toutes les commandes liées aux fichiers
sont initialement désactivées jusqu’à la fin de l’initialisation.
<!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>
Enregistrez les deux blocs TypeScript ci-dessous dans main.ts, dans l’ordre.
Les déclarations décrivent le sélecteur natif et les méthodes d’autorisation pour TypeScript ;
la détection des fonctionnalités se fait toujours à l’exécution.
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)'}`
}
Le gestionnaire de réouverture appelle requestPermission immédiatement lors du clic,
en utilisant le handle chargé au démarrage. Il n’attend jamais IndexedDB avant de demander
l’autorisation. Un handle enregistré peut subsister plus longtemps que son autorisation ;
si celle-ci n’est pas accordée, la lecture s’arrête.
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()
Installez les dépendances, compilez le projet et démarrez la prévisualisation depuis ce dossier.
L’enchaînement avec && s’arrête si l’installation ou la compilation
échoue. Si l’installation échoue, corrigez le problème signalé et répétez cette commande ;
conservez vos fichiers sources et tout fichier de verrouillage existant. Arrêtez le serveur
au premier plan avec Ctrl+C.
corepack yarn install && corepack yarn build && corepack yarn start
Ouvrez http://127.0.0.1:4173/ en gardant le même profil de navigateur pendant tout
l’exercice. Le réglage imposant le port signale un conflit au lieu de changer silencieusement
l’origine. Arrêtez le service en conflit ou choisissez un autre port dans
package.json et recompilez ; cette nouvelle origine possède ses propres
handles enregistrés.
Rouvrir un fichier et rétablir l’accès
Créez un fichier jetable hello.txt contenant exactement
hello, sans saut de ligne final. Cliquez sur
Choose file et sélectionnez-le dans le sélecteur système. Le résultat devrait
indiquer 5 bytes read. First 16 bytes (hex): 68 65 6c 6c 6f.
Rechargez la page, puis cliquez sur Reopen saved file. Accordez l’accès en lecture
si le navigateur le demande. L’état devient Reopened hello.txt.
uniquement une fois la lecture terminée.
Modifiez le fichier jetable en dehors du navigateur, puis rouvrez-le pour lire son contenu actuel. Le handle désigne un fichier ; il ne fige pas ses octets d’origine. Fermer et redémarrer le navigateur peut nécessiter une nouvelle autorisation, et effacer les données du site supprime le handle enregistré. Les options d’autorisation persistante de Chrome ne font pas de l’accès permanent un prérequis de cette démo.
Pour tester le rétablissement de l’accès, fermez et redémarrez le navigateur, puis cliquez sur
le bouton de réouverture et refusez sa demande d’accès en lecture. Vous pouvez aussi retirer
l’autorisation d’accès à un fichier dans les paramètres du site du navigateur avant de recharger
la page. Refuser ou fermer une demande empêche la lecture d’aboutir. Dans la version de Chromium
testée, une demande de lecture refusée peut renvoyer prompt plutôt que
denied ; les deux arrêtent cet exemple.
La page efface le résultat précédent et affiche
Access not granted. Allow access or choose the file again..
Accordez l’accès lors d’un clic ultérieur ou sélectionnez à nouveau le fichier. Un handle
enregistré ne permet pas de contourner ce choix.
Déplacer ou supprimer le fichier jetable peut invalider son handle. La réouverture affiche alors File unavailable. It may have moved or been deleted. Choose it again.. Annuler le sélecteur conserve le handle précédemment enregistré, mais efface le résultat de lecture affiché. Forget saved file supprime l’entrée IndexedDB sans supprimer ni modifier le fichier sur le disque. Les commandes liées aux fichiers restent désactivées pendant chaque opération, ce qui empêche les clics répétés de lancer des lectures qui se chevauchent ou de remplacer leur état.
Considérations de sécurité
Les sélecteurs natifs nécessitent un contexte sécurisé et un geste de l’utilisateur. L’URL de bouclage fonctionne localement ; utilisez HTTPS pour servir une application déployée. Un schéma, un hôte ou un port différent correspond à une origine différente ; revenez donc à l’URL exacte pour réutiliser l’entrée IndexedDB de cette application.
IndexedDB peut stocker des handles natifs par clonage structuré. JSON et localStorage ne peuvent pas préserver leurs capacités. Cette démo demande uniquement l’autorisation de lecture et n’écrit jamais dans le fichier sélectionné. Un handle enregistré n’est ni une sauvegarde ni la preuve que le fichier correspond toujours à un téléversement précédemment commencé ; vérifiez cette identité avant de reprendre tout transfert.
Gestion des fichiers volumineux
La démo consomme chaque bloc du flux avant d’en demander un autre et ne conserve qu’un aperçu
de 16 octets. Elle n’assemble pas de Blob contenant le fichier entier et n’utilise pas
arrayBuffer() pour le traitement. Cela limite la taille de l’aperçu conservé
par la démo, mais pas la mise en mémoire tampon interne du navigateur ni la mémoire totale du
processus. Un fichier vide donne une lecture réussie de zéro octet. Dans un composant chargé du
téléversement, attendez l’accusé de réception effectif du bloc dans
consumeChunk ; un handle seul ne permet ni de réessayer ni de reprendre le transfert.
Le parcours des répertoires et la sélection par glisser-déposer nécessitent leurs propres points d’entrée et leur propre gestion des autorisations. Ils sortent du cadre de cet exemple de réouverture d’un seul fichier. Conservez son comportement au démarrage, pendant les opérations et en cas d’échec lorsque vous ajoutez ces parcours. Pour transférer le fichier rouvert, tus fournit un protocole de téléversement avec reprise ; conservez l’état du téléversement séparément du handle local.
