clone

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

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