clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / docs / recipes / file-handler.md
file-handler.md 3.8K rendered
1# File handling and import previews23Use this recipe when an installed desktop app should open a narrow, product-owned export format. The4normal `<input type="file">` import remains the portable fallback.5[Installed PWA file handling](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Associate_files_with_your_PWA)6is experimental and currently limited to Chromium-based browsers on desktop operating systems.78The recipe deliberately ends at an import draft:910```mermaid11flowchart LR12 OS["OS file launch"] --> Queue["launchQueue"]13 Picker["Ordinary file picker"] --> Validate["Validate metadata and limits"]14 Queue --> Validate15 Validate --> Parse["Parse product-owned content"]16 Parse --> Preview["Show preview"]17 Preview --> Confirm{"User confirms?"}18 Confirm -- "yes" --> Persist["Product persists data"]19 Confirm -- "no" --> Discard["Discard transient draft"]20```2122## 1. Add a narrow manifest association2324The generated starter has no `file_handlers` member. Add one only for a concrete import format:2526```json27{28 "file_handlers": [29 {30 "action": "./import/",31 "accept": {32 "application/json": [".field-notes.json"]33 }34 }35 ]36}37```3839Use exact MIME types and extensions—no wildcards. The action must be a prerendered route inside the40manifest scope so the import screen can open offline. Browsers read this association during install;41after changing it, refresh or reinstall the PWA and verify the operating-system association.4243## 2. Use one validator for launches and the picker4445```ts46import {47 type FileImportDraft,48 installFileLaunchConsumer,49 prepareFileImportDrafts,50} from "@nzip/lofi/recipes/file-handler";5152type ExportPreview = { title: string; rows: readonly unknown[] };5354const importOptions = {55 accept: { "application/json": [".field-notes.json"] },56 maxFiles: 1,57 maxFileBytes: 256_000,58 async parse(file: File): Promise<ExportPreview> {59 const value: unknown = JSON.parse(await file.text());60 if (!isFieldNotesExport(value)) throw new Error("invalid export");61 return value;62 },63};6465let preview: readonly FileImportDraft<ExportPreview>[] = [];6667const launched = installFileLaunchConsumer({68 ...importOptions,69 onDrafts: (drafts) => {70 preview = drafts;71 renderImportPreview(drafts);72 },73 onRejected: () => renderGenericImportError(),74});7576async function onPickerChange(files: FileList | null) {77 const result = await prepareFileImportDrafts(files ? [...files] : [], importOptions);78 if (result.ok) renderImportPreview(result.drafts);79 else renderGenericImportError();80}81```8283Register one `launchQueue` consumer early on the action route. The recipe's shared ownership seam84prevents an older development/HMR consumer from replacing a newer launch recipe. Do not register a85second raw queue consumer in the same window.8687The helper treats handles, names, MIME types, extensions, sizes, access errors, and parsed content88as untrusted. Rejections expose stable reason codes and an index, never file names or contents. A89denied handle becomes `unreadable-file`; invalid and oversized inputs never produce a partial draft.9091## 3. Confirm before persistence9293Keep returned drafts in transient component state. Render a preview and use a separate button such94as “Import these notes” for the application-owned database write. Neither `prepareFileImportDrafts`95nor `installFileLaunchConsumer` writes to Jazz, OPFS, the service worker, or the source file.9697Test direct navigation to the action route, an installed file launch, an ordinary picker import,98offline startup, denied access, malformed content, invalid metadata, oversized files, cancellation,99and a browser without `launchQueue`. Removing `file_handlers` should leave the picker flow intact;100reinstall or refresh the app before verifying that the OS association disappeared.