clone

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

feat/prf-provision-custody
lofi / package / recipes / file-handler.ts
file-handler.ts 8.4K
1/**2 * Opt-in helpers for turning file launches or ordinary picker files into3 * bounded, validated import drafts.4 *5 * @module6 */78import {9 type InstalledAppLaunchQueue,10 type InstalledLaunchConsumer,11 installInstalledAppLaunchQueueConsumer,12} from "./launch-handler.ts";1314/** A minimal read-only file handle delivered by an installed-app launch. */15export type InstalledAppFileHandle = {16 readonly kind: "file";17 readonly name: string;18 getFile(): Promise<File>;19};2021/** Explicit MIME-to-extension allow-list shared by the manifest and importer. */22export type FileImportAccept = Readonly<Record<string, readonly string[]>>;2324/** Stable rejection that never contains a received file name or content. */25export type FileImportIssue =26 | "empty-selection"27 | "too-many-files"28 | "invalid-handle"29 | "unreadable-file"30 | "invalid-name"31 | "unsupported-type"32 | "unsupported-extension"33 | "file-too-large"34 | "selection-too-large"35 | "invalid-content";3637/** One parsed file ready for product-owned preview, but not persistence. */38export type FileImportDraft<Parsed> = {39 readonly file: File;40 readonly name: string;41 readonly type: string;42 readonly size: number;43 readonly parsed: Parsed;44};4546/** Accepted import drafts or a value-free rejection. */47export type FileImportResult<Parsed> =48 | { ok: true; drafts: readonly FileImportDraft<Parsed>[] }49 | { ok: false; issue: FileImportIssue; index?: number };5051/** Limits and product-owned content parser for preparing import drafts. */52export type PrepareFileImportOptions<Parsed> = {53 /** Exact MIME types and lowercase extensions the product understands. */54 accept: FileImportAccept;55 /** Maximum number of files accepted in one user action. */56 maxFiles: number;57 /** Maximum bytes accepted for any one file. */58 maxFileBytes: number;59 /** Maximum bytes accepted across the selection. Defaults to maxFiles * maxFileBytes. */60 maxTotalBytes?: number;61 /** Parse and validate file content for preview. Throw to reject it. */62 parse(file: File): Parsed | Promise<Parsed>;63};6465/** Options for consuming installed file launches into preview-only drafts. */66export type InstallFileLaunchConsumerOptions<Parsed> = PrepareFileImportOptions<Parsed> & {67 /** Override the browser queue for tests. */68 queue?: InstalledAppLaunchQueue;69 /** Receives only fully validated drafts. The product must still ask before persistence. */70 onDrafts(drafts: readonly FileImportDraft<Parsed>[]): void;71 /** Receives a value-free reason for a rejected launch. */72 onRejected?(issue: FileImportIssue, index?: number): void;73};7475function isPositiveSafeInteger(value: number): boolean {76 return Number.isSafeInteger(value) && value > 0;77}7879function validateOptions<Parsed>(options: PrepareFileImportOptions<Parsed>): {80 accept: Map<string, Set<string>>;81 maxTotalBytes: number;82} {83 if (!isPositiveSafeInteger(options.maxFiles) || !isPositiveSafeInteger(options.maxFileBytes)) {84 throw new TypeError("file import limits must be positive safe integers");85 }86 const maxTotalBytes = options.maxTotalBytes ?? options.maxFiles * options.maxFileBytes;87 if (!isPositiveSafeInteger(maxTotalBytes)) {88 throw new TypeError("file import total limit must be a positive safe integer");89 }90 const accept = new Map<string, Set<string>>();91 for (const [type, extensions] of Object.entries(options.accept)) {92 if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(type)) {93 throw new TypeError("file import MIME types must be explicit lowercase types");94 }95 if (!Array.isArray(extensions) || extensions.length === 0) {96 throw new TypeError("each file import MIME type needs extensions");97 }98 const normalized = new Set<string>();99 for (const extension of extensions) {100 if (!/^\.[a-z0-9][a-z0-9.+_-]*$/.test(extension)) {101 throw new TypeError("file import extensions must be explicit lowercase dot extensions");102 }103 normalized.add(extension);104 }105 if (normalized.size !== extensions.length) {106 throw new TypeError("file import extensions must not repeat within a MIME type");107 }108 accept.set(type, normalized);109 }110 if (accept.size === 0) throw new TypeError("file import accept list must not be empty");111 return { accept, maxTotalBytes };112}113114function validName(name: string): boolean {115 return name.length > 0 && name.length <= 255 && name !== "." && name !== ".." &&116 !/[\\/]/.test(name) &&117 ![...name].some((character) => {118 const code = character.codePointAt(0)!;119 return code <= 0x1f || code === 0x7f;120 });121}122123function hasAcceptedExtension(name: string, extensions: ReadonlySet<string>): boolean {124 const normalized = name.toLowerCase();125 return [...extensions].some((extension) =>126 normalized.length > extension.length && normalized.endsWith(extension)127 );128}129130function isInstalledFileHandle(value: unknown): value is InstalledAppFileHandle {131 return value !== null && typeof value === "object" &&132 (value as { kind?: unknown }).kind === "file" &&133 typeof (value as { name?: unknown }).name === "string" &&134 typeof (value as { getFile?: unknown }).getFile === "function";135}136137async function readInput(input: File | InstalledAppFileHandle): Promise<138 | { ok: true; file: File }139 | { ok: false; issue: FileImportIssue }140> {141 if (input instanceof File) return { ok: true, file: input };142 if (!isInstalledFileHandle(input)) return { ok: false, issue: "invalid-handle" };143 if (!validName(input.name)) return { ok: false, issue: "invalid-name" };144 try {145 const file = await input.getFile();146 if (!(file instanceof File)) return { ok: false, issue: "invalid-handle" };147 if (file.name !== input.name) return { ok: false, issue: "invalid-name" };148 return { ok: true, file };149 } catch {150 return { ok: false, issue: "unreadable-file" };151 }152}153154/**155 * Validate picker files or installed-app handles and parse them into drafts.156 *157 * The function performs no writes. Keep the returned drafts in transient UI158 * state, show a preview, and persist only after an explicit user confirmation.159 */160export async function prepareFileImportDrafts<Parsed>(161 inputs: readonly (File | InstalledAppFileHandle)[],162 options: PrepareFileImportOptions<Parsed>,163): Promise<FileImportResult<Parsed>> {164 const configured = validateOptions(options);165 if (inputs.length === 0) return { ok: false, issue: "empty-selection" };166 if (inputs.length > options.maxFiles) return { ok: false, issue: "too-many-files" };167168 const drafts: FileImportDraft<Parsed>[] = [];169 let totalBytes = 0;170 for (const [index, input] of inputs.entries()) {171 const read = await readInput(input);172 if (!read.ok) return { ok: false, issue: read.issue, index };173 const file = read.file;174 if (!validName(file.name)) return { ok: false, issue: "invalid-name", index };175 const extensions = configured.accept.get(file.type);176 if (!extensions) return { ok: false, issue: "unsupported-type", index };177 if (!hasAcceptedExtension(file.name, extensions)) {178 return { ok: false, issue: "unsupported-extension", index };179 }180 if (file.size > options.maxFileBytes) {181 return { ok: false, issue: "file-too-large", index };182 }183 totalBytes += file.size;184 if (totalBytes > configured.maxTotalBytes) {185 return { ok: false, issue: "selection-too-large", index };186 }187 let parsed: Parsed;188 try {189 parsed = await options.parse(file);190 } catch {191 return { ok: false, issue: "invalid-content", index };192 }193 drafts.push({ file, name: file.name, type: file.type, size: file.size, parsed });194 }195 return { ok: true, drafts };196}197198/** Feature-detect `launchQueue` and turn file launches into validated drafts. */199export function installFileLaunchConsumer<Parsed>(200 options: InstallFileLaunchConsumerOptions<Parsed>,201): InstalledLaunchConsumer {202 validateOptions(options);203 let generation = 0;204 const consumer = installInstalledAppLaunchQueueConsumer({205 queue: options.queue,206 onLaunch(parameters) {207 const current = ++generation;208 const inputs = parameters.files ?? [];209 void prepareFileImportDrafts(210 inputs as unknown as readonly InstalledAppFileHandle[],211 options,212 ).then(213 (result) => {214 if (current !== generation) return;215 if (result.ok) options.onDrafts(result.drafts);216 else options.onRejected?.(result.issue, result.index);217 },218 );219 },220 });221 return {222 supported: consumer.supported,223 dispose() {224 generation++;225 consumer.dispose();226 },227 };228}