├── .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://uithub.com/janwilmake/cloudflare-fs) [](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 |
--------------------------------------------------------------------------------