opfs-mock 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # opfs-mock
2
2
 
3
3
  In-memory implementation of the [origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). Its main utility is for testing OPFS-dependent code in Node.js. It's tested
4
- on Node.js versions 20-25.
4
+ on all supported Node.js versions (`22.x`, `24.x`, `25.x`, `26.x`).
5
5
 
6
6
  ## Installation
7
7
 
package/dist/index.d.mts CHANGED
@@ -3,20 +3,39 @@
3
3
  // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/queryPermission
4
4
  // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/requestPermission
5
5
  // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/remove
6
+ // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemObserver
7
+ // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemChangeRecord
6
8
  type PermissionHandler = (descriptor?: FileSystemHandlePermissionDescriptor) => Promise<PermissionState>;
7
9
  declare global {
10
+ type FileSystemChangeType = 'appeared' | 'disappeared' | 'errored' | 'modified' | 'moved' | 'unknown';
11
+ interface FileSystemChangeRecord {
12
+ changedHandle: FileSystemHandle | FileSystemSyncAccessHandle | null;
13
+ relativePathComponents: string[];
14
+ relativePathMovedFrom: string[] | null;
15
+ root: FileSystemHandle | FileSystemSyncAccessHandle;
16
+ type: FileSystemChangeType;
17
+ }
18
+ type FileSystemObserverCallback = (records: FileSystemChangeRecord[], observer: FileSystemObserver) => void;
19
+ interface FileSystemObserver {
20
+ disconnect(): void;
21
+ observe(handle: FileSystemHandle | FileSystemSyncAccessHandle): Promise<void>;
22
+ }
23
+ const FileSystemObserver: {
24
+ prototype: FileSystemObserver;
25
+ new (callback: FileSystemObserverCallback): FileSystemObserver;
26
+ };
8
27
  interface FileSystemHandlePermissionDescriptor {
9
28
  mode?: 'read' | 'readwrite';
10
29
  }
11
30
  interface FileSystemFileHandle extends FileSystemHandle {
12
31
  queryPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
13
32
  requestPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
14
- remove(): Promise<void>;
33
+ remove(options?: FileSystemRemoveOptions): Promise<void>;
15
34
  }
16
35
  interface FileSystemDirectoryHandle extends FileSystemHandle {
17
36
  queryPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
18
37
  requestPermission(descriptor?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
19
- remove(): Promise<void>;
38
+ remove(options?: FileSystemRemoveOptions): Promise<void>;
20
39
  }
21
40
  }
22
41
  //#endregion
package/dist/index.mjs CHANGED
@@ -14,22 +14,94 @@ const getSizeOfDirectory = async (directory) => {
14
14
  return totalSize;
15
15
  };
16
16
  //#endregion
17
+ //#region src/observer.ts
18
+ const handleMetadata = /* @__PURE__ */ new WeakMap();
19
+ const observers = /* @__PURE__ */ new Set();
20
+ const isPathWithin = (path, rootPath) => {
21
+ if (path.length < rootPath.length) return false;
22
+ return rootPath.every((component, index) => path[index] === component);
23
+ };
24
+ const relativePath = (path, rootPath) => {
25
+ return path.slice(rootPath.length);
26
+ };
27
+ const setFileSystemHandleMetadata = (handle, path) => {
28
+ handleMetadata.set(handle, { path });
29
+ };
30
+ const notifyFileSystemObservers = (type, changedHandle, path, relativePathMovedFrom = null) => {
31
+ for (const observer of observers) observer.queueRecord(type, changedHandle, path, relativePathMovedFrom);
32
+ };
33
+ var FileSystemObserver = class {
34
+ #callback;
35
+ #observations = /* @__PURE__ */ new Map();
36
+ #records = [];
37
+ #scheduled = false;
38
+ constructor(callback) {
39
+ if (typeof callback !== "function") throw new TypeError("FileSystemObserver callback must be a function");
40
+ this.#callback = callback;
41
+ }
42
+ async observe(handle) {
43
+ const metadata = handleMetadata.get(handle);
44
+ if (!metadata) throw new DOMException("The supplied handle is not managed by opfs-mock.", "NotFoundError");
45
+ this.#observations.set(handle, {
46
+ root: handle,
47
+ path: metadata.path
48
+ });
49
+ observers.add(this);
50
+ }
51
+ disconnect() {
52
+ this.#observations.clear();
53
+ this.#records = [];
54
+ this.#scheduled = false;
55
+ observers.delete(this);
56
+ }
57
+ queueRecord(type, changedHandle, path, relativePathMovedFrom) {
58
+ for (const observation of this.#observations.values()) {
59
+ if (!isPathWithin(path, observation.path)) continue;
60
+ this.#records.push({
61
+ changedHandle: type === "disappeared" || type === "errored" || type === "unknown" ? null : changedHandle,
62
+ relativePathComponents: relativePath(path, observation.path),
63
+ relativePathMovedFrom,
64
+ root: observation.root,
65
+ type
66
+ });
67
+ }
68
+ if (this.#records.length > 0 && !this.#scheduled) {
69
+ this.#scheduled = true;
70
+ queueMicrotask(() => {
71
+ this.#scheduled = false;
72
+ const records = this.#records;
73
+ this.#records = [];
74
+ if (records.length > 0) this.#callback(records, this);
75
+ });
76
+ }
77
+ }
78
+ };
79
+ const installFileSystemObserver = () => {
80
+ Object.defineProperty(globalThis, "FileSystemObserver", {
81
+ configurable: true,
82
+ value: FileSystemObserver,
83
+ writable: true
84
+ });
85
+ };
86
+ //#endregion
17
87
  //#region src/opfs.ts
