UNPKG

opfs-worker

Version:

A robust TypeScript library for working with Origin Private File System (OPFS) through Web Workers

724 lines (723 loc) 26.7 kB
import { expose as v } from "comlink"; import { m as h, d as p, O as o, n as u, s as w, P as S, i as d, o as b, y as C, F as f, p as g, j as x, e as F, f as m, q as A, v as I, r as H, g as $, u as O } from "./helpers-CTCvNFs1.js"; class T { /** Root directory handle for the file system */ root; /** Map of watched paths and options */ watchers = /* @__PURE__ */ new Map(); /** Promise to prevent concurrent mount operations */ mountingPromise = null; /** BroadcastChannel instance for sending events */ broadcastChannel = null; /** Configuration options */ options = { root: "/", namespace: "", maxFileSize: 50 * 1024 * 1024, hashAlgorithm: null, broadcastChannel: "opfs-worker" }; /** * Notify about internal changes to the file system * * This method is called by internal operations to notify clients about * changes, even when no specific paths are being watched. * * @param path - The path that was changed * @param type - The type of change (create, change, delete) */ async notifyChange(t) { if (!this.options.broadcastChannel) return; const e = t.path; if (![...this.watchers.values()].some((r) => h(e, r.pattern) && r.include.some((s) => s && h(e, s)) && !r.exclude.some((s) => s && h(e, s)))) return; let i; if (this.options.hashAlgorithm) try { i = (await this.stat(e)).hash; } catch { } try { this.broadcastChannel || (this.broadcastChannel = new BroadcastChannel(this.options.broadcastChannel)); const r = { namespace: this.options.namespace, timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...t, ...i && { hash: i } }; this.broadcastChannel.postMessage(r); } catch (r) { console.warn("Failed to send event via BroadcastChannel:", r); } } /** * Creates a new OPFSFileSystem instance * * @param options - Optional configuration options * @param options.root - Root path for the file system (default: '/') * @param options.watchInterval - Polling interval in milliseconds for file watching * @param options.hashAlgorithm - Hash algorithm for file hashing * @param options.maxFileSize - Maximum file size for hashing in bytes (default: 50MB) * @throws {OPFSError} If OPFS is not supported in the current browser */ constructor(t) { p(), t && this.setOptions(t); } /** * Initialize the file system within a given directory * * This method sets up the root directory for all subsequent operations. * If no root is specified, it will use the OPFS root directory. * * @param root - The root path for the file system (default: '/') * @returns Promise that resolves to true if initialization was successful * @throws {OPFSError} If initialization fails * * @example * ```typescript * const fs = new OPFSFileSystem(); * * // Use OPFS root (default) * await fs.mount(); * * // Use custom directory * await fs.mount('/my-app'); * ``` */ async mount() { const t = this.options.root; return this.mountingPromise && await this.mountingPromise, this.mountingPromise = new Promise(async (e, a) => { try { const i = await navigator.storage.getDirectory(); this.root = t === "/" ? i : await this.getDirectoryHandle(t, !0, i), e(!0); } catch (i) { a(new o("Failed to initialize OPFS", "INIT_FAILED", t, i)); } finally { this.mountingPromise = null; } }), this.mountingPromise; } /** * Update configuration options * * @param options - Configuration options to update * @param options.root - Root path for the file system * @param options.watchInterval - Polling interval in milliseconds for file watching * @param options.hashAlgorithm - Hash algorithm for file hashing * @param options.maxFileSize - Maximum file size for hashing in bytes * @param options.broadcastChannel - Custom name for the broadcast channel */ async setOptions(t) { t.hashAlgorithm !== void 0 && (this.options.hashAlgorithm = t.hashAlgorithm), t.maxFileSize !== void 0 && (this.options.maxFileSize = t.maxFileSize), t.broadcastChannel !== void 0 && (this.broadcastChannel && this.options.broadcastChannel !== t.broadcastChannel && (this.broadcastChannel.close(), this.broadcastChannel = null), this.options.broadcastChannel = t.broadcastChannel), t.namespace && (this.options.namespace = t.namespace), t.root !== void 0 && (this.options.root = u(t.root), this.options.namespace || (this.options.namespace = `opfs-worker:${this.options.root}`), await this.mount()); } /** * Get a directory handle from a path * * Navigates through the directory structure to find or create a directory * at the specified path. * * @param path - The path to the directory (string or array of segments) * @param create - Whether to create the directory if it doesn't exist (default: false) * @param from - The directory to start from (default: root directory) * @returns Promise that resolves to the directory handle * @throws {OPFSError} If the directory cannot be accessed or created * * @example * ```typescript * const docsDir = await fs.getDirectoryHandle('/users/john/documents', true); * const docsDir2 = await fs.getDirectoryHandle(['users', 'john', 'documents'], true); * ``` */ async getDirectoryHandle(t, e = !1, a = this.root) { const i = Array.isArray(t) ? t : w(t); let r = a; for (const s of i) r = await r.getDirectoryHandle(s, { create: e }); return r; } /** * Get a file handle from a path * * Navigates to the parent directory and retrieves or creates a file handle * for the specified file path. * * @param path - The path to the file (string or array of segments) * @param create - Whether to create the file if it doesn't exist (default: false) * @param from - The directory to start from (default: root directory) * @returns Promise that resolves to the file handle * @throws {PathError} If the path is empty * @throws {OPFSError} If the file cannot be accessed or created * * @example * ```typescript * const fileHandle = await fs.getFileHandle('/config/settings.json', true); * const fileHandle2 = await fs.getFileHandle(['config', 'settings.json'], true); * ``` */ async getFileHandle(t, e = !1, a = this.root) { const i = w(t); if (i.length === 0) throw new S("Path must not be empty", Array.isArray(t) ? t.join("/") : t); const r = i.pop(); return (await this.getDirectoryHandle(i, e, a)).getFileHandle(r, { create: e }); } /** * Get a complete index of all files and directories in the file system * * This method recursively traverses the entire file system and returns * a Map containing FileStat objects for every file and directory. * * @returns Promise that resolves to a Map of paths to FileStat objects * @throws {OPFSError} If the file system is not mounted * * @example * ```typescript * const index = await fs.index(); * const fileStats = index.get('/data/config.json'); * if (fileStats) { * console.log(`File size: ${fileStats.size} bytes`); * if (fileStats.hash) console.log(`Hash: ${fileStats.hash}`); * } * ``` */ async index() { const t = /* @__PURE__ */ new Map(), e = async (a) => { const i = await this.readDir(a); for (const r of i) { const s = `${a === "/" ? "" : a}/${r.name}`; try { const n = await this.stat(s); t.set(s, n), n.isDirectory && await e(s); } catch (n) { console.warn(`Skipping broken entry: ${s}`, n); } } }; return t.set("/", { kind: "directory", size: 0, mtime: (/* @__PURE__ */ new Date(0)).toISOString(), ctime: (/* @__PURE__ */ new Date(0)).toISOString(), isFile: !1, isDirectory: !0 }), await e("/"), t; } async readFile(t, e) { await this.mount(), e || (e = d(t) ? "binary" : "utf-8"); try { const a = await this.getFileHandle(t, !1, this.root), i = await b(a, t); return e === "binary" ? i : C(i, e); } catch (a) { throw new f(t, a); } } /** * Write data to a file * * Creates or overwrites a file with the specified data. If the file already * exists, it will be truncated before writing. * * @param path - The path to the file to write * @param data - The data to write to the file (string, Uint8Array, or ArrayBuffer) * @param encoding - The encoding to use when writing string data (default: 'utf-8') * @returns Promise that resolves when the write operation is complete * @throws {OPFSError} If writing the file fails * * @example * ```typescript * // Write text data * await fs.writeFile('/config/settings.json', JSON.stringify({ theme: 'dark' })); * * // Write binary data * const binaryData = new Uint8Array([1, 2, 3, 4, 5]); * await fs.writeFile('/data/binary.dat', binaryData); * * // Write with specific encoding * await fs.writeFile('/data/utf16.txt', 'Hello World', 'utf-16le'); * ``` */ async writeFile(t, e, a) { await this.mount(); const i = await this.exists(t), r = await this.getFileHandle(t, !0); a || (a = typeof e != "string" || d(t) ? "binary" : "utf-8"), await g(r, e, a, t), i ? await this.notifyChange({ path: t, type: "changed", isDirectory: !1 }) : await this.notifyChange({ path: t, type: "added", isDirectory: !1 }); } /** * Append data to a file * * Adds data to the end of an existing file. If the file doesn't exist, * it will be created. * * @param path - The path to the file to append to * @param data - The data to append to the file (string, Uint8Array, or ArrayBuffer) * @param encoding - The encoding to use when appending string data (default: 'utf-8') * @returns Promise that resolves when the append operation is complete * @throws {OPFSError} If appending to the file fails * * @example * ```typescript * // Append text to a log file * await fs.appendFile('/logs/app.log', `[${new Date().toISOString()}] User logged in\n`); * * // Append binary data * const additionalData = new Uint8Array([6, 7, 8]); * await fs.appendFile('/data/binary.dat', additionalData); * ``` */ async appendFile(t, e, a) { await this.mount(); const i = await this.getFileHandle(t, !0); a || (a = typeof e != "string" || d(t) ? "binary" : "utf-8"), await g(i, e, a, t, { append: !0 }), await this.notifyChange({ path: t, type: "changed", isDirectory: !1 }); } /** * Create a directory * * Creates a new directory at the specified path. If the recursive option * is enabled, parent directories will be created as needed. * * @param path - The path where the directory should be created * @param options - Options for directory creation * @param options.recursive - Whether to create parent directories if they don't exist (default: false) * @returns Promise that resolves when the directory is created * @throws {OPFSError} If the directory cannot be created * * @example * ```typescript * // Create a single directory * await fs.mkdir('/users/john'); * * // Create nested directories * await fs.mkdir('/users/john/documents/projects', { recursive: true }); * ``` */ async mkdir(t, e) { await this.mount(); const a = e?.recursive ?? !1, i = w(t); let r = this.root; for (let s = 0; s < i.length; s++) { const n = i[s]; try { r = await r.getDirectoryHandle(n, { create: a || s === i.length - 1 }); } catch (c) { throw c.name === "NotFoundError" ? new o( `Parent directory does not exist: ${x(i.slice(0, s + 1))}`, "ENOENT", void 0, c ) : c.name === "TypeMismatchError" ? new o(`Path segment is not a directory: ${n}`, "ENOTDIR", void 0, c) : new o("Failed to create directory", "MKDIR_FAILED", void 0, c); } } await this.notifyChange({ path: t, type: "added", isDirectory: !0 }); } /** * Get file or directory statistics * * Returns detailed information about a file or directory, including * size, modification time, and optionally a hash of the file content. * * @param path - The path to the file or directory * @returns Promise that resolves to FileStat object * @throws {OPFSError} If the path does not exist or cannot be accessed * * @example * ```typescript * const stats = await fs.stat('/data/config.json'); * console.log(`File size: ${stats.size} bytes`); * console.log(`Last modified: ${stats.mtime}`); * * // If hashing is enabled, hash will be included * if (stats.hash) { * console.log(`Hash: ${stats.hash}`); * } * ``` */ async stat(t) { if (await this.mount(), t === "/") return { kind: "directory", size: 0, mtime: (/* @__PURE__ */ new Date(0)).toISOString(), ctime: (/* @__PURE__ */ new Date(0)).toISOString(), isFile: !1, isDirectory: !0 }; const e = F(t), a = await this.getDirectoryHandle(m(t), !1), i = this.options.hashAlgorithm !== null; try { const s = await (await a.getFileHandle(e, { create: !1 })).getFile(), n = { kind: "file", size: s.size, mtime: new Date(s.lastModified).toISOString(), ctime: new Date(s.lastModified).toISOString(), isFile: !0, isDirectory: !1 }; if (i && this.options.hashAlgorithm) try { const c = await A(s, this.options.hashAlgorithm, this.options.maxFileSize); n.hash = c; } catch (c) { console.warn(`Failed to calculate hash for ${t}:`, c); } return n; } catch (r) { if (r.name !== "TypeMismatchError" && r.name !== "NotFoundError") throw new o("Failed to stat (file)", "STAT_FAILED", void 0, r); } try { return await a.getDirectoryHandle(e, { create: !1 }), { kind: "directory", size: 0, mtime: (/* @__PURE__ */ new Date(0)).toISOString(), ctime: (/* @__PURE__ */ new Date(0)).toISOString(), isFile: !1, isDirectory: !0 }; } catch (r) { throw r.name === "NotFoundError" ? new o(`No such file or directory: ${t}`, "ENOENT", void 0, r) : new o("Failed to stat (directory)", "STAT_FAILED", void 0, r); } } /** * Read a directory's contents * * Lists all files and subdirectories within the specified directory. * * @param path - The path to the directory to read * @returns Promise that resolves to an array of detailed file/directory information * @throws {OPFSError} If the directory does not exist or cannot be accessed * * @example * ```typescript * // Get detailed information about files and directories * const detailed = await fs.readDir('/users/john/documents'); * detailed.forEach(item => { * console.log(`${item.name} - ${item.isFile ? 'file' : 'directory'}`); * }); * ``` */ async readDir(t) { await this.mount(); const e = await this.getDirectoryHandle(t, !1), a = []; for await (const [i, r] of e.entries()) { const s = r.kind === "file"; a.push({ name: i, kind: r.kind, isFile: s, isDirectory: !s }); } return a; } /** * Check if a file or directory exists * * Verifies if a file or directory exists at the specified path. * * @param path - The path to check * @returns Promise that resolves to true if the file or directory exists, false otherwise * * @example * ```typescript * const exists = await fs.exists('/config/settings.json'); * console.log(`File exists: ${exists}`); * ``` */ async exists(t) { if (await this.mount(), t === "/") return !0; const e = F(t); let a = null; try { a = await this.getDirectoryHandle(m(t), !1); } catch (i) { throw (i.name === "NotFoundError" || i.name === "TypeMismatchError") && (a = null), i; } if (!a || !e) return !1; try { return await a.getFileHandle(e, { create: !1 }), !0; } catch (i) { if (i.name !== "NotFoundError" && i.name !== "TypeMismatchError") throw i; try { return await a.getDirectoryHandle(e, { create: !1 }), !0; } catch (r) { if (r.name !== "NotFoundError" && r.name !== "TypeMismatchError") throw r; return !1; } } } /** * Clear all contents of a directory without removing the directory itself * * Removes all files and subdirectories within the specified directory, * but keeps the directory itself. * * @param path - The path to the directory to clear (default: '/') * @returns Promise that resolves when all contents are removed * @throws {OPFSError} If the operation fails * * @example * ```typescript * // Clear root directory contents * await fs.clear('/'); * * // Clear specific directory contents * await fs.clear('/data'); * ``` */ async clear(t = "/") { await this.mount(); try { const e = await this.readDir(t); for (const a of e) { const i = `${t === "/" ? "" : t}/${a.name}`; await this.remove(i, { recursive: !0 }); } await this.notifyChange({ path: t, type: "changed", isDirectory: !0 }); } catch (e) { throw e instanceof o ? e : new o(`Failed to clear directory: ${t}`, "CLEAR_FAILED", void 0, e); } } /** * Remove files and directories * * Removes files and directories. Similar to Node.js fs.rm(). * * @param path - The path to remove * @param options - Options for removal * @param options.recursive - Whether to remove directories and their contents recursively (default: false) * @param options.force - Whether to ignore errors if the path doesn't exist (default: false) * @returns Promise that resolves when the removal is complete * @throws {OPFSError} If the removal fails * * @example * ```typescript * // Remove a file * await fs.rm('/path/to/file.txt'); * * // Remove a directory and all its contents * await fs.rm('/path/to/directory', { recursive: true }); * * // Remove with force (ignore if doesn't exist) * await fs.rm('/maybe/exists', { force: true }); * ``` */ async remove(t, e) { if (await this.mount(), t === "/") throw new o("Cannot remove root directory", "EROOT"); const { recursive: a = !1, force: i = !1 } = e || {}, r = await this.getDirectoryHandle(m(t), !1); await I(r, t, { recursive: a, force: i }), await this.notifyChange({ path: t, type: "removed", isDirectory: !1 }); } /** * Resolve a path to an absolute path * * Resolves relative paths and normalizes path segments (like '..' and '.'). * Similar to Node.js fs.realpath() but without symlink resolution since OPFS doesn't support symlinks. * * @param path - The path to resolve * @returns Promise that resolves to the absolute normalized path * @throws {FileNotFoundError} If the path does not exist * @throws {OPFSError} If path resolution fails * * @example * ```typescript * // Resolve relative path * const absolute = await fs.realpath('./config/../data/file.txt'); * console.log(absolute); // '/data/file.txt' * ``` */ async realpath(t) { await this.mount(); try { const e = H(t); if (!await this.exists(e)) throw new f(e); return e; } catch (e) { throw e instanceof o ? e : new o(`Failed to resolve path: ${t}`, "REALPATH_FAILED", void 0, e); } } /** * Rename a file or directory * * Changes the name of a file or directory. If the target path already exists, * it will be replaced only if overwrite option is enabled. * * @param oldPath - The current path of the file or directory * @param newPath - The new path for the file or directory * @param options - Options for renaming * @param options.overwrite - Whether to overwrite existing files (default: false) * @returns Promise that resolves when the rename operation is complete * @throws {OPFSError} If the rename operation fails * * @example * ```typescript * // Basic rename (fails if target exists) * await fs.rename('/old/path/file.txt', '/new/path/renamed.txt'); * * // Rename with overwrite * await fs.rename('/old/path/file.txt', '/new/path/renamed.txt', { overwrite: true }); * ``` */ async rename(t, e, a) { await this.mount(); try { const i = a?.overwrite ?? !1; if (!await this.exists(t)) throw new f(t); if (await this.exists(e) && !i) throw new o(`Destination already exists: ${e}`, "EEXIST", void 0); await this.copy(t, e, { recursive: !0, overwrite: i }), await this.remove(t, { recursive: !0 }), await this.notifyChange({ path: t, type: "removed", isDirectory: !1 }), await this.notifyChange({ path: e, type: "added", isDirectory: !1 }); } catch (i) { throw i instanceof o ? i : new o(`Failed to rename from ${t} to ${e}`, "RENAME_FAILED", void 0, i); } } /** * Copy files and directories * * Copies files and directories. Similar to Node.js fs.cp(). * * @param source - The source path to copy from * @param destination - The destination path to copy to * @param options - Options for copying * @param options.recursive - Whether to copy directories recursively (default: false) * @param options.overwrite - Whether to overwrite existing files (default: true) * @returns Promise that resolves when the copy operation is complete * @throws {OPFSError} If the copy operation fails * * @example * ```typescript * // Copy a file * await fs.copy('/source/file.txt', '/dest/file.txt'); * * // Copy a directory and all its contents * await fs.copy('/source/dir', '/dest/dir', { recursive: true }); * * // Copy without overwriting existing files * await fs.copy('/source', '/dest', { recursive: true, overwrite: false }); * ``` */ async copy(t, e, a) { await this.mount(); try { const i = a?.recursive ?? !1, r = a?.overwrite ?? !0; if (!await this.exists(t)) throw new o(`Source does not exist: ${t}`, "ENOENT", void 0); if (await this.exists(e) && !r) throw new o(`Destination already exists: ${e}`, "EEXIST", void 0); if ((await this.stat(t)).isFile) { const l = await this.readFile(t, "binary"); await this.writeFile(e, l); } else { if (!i) throw new o(`Cannot copy directory without recursive option: ${t}`, "EISDIR", void 0); await this.mkdir(e, { recursive: !0 }); const l = await this.readDir(t); for (const y of l) { const D = `${t}/${y.name}`, E = `${e}/${y.name}`; await this.copy(D, E, { recursive: !0, overwrite: r }); } } } catch (i) { throw i instanceof o ? i : new o(`Failed to copy from ${t} to ${e}`, "CP_FAILED", void 0, i); } } /** * Start watching a file or directory for changes * * @param path - The path to watch (minimatch syntax allowed) * @param options - Watch options * @param options.recursive - Whether to watch recursively (default: true) * @param options.exclude - Glob pattern(s) to exclude (minimatch). * @returns Promise that resolves when watching starts * * @example * ```typescript * // Watch entire directory tree recursively (default) * await fs.watch('/data'); * * // Watch only immediate children (shallow) * await fs.watch('/data', { recursive: false }); * * // Watch a single file * await fs.watch('/config.json', { recursive: false }); * * // Watch all json files but not in dist directory * await fs.watch('/**\/*.json', { recursive: false, exclude: ['dist/**'] }); * * ``` */ async watch(t, e) { if (!this.options.broadcastChannel) throw new o("This instance is not configured to send events. Please specify options.broadcastChannel to enable watching.", "ENOENT"); const a = { pattern: $(t, e?.recursive ?? !0), include: Array.isArray(e?.include) ? e.include : [e?.include ?? "**"], exclude: Array.isArray(e?.exclude) ? e.exclude : [e?.exclude ?? ""] }; this.watchers.set(t, a); } /** * Stop watching a previously watched path */ unwatch(t) { this.watchers.delete(t); } /** * Dispose of resources and clean up the file system instance * * This method should be called when the file system instance is no longer needed * to properly clean up resources like the broadcast channel and watch timers. */ dispose() { this.broadcastChannel && (this.broadcastChannel.close(), this.broadcastChannel = null), this.watchers.clear(); } /** * Synchronize the file system with external data * * Syncs the file system with an array of entries containing paths and data. * This is useful for importing data from external sources or syncing with remote data. * * @param entries - Array of [path, data] tuples to sync * @param options - Options for synchronization * @param options.cleanBefore - Whether to clear the file system before syncing (default: false) * @returns Promise that resolves when synchronization is complete * @throws {OPFSError} If the synchronization fails * * @example * ```typescript * // Sync with external data * const entries: [string, string | Uint8Array | Blob][] = [ * ['/config.json', JSON.stringify({ theme: 'dark' })], * ['/data/binary.dat', new Uint8Array([1, 2, 3, 4])], * ['/upload.txt', new Blob(['file content'], { type: 'text/plain' })] * ]; * * // Sync without clearing existing files * await fs.sync(entries); * * // Clean file system and then sync * await fs.sync(entries, { cleanBefore: true }); * ``` */ async sync(t, e) { await this.mount(); try { (e?.cleanBefore ?? !1) && await this.clear("/"); for (const [i, r] of t) { const s = u(i); let n; r instanceof Blob ? n = await O(r) : n = r, await this.writeFile(s, n); } } catch (a) { throw a instanceof o ? a : new o("Failed to sync file system", "SYNC_FAILED", void 0, a); } } } typeof globalThis < "u" && globalThis.constructor.name === "DedicatedWorkerGlobalScope" && v(new T()); export { T as OPFSWorker }; //# sourceMappingURL=raw.js.map