UNPKG

@terbiumos/tfs

Version:

The drop in Filer replacement you have been waiting for. Completely Typed and Built with TypeScript

1,473 lines (1,439 loc) 55.3 kB
import { Shell } from "../shell"; import { genError } from "./errors"; interface FSStats { name: string; size: number; type: string; // Non-Standard but should be included mime: string; ctime: Date | number; mtime: Date | number; atime: Date | number; atimeMs: number; ctimeMs: number; mtimeMs: number; dev: string; isSymbolicLink: () => boolean; isDirectory: () => boolean; isFile: () => boolean; uid: number; gid: number; mode: number; } export const FSConstants = { O_RDONLY: 0, O_WRONLY: 1, O_RDWR: 2, S_IFMT: 61440, S_IFREG: 32768, S_IFDIR: 16384, S_IFCHR: 8192, S_IFBLK: 24576, S_IFIFO: 4096, S_IFLNK: 40960, S_IFSOCK: 49152, O_CREAT: 512, O_EXCL: 2048, O_NOCTTY: 131072, O_TRUNC: 1024, O_APPEND: 8, O_DIRECTORY: 1048576, O_NOFOLLOW: 256, O_SYNC: 128, O_DSYNC: 4194304, O_SYMLINK: 2097152, O_NONBLOCK: 4, S_IRWXU: 448, S_IRUSR: 256, S_IWUSR: 128, S_IXUSR: 64, S_IRWXG: 56, S_IRGRP: 32, S_IWGRP: 16, S_IXGRP: 8, S_IRWXO: 7, S_IROTH: 4, S_IWOTH: 2, S_IXOTH: 1, F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1, UV_FS_COPYFILE_EXCL: 1, COPYFILE_EXCL: 1, }; export const fdS = Symbol.for("TFSFD"); export type TFSFD = { fd: number; [fdS]: string; }; export const updMeta = async (handle: FileSystemDirectoryHandle, perms?: { [key: string]: { perms: string[]; uid: number; gid: number } | boolean }) => { const fileHandle = await handle.getFileHandle(".TFS_STORE", { create: true }); const store = await fileHandle.getFile(); const currentPerms = JSON.parse(await store.text()); let updatedPerms = { ...currentPerms }; if (perms) { for (const key in perms) { const value = perms[key]; if (value === true) { delete updatedPerms[key]; } else if (value !== false) { updatedPerms[key] = value; } } } const writable = await fileHandle.createWritable(); await writable.write(JSON.stringify(updatedPerms, null, 2)); await writable.close(); }; /** * The TFS File System Operations Class */ export class FS { handle: FileSystemDirectoryHandle; currPath: string; shell: Shell; perms: { [key: string]: { perms: string[]; uid: number; gid: number } } = {}; constants = FSConstants; constructor(handle: FileSystemDirectoryHandle) { this.handle = handle; this.currPath = "/"; this.shell = new Shell(this.handle, this); this.promises.exists(".TFS_STORE").then(async exists => { if (!exists) { const fileHandle = await this.handle.getFileHandle(".TFS_STORE", { create: true }); const writable = await fileHandle.createWritable(); await writable.write( JSON.stringify( { "/.TFS_STORE": { perms: ["r"], uid: 0, gid: 0, }, }, null, 2, ), ); await writable.close(); } this.perms = await this.promises .readFile(".TFS_STORE", "utf8") .then(data => JSON.parse(data)) .catch(() => ({})); }); } /** * Normalizes the given path, resolving relative segments like "." and "..". * If `currPath` is provided, it is used as the base for relative paths. * @param path - The absolute or relative path to normalize. * @param currPath - (Optional) The current working directory to resolve relative paths against. * @returns The normalized absolute path as a string. * @example * // In this example currPath is "/home/user" * tfs.fs.normalizePath("documents/file.txt") // "/home/user/documents/file.txt" * tfs.fs.normalizePath("../file.txt", "/home/user/documents") // "/home/user/file.txt" */ normalizePath(path: string, currPath?: string): string { if (currPath) this.currPath = currPath; if (!path) return this.currPath; if (!path.startsWith("/")) path = this.currPath + "/" + path; const parts = path.split("/").filter(Boolean); const stack: string[] = []; for (const part of parts) { if (part === "." || part === "") continue; if (part === "..") { if (stack.length > 0) stack.pop(); } else { stack.push(part); } } let newPath = "/" + stack.join("/"); if (newPath === "//") newPath = "/"; return newPath; } /** * Writes data to a file at the specified path. If the file or any parent directories do not exist, they are created. * @param file - The absolute or relative path to the file to write. * @param content - The content to write to the file. Can be a string, ArrayBuffer, or Blob. * @param type - The type of data being written: "utf8" for string, "arraybuffer" for ArrayBuffer, "blob" for Blob, or "base64" for a base64-encoded string. Defaults to "utf8". * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. * @example * tfs.fs.writeFile("/documents/file.txt", "Hello, World!", (err) => { * if (err) throw err; * console.log("File written successfully!"); * }); * * // You can also specify the type of content being written: * tfs.fs.writeFile("/documents/file.txt", "Hello, World!", "utf8", (err) => { * if (err) throw err; * console.log("File written successfully!"); * }); */ writeFile(file: string, content: string | ArrayBuffer | Blob | Uint8Array, torb?: "utf8" | "base64" | "arraybuffer" | "blob" | ((err: Error | null) => void), callback?: (err: Error | null) => void) { let encoding: "utf8" | "base64" | "arraybuffer" | "blob" = "utf8"; let cb: (err: Error | null) => void; if (typeof torb === "function") { cb = torb; } else { encoding = torb || "utf8"; cb = callback!; } const normalizedPath = this.normalizePath(file); const parts = normalizedPath.split("/").filter(Boolean); let dirPromise = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string, { create: true })); } if (normalizedPath in this.perms && this.perms[normalizedPath] && !(this.perms[normalizedPath].perms.includes("w") || this.perms[normalizedPath].perms.includes("a"))) { if (cb) cb(genError("SecurityError", normalizedPath)); return; } const fileName = parts[parts.length - 1]; dirPromise .then(dirHandle => dirHandle.getFileHandle(fileName as string, { create: true })) .then(fileHandle => fileHandle.createWritable()) .then(async writable => { let toWrite: string | ArrayBuffer | Blob | ArrayBufferLike | BlobPart[]; if (!torb || typeof torb === "function") { if (typeof content === "string") { toWrite = content; encoding = "utf8"; } else if (content instanceof ArrayBuffer) { toWrite = content; encoding = "arraybuffer"; } else if (content instanceof Uint8Array) { toWrite = content.buffer; encoding = "arraybuffer"; } else if (content instanceof Blob) { toWrite = content; encoding = "blob"; } else { toWrite = String(content); encoding = "utf8"; } } else { switch (encoding) { case "arraybuffer": if (typeof content === "string") { toWrite = new TextEncoder().encode(content).buffer; } else if (content instanceof ArrayBuffer) { toWrite = content; } else if (content instanceof Uint8Array) { toWrite = content.buffer; } else if (content instanceof Blob) { toWrite = await content.arrayBuffer(); } else { toWrite = new ArrayBuffer(0); } break; case "blob": if (content instanceof Blob) { toWrite = content; } else if (typeof content === "string" || content instanceof ArrayBuffer || content instanceof Uint8Array) { // @ts-expect-error toWrite = new Blob([content]); } else { toWrite = new Blob([]); } break; case "base64": if (typeof content === "string") { const binary = atob(content); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); } toWrite = bytes.buffer; } else if (content instanceof ArrayBuffer) { toWrite = content; } else if (content instanceof Uint8Array) { toWrite = content.buffer; } else if (content instanceof Blob) { toWrite = await content.arrayBuffer(); } else { toWrite = new ArrayBuffer(0); } break; case "utf8": default: if (typeof content === "string") { toWrite = content; } else if (content instanceof ArrayBuffer) { toWrite = new TextDecoder().decode(content); } else if (content instanceof Uint8Array) { toWrite = new TextDecoder().decode(content); } else if (content instanceof Blob) { toWrite = await content.text(); } else { toWrite = String(content); } } } // @ts-expect-error await writable.write(toWrite); if (!this.perms[normalizedPath]) { updMeta(this.handle, { [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }); this.perms = { ...this.perms, [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }; } await writable.close(); }) .then(() => { if (cb) cb(null); }) .catch(err => { if (cb) cb(genError(err, normalizedPath)); }); } /** * Reads the contents of a file at the specified path. * @param file - The absolute or relative path to the file to read. * @param type - The type of data to return: "utf8" for string, "arraybuffer" for ArrayBuffer, "blob" for Blob, or "base64" for a base64-encoded string. * @param callback - Callback function called with the result. Receives an error (or null) and the file data. * @example * tfs.fs.readFile("/documents/file.txt", "utf8", (err, data) => { * if (err) throw err; * console.log("File contents:", data); * }); * * // You can also call it without specifying the type, in which case it defaults to "utf8": * tfs.fs.readFile("/documents/file.txt", (err, data) => { * if (err) throw err; * console.log("File contents:", data); * }); */ readFile(file: string, fTypeorcb: "utf8" | "arraybuffer" | "blob" | "base64" | ((err: Error | null, data: any) => void), callback?: (err: Error | null, data: any) => void) { let type: "utf8" | "arraybuffer" | "blob" | "base64" = "utf8"; let cb: (err: Error | null, data: any) => void; if (typeof fTypeorcb === "string") { type = fTypeorcb; cb = callback!; } else { cb = fTypeorcb; } const normalizedPath = this.normalizePath(file); if (normalizedPath in this.perms && this.perms[normalizedPath] && !(this.perms[normalizedPath].perms.includes("r") || this.perms[normalizedPath].perms.includes("a"))) { if (cb) cb(genError("SecurityError", normalizedPath), null); return; } const parts = normalizedPath.split("/").filter(Boolean); let dirPromise: Promise<FileSystemDirectoryHandle> = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string)); } const fileName = parts[parts.length - 1]; dirPromise .then(dirHandle => dirHandle.getFileHandle(fileName as string)) .then(fileHandle => fileHandle.getFile()) .then(file => { file.text() .then(text => { const isSymlink = /^symlink:(.+?):(file|dir|junction)$/.exec(text); if (isSymlink) { const target = isSymlink[1]; const linkType = isSymlink[2]; if (linkType === "file") { this.readFile(target as string, type, cb); } else { if (cb) cb(genError(new Error("TypeMismatchError"), file.name), null); } return; } if (type === "arraybuffer") { file.arrayBuffer().then(data => { if (cb) cb(null, data); }); } else if (type === "blob") { if (cb) cb(null, file); } else if (type === "base64") { const reader = new FileReader(); reader.onload = () => { if (cb) cb(null, reader.result); }; reader.readAsDataURL(file); } else { if (cb) cb(null, text); } }) .catch(err => { if (cb) cb(genError(err, file.name), null); }); }) .catch(err => { if (cb) cb(genError(err, file), null); }); } /** * Creates a directory at the specified path * @param dir - The absolute or relative path of the directory to create. * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. * @example * tfs.fs.mkdir("/documents/newFolder", (err) => { * if (err) throw err; * console.log("Directory created successfully!"); * }); */ mkdir(dir: string, callback?: (err: Error | null) => void) { const normalizedPath = this.normalizePath(dir); const parts = normalizedPath.split("/").filter(Boolean); let dirPromise = Promise.resolve(this.handle); for (const part of parts) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(part, { create: true })); } if (callback) { dirPromise .then(() => callback(null)) .catch(err => { callback(genError(err, dir)); }); updMeta(this.handle, { [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }); this.perms = { ...this.perms, [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }; } } /** * Reads the contents of a directory. * @param dir - The absolute or relative path of the directory to read. * @param callback - Callback function called with the result. Receives an error (or null) and the directory contents. * @returns An array of file and directory names in the specified directory. * @example * tfs.fs.readdir("/documents", (err, files) => { * if (err) throw err; * console.log(files); * }); */ readdir(dir: string, callback: (err: Error | null, data: any) => void) { const normalizedPath = this.normalizePath(dir); const parts = normalizedPath.split("/").filter(Boolean); let dirPromise = Promise.resolve(this.handle); for (const part of parts) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(part)); } dirPromise .then(dirHandle => { const entries: string[] = []; const ent = dirHandle.entries(); function next() { ent.next() .then(result => { if (result.done) { callback(null, entries); } else { const [name] = result.value; if (name !== ".TFS_STORE") entries.push(name); next(); } }) .catch(err => { callback(genError(err, dir), null); }); } next(); }) .catch(err => { callback(genError(err, dir), null); }); } /** * Retrieves information about a file or directory. * @param path - The absolute or relative path of the file or directory to retrieve information for. * @param callback - Callback function called with the result. Receives an error (or null) and the file/directory information. * @returns An object containing the name, size, mime type of the file or just as directory, lastModified timestamp and also the methods: isSymbolicLink, isDirectory, isFile which return booleans. * @example * tfs.fs.stat("/documents/file.txt", (err, stats) => { * if (err) throw err; * console.log(stats); * }); */ stat(path: string, callback: (err: Error | null, stats?: FSStats | null) => void) { const normalizedPath = this.normalizePath(path); if (normalizedPath === "/") { callback(null, { name: "/", size: 0, mime: "DIRECTORY", type: "DIRECTORY", ctime: 0, mtime: 0, atime: new Date(), atimeMs: new Date().getTime(), ctimeMs: 0, mtimeMs: 0, dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false, uid: 0, gid: 0, mode: 0o40755, }); return; } const parts = normalizedPath.split("/").filter(Boolean); let dirPromise = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string)); } const lastPart = parts[parts.length - 1]; const perms = this.perms[normalizedPath]; let perm: number; if (perms && perms.perms.includes("a")) { perm = 0o100700; } else if (perms && perms.perms.includes("w")) { perm = 0o100200; } else if (perms && perms.perms.includes("r")) { perm = 0o100400; } else { perm = 0o100644; } dirPromise .then(dirHandle => dirHandle .getFileHandle(lastPart as string) .then(fileHandle => fileHandle.getFile().then(file => file.text().then(text => { const isSymlink = /^symlink:(.+?):(file|dir|junction)$/.exec(text); if (isSymlink) { const target = isSymlink[1]; this.stat(target as string, (err, stats) => { if (err) { callback(genError(err, target), null); } else if (stats) { callback(null, { ...stats, dev: "OPFS", mime: "application/symlink", type: "SYMLINK", isSymbolicLink: () => true, isDirectory: () => stats.type === "DIRECTORY", isFile: () => stats.type !== "DIRECTORY", mode: this.perms[normalizedPath]?.uid! || 0o120777, atime: new Date(), atimeMs: new Date().getTime(), }); } }); } else { callback(null, { name: file.name, size: file.size, mime: file.type, type: file.type === "DIRECTORY" ? "DIRECTORY" : "FILE", ctime: new Date(file.lastModified), mtime: new Date(file.lastModified), atime: new Date(), ctimeMs: file.lastModified, mtimeMs: file.lastModified, atimeMs: new Date().getTime(), dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => file.type === "DIRECTORY", isFile: () => file.type !== "DIRECTORY", uid: 0, gid: 0, mode: perm, }); } }), ), ) .catch(err => { if (err && err.name === "NotFoundError") { dirHandle .getDirectoryHandle(lastPart as string) .then(() => callback(null, { name: lastPart as string, size: 0, type: "DIRECTORY", mime: "DIRECTORY", ctime: 0, mtime: 0, atime: new Date(), atimeMs: new Date().getTime(), ctimeMs: 0, mtimeMs: 0, dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false, uid: 0, gid: 0, mode: perm, }), ) .catch(dirErr => { callback(genError(dirErr, path), null); }); } else if (err && err.name === "TypeMismatchError") { dirHandle .getDirectoryHandle(lastPart as string) .then(() => callback(null, { name: lastPart as string, size: 0, type: "DIRECTORY", mime: "DIRECTORY", ctime: 0, mtime: 0, atime: new Date(), ctimeMs: 0, mtimeMs: 0, atimeMs: new Date().getTime(), dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false, uid: 0, gid: 0, mode: perm, }), ) .catch(dirErr => { callback(genError(dirErr, path), null); }); } else { callback(genError(err, path), null); } }), ) .catch(err => { callback(genError(err, path), null); }); } /** * Retrieves information about a symlink or file/directory. * @param path - The absolute or relative path of the symlink or file/directory to retrieve information for. * @param callback - Callback function called with the result. Receives an error (or null) and the file/directory information. * @returns An object containing the name, size, mime type of the file or just as directory, lastModified timestamp and also the methods: isSymbolicLink, isDirectory, isFile which return booleans. * @example * tfs.fs.lstat("/documents/file.txt", (err, stats) => { * if (err) throw err; * console.log(stats); * }); */ lstat(path: string, callback: (err: Error | null, stats?: FSStats | null) => void) { const normalizedPath = this.normalizePath(path); if (normalizedPath === "/") { callback(null, { name: "/", size: 0, type: "DIRECTORY", mime: "DIRECTORY", ctime: 0, mtime: 0, atime: new Date(), atimeMs: new Date().getTime(), ctimeMs: 0, mtimeMs: 0, dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false, uid: 0, gid: 0, mode: 0o40755, }); return; } const parts = normalizedPath.split("/").filter(Boolean); let dirPromise = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string)); } const lastPart = parts[parts.length - 1]; const perms = this.perms[normalizedPath]; let perm: number; if (perms && perms.perms.includes("a")) { perm = 0o100700; } else if (perms && perms.perms.includes("w")) { perm = 0o100200; } else if (perms && perms.perms.includes("r")) { perm = 0o100400; } else { perm = 0o100644; } dirPromise .then(dirHandle => dirHandle .getFileHandle(lastPart as string) .then(fileHandle => fileHandle.getFile().then(file => callback(null, { name: file.name, size: file.size, mime: file.type, type: file.type === "DIRECTORY" ? "DIRECTORY" : "FILE", ctime: new Date(file.lastModified), mtime: new Date(file.lastModified), atime: new Date(), ctimeMs: file.lastModified, mtimeMs: file.lastModified, atimeMs: new Date().getTime(), dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => file.type === "DIRECTORY", isFile: () => file.type !== "DIRECTORY", uid: 0, gid: 0, mode: perm, }), ), ) .catch(err => { if (err && err.name === "NotFoundError") { dirHandle .getDirectoryHandle(lastPart as string) .then(() => callback(null, { name: lastPart as string, size: 0, type: "DIRECTORY", mime: "DIRECTORY", ctime: 0, mtime: 0, atime: new Date(), ctimeMs: 0, mtimeMs: 0, atimeMs: new Date().getTime(), dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false, uid: 0, gid: 0, mode: perm, }), ) .catch(dirErr => { callback(genError(dirErr, path), null); }); } else if (err && err.name === "TypeMismatchError") { dirHandle .getDirectoryHandle(lastPart as string) .then(() => callback(null, { name: lastPart as string, size: 0, type: "DIRECTORY", mime: "DIRECTORY", ctime: 0, mtime: 0, atime: new Date(), atimeMs: new Date().getTime(), ctimeMs: 0, mtimeMs: 0, dev: "OPFS", isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false, uid: 0, gid: 0, mode: perm, }), ) .catch(dirErr => { callback(genError(dirErr, path), null); }); } else { callback(genError(err, path), null); } }), ) .catch(err => { callback(genError(err, path), null); }); } /** * Appends data to a file. If the file does not exist, it is created. * @param path - The absolute or relative path to the file to append data to. * @param data - The data to append to the file. * @param callback - The callback function to call when the operation is complete. * @example * tfs.fs.appendFile("/documents/file.txt", "Additional content", (err) => { * if (err) throw err; * console.log("Data appended successfully!"); * }); */ appendFile(path: string, data: string | ArrayBuffer | ArrayBufferView, callback: (err: Error | null) => void) { this.readFile(path, "arraybuffer", (err, existingData) => { if (err && err.name !== "NotFoundError") { callback(err); return; } const normalizedPath = this.normalizePath(path); if (normalizedPath in this.perms && this.perms[normalizedPath] && !(this.perms[normalizedPath].perms.includes("w") || this.perms[normalizedPath].perms.includes("a"))) { if (callback) callback(genError("SecurityError", normalizedPath)); return; } let newData: ArrayBuffer; if (existingData) { const existingArray = new Uint8Array(existingData); let dataArray: Uint8Array; if (typeof data === "string") { dataArray = new TextEncoder().encode(data); } else if (data instanceof ArrayBuffer) { dataArray = new Uint8Array(data); } else if (ArrayBuffer.isView(data)) { dataArray = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } else { callback(genError("invalid data type")); return; } const combined = new Uint8Array(existingArray.length + dataArray.length); combined.set(existingArray, 0); combined.set(dataArray, existingArray.length); newData = combined.buffer; } else { if (typeof data === "string") { newData = new TextEncoder().encode(data).buffer; } else if (data instanceof ArrayBuffer) { newData = data; } else if (ArrayBuffer.isView(data)) { const sliced = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); newData = sliced instanceof ArrayBuffer ? sliced : new ArrayBuffer(0); } else { callback(genError("invalid data type")); return; } } this.writeFile(path, newData, "arraybuffer", callback); }); } /** * Watches for changes to a file or directory. * @param path - The absolute or relative path of the file or directory to watch. * @param options - Options for the watcher (e.g., recursive). * @param listener - Callback function called when a change is detected. * @returns An object representing the watcher. * @example * const watcher = tfs.fs.watch("/documents", { recursive: true }, (event, filename) => { * console.log(`Event: ${event}, File: ${filename}`); * watcher.close(); * }); * * // Alternatively you can also do it this way: * const watcher = tfs.fs.watch("/documents"); * watcher.on('change', function(event, filename) { * console.log(`Event: ${event}, File: ${filename}`); * watcher.close(); * }); */ watch(path: string, options?: { recursive?: boolean }, listener?: (event: "rename" | "change", filename: string) => void) { const normalizedPath = this.normalizePath(path); let closed = false; let prevSnapshot: Map<string, { size: number; lastModified: number; type: string }> = new Map(); const EventEmitter = class { private listeners: { [event in "rename" | "change"]?: Array<(event: "rename" | "change", filename: string) => void> } = {}; on(event: "rename" | "change", cb: (event: "rename" | "change", filename: string) => void) { if (!this.listeners[event]) this.listeners[event] = []; this.listeners[event]!.push(cb); } emit(event: "rename" | "change", filename: string) { if (this.listeners[event]) { for (const cb of this.listeners[event]!) cb(event, filename); } } removeAllListeners() { this.listeners = {}; } }; const emitter = new EventEmitter(); if (listener) { emitter.on("change", listener); emitter.on("rename", listener); } const scan = async () => { if (closed) return; const snapshot = new Map<string, { size: number; lastModified: number; type: string }>(); const walk = async (dir: string) => { const entries: string[] = await this.promises.readdir(dir).catch(() => []); for (const entry of entries) { const fullPath = this.normalizePath(dir + "/" + entry); const stat = await this.promises.stat(fullPath).catch(() => null); if (stat) { snapshot.set(fullPath, { size: stat.size, lastModified: stat.mtimeMs, type: stat.type }); if (options?.recursive && stat.type === "DIRECTORY") { await walk(fullPath); } } } }; const stat = await this.promises.stat(normalizedPath).catch(() => null); if (stat) { snapshot.set(normalizedPath, { size: stat.size, lastModified: stat.mtimeMs, type: stat.type }); if (options?.recursive && stat.type === "DIRECTORY") { await walk(normalizedPath); } } for (const [file, info] of snapshot) { if (!prevSnapshot.has(file)) { emitter.emit("rename", file); } else { const prev = prevSnapshot.get(file)!; if (info.size !== prev.size || info.lastModified !== prev.lastModified) { emitter.emit("change", file); } } } for (const file of prevSnapshot.keys()) { if (!snapshot.has(file)) { emitter.emit("rename", file); } } prevSnapshot = snapshot; }; let interval: any = setInterval(scan, 500); const watcher = { on: (event: "rename" | "change", cb: (event: "rename" | "change", filename: string) => void) => { emitter.on(event, cb); }, close: () => { closed = true; clearInterval(interval); emitter.removeAllListeners(); }, }; scan(); return watcher; } /** * Deletes a file or symlink. * @param path - The absolute or relative path of the file or symlink to delete. * @param callback - Callback function called with the result. Receives an error (or null) if the deletion failed. * @example * tfs.fs.unlink("/documents/file.txt", (err) => { * if (err) throw err; * console.log("File deleted successfully!"); * }); */ unlink(path: string, callback?: (err: Error | null) => void) { const normalizedPath = this.normalizePath(path); const parts = normalizedPath.split("/").filter(Boolean); let dirPromise: Promise<FileSystemDirectoryHandle> = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string)); } if (normalizedPath in this.perms && this.perms[normalizedPath] && !(this.perms[normalizedPath].perms.includes("w") || this.perms[normalizedPath].perms.includes("a"))) { if (callback) callback(genError("SecurityError", normalizedPath)); return; } updMeta(this.handle, { [normalizedPath]: true }); const fileName = parts[parts.length - 1]; dirPromise .then(dirHandle => { return dirHandle.removeEntry(fileName as string); }) .then(() => { if (callback) callback(null); }) .catch(err => { if (callback) callback(genError(err, path)); }); } /** * Deletes an empty directory. * @param path - The absolute or relative path of the directory to delete. * @param callback - Callback function called with the result. Receives an error (or null) if the deletion failed. * @example * tfs.fs.rmdir("/documents/oldFolder", (err) => { * if (err) throw err; * console.log("Directory deleted successfully!"); * }); */ rmdir(path: string, callback?: (err: Error | null) => void) { const normalizedPath = this.normalizePath(path); const parts = normalizedPath.split("/").filter(Boolean); let dirPromise: Promise<FileSystemDirectoryHandle> = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string)); } const dirName = parts[parts.length - 1]; if (normalizedPath in this.perms && this.perms[normalizedPath] && !(this.perms[normalizedPath].perms.includes("w") || this.perms[normalizedPath].perms.includes("a"))) { if (callback) callback(genError("SecurityError", normalizedPath)); return; } updMeta(this.handle, { [normalizedPath]: true }); dirPromise .then(dirHandle => { return dirHandle.removeEntry(dirName as string); }) .then(() => { if (callback) callback(null); }) .catch(err => { if (callback) callback(genError(err, path)); }); } /** * Renames a file or directory. * @param oldPath - The absolute or relative path of the file or directory to rename. * @param newPath - The new absolute or relative path for the file or directory. * @param callback - Callback function called with the result. Receives an error (or null) if the rename failed. * @example * // Syntax is the same for renaming both files and directories * tfs.fs.rename("/documents/oldName.txt", "/documents/newName.txt", (err) => { * if (err) throw err; * console.log("File renamed successfully!"); * }); */ rename(oldPath: string, newPath: string, callback?: (err: Error | null) => void) { const oldP = this.normalizePath(oldPath); const newP = this.normalizePath(newPath); this.stat(oldP, (err, stats) => { if (err || !stats) { if (callback) callback(genError(err, oldP)); return; } if (stats.type === "DIRECTORY") { this.cp(oldP, newP, cpErr => { if (cpErr) { if (callback) callback(genError(cpErr, newP)); return; } this.shell.promises .rm(oldP, { recursive: true }) .then(() => { if (callback) callback(null); }) .catch(rmErr => { if (callback) callback(genError(rmErr, oldP)); }); }); } else { this.copyFile(oldP, newP, copyErr => { if (copyErr) { if (callback) callback(genError(copyErr, oldP)); return; } this.unlink(oldP, unlinkErr => { if (unlinkErr) { if (callback) callback(genError(unlinkErr, oldP)); } else { if (callback) callback(null); } }); }); } }); } /** * Checks if a file or directory exists at the specified path. * @param path - The absolute or relative path to check for existence. * @param callback - Optional callback function called with the result. Receives true if the file/directory exists, false otherwise. * @returns True if the file or directory exists, false otherwise. * @example * tfs.fs.exists("/documents/file.txt", (exists) => { * console.log("File exists:", exists); * }); */ exists(path: string, callback?: (exists: boolean) => void) { const normalizedPath = this.normalizePath(path); this.stat(normalizedPath, (err, _) => { if (err) { if (callback) callback(false); } else { if (callback) callback(true); } }); } /** * Creates a symbolic link. * @param target - The target path the symlink points to. * @param path - The absolute or relative path where the symlink should be created. * @param type - (Optional) The type of the symlink: "file", "dir", or "junction". Defaults to "file". * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. */ symlink(target: string, path: string, type?: "file" | "dir" | "junction", callback?: (err: Error | null) => void) { const symlinkType = type || "file"; this.writeFile(path, `symlink:${target}:${symlinkType}`, "utf8", (err: Error | null) => { if (err) { if (callback) callback(genError(err, path)); } else { if (callback) callback(null); } }); } /** * Checks the accessibility of a file or directory at the given path with the specified mode. * @param path - The path to the file or directory to check. * @param mode - The accessibility mode to check (defaults to `this.constants.F_OK`). Can be a combination of `F_OK`, `R_OK`, `W_OK`, and `X_OK`. * @param callback - Optional callback function that receives an error if access is denied or the path does not exist, or `null` if access is allowed. * @example * tfs.fs.access("/documents/file.txt", tfs.fs.constants.R_OK | tfs.fs.constants.W_OK, (err) => { * if (err) { * console.error(`Access denied or file does not exist: ${err}`); * } else { * console.log("Access granted"); * } * }); */ access(path: string, mode: number = this.constants.F_OK, callback?: (err: Error | null) => void) { const normalizedPath = this.normalizePath(path); const perms = this.perms[normalizedPath]; if (!perms) { this.exists(normalizedPath, exists => { if (callback) callback(exists ? null : genError("NotFoundError", normalizedPath)); }); return; } let allowed = true; if (mode & this.constants.R_OK) { allowed = allowed && (perms.perms.includes("r") || perms.perms.includes("a")); } if (mode & this.constants.W_OK) { allowed = allowed && (perms.perms.includes("w") || perms.perms.includes("a")); } if (mode & this.constants.X_OK) { allowed = allowed && perms.perms.includes("x"); } if (callback) callback(allowed ? null : genError("SecurityError", normalizedPath)); } /** * Reads the target of a symbolic link. * @param path - The absolute or relative path of the symlink to read. * @param callback - Callback function called with the result. Receives an error (or null) and the target path of the symlink. * @example * tfs.fs.readlink("/documents/symlink.lnk", (err, target) => { * if (err) throw err; * console.log("Symlink points to:", target); * }); */ readlink(path: string, callback?: (err: Error | null, target: string | null) => void) { const normalizedPath = this.normalizePath(path); const parts = normalizedPath.split("/").filter(Boolean); let dirPromise: Promise<FileSystemDirectoryHandle> = Promise.resolve(this.handle); for (let i = 0; i < parts.length - 1; i++) { dirPromise = dirPromise.then(dirHandle => dirHandle.getDirectoryHandle(parts[i] as string)); } const fileName = parts[parts.length - 1]; dirPromise .then(dirHandle => dirHandle.getFileHandle(fileName as string)) .then(fileHandle => fileHandle.getFile()) .then(file => { file.text().then(text => { const isSymlink = /^symlink:(.+?):(file|dir|junction)$/.exec(text); if (isSymlink) { const target = isSymlink[1]; if (callback) callback(null, target as string); } }); }) .catch(err => { if (callback) callback(genError(err, path), null); }); } /** * Copies a file from one path to another. * @param oldPath - The absolute or relative path of the file to copy. * @param newPath - The absolute or relative path where the file should be copied to. * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. * @example * tfs.fs.copyFile("/documents/source.txt", "/documents/destination.txt", (err) => { * if (err) throw err; * console.log("File copied successfully!"); * }); */ copyFile(oldPath: string, newPath: string, callback?: (err: Error | null) => void) { const oldP = this.normalizePath(oldPath); const newP = this.normalizePath(newPath); this.readFile(oldP, "arraybuffer", (err, data) => { if (err) genError(err, oldP); this.writeFile(newP, data, "arraybuffer", callback); }); } /** * Copies a file or directory to a new location. * @param oldPath - The absolute or relative path of the file or directory to copy. * @param newPath - The absolute or relative path where the file or directory should be copied to. * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. * @example * tfs.fs.cp("/documents/sourceFolder", "/documents/destinationFolder", (err) => { * if (err) throw err; * console.log("Directory copied successfully!"); * }); */ cp(oldPath: string, newPath: string, callback?: (err: Error | null) => void) { this.stat(oldPath, (err, stats) => { if (err || !stats) { if (callback) callback(genError(err, oldPath)); return; } if (stats.type === "DIRECTORY") { this.mkdir(newPath, err => { if (err) { if (callback) callback(genError(err, newPath)); return; } this.readdir(oldPath, (err, entries) => { if (err) { if (callback) callback(genError(err, oldPath)); return; } let pending = entries.length; if (!pending) { if (callback) callback(null); return; } let errorOccurred = false; entries.forEach((entry: string) => { this.cp(this.normalizePath(oldPath + "/" + entry), this.normalizePath(newPath + "/" + entry), err => { if (errorOccurred) return; if (err) { errorOccurred = true; if (callback) callback(genError(err, oldPath + "/" + entry)); return; } if (!--pending && callback) callback(null); }); }); }); }); } else { this.copyFile(oldPath, newPath, callback); } }); } /** * Changes the permissions of a file or directory. * @param path - The path to the file or directory. * @param mode - The new permissions mode. * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. * @example * tfs.fs.chmod("/documents/file.txt", 0o644, (err) => { * if (err) throw err; * console.log("Permissions changed successfully!"); * }); */ chmod(path: string, mode: number, callback?: (err: Error | null) => void) { const normalizedPath = this.normalizePath(path); const permsEntry = this.perms[normalizedPath] || this.perms[path]; if (!permsEntry) { if (callback) callback(genError("NotFoundError", normalizedPath)); return; } const perms: string[] = []; if (mode & this.constants.S_IRUSR || mode & this.constants.S_IRGRP || mode & this.constants.S_IROTH) perms.push("r"); if (mode & this.constants.S_IWUSR || mode & this.constants.S_IWGRP || mode & this.constants.S_IWOTH) perms.push("w"); if (mode & this.constants.S_IXUSR || mode & this.constants.S_IXGRP || mode & this.constants.S_IXOTH) perms.push("x"); if (mode & this.constants.O_APPEND) perms.push("a"); permsEntry.perms = perms; updMeta(this.handle, { [normalizedPath]: permsEntry }); this.perms = { ...this.perms, [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }; if (callback) callback(null); } /** * Changes the ownership of a file or directory. * @param path - The path to the file or directory. * @param uid - The new user ID. * @param gid - The new group ID. * @param callback - Optional callback function called when the operation completes. Receives an error if one occurs, or null on success. * @example * tfs.fs.chown("/documents/file.txt", 1000, 1000, (err) => { * if (err) throw err; * console.log("Ownership changed successfully!"); * }); */ chown(path: string, uid: number, gid: number, callback?: (err: Error | null) => void) { const normalizedPath = this.normalizePath(path); const permsEntry = this.perms[normalizedPath] || this.perms[path]; if (!permsEntry) { if (callback) callback(genError("NotFoundError", normalizedPath)); return; } permsEntry.uid = uid; permsEntry.gid = gid; updMeta(this.handle, { [normalizedPath]: permsEntry }); this.perms = { ...this.perms, [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }; if (callback) callback(null); } /** * Checks if the current user has execute (x), access (a), or read (r) permissions for the given path. * Returns true if any of those permissions are present, similar to NodeFS's fs.access. * @param path - The path to check permissions for. * @example * const canExecute = tfs.fs.getaxxr("/documents/file.txt"); * console.log("Can execute/access/read:", canExecute); */ getaxxr(path: string, callback?: (canAccess: boolean) => void) { const normalizedPath = this.normalizePath(path); const perms = this.perms[normalizedPath]; if (!perms || !Array.isArray(perms.perms)) return false; const canAccess = perms.perms.includes("x") || perms.perms.includes("a") || perms.perms.includes("r"); if (callback) callback(canAccess); } /** * Sets execute (x), access (a), or read (r) permission for the given path, similar to NodeFS's chmod. * Adds "x" permission if not present. * @param path - The path to set permissions for. * @example * const changed = tfs.fs.setxxr("/documents/file.txt"); * console.log("Permissions changed:", changed); */ setxxr(path: string, callback?: (changed: boolean) => void) { const normalizedPath = this.normalizePath(path); const perms = this.perms[normalizedPath]; if (!perms || !Array.isArray(perms.perms)) return false; if (!perms.perms.includes("x")) { perms.perms.push("x"); updMeta(this.handle, { [normalizedPath]: perms }); this.perms = { ...this.perms, [normalizedPath]: { perms: ["a"], uid: 0, gid: 0 } }; if (callback) callback(true); } else { if (callback) callback(false); } } promises = { /** * Writes data to a file. * @param file - The path to the file. * @param content - The content to write to the file. * @returns A promise that resolves when the file has been written. * @example * await tfs.fs.promises.writeFile("/documents/file.txt", "Hello, World!"); */ writeFile: (file: string, content: string | ArrayBuffer | Blob, type?: "utf8" | "arraybuffer" | "blob" | "base64") => { return new Promise<void>((resolve, reject) => { this.writeFile(file, content, type, err => { if (err) { reject(err); } else { resolve(); } }); }); }, /** * Reads the contents of a file. * @param file - The path to the file. * @param type - The type of the file contents. * @returns A promise that resolves with the contents of the file. * @example * const data = await tfs.fs.promises.readFile("/documents/file.txt", "utf8"); */ readFile: (file: string, type?: "utf8" | "arraybuffer" | "blob" | "base64") => { return new Promise<any>((resolve, reject) => { if (!type) type = "utf8"; this.readFile(file, type, (err: Error | null, data: any) => { if (err) { reject(err); } else { resolve(data); } }); }); }, /** * Creates a new directory. * @param dir - The path to the directory to create. * @returns A promise that resolves when the directory has been created. * @example * await tfs.fs.promises.mkdir("/documents/newFolder"); */ mkdir: (dir: string) => { return new Promise<void>((resolve, reject) => { this.mkdir(dir, err => { if (err) { reject(err); } else { resolve(); } }); }); }, /** * Reads the contents of a directory. * @param dir - The path to the directory to read. * @returns A promise that resolves with an array of file names in the directory. * @example * const contents = await tfs.fs.promises.readdir("/documents"); */ readdir: (dir: string) => { return new Promise<string[]>((resolve, reject) => { this.readdir(dir, (err, files) => { if (err) { reject(err); } else { resolve(files); } }); }); }, /** * Retrieves information about a file or directory. * @param path - The absolute or relative path of the file or directory to retrieve information for. * @returns A promise that resolves with an object containing the name, size, mime type of the file or just as directory, lastModified timestamp and also the methods: isSymbolicLink, isDirectory, isFile which return booleans. * @example * const stats = await tfs.fs.promises.stat("/documents/file.txt"); */ stat: (path: string) => { return new Promise<FSStats | null>((resolve, reject) => { this.stat(path, (err, stats) => { if (err) { reject(err); } else { resolve(stats!); } }); }); }, /** * Retrieves information about a symlink or file/directory. * @param path - The absolute or relative path of the symlink or file/directory to retrieve information for. * @returns A promise that resolves with an object containing the name, size, mime type of the file or just as directory, lastModified timestamp and also the methods: isSymbolicLink, isDirectory, isFile which return booleans. * @example * const stats = await tfs.fs.promises.stat("/documents/file.txt"); */ lstat: (path: string) => { return new Promise<FSStats | null>((resolve, reject) => { this.lstat(path, (err, stats) => { if (err) { reject(err); } else { resolve(stats!); } }); }); }, /** * Appends data to a file. If the file does not exist, it is created. * @param path - The absolute or relative path to the file to append data to. * @param data - The data to append to the file. * @returns A promise that resolves when the data has been appended. * @example * await tfs.fs.promises.appendFile("/documents/file.txt", "Additional content"); */ appendFile: (path: string, data: string | ArrayBuffer | ArrayBufferView) => { return new Promise<void>((resolve, reject) => { this.appendFile(path, data, err => { if (err) { reject(err); } else { resolve(); } }); }); }, /** * Deletes a file or symlink. * @param path - The absolute or relative path of the file or symlink to delete. * @returns A promise that resolves when the file or symlink has been deleted. * @example * await tfs.fs.promises.unlink("/documents/file.txt"); */ unlink: (path: string) => { return new Promise<void>((resolve, reject) => { this.unlink(path, err => { if (err) { reject(err); } else { resolve(); } }); }); }, /** * Checks if a file or directory exists. * @para