18
88
  const isObject = (v) => typeof v === "object" && v !== null;
19
89
  const isLegacyWriteParams = (v) => isObject(v) && !("type" in v) && "data" in v;
20
- const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissions) => {
90
+ const isArrayBuffer = (v) => Object.prototype.toString.call(v) === "[object ArrayBuffer]";
91
+ const fileSystemFileHandleFactory = (name, fileData, path, exists, onRemove, permissions) => {
21
92
  const checkPermission = async (mode = "read") => {
22
93
  if (await (permissions?.queryPermission?.({ mode }) ?? Promise.resolve("granted")) !== "granted") throw new DOMException("Permission denied", "NotAllowedError");
23
94
  };
24
- return {
95
+ const handle = {
25
96
  kind: "file",
26
97
  name,
27
98
  queryPermission: permissions?.queryPermission ?? (async () => "granted"),
28
99
  requestPermission: permissions?.requestPermission ?? (async () => "granted"),
29
- remove: async () => {
100
+ remove: async (_options) => {
30
101
  await checkPermission("readwrite");
31
102
  if (!exists()) throw new DOMException("A requested file or directory could not be found at the time an operation was processed.", "NotFoundError");
32
103
  onRemove?.();
104
+ notifyFileSystemObservers("disappeared", null, path);
33
105
  },
34
106
  isSameEntry: async function(other) {
35
107
  return other === this;
@@ -89,7 +161,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
89
161
  const ab = await chunk.arrayBuffer();
90
162
  encoded = new Uint8Array(ab);
91
163
  } else if (ArrayBuffer.isView(chunk)) encoded = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
92
- else if (chunk instanceof ArrayBuffer) encoded = new Uint8Array(chunk);
164
+ else if (isArrayBuffer(chunk)) encoded = new Uint8Array(chunk);
93
165
  else if (isLegacyWriteParams(chunk)) {
94
166
  const wp = chunk;
95
167
  if (wp.position !== void 0 && wp.position !== null) {
@@ -103,7 +175,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
103
175
  const ab = await data.arrayBuffer();
104
176
  encoded = new Uint8Array(ab);
105
177
  } else if (ArrayBuffer.isView(data)) encoded = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
106
- else if (data instanceof ArrayBuffer) encoded = new Uint8Array(data);
178
+ else if (isArrayBuffer(data)) encoded = new Uint8Array(data);
107
179
  else throw new TypeError("Invalid data in WriteParams");
108
180
  } else throw new TypeError("Invalid data type written to the file. Data must be of type FileSystemWriteChunkType.");
109
181
  const requiredSize = cursorPosition + encoded.length;
@@ -121,6 +193,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
121
193
  isClosed = true;
122
194
  fileData.content = content;
123
195
  fileData.lastModified = Date.now();
196
+ notifyFileSystemObservers("modified", handle, path);
124
197
  };
125
198
  const doAbort = async (reason) => {
126
199
  if (isAborted) return;
@@ -161,7 +234,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
161
234
  if (fileData.locked) throw new DOMException("A sync access handle is already open for this file", "InvalidStateError");
162
235
  fileData.locked = true;
163
236
  let closed = false;
164
- return {
237
+ const syncHandle = {
165
238
  getSize: () => {
166
239
  if (closed) throw new DOMException("The access handle is closed", "InvalidStateError");
167
240
  return fileData.content.byteLength;
@@ -190,6 +263,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
190
263
  if (data instanceof DataView) for (let i = 0; i < data.byteLength; i++) fileData.content[at + i] = data.getUint8(i);
191
264
  else fileData.content.set(data, at);
192
265
  fileData.lastModified = Date.now();
266
+ notifyFileSystemObservers("modified", syncHandle, path);
193
267
  return writeLength;
194
268
  },
195
269
  truncate: (size) => {
@@ -201,6 +275,7 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
201
275
  fileData.content = newBuffer;
202
276
  }
203
277
  fileData.lastModified = Date.now();
278
+ notifyFileSystemObservers("modified", syncHandle, path);
204
279
  },
205
280
  flush: async () => {
206
281
  if (closed) throw new DOMException("The access handle is closed", "InvalidStateError");
@@ -210,10 +285,14 @@ const fileSystemFileHandleFactory = (name, fileData, exists, onRemove, permissio
210
285
  fileData.locked = false;
211
286
  }
212
287
  };
288
+ setFileSystemHandleMetadata(syncHandle, path);
289
+ return syncHandle;
213
290
  }
214
291
  };
292
+ setFileSystemHandleMetadata(handle, path);
293
+ return handle;
215
294
  };
216
- const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
295
+ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove, path = []) => {
217
296
  const files = /* @__PURE__ */ new Map();
218
297
  const directories = /* @__PURE__ */ new Map();
219
298
  const getJoinedMaps = () => {
@@ -227,11 +306,20 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
227
306
  name,
228
307
  queryPermission: permissions?.queryPermission ?? (async () => "granted"),
229
308
  requestPermission: permissions?.requestPermission ?? (async () => "granted"),
230
- remove: async () => {
309
+ remove: async (options) => {
231
310
  await checkPermission("readwrite");
232
- if (!onRemove) throw new DOMException("The root directory cannot be removed.", "InvalidModificationError");
233
- for await (const _ of handle.values()) throw new DOMException("The directory is not empty", "InvalidModificationError");
311
+ if (!onRemove) {
312
+ if (options?.recursive) {
313
+ files.clear();
314
+ directories.clear();
315
+ notifyFileSystemObservers("modified", handle, path);
316
+ return;
317
+ }
318
+ throw new DOMException("The root directory cannot be removed.", "InvalidModificationError");
319
+ }
320
+ if (!options?.recursive) for await (const _ of handle.values()) throw new DOMException("The directory is not empty", "InvalidModificationError");
234
321
  onRemove();
322
+ notifyFileSystemObservers("disappeared", null, path);
235
323
  },
236
324
  isSameEntry: async function(other) {
237
325
  return other === this;
@@ -244,7 +332,8 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
244
332
  content: new Uint8Array(),
245
333
  lastModified: Date.now(),
246
334
  id: Symbol("file")
247
- }, () => files.has(fileName), () => files.delete(fileName), permissions));
335
+ }, [...path, fileName], () => files.has(fileName), () => files.delete(fileName), permissions));
336
+ notifyFileSystemObservers("appeared", files.get(fileName) ?? null, [...path, fileName]);
248
337
  } else await checkPermission("read");
