UNPKG

@mjackson/file-storage

Version:
184 lines (147 loc) 5.3 kB
import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; import * as path from 'node:path'; import { openFile, writeFile } from '@mjackson/lazy-file/fs'; import type { FileStorage, FileMetadata, ListOptions, ListResult } from './file-storage.ts'; type MetadataJson = Omit<FileMetadata, 'size'>; /** * A `FileStorage` that is backed by a directory on the local filesystem. * * Important: No attempt is made to avoid overwriting existing files, so the directory used should * be a new directory solely dedicated to this storage object. * * Note: Keys have no correlation to file names on disk, so they may be any string including * characters that are not valid in file names. Additionally, individual `File` names have no * correlation to names of files on disk, so multiple files with the same name may be stored in the * same storage object. */ export class LocalFileStorage implements FileStorage { #dirname: string; /** * @param directory The directory where files are stored */ constructor(directory: string) { this.#dirname = path.resolve(directory); try { let stats = fs.statSync(this.#dirname); if (!stats.isDirectory()) { throw new Error(`Path "${this.#dirname}" is not a directory`); } } catch (error) { if (!isNoEntityError(error)) { throw error; } fs.mkdirSync(this.#dirname, { recursive: true }); } } async get(key: string): Promise<File | null> { let { filePath, metaPath } = await this.#getPaths(key); try { let meta = await readMetadata(metaPath); return openFile(filePath, { lastModified: meta.lastModified, name: meta.name, type: meta.type, }); } catch (error) { if (!isNoEntityError(error)) { throw error; } return null; } } async has(key: string): Promise<boolean> { let { metaPath } = await this.#getPaths(key); try { await fsp.access(metaPath); return true; } catch { return false; } } async list<T extends ListOptions>(options?: T): Promise<ListResult<T>> { let { cursor, includeMetadata = false, limit = 32, prefix } = options ?? {}; let files: any[] = []; let foundCursor = cursor === undefined; let nextCursor: string | undefined; let lastHash: string | undefined; outerLoop: for await (let subdir of await fsp.opendir(this.#dirname)) { if (!subdir.isDirectory()) continue; for await (let file of await fsp.opendir(path.join(this.#dirname, subdir.name))) { if (!file.isFile() || !file.name.endsWith('.meta.json')) continue; let hash = file.name.slice(0, -10); // Remove ".meta.json" if (foundCursor) { let meta = await readMetadata(path.join(this.#dirname, subdir.name, file.name)); if (prefix != null && !meta.key.startsWith(prefix)) { continue; } if (files.length >= limit) { nextCursor = lastHash; break outerLoop; } if (includeMetadata) { let size = (await fsp.stat(path.join(this.#dirname, subdir.name, `${hash}.dat`))).size; files.push({ ...meta, size }); } else { files.push({ key: meta.key }); } } else if (hash === cursor) { foundCursor = true; } lastHash = hash; } } return { cursor: nextCursor, files, }; } async put(key: string, file: File): Promise<File> { await this.set(key, file); return (await this.get(key))!; } async remove(key: string): Promise<void> { let { filePath, metaPath } = await this.#getPaths(key); try { await Promise.all([fsp.unlink(filePath), fsp.unlink(metaPath)]); } catch (error) { if (!isNoEntityError(error)) { throw error; } } } async set(key: string, file: File): Promise<void> { let { directory, filePath, metaPath } = await this.#getPaths(key); // Ensure directory exists await fsp.mkdir(directory, { recursive: true }); await writeFile(filePath, file); let meta: MetadataJson = { key, lastModified: file.lastModified, name: file.name, type: file.type, }; await fsp.writeFile(metaPath, JSON.stringify(meta)); } async #getPaths(key: string): Promise<{ directory: string; filePath: string; metaPath: string }> { let hash = await computeHash(key); let directory = path.join(this.#dirname, hash.slice(0, 2)); return { directory, filePath: path.join(directory, `${hash}.dat`), metaPath: path.join(directory, `${hash}.meta.json`), }; } } async function readMetadata(metaPath: string): Promise<MetadataJson> { return JSON.parse(await fsp.readFile(metaPath, 'utf-8')); } async function computeHash(key: string, algorithm = 'SHA-256'): Promise<string> { let digest = await crypto.subtle.digest(algorithm, new TextEncoder().encode(key)); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } function isNoEntityError(obj: unknown): obj is NodeJS.ErrnoException & { code: 'ENOENT' } { return obj instanceof Error && 'code' in obj && (obj as NodeJS.ErrnoException).code === 'ENOENT'; }