├── .gitignore ├── package.json ├── wrangler.toml ├── README.md ├── test.js └── fs.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .git 4 | .wrangler -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-fs", 3 | "version": "0.0.2", 4 | "main": "fs.js", 5 | "files": [ 6 | "fs.js" 7 | ], 8 | "devDependencies": { 9 | "@cloudflare/workers-types": "^4.20250719.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "fs-test-worker" 2 | main = "test.js" 3 | compatibility_date = "2025-07-15" 4 | route.custom_domain = true 5 | route.pattern = "fs.itscooldo.com" 6 | 7 | [[durable_objects.bindings]] 8 | name = "DOFS" 9 | class_name = "DOFS" 10 | 11 | [[migrations]] 12 | tag = "v1" 13 | new_sqlite_classes = ["DOFS"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://badge.forgithub.com/janwilmake/cloudflare-fs)](https://uithub.com/janwilmake/cloudflare-fs) [![](https://b.lmpify.com)](https://letmeprompt.com?q=https://uithub.com/janwilmake/cloudflare-fs) 2 | 3 | This package implements an opinionated durable object powered file-system that aims to replicate the exact node `fs/promises` api and make it available in workers. [Discuss on X](https://x.com/janwilmake/status/1946939223673544864) 4 | 5 | ``` 6 | npm i cloudflare-fs 7 | ``` 8 | 9 | Usage: 10 | 11 | ```js 12 | // worker.js 13 | import { writeFile, DOFS } from "cloudflare-fs"; 14 | export { DOFS }; 15 | export default { 16 | fetch: async (request) => { 17 | await writeFile("/latest-request.txt", request.url, "utf8"); 18 | return new Response("written!"); 19 | }, 20 | }; 21 | ``` 22 | 23 | Add to your `wrangler.toml` 24 | 25 | ```toml 26 | [[durable_objects.bindings]] 27 | name = "DOFS" 28 | class_name = "DOFS" 29 | 30 | [[migrations]] 31 | tag = "v1" 32 | new_sqlite_classes = ["DOFS"] 33 | ``` 34 | 35 | # Limitations compared to Node.js fs: 36 | 37 | - **No streaming APIs** - missing `createReadStream`, `createWriteStream` 38 | - **No sync versions** - missing `readFileSync`, `writeFileSync`, etc. 39 | - **No watch functionality** - missing `watch`, `watchFile` 40 | - **No advanced features** - missing `link`, `symlink`, `readlink`, `chmod`, `chown` 41 | - **No file descriptors** - missing `open`, `close`, `read`, `write` with fd 42 | - **Cross-instance operations** are simplified and may be slower 43 | - **No proper error codes** - Node.js fs uses specific error codes like ENOENT, EISDIR 44 | - **Limited encoding support** - only basic TextEncoder/TextDecoder 45 | - **Limited max filesize** - capped at the max rowsize of 2MB 46 | - **Limited max total disk size** - capped at 10GB per disk\* 47 | - Every fs request does a round-trip to the DO! This can make these operations rather slow if you have lots of them if the DO is not in the same place as the worker. I wonder though, how fast it will be if ran from a DO in the same spot. 48 | 49 | # How do disks work? 50 | 51 | - every username in paths starting with `/Users/{username}` becomes its own disk (DO) 52 | - anything else goes to the 'default' disk. 53 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import { 2 | copyFile, 3 | cp, 4 | mkdir, 5 | readdir, 6 | readFile, 7 | rename, 8 | rm, 9 | stat, 10 | writeFile, 11 | DOFS, 12 | } from "./fs.js"; 13 | 14 | export { DOFS }; 15 | 16 | export default { 17 | async fetch(request, env, ctx) { 18 | try { 19 | const url = new URL(request.url); 20 | 21 | if (url.pathname === "/test") { 22 | return new Response(await runTests(), { 23 | headers: { "Content-Type": "text/plain;charset=utf8" }, 24 | }); 25 | } 26 | 27 | return new Response( 28 | "FS Test Worker\n\nVisit /test to run filesystem tests", 29 | { 30 | headers: { "Content-Type": "text/plain" }, 31 | } 32 | ); 33 | } catch (error) { 34 | return new Response(`Error: ${error.message}\n${error.stack}`, { 35 | status: 500, 36 | headers: { "Content-Type": "text/plain" }, 37 | }); 38 | } 39 | }, 40 | }; 41 | 42 | async function runTests() { 43 | const results = []; 44 | 45 | function log(message) { 46 | results.push(message); 47 | console.log(message); 48 | } 49 | 50 | try { 51 | log("🧪 Starting filesystem tests...\n"); 52 | 53 | // Test 1: Create directories 54 | log("Test 1: Creating directories"); 55 | await mkdir("/Users/testuser/documents", { recursive: true }); 56 | await mkdir("/Users/testuser/projects/myapp", { recursive: true }); 57 | await mkdir("/tmp", { recursive: true }); 58 | log("✅ Directories created successfully\n"); 59 | 60 | // Test 2: Write files 61 | log("Test 2: Writing files"); 62 | await writeFile("/Users/testuser/documents/readme.txt", "Hello, World!"); 63 | await writeFile( 64 | "/Users/testuser/projects/myapp/package.json", 65 | JSON.stringify( 66 | { 67 | name: "myapp", 68 | version: "1.0.0", 69 | }, 70 | null, 71 | 2 72 | ) 73 | ); 74 | await writeFile("/tmp/temp.log", "Temporary file content"); 75 | log("✅ Files written successfully\n"); 76 | 77 | // Test 3: Read files 78 | log("Test 3: Reading files"); 79 | const readme = await readFile( 80 | "/Users/testuser/documents/readme.txt", 81 | "utf8" 82 | ); 83 | log(`readme.txt content: "${readme}"`); 84 | 85 | const packageJson = await readFile( 86 | "/Users/testuser/projects/myapp/package.json", 87 | "utf8" 88 | ); 89 | const parsed = JSON.parse(packageJson); 90 | log(`package.json name: "${parsed.name}"`); 91 | 92 | const tempLog = await readFile("/tmp/temp.log", "utf8"); 93 | log(`temp.log content: "${tempLog}"`); 94 | log("✅ Files read successfully\n"); 95 | 96 | // Test 4: List directories 97 | log("Test 4: Listing directories"); 98 | const userFiles = await readdir("/Users/testuser"); 99 | log(`/Users/testuser contents: ${userFiles.join(", ")}`); 100 | 101 | const docsFiles = await readdir("/Users/testuser/documents"); 102 | log(`/Users/testuser/documents contents: ${docsFiles.join(", ")}`); 103 | 104 | const projectFiles = await readdir("/Users/testuser/projects"); 105 | log(`/Users/testuser/projects contents: ${projectFiles.join(", ")}`); 106 | log("✅ Directory listings successful\n"); 107 | 108 | // Test 5: File stats 109 | log("Test 5: Getting file stats"); 110 | const readmeStats = await stat("/Users/testuser/documents/readme.txt"); 111 | log( 112 | `readme.txt - isFile: ${readmeStats.isFile}, size: ${readmeStats.size} bytes` 113 | ); 114 | 115 | const docsStats = await stat("/Users/testuser/documents"); 116 | log(`documents - isDirectory: ${docsStats.isDirectory}`); 117 | log("✅ File stats retrieved successfully\n"); 118 | 119 | // Test 6: Copy files 120 | log("Test 6: Copying files"); 121 | await copyFile( 122 | "/Users/testuser/documents/readme.txt", 123 | "/tmp/readme-copy.txt" 124 | ); 125 | const copiedContent = await readFile("/tmp/readme-copy.txt", "utf8"); 126 | log(`Copied file content: "${copiedContent}"`); 127 | log("✅ File copied successfully\n"); 128 | 129 | // Test 7: Copy directory (cross-instance) 130 | log("Test 7: Copying directory across instances"); 131 | await cp("/Users/testuser/documents", "/tmp/documents-backup", { 132 | recursive: true, 133 | }); 134 | const backupFiles = await readdir("/tmp/documents-backup"); 135 | log(`Backup directory contents: ${backupFiles.join(", ")}`); 136 | log("✅ Directory copied successfully\n"); 137 | 138 | // Test 8: Rename files 139 | log("Test 8: Renaming files"); 140 | await rename("/tmp/temp.log", "/tmp/renamed.log"); 141 | const renamedFiles = await readdir("/tmp"); 142 | log(`/tmp after rename: ${renamedFiles.join(", ")}`); 143 | log("✅ File renamed successfully\n"); 144 | 145 | // Test 9: Binary file handling 146 | log("Test 9: Binary file handling"); 147 | const binaryData = new Uint8Array([ 148 | 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 149 | ]); // PNG header 150 | await writeFile("/Users/testuser/documents/test.png", binaryData); 151 | 152 | // Read without encoding to get ArrayBuffer 153 | const readBinary = await readFile("/Users/testuser/documents/test.png"); 154 | const readArray = new Uint8Array(readBinary); 155 | log( 156 | `Binary file size: ${readArray.length} bytes, first byte: 0x${readArray[0] 157 | .toString(16) 158 | .padStart(2, "0")}` 159 | ); 160 | log("✅ Binary file handling successful\n"); 161 | 162 | // Test 10: Error handling 163 | log("Test 10: Error handling"); 164 | try { 165 | await readFile("/nonexistent/file.txt"); 166 | log("❌ Should have thrown an error"); 167 | } catch (error) { 168 | log(`✅ Correctly threw error: ${error.message}`); 169 | } 170 | 171 | try { 172 | await mkdir("/Users/testuser/documents/readme.txt/invalid"); 173 | log("❌ Should have thrown an error"); 174 | } catch (error) { 175 | log(`✅ Correctly threw error: ${error.message}`); 176 | } 177 | log(""); 178 | 179 | // Test 11: Remove files and directories 180 | log("Test 11: Cleaning up (removing files)"); 181 | await rm("/tmp/readme-copy.txt"); 182 | await rm("/tmp/documents-backup", { recursive: true }); 183 | await rm("/tmp/renamed.log"); 184 | await rm("/Users/testuser/projects", { recursive: true }); 185 | 186 | const finalUserFiles = await readdir("/Users/testuser"); 187 | log(`Final /Users/testuser contents: ${finalUserFiles.join(", ")}`); 188 | log("✅ Cleanup successful\n"); 189 | 190 | // Test 12: Cross-instance operations 191 | log("Test 12: Cross-instance operations test"); 192 | 193 | // First ensure parent directories exist 194 | await mkdir("/Users/alice", { recursive: true }); 195 | await mkdir("/Users/bob", { recursive: true }); 196 | 197 | await writeFile("/Users/alice/data.txt", "Alice data"); 198 | await writeFile("/Users/bob/data.txt", "Bob data"); 199 | 200 | // This should use different DO instances 201 | const aliceData = await readFile("/Users/alice/data.txt", "utf8"); 202 | const bobData = await readFile("/Users/bob/data.txt", "utf8"); 203 | log(`Alice's data: "${aliceData}"`); 204 | log(`Bob's data: "${bobData}"`); 205 | 206 | // Cross-instance copy 207 | await copyFile("/Users/alice/data.txt", "/Users/bob/alice-data.txt"); 208 | const crossCopy = await readFile("/Users/bob/alice-data.txt", "utf8"); 209 | log(`Cross-instance copy result: "${crossCopy}"`); 210 | log("✅ Cross-instance operations successful\n"); 211 | 212 | log("🎉 All tests passed!"); 213 | } catch (error) { 214 | log(`❌ Test failed: ${error.message}`); 215 | log(`Stack trace: ${error.stack}`); 216 | } 217 | 218 | return results.join("\n"); 219 | } 220 | -------------------------------------------------------------------------------- /fs.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | /// 3 | /// 4 | import { DurableObject, env } from "cloudflare:workers"; 5 | 6 | /** 7 | * @typedef Env 8 | * @property {DurableObjectNamespace} DOFS 9 | */ 10 | 11 | /** 12 | * Represents a file or directory entry in the filesystem 13 | * @typedef {Object} File 14 | * @property {string} path - The full path of the file/directory (PRIMARY KEY) 15 | * @property {string|null} parent_path - The path of the parent directory 16 | * @property {string} name - The name of the file/directory (NOT NULL) 17 | * @property {'file'|'directory'} type - The type of entry (NOT NULL, must be 'file' or 'directory') 18 | * @property {Blob|null} content - The binary content of the file (null for directories) 19 | * @property {number} size - The size of the file in bytes (default: 0) 20 | * @property {number} mode - The file permissions/mode (default: 33188 for regular files) 21 | * @property {number} uid - The user ID of the file owner (default: 0) 22 | * @property {number} gid - The group ID of the file owner (default: 0) 23 | * @property {number} mtime - The modification time as Unix timestamp (default: current time) 24 | * @property {number} ctime - The creation time as Unix timestamp (default: current time) 25 | * @property {number} atime - The access time as Unix timestamp (default: current time) 26 | */ 27 | 28 | /** 29 | * @typedef {Object} Stats 30 | * @property {boolean} isFile - Returns true if the item is a file 31 | * @property {boolean} isDirectory - Returns true if the item is a directory 32 | * @property {boolean} isSymbolicLink - Returns true if the item is a symbolic link 33 | * @property {number} size - Size of the file in bytes 34 | * @property {Date} mtime - Modified time 35 | * @property {Date} ctime - Created time 36 | * @property {Date} atime - Accessed time 37 | * @property {number} mode - File mode/permissions 38 | * @property {number} uid - User ID of owner 39 | * @property {number} gid - Group ID of owner 40 | */ 41 | 42 | /** 43 | * @typedef {Object} CopyOptions 44 | * @property {boolean} [force] - Overwrite existing file or directory 45 | * @property {boolean} [preserveTimestamps] - Preserve timestamps 46 | * @property {boolean} [recursive] - Copy directories recursively 47 | * @property {function} [filter] - Function to filter copied files 48 | */ 49 | 50 | /** 51 | * @typedef {Object} WriteFileOptions 52 | * @property {string} [encoding='utf8'] - Character encoding 53 | * @property {number} [mode=0o666] - File mode 54 | * @property {string} [flag='w'] - File system flag 55 | */ 56 | 57 | /** 58 | * @typedef {Object} ReadFileOptions 59 | * @property {string} [encoding] - Character encoding (if not specified, returns Buffer) 60 | * @property {string} [flag='r'] - File system flag 61 | */ 62 | 63 | /** 64 | * @typedef {Object} MkdirOptions 65 | * @property {boolean} [recursive=false] - Create parent directories if they don't exist 66 | * @property {number} [mode=0o777] - Directory mode 67 | */ 68 | 69 | /** 70 | * @typedef {Object} RmOptions 71 | * @property {boolean} [force=false] - Ignore nonexistent files 72 | * @property {boolean} [recursive=false] - Remove directories recursively 73 | * @property {number} [maxRetries=0] - Maximum number of retry attempts 74 | * @property {number} [retryDelay=100] - Delay between retries in ms 75 | */ 76 | 77 | // Global env reference 78 | /** 79 | * Set the environment for the fs module 80 | * @type {Env} env - The environment object 81 | */ 82 | let globalEnv = env; 83 | 84 | /** 85 | * Get DO instance name from path 86 | * @param {string} path - File path 87 | * @returns {string} - DO instance name 88 | */ 89 | function getInstanceName(path) { 90 | const userMatch = path.match(/^\/Users\/([^\/]+)/); 91 | return userMatch ? userMatch[1] : "default"; 92 | } 93 | 94 | /** 95 | * Get DOFS instance for path 96 | * @param {string} path - File path 97 | * @returns {DurableObjectStub} - DOFS instance 98 | */ 99 | function getInstance(path) { 100 | if (!globalEnv) { 101 | throw new Error("Environment not set. Call setEnv(env) first."); 102 | } 103 | const name = getInstanceName(path); 104 | return globalEnv.DOFS.get(globalEnv.DOFS.idFromName(name)); 105 | } 106 | 107 | /** 108 | * Ensure parent directories exist across instances 109 | * @param {string} path - File path 110 | */ 111 | async function ensureParentExists(path) { 112 | const normalized = path.replace(/\/+$/, "").replace(/\/+/g, "/"); 113 | if (normalized === "/" || !normalized.includes("/")) return; 114 | 115 | const lastSlash = normalized.lastIndexOf("/"); 116 | const parentPath = lastSlash <= 0 ? "/" : normalized.substring(0, lastSlash); 117 | 118 | if (parentPath === "/") return; 119 | 120 | try { 121 | await stat(parentPath); 122 | } catch { 123 | // Parent doesn't exist, create it 124 | await mkdir(parentPath, { recursive: true }); 125 | } 126 | } 127 | 128 | /** 129 | * Copy a file from source to destination 130 | * @param {string} src - Source file path 131 | * @param {string} dest - Destination file path 132 | * @param {number} [mode] - Optional mode specifying behavior 133 | * @returns {Promise} 134 | */ 135 | export async function copyFile(src, dest, mode = 0) { 136 | const srcInstance = getInstance(src); 137 | const destInstance = getInstance(dest); 138 | 139 | if (srcInstance === destInstance) { 140 | await srcInstance.copyFile(src, dest, mode); 141 | } else { 142 | // Ensure parent directory exists in destination instance 143 | await ensureParentExists(dest); 144 | 145 | const content = await srcInstance.readFileBuffer(src); 146 | const stats = await srcInstance.stat(src); 147 | await destInstance.writeFileBuffer(dest, content, { mode: stats.mode }); 148 | } 149 | } 150 | 151 | /** 152 | * Copy files and directories 153 | * @param {string} src - Source path 154 | * @param {string} dest - Destination path 155 | * @param {CopyOptions} [options] - Copy options 156 | * @returns {Promise} 157 | */ 158 | export async function cp(src, dest, options = {}) { 159 | const srcInstance = getInstance(src); 160 | const destInstance = getInstance(dest); 161 | 162 | if (srcInstance === destInstance) { 163 | await srcInstance.cp(src, dest, options); 164 | } else { 165 | // Cross-instance copy - simplified implementation 166 | const stats = await srcInstance.stat(src); 167 | if (stats.isDirectory) { 168 | if (!options.recursive) { 169 | throw new Error("Cannot copy directory without recursive option"); 170 | } 171 | await mkdir(dest, { recursive: true }); 172 | const entries = await srcInstance.readdir(src); 173 | for (const entry of entries) { 174 | await cp(`${src}/${entry}`, `${dest}/${entry}`, options); 175 | } 176 | } else { 177 | await ensureParentExists(dest); 178 | const content = await srcInstance.readFileBuffer(src); 179 | await destInstance.writeFileBuffer(dest, content, { mode: stats.mode }); 180 | } 181 | } 182 | } 183 | 184 | /** 185 | * Create a directory 186 | * @param {string} path - Directory path to create 187 | * @param {MkdirOptions} [options] - Directory creation options 188 | * @returns {Promise} - Returns path of first directory created (when recursive) 189 | */ 190 | export async function mkdir(path, options = {}) { 191 | const instance = getInstance(path); 192 | return await instance.mkdir(path, options); 193 | } 194 | 195 | /** 196 | * Read directory contents 197 | * @param {string} path - Directory path to read 198 | * @param {Object} [options] - Read options 199 | * @param {string} [options.encoding='utf8'] - Character encoding for filenames 200 | * @param {boolean} [options.withFileTypes=false] - Return Dirent objects instead of strings 201 | * @returns {Promise} - Array of filenames or Dirent objects 202 | */ 203 | export async function readdir(path, options = {}) { 204 | const instance = getInstance(path); 205 | return await instance.readdir(path, options); 206 | } 207 | 208 | /** 209 | * Read file contents 210 | * @param {string} path - File path to read 211 | * @param {ReadFileOptions|string} [options] - Read options or encoding string 212 | * @returns {Promise} - File contents as Buffer or string 213 | */ 214 | export async function readFile(path, options) { 215 | const instance = getInstance(path); 216 | return await instance.readFile(path, options); 217 | } 218 | 219 | /** 220 | * Rename/move a file or directory 221 | * @param {string} oldPath - Current path 222 | * @param {string} newPath - New path 223 | * @returns {Promise} 224 | */ 225 | export async function rename(oldPath, newPath) { 226 | const oldInstance = getInstance(oldPath); 227 | const newInstance = getInstance(newPath); 228 | 229 | if (oldInstance === newInstance) { 230 | await oldInstance.rename(oldPath, newPath); 231 | } else { 232 | // Cross-instance move 233 | await cp(oldPath, newPath, { recursive: true }); 234 | await rm(oldPath, { recursive: true }); 235 | } 236 | } 237 | 238 | /** 239 | * Remove files and directories 240 | * @param {string} path - Path to remove 241 | * @param {RmOptions} [options] - Remove options 242 | * @returns {Promise} 243 | */ 244 | export async function rm(path, options = {}) { 245 | const instance = getInstance(path); 246 | await instance.rm(path, options); 247 | } 248 | 249 | /** 250 | * Get file/directory statistics 251 | * @param {string} path - Path to stat 252 | * @param {Object} [options] - Stat options 253 | * @param {boolean} [options.bigint=false] - Return BigInt values for numeric properties 254 | * @returns {Promise} - File statistics object 255 | */ 256 | export async function stat(path, options = {}) { 257 | const instance = getInstance(path); 258 | return await instance.stat(path, options); 259 | } 260 | 261 | /** 262 | * Write data to a file 263 | * @param {string} file - File path to write 264 | * @param {string|ArrayBuffer|Uint8Array} data - Data to write 265 | * @param {WriteFileOptions|string} [options] - Write options or encoding string 266 | * @returns {Promise} 267 | */ 268 | export async function writeFile(file, data, options) { 269 | const instance = getInstance(file); 270 | await instance.writeFile(file, data, options); 271 | } 272 | 273 | export class DOFS extends DurableObject { 274 | /** @param {DurableObjectState} state @param {Env} env */ 275 | constructor(state, env) { 276 | super(state, env); 277 | this.sql = state.storage.sql; 278 | this.env = env; 279 | this.initTables(); 280 | } 281 | 282 | initTables() { 283 | this.sql.exec(` 284 | CREATE TABLE IF NOT EXISTS files ( 285 | path TEXT PRIMARY KEY, 286 | parent_path TEXT, 287 | name TEXT NOT NULL, 288 | type TEXT NOT NULL CHECK (type IN ('file', 'directory')), 289 | content BLOB, 290 | size INTEGER NOT NULL DEFAULT 0, 291 | mode INTEGER NOT NULL DEFAULT 33188, 292 | uid INTEGER NOT NULL DEFAULT 0, 293 | gid INTEGER NOT NULL DEFAULT 0, 294 | mtime INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 295 | ctime INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 296 | atime INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 297 | ) 298 | `); 299 | 300 | this.sql.exec( 301 | `CREATE INDEX IF NOT EXISTS idx_parent_path ON files(parent_path)` 302 | ); 303 | this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_name ON files(name)`); 304 | this.sql.exec(`CREATE INDEX IF NOT EXISTS idx_type ON files(type)`); 305 | } 306 | 307 | /** 308 | * Normalize path by removing trailing slashes and resolving relative parts 309 | * @param {string} path - Path to normalize 310 | * @returns {string} - Normalized path 311 | */ 312 | normalizePath(path) { 313 | if (path === "/") return "/"; 314 | return path.replace(/\/+$/, "").replace(/\/+/g, "/"); 315 | } 316 | 317 | /** 318 | * Get parent directory path 319 | * @param {string} path - File path 320 | * @returns {string} - Parent directory path 321 | */ 322 | getParentPath(path) { 323 | const normalized = this.normalizePath(path); 324 | if (normalized === "/") return null; 325 | const lastSlash = normalized.lastIndexOf("/"); 326 | return lastSlash <= 0 ? "/" : normalized.substring(0, lastSlash); 327 | } 328 | 329 | /** 330 | * Get filename from path 331 | * @param {string} path - File path 332 | * @returns {string} - Filename 333 | */ 334 | getFileName(path) { 335 | const normalized = this.normalizePath(path); 336 | if (normalized === "/") return ""; 337 | const lastSlash = normalized.lastIndexOf("/"); 338 | return normalized.substring(lastSlash + 1); 339 | } 340 | 341 | /** 342 | * Copy a file 343 | * @param {string} src - Source path 344 | * @param {string} dest - Destination path 345 | * @param {number} mode - Copy mode 346 | */ 347 | async copyFile(src, dest, mode) { 348 | const srcFile = this.sql 349 | .exec("SELECT * FROM files WHERE path = ?", src) 350 | .toArray()[0]; 351 | if (!srcFile || srcFile.type !== "file") { 352 | throw new Error("Source file not found"); 353 | } 354 | 355 | const destParent = this.getParentPath(dest); 356 | if (destParent && destParent !== "/") { 357 | const parent = this.sql 358 | .exec("SELECT * FROM files WHERE path = ?", destParent) 359 | .toArray()[0]; 360 | if (!parent || parent.type !== "directory") { 361 | throw new Error("Destination directory does not exist"); 362 | } 363 | } 364 | 365 | const now = Math.floor(Date.now() / 1000); 366 | this.sql.exec( 367 | ` 368 | INSERT OR REPLACE INTO files 369 | (path, parent_path, name, type, content, size, mode, uid, gid, mtime, ctime, atime) 370 | VALUES (?, ?, ?, 'file', ?, ?, ?, ?, ?, ?, ?, ?) 371 | `, 372 | dest, 373 | destParent, 374 | this.getFileName(dest), 375 | srcFile.content, 376 | srcFile.size, 377 | srcFile.mode, 378 | srcFile.uid, 379 | srcFile.gid, 380 | now, 381 | now, 382 | now 383 | ); 384 | } 385 | 386 | /** 387 | * Copy files and directories recursively 388 | * @param {string} src - Source path 389 | * @param {string} dest - Destination path 390 | * @param {CopyOptions} options - Copy options 391 | */ 392 | async cp(src, dest, options = {}) { 393 | const srcFile = this.sql 394 | .exec("SELECT * FROM files WHERE path = ?", src) 395 | .toArray()[0]; 396 | if (!srcFile) { 397 | throw new Error("Source does not exist"); 398 | } 399 | 400 | if (srcFile.type === "file") { 401 | await this.copyFile(src, dest, 0); 402 | } else if (srcFile.type === "directory") { 403 | if (!options.recursive) { 404 | throw new Error("Cannot copy directory without recursive option"); 405 | } 406 | 407 | // Create destination directory 408 | await this.mkdir(dest, { recursive: true }); 409 | 410 | // Copy all children 411 | const children = this.sql 412 | .exec("SELECT * FROM files WHERE parent_path = ?", src) 413 | .toArray(); 414 | for (const child of children) { 415 | const childSrc = child.path; 416 | const childDest = `${dest}/${child.name}`; 417 | await this.cp(childSrc, childDest, options); 418 | } 419 | } 420 | } 421 | 422 | /** 423 | * Create directory 424 | * @param {string} path - Directory path 425 | * @param {MkdirOptions} options - Options 426 | * @returns {Promise} - First created directory path 427 | */ 428 | async mkdir(path, options = {}) { 429 | const normalizedPath = this.normalizePath(path); 430 | 431 | // Check if already exists 432 | const existing = this.sql 433 | .exec("SELECT * FROM files WHERE path = ?", normalizedPath) 434 | .toArray()[0]; 435 | if (existing) { 436 | if (existing.type === "directory") { 437 | return undefined; // Already exists 438 | } else { 439 | throw new Error("File exists and is not a directory"); 440 | } 441 | } 442 | 443 | const parentPath = this.getParentPath(normalizedPath); 444 | let firstCreated = undefined; 445 | 446 | if (parentPath && parentPath !== "/") { 447 | const parent = this.sql 448 | .exec("SELECT * FROM files WHERE path = ?", parentPath) 449 | .toArray()[0]; 450 | if (!parent) { 451 | if (options.recursive) { 452 | firstCreated = await this.mkdir(parentPath, options); 453 | } else { 454 | throw new Error("Parent directory does not exist"); 455 | } 456 | } else if (parent.type !== "directory") { 457 | throw new Error("Parent is not a directory"); 458 | } 459 | } 460 | 461 | const now = Math.floor(Date.now() / 1000); 462 | const mode = options.mode || 0o777; 463 | 464 | this.sql.exec( 465 | ` 466 | INSERT INTO files 467 | (path, parent_path, name, type, size, mode, uid, gid, mtime, ctime, atime) 468 | VALUES (?, ?, ?, 'directory', 0, ?, 0, 0, ?, ?, ?) 469 | `, 470 | normalizedPath, 471 | parentPath, 472 | this.getFileName(normalizedPath), 473 | mode, 474 | now, 475 | now, 476 | now 477 | ); 478 | 479 | return firstCreated || normalizedPath; 480 | } 481 | 482 | /** 483 | * Read directory contents 484 | * @param {string} path - Directory path 485 | * @param {Object} options - Read options 486 | * @returns {Promise} - Directory entries 487 | */ 488 | async readdir(path, options = {}) { 489 | const normalizedPath = this.normalizePath(path); 490 | const dir = this.sql 491 | .exec("SELECT * FROM files WHERE path = ?", normalizedPath) 492 | .toArray()[0]; 493 | 494 | if (!dir) { 495 | throw new Error("Directory does not exist"); 496 | } 497 | if (dir.type !== "directory") { 498 | throw new Error("Not a directory"); 499 | } 500 | 501 | /** 502 | * @type {File[]} 503 | */ 504 | const entries = this.sql 505 | .exec( 506 | "SELECT * FROM files WHERE parent_path = ? ORDER BY name", 507 | normalizedPath 508 | ) 509 | .toArray(); 510 | 511 | if (options.withFileTypes) { 512 | return entries.map((entry) => ({ 513 | name: entry.name, 514 | isFile: () => entry.type === "file", 515 | isDirectory: () => entry.type === "directory", 516 | isSymbolicLink: () => false, 517 | })); 518 | } 519 | 520 | return entries.map((entry) => entry.name); 521 | } 522 | 523 | /** 524 | * Read file contents as buffer 525 | * @param {string} path - File path 526 | * @returns {Promise} - File contents 527 | */ 528 | async readFileBuffer(path) { 529 | /** @type {File} */ 530 | const file = this.sql 531 | .exec("SELECT * FROM files WHERE path = ?", path) 532 | .toArray()[0]; 533 | 534 | if (!file) { 535 | throw new Error("File does not exist"); 536 | } 537 | if (file.type !== "file") { 538 | throw new Error("Not a file"); 539 | } 540 | 541 | // Update access time 542 | const now = Math.floor(Date.now() / 1000); 543 | this.sql.exec("UPDATE files SET atime = ? WHERE path = ?", now, path); 544 | 545 | return file.content || new ArrayBuffer(0); 546 | } 547 | 548 | /** 549 | * Read file contents 550 | * @param {string} path - File path 551 | * @param {ReadFileOptions|string} options - Read options 552 | * @returns {Promise} - File contents 553 | */ 554 | async readFile(path, options) { 555 | const buffer = await this.readFileBuffer(path); 556 | 557 | let encoding = null; 558 | if (typeof options === "string") { 559 | encoding = options; 560 | } else if (options && options.encoding) { 561 | encoding = options.encoding; 562 | } 563 | 564 | // If no encoding specified, return ArrayBuffer (like Node.js Buffer) 565 | if (!encoding) { 566 | return buffer; 567 | } 568 | 569 | const decoder = new TextDecoder(encoding); 570 | return decoder.decode(buffer); 571 | } 572 | 573 | /** 574 | * Rename/move a file or directory 575 | * @param {string} oldPath - Current path 576 | * @param {string} newPath - New path 577 | */ 578 | async rename(oldPath, newPath) { 579 | const normalizedOld = this.normalizePath(oldPath); 580 | const normalizedNew = this.normalizePath(newPath); 581 | 582 | const file = this.sql 583 | .exec("SELECT * FROM files WHERE path = ?", normalizedOld) 584 | .toArray()[0]; 585 | if (!file) { 586 | throw new Error("Source does not exist"); 587 | } 588 | 589 | const newParent = this.getParentPath(normalizedNew); 590 | if (newParent && newParent !== "/") { 591 | const parent = this.sql 592 | .exec("SELECT * FROM files WHERE path = ?", newParent) 593 | .toArray()[0]; 594 | if (!parent || parent.type !== "directory") { 595 | throw new Error("Destination directory does not exist"); 596 | } 597 | } 598 | 599 | const now = Math.floor(Date.now() / 1000); 600 | 601 | // Update the file itself 602 | this.sql.exec( 603 | ` 604 | UPDATE files 605 | SET path = ?, parent_path = ?, name = ?, mtime = ? 606 | WHERE path = ? 607 | `, 608 | normalizedNew, 609 | newParent, 610 | this.getFileName(normalizedNew), 611 | now, 612 | normalizedOld 613 | ); 614 | 615 | // If it's a directory, update all children 616 | if (file.type === "directory") { 617 | /** @type {File[]} */ 618 | const children = this.sql 619 | .exec("SELECT * FROM files WHERE path LIKE ?", `${normalizedOld}/%`) 620 | .toArray(); 621 | for (const child of children) { 622 | const newChildPath = child.path.replace(normalizedOld, normalizedNew); 623 | const newChildParent = this.getParentPath(newChildPath); 624 | this.sql.exec( 625 | ` 626 | UPDATE files 627 | SET path = ?, parent_path = ? 628 | WHERE path = ? 629 | `, 630 | newChildPath, 631 | newChildParent, 632 | child.path 633 | ); 634 | } 635 | } 636 | } 637 | 638 | /** 639 | * Remove files and directories 640 | * @param {string} path - Path to remove 641 | * @param {RmOptions} options - Remove options 642 | */ 643 | async rm(path, options = {}) { 644 | const normalizedPath = this.normalizePath(path); 645 | const file = this.sql 646 | .exec("SELECT * FROM files WHERE path = ?", normalizedPath) 647 | .toArray()[0]; 648 | 649 | if (!file) { 650 | if (options.force) { 651 | return; 652 | } 653 | throw new Error("File does not exist"); 654 | } 655 | 656 | if (file.type === "directory") { 657 | if (!options.recursive) { 658 | /** @type {{count:number}} */ 659 | const child = this.sql 660 | .exec( 661 | "SELECT COUNT(*) as count FROM files WHERE parent_path = ?", 662 | normalizedPath 663 | ) 664 | .toArray()[0]; 665 | if (child.count > 0) { 666 | throw new Error("Directory not empty"); 667 | } 668 | } else { 669 | // Remove all children recursively 670 | this.sql.exec( 671 | "DELETE FROM files WHERE path LIKE ?", 672 | `${normalizedPath}/%` 673 | ); 674 | } 675 | } 676 | 677 | // Remove the file/directory itself 678 | this.sql.exec("DELETE FROM files WHERE path = ?", normalizedPath); 679 | } 680 | 681 | /** 682 | * Get file statistics 683 | * @param {string} path - File path 684 | * @param {Object} options - Stat options 685 | * @returns {Promise} - File statistics 686 | */ 687 | async stat(path, options = {}) { 688 | const file = this.sql 689 | .exec("SELECT * FROM files WHERE path = ?", path) 690 | .toArray()[0]; 691 | if (!file) { 692 | throw new Error("File does not exist"); 693 | } 694 | 695 | return { 696 | isFile: file.type === "file", 697 | isDirectory: file.type === "directory", 698 | isSymbolicLink: false, 699 | size: file.size, 700 | mode: file.mode, 701 | uid: file.uid, 702 | gid: file.gid, 703 | mtime: new Date(file.mtime * 1000), 704 | ctime: new Date(file.ctime * 1000), 705 | atime: new Date(file.atime * 1000), 706 | }; 707 | } 708 | 709 | /** 710 | * Write buffer to file 711 | * @param {string} path - File path 712 | * @param {ArrayBuffer|Uint8Array} data - Data to write 713 | * @param {Object} options - Write options 714 | */ 715 | async writeFileBuffer(path, data, options = {}) { 716 | const normalizedPath = this.normalizePath(path); 717 | const parentPath = this.getParentPath(normalizedPath); 718 | 719 | if (parentPath && parentPath !== "/") { 720 | const parent = this.sql 721 | .exec("SELECT * FROM files WHERE path = ?", parentPath) 722 | .toArray()[0]; 723 | if (!parent || parent.type !== "directory") { 724 | throw new Error("Parent directory does not exist"); 725 | } 726 | } 727 | 728 | const buffer = 729 | data instanceof ArrayBuffer 730 | ? data 731 | : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); 732 | const size = buffer.byteLength; 733 | const mode = options.mode || 0o666; 734 | const now = Math.floor(Date.now() / 1000); 735 | 736 | const existing = this.sql 737 | .exec("SELECT * FROM files WHERE path = ?", normalizedPath) 738 | .toArray()[0]; 739 | 740 | if (existing) { 741 | // Update existing file 742 | this.sql.exec( 743 | ` 744 | UPDATE files 745 | SET content = ?, size = ?, mode = ?, mtime = ?, atime = ? 746 | WHERE path = ? 747 | `, 748 | buffer, 749 | size, 750 | mode, 751 | now, 752 | now, 753 | normalizedPath 754 | ); 755 | } else { 756 | // Create new file 757 | this.sql.exec( 758 | ` 759 | INSERT INTO files 760 | (path, parent_path, name, type, content, size, mode, uid, gid, mtime, ctime, atime) 761 | VALUES (?, ?, ?, 'file', ?, ?, ?, 0, 0, ?, ?, ?) 762 | `, 763 | normalizedPath, 764 | parentPath, 765 | this.getFileName(normalizedPath), 766 | buffer, 767 | size, 768 | mode, 769 | now, 770 | now, 771 | now 772 | ); 773 | } 774 | } 775 | 776 | /** 777 | * Write data to file 778 | * @param {string} path - File path 779 | * @param {string|ArrayBuffer|Uint8Array} data - Data to write 780 | * @param {WriteFileOptions|string} options - Write options 781 | */ 782 | async writeFile(path, data, options) { 783 | let buffer; 784 | let writeOptions = {}; 785 | 786 | if (typeof options === "string") { 787 | writeOptions = { encoding: options }; 788 | } else if (options) { 789 | writeOptions = options; 790 | } 791 | 792 | if (typeof data === "string") { 793 | const encoding = writeOptions.encoding || "utf8"; 794 | const encoder = new TextEncoder(); 795 | buffer = encoder.encode(data).buffer; 796 | } else if (data instanceof ArrayBuffer) { 797 | buffer = data; 798 | } else if (data instanceof Uint8Array) { 799 | buffer = data.buffer.slice( 800 | data.byteOffset, 801 | data.byteOffset + data.byteLength 802 | ); 803 | } else { 804 | throw new Error("Unsupported data type"); 805 | } 806 | 807 | await this.writeFileBuffer(path, buffer, writeOptions); 808 | } 809 | } 810 | --------------------------------------------------------------------------------