249
338
  const fileHandle = files.get(fileName);
250
339
  if (!fileHandle) throw new DOMException(`File not found: ${fileName}`, "NotFoundError");
@@ -254,8 +343,9 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
254
343
  if (files.has(dirName)) throw new DOMException(`A file with the same name exists: ${dirName}`, "TypeMismatchError");
255
344
  if (!directories.has(dirName) && options?.create) {
256
345
  await checkPermission("readwrite");
257
- const dir = fileSystemDirectoryHandleFactory(dirName, permissions, () => directories.delete(dirName));
346
+ const dir = fileSystemDirectoryHandleFactory(dirName, permissions, () => directories.delete(dirName), [...path, dirName]);
258
347
  directories.set(dirName, dir);
348
+ notifyFileSystemObservers("appeared", dir, [...path, dirName]);
259
349
  } else await checkPermission("read");
260
350
  const directoryHandle = directories.get(dirName);
261
351
  if (!directoryHandle) throw new DOMException(`Directory not found: ${dirName}`, "NotFoundError");
@@ -265,12 +355,14 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
265
355
  await checkPermission("readwrite");
266
356
  if (files.has(entryName)) {
267
357
  files.delete(entryName);
358
+ notifyFileSystemObservers("disappeared", null, [...path, entryName]);
268
359
  return;
269
360
  }
