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
JavaScript
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