Persistente Dateiverwaltung mit der File System Access API
Speichern Sie ein natives Datei-Handle in IndexedDB, damit wiederkehrende Nutzer eine lokale Datei öffnen können, ohne sie erneut auszuwählen. Diese schreibgeschützte Demo umfasst die Seite, den Startvorgang, Schaltflächen und die Wiederherstellung von Zugriffsrechten. Sie liest Dateien lokal; zum Fortsetzen eines Uploads wären zusätzlich ein gespeicherter Upload-Status und ein kompatibles Serverprotokoll erforderlich.
Browserunterstützung und progressive Erweiterung
Nutzen Sie für den persistenten Ablauf einen Desktop-Browser, der
showOpenFilePicker bereitstellt. Die Demo wurde unter Linux mit Chromium 145.0.7632.6
getestet, die Ausweichlösung mit Dateieingabe mit Firefox 146.0.1. Prüfen Sie die
Kompatibilitätstabelle zur Dateiauswahl
für Ihre Zielbrowser. Die Unterstützung von IndexedDB oder dem Origin Private File System (OPFS)
belegt nicht, dass die Auswahl von Dateien auf dem Datenträger des Nutzers unterstützt wird.
Die Ausweichlösung liest ein ausgewähltes File für diesen Besuch.
Sie kann kein natives Handle dauerhaft speichern und die Datei nach dem Neuladen nicht erneut
öffnen. Die Seite macht diesen Unterschied sichtbar und deaktiviert die Schaltfläche zum erneuten
Öffnen.
Lokale Demo einrichten
Verwenden Sie Node.js 26.8.1 und Corepack 0.34.5 für die folgenden Befehle. Corepack ist unter Node 26 eine separate Voraussetzung; folgen Sie der Installationsanleitung für Corepack, falls es fehlt. Dieses Beispiel legt Yarn 4.12.0 und Vite 8.3.2 als Versionen fest. Vite kompiliert das TypeScript für den Browser; es gibt weder einen Upload-Empfänger noch serverseitigen Dateizugriff.
Prüfen Sie in Bash die Voraussetzungen, bevor Sie ein Projekt erstellen. Durch die Subshell bleibt Ihr Terminal im ursprünglichen Verzeichnis. Falls der Name bereits existiert, wählen Sie einen anderen Namen; überschreiben Sie diesen Ordner nicht.
(
node --version && corepack --version &&
mkdir file-handle-demo &&
cd file-handle-demo &&
touch yarn.lock
)
Öffnen Sie ein Terminal im neuen Ordner file-handle-demo und speichern Sie dort die
folgenden vier Dateien. Die lokale Datei yarn.lock richtet ein separates
Yarn-Projekt ein, auch innerhalb eines bestehenden Workspaces. Falls die Einrichtung nach dem
Erstellen des Ordners abgebrochen wurde, behalten Sie ihn bei und erstellen Sie dort die leere
Lockdatei, bevor Sie fortfahren. Ersetzen Sie nur die Dateien dieser neuen Demo, nicht die
Konfiguration einer bestehenden Anwendung.
Speichern Sie dies als 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"
}
}
Speichern Sie dies als .yarnrc.yml, um eine lokale Installation von
node_modules zu verwenden:
nodeLinker: node-modules
Speichern Sie dies als index.html. Alle Bedienelemente für Dateien sind zunächst
deaktiviert, bis die Initialisierung abgeschlossen ist.
<!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>
Speichern Sie die beiden folgenden TypeScript-Blöcke in der angegebenen Reihenfolge in
main.ts. Die Deklarationen beschreiben für TypeScript die native Dateiauswahl
und die Berechtigungsmethoden; die Funktionserkennung erfolgt weiterhin zur Laufzeit.
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)'}`
}
Der Handler zum erneuten Öffnen ruft requestPermission unmittelbar beim Klick auf und
nutzt dabei das beim Start geladene Handle. Er wartet nie auf IndexedDB, bevor er die Berechtigung
anfordert. Ein gespeichertes Handle kann länger bestehen als seine Berechtigung. Wird der Zugriff
nicht gewährt, stoppt der Lesevorgang daher.
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()
Installieren Sie die Abhängigkeiten, erstellen Sie den Build und starten Sie die Vorschau aus diesem
Ordner. Die Befehlskette mit && stoppt, wenn die Installation oder der Build
fehlschlägt. Falls die Installation fehlschlägt, beheben Sie das gemeldete Problem und wiederholen Sie
diesen Befehl; behalten Sie Ihre Quelldateien und eine gegebenenfalls vorhandene Lockdatei bei.
Stoppen Sie den Server im Vordergrund mit Strg+C.
corepack yarn install && corepack yarn build && corepack yarn start
Öffnen Sie http://127.0.0.1:4173/ während der gesamten Übung im selben Browserprofil. Die
strikte Porteinstellung meldet einen Konflikt, statt unbemerkt den Ursprung zu ändern. Stoppen Sie
den Dienst, der den Port belegt, oder wählen Sie in package.json einen anderen Port
und erstellen Sie den Build erneut; dieser neue Ursprung hat eigene gespeicherte Handles.
Datei erneut öffnen und Zugriff wiederherstellen
Erstellen Sie eine Testdatei namens hello.txt, die exakt
hello ohne abschließenden Zeilenumbruch enthält. Klicken Sie auf
Choose file und wählen Sie die Datei im Systemdialog aus. Das Ergebnis sollte
5 bytes read. First 16 bytes (hex): 68 65 6c 6c 6f lauten.
Laden Sie die Seite neu und klicken Sie dann auf
Reopen saved file. Erlauben Sie den Lesezugriff,
falls der Browser danach fragt. Erst nach Abschluss des Lesevorgangs wechselt der Status zu
Reopened hello.txt..
Bearbeiten Sie die Testdatei außerhalb des Browsers und öffnen Sie sie dann erneut, um ihren aktuellen Inhalt zu lesen. Das Handle identifiziert eine Datei; es hält nicht die ursprünglichen Bytes fest. Nach dem Schließen und Neustarten des Browsers kann eine erneute Freigabe erforderlich sein. Das Löschen des Websitespeichers entfernt das gespeicherte Handle. Die Optionen für dauerhafte Berechtigungen in Chrome machen dauerhaften Zugriff nicht zur Voraussetzung für diese Demo.
Um die Wiederherstellung zu testen, schließen Sie den Browser und starten Sie ihn neu. Klicken Sie
dann auf die Schaltfläche zum erneuten Öffnen und lehnen Sie die Anfrage zum Lesezugriff ab. Sie
können einer Datei auch vor dem Neuladen in den Website-Einstellungen des Browsers den Zugriff
entziehen. Wenn Sie eine Anfrage ablehnen oder schließen, bleibt der Lesevorgang erfolglos. In der
getesteten Chromium-Version kann eine abgelehnte Leseanfrage prompt statt
denied zurückgeben; beides stoppt dieses Beispiel.
Die Seite löscht das vorherige Ergebnis und zeigt
Access not granted. Allow access or choose the file again. an.
Gewähren Sie den Zugriff bei einem späteren Klick oder wählen Sie die Datei erneut aus. Ein
gespeichertes Handle umgeht diese Entscheidung nicht.
Wenn Sie die Testdatei verschieben oder löschen, kann ihr Handle ungültig werden. Beim erneuten Öffnen erscheint dann File unavailable. It may have moved or been deleted. Choose it again.. Wenn Sie die Dateiauswahl abbrechen, bleibt das zuvor gespeicherte Handle erhalten, das angezeigte Leseergebnis wird jedoch gelöscht. Forget saved file entfernt den IndexedDB-Eintrag, ohne die Datei auf dem Datenträger zu löschen oder zu ändern. Die Bedienelemente für Dateien bleiben während jedes Vorgangs deaktiviert. So können wiederholte Klicks keine überlappenden Lesevorgänge starten oder deren Status ersetzen.
Sicherheitsaspekte
Native Dateiauswahldialoge erfordern einen sicheren Kontext und eine Nutzeraktion. Die Loopback-URL funktioniert lokal; verwenden Sie HTTPS, wenn Sie eine bereitgestellte App ausliefern. Ein anderes Schema, ein anderer Host oder Port bedeutet einen anderen Ursprung. Rufen Sie daher wieder exakt dieselbe URL auf, um den IndexedDB-Eintrag dieser App erneut zu nutzen.
IndexedDB kann native Handles speichern, und zwar mittels strukturiertem Klonen. JSON und localStorage können deren Funktionen nicht erhalten. Diese Demo fordert nur Leserechte an und schreibt niemals in die ausgewählte Datei. Ein gespeichertes Handle ist weder eine Sicherung noch ein Beleg dafür, dass die Datei weiterhin mit einem zuvor gestarteten Upload übereinstimmt. Prüfen Sie diese Identität, bevor Sie eine Übertragung fortsetzen.
Umgang mit großen Dateien
Die Demo verarbeitet jeden Stream-Block, bevor sie einen weiteren anfordert, und behält nur eine
Vorschau von 16 Bytes. Sie setzt weder ein Blob der gesamten Datei zusammen noch nutzt sie
arrayBuffer() zur Verarbeitung. Dies begrenzt die gespeicherte Vorschau der Demo,
nicht die interne Pufferung des Browsers oder den gesamten Prozessspeicher. Eine leere Datei gilt
als erfolgreicher Lesevorgang mit null Bytes. Warten Sie bei einem Upload auf die tatsächliche
Bestätigung des Blocks innerhalb von consumeChunk; ein Handle allein bietet keine
Funktion zum Wiederholen oder Fortsetzen.
Das Durchlaufen von Verzeichnissen und die Auswahl per Drag-and-drop benötigen eigene Einstiegspunkte und eine eigene Berechtigungsverwaltung. Sie sind nicht Teil dieses Beispiels zum erneuten Öffnen einer einzelnen Datei. Behalten Sie dessen Verhalten beim Start, während laufender Vorgänge und bei Fehlern bei, wenn Sie diese Abläufe ergänzen. Für die Übertragung der erneut geöffneten Datei bietet tus ein Protokoll für fortsetzbare Uploads; speichern Sie den Upload-Status dauerhaft und getrennt vom lokalen Handle.