270
361
  const dir = directories.get(entryName);
271
362
  if (dir) {
272
363
  if (!options?.recursive) for await (const _ of dir.values()) throw new DOMException("The directory is not empty", "InvalidModificationError");
273
364
  directories.delete(entryName);
365
+ notifyFileSystemObservers("disappeared", null, [...path, entryName]);
274
366
  return;
275
367
  }
276
368
  throw new DOMException(`No such file or directory: ${entryName}`, "NotFoundError");
@@ -283,7 +375,8 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
283
375
  },
284
376
  entries: async function* () {
285
377
  await checkPermission("read");
286
- yield* getJoinedMaps().entries();
378
+ const joinedMaps = getJoinedMaps();
379
+ for (const [n, h] of joinedMaps.entries()) yield [n, h];
287
380
  },
288
381
  keys: async function* () {
289
382
  await checkPermission("read");
@@ -291,7 +384,8 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
291
384
  },
292
385
  values: async function* () {
293
386
  await checkPermission("read");
294
- yield* getJoinedMaps().values();
387
+ const joinedMaps = getJoinedMaps();
388
+ for (const h of joinedMaps.values()) yield h;
295
389
  },
296
390
  resolve: async function(possibleDescendant) {
297
391
  await checkPermission("read");
@@ -308,6 +402,7 @@ const fileSystemDirectoryHandleFactory = (name, permissions, onRemove) => {
308
402
  return traverseDirectory(this, possibleDescendant);
309
403
  }
310
404
  };
405
+ setFileSystemHandleMetadata(handle, path);
311
406
  return handle;
312
407
  };
313
408
  //#endregion
@@ -337,13 +432,16 @@ const storageFactory = ({ usage = 0, quota = 1024 ** 3, queryPermission, request
337
432
  };
338
433
  const mockOPFS = () => {
339
434
  if (!("navigator" in globalThis)) Object.defineProperty(globalThis, "navigator", {
435
+ configurable: true,
340
436
  value: {},
341
437
  writable: true
342
438
  });
343
439
  if (!globalThis.navigator.storage) Object.defineProperty(globalThis.navigator, "storage", {
440
+ configurable: true,
344
441
  value: storageFactory(),
345
442
  writable: true
346
443
  });
444
+ installFileSystemObserver();
347
445
  };
348
446
  const resetMockOPFS = (options = {}) => {
349
447
  const root = fileSystemDirectoryHandleFactory("root", {
@@ -351,6 +449,7 @@ const resetMockOPFS = (options = {}) => {
351
449
  requestPermission: options.requestPermission
352
450
  });
353
451
  Object.defineProperty(globalThis.navigator.storage, "getDirectory", {
452
+ configurable: true,
354
453
  value: () => root,
355
454
  writable: true
356
455
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opfs-mock",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "type": "module",
5
5
  "description": "Mock all origin private file system APIs for your Jest or Vitest tests",
6
6
  "author": "Jure Rotar <hello@jurerotar.com>",
@@ -60,14 +60,14 @@
60
60
  "release": "npm publish --access public"
61
61
  },
62
62
  "devDependencies": {
63
- "@biomejs/biome": "2.4.6",
64
- "@typescript/native-preview": "7.0.0-dev.20260309.1",
65
- "@vitest/coverage-v8": "4.0.18",
63
+ "@biomejs/biome": "2.4.15",
64
+ "@typescript/native-preview": "7.0.0-dev.20260522.1",
65
+ "@vitest/coverage-v8": "4.1.7",
66
66
  "@web-std/file": "3.0.3",
67
- "happy-dom": "20.8.3",
68
- "jsdom": "28.1.0",
69
- "tsdown": "0.21.1",
70
- "typescript": "5.9.3",
71
- "vitest": "4.0.18"
67
+ "happy-dom": "20.9.0",
68
+ "jsdom": "29.1.1",
69
+ "tsdown": "0.22.0",
70
+ "typescript": "6.0.3",
71
+ "vitest": "4.1.7"
72
72
  }
73
73
  }