@gjsify/fs 0.1.15 → 0.2.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.
Files changed (67) hide show
  1. package/lib/esm/callback.js +22 -13
  2. package/lib/esm/cp.js +253 -0
  3. package/lib/esm/dir.js +160 -0
  4. package/lib/esm/fd-ops.js +189 -0
  5. package/lib/esm/file-handle.js +263 -84
  6. package/lib/esm/fs-watcher.js +88 -4
  7. package/lib/esm/glob.js +164 -0
  8. package/lib/esm/index.js +128 -2
  9. package/lib/esm/promises.js +90 -27
  10. package/lib/esm/read-stream.js +53 -43
  11. package/lib/esm/stat-watcher.js +121 -0
  12. package/lib/esm/statfs.js +57 -0
  13. package/lib/esm/sync.js +70 -52
  14. package/lib/esm/utils.js +7 -0
  15. package/lib/esm/utimes.js +62 -0
  16. package/lib/esm/write-stream.js +2 -5
  17. package/lib/types/cp.d.ts +18 -0
  18. package/lib/types/cp.spec.d.ts +2 -0
  19. package/lib/types/dir.d.ts +29 -0
  20. package/lib/types/dir.spec.d.ts +2 -0
  21. package/lib/types/fd-ops.d.ts +57 -0
  22. package/lib/types/fd-ops.spec.d.ts +2 -0
  23. package/lib/types/file-handle.d.ts +34 -4
  24. package/lib/types/fs-watcher.d.ts +9 -2
  25. package/lib/types/glob.d.ts +8 -0
  26. package/lib/types/glob.spec.d.ts +2 -0
  27. package/lib/types/index.d.ts +51 -1
  28. package/lib/types/promises.d.ts +31 -4
  29. package/lib/types/read-stream.d.ts +3 -1
  30. package/lib/types/stat-watcher.d.ts +21 -0
  31. package/lib/types/statfs.d.ts +35 -0
  32. package/lib/types/statfs.spec.d.ts +2 -0
  33. package/lib/types/sync.d.ts +4 -7
  34. package/lib/types/utils.d.ts +2 -0
  35. package/lib/types/utimes.d.ts +13 -0
  36. package/lib/types/utimes.spec.d.ts +2 -0
  37. package/lib/types/watch.spec.d.ts +2 -0
  38. package/lib/types/watchfile.spec.d.ts +2 -0
  39. package/lib/types/write-stream.d.ts +1 -2
  40. package/package.json +12 -12
  41. package/src/callback.ts +22 -13
  42. package/src/cp.spec.ts +181 -0
  43. package/src/cp.ts +328 -0
  44. package/src/dir.spec.ts +204 -0
  45. package/src/dir.ts +199 -0
  46. package/src/fd-ops.spec.ts +234 -0
  47. package/src/fd-ops.ts +251 -0
  48. package/src/file-handle.ts +264 -94
  49. package/src/fs-watcher.ts +101 -6
  50. package/src/glob.spec.ts +201 -0
  51. package/src/glob.ts +205 -0
  52. package/src/index.ts +74 -0
  53. package/src/promises.ts +94 -29
  54. package/src/read-stream.ts +49 -43
  55. package/src/stat-watcher.ts +116 -0
  56. package/src/statfs.spec.ts +67 -0
  57. package/src/statfs.ts +92 -0
  58. package/src/streams.spec.ts +58 -0
  59. package/src/sync.ts +75 -57
  60. package/src/test.mts +13 -2
  61. package/src/utils.ts +10 -0
  62. package/src/utimes.spec.ts +113 -0
  63. package/src/utimes.ts +97 -0
  64. package/src/watch.spec.ts +171 -0
  65. package/src/watchfile.spec.ts +185 -0
  66. package/src/write-stream.ts +5 -8
  67. package/tsconfig.tsbuildinfo +1 -1
@@ -1,13 +1,16 @@
1
1
  // Reference: Node.js lib/internal/fs/promises.js (FileHandle)
2
2
  // Reimplemented for GJS using Gio.File
3
3
 
4
- import { warnNotImplemented, notImplemented } from '@gjsify/utils';
4
+ import { warnNotImplemented, notImplemented, createGLibFileError } from '@gjsify/utils';
5
5
  import { ReadStream } from "./read-stream.js";
6
6
  import { WriteStream } from "./write-stream.js";
7
- import { Stats } from "./stats.js";
7
+ import { Stats, BigIntStats, STAT_ATTRIBUTES } from "./stats.js";
8
8
  import { getEncodingFromOptions, encodeUint8Array } from './encoding.js';
9
+ import { normalizePath } from './utils.js';
10
+ import { chmodSync, chownSync } from './sync.js';
9
11
  import GLib from '@girs/glib-2.0';
10
12
  import Gio from '@girs/gio-2.0';
13
+ import { createInterface } from 'node:readline';
11
14
  // Type-only import for ReadableStream — the runtime constructor is resolved
12
15
  // via globalThis inside readableWebStream() to avoid bundling the entire
13
16
  // WHATWG streams implementation for apps that never call this method.
@@ -28,51 +31,69 @@ import type {
28
31
  OpenMode,
29
32
  PathLike,
30
33
  StatOptions,
31
- BigIntStats,
32
34
  WriteVResult,
33
35
  ReadVResult,
34
36
  ReadPosition,
35
37
  } from 'node:fs';
36
38
  import type { Interface as ReadlineInterface } from 'node:readline';
37
39
 
40
+ // POSIX numeric open(2) flags (values on Linux x86-64).
41
+ const O_WRONLY = 1;
42
+ const O_RDWR = 2;
43
+ const O_CREAT = 64;
44
+ const O_TRUNC = 512;
45
+ const O_APPEND = 1024;
46
+
47
+ type IOMode = 'r' | 'r+' | 'w' | 'w+' | 'a' | 'a+';
48
+
38
49
  /**
39
- * GLib.FileError enum numeric values → Node.js error code strings.
40
- * GLib.IOChannel.new_file() throws GLib.FileError (different from Gio.IOErrorEnum).
50
+ * Convert open flags (Node.js string or POSIX numeric) to a GLib.IOChannel mode.
51
+ * IOChannel.new_file() takes fopen(3) modes: 'r', 'r+', 'w', 'w+', 'a', 'a+'.
41
52
  */
42
- const GLIB_FILE_ERROR_TO_NODE: Record<number, string> = {
43
- 0: 'EEXIST',
44
- 1: 'EISDIR',
45
- 2: 'EACCES',
46
- 3: 'ENAMETOOLONG',
47
- 4: 'ENOENT',
48
- 5: 'ENOTDIR',
49
- 6: 'ENXIO',
50
- 7: 'ENODEV',
51
- 8: 'EROFS',
52
- 11: 'ELOOP',
53
- 12: 'ENOSPC',
54
- 13: 'ENOMEM',
55
- 14: 'EMFILE',
56
- 15: 'ENFILE',
57
- 16: 'EBADF',
58
- 17: 'EINVAL',
59
- 18: 'EPIPE',
60
- 21: 'EIO',
61
- 22: 'EPERM',
62
- 24: 'EIO',
63
- };
53
+ function resolveIOMode(flags: OpenFlags | number | undefined): IOMode {
54
+ if (flags === undefined || flags === null) return 'r';
55
+ if (typeof flags === 'number') {
56
+ const rdwr = (flags & O_RDWR) !== 0;
57
+ const wronly = (flags & O_WRONLY) !== 0;
58
+ const append = (flags & O_APPEND) !== 0;
59
+ const trunc = (flags & O_TRUNC) !== 0;
60
+ if (rdwr) return trunc ? 'w+' : 'r+';
61
+ if (wronly) return append ? 'a' : 'w';
62
+ return 'r';
63
+ }
64
+ // Node.js string flags — map extras to IOChannel equivalents.
65
+ switch (flags) {
66
+ case 'ax': case 'wx': return 'w';
67
+ case 'ax+': case 'wx+': return 'w+';
68
+ case 'as': case 'rs+': return 'r+';
69
+ case 'as+': return 'a+';
70
+ default: return flags as IOMode;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Open the file with the given IOChannel mode. When the flags request
76
+ * create-if-missing + read/write without truncation (numeric O_CREAT | O_RDWR,
77
+ * which maps to IOChannel 'r+' — a mode that requires the file to exist), we
78
+ * catch the ENOENT and create an empty file, then retry. This avoids a TOCTOU
79
+ * existence check and keeps the common "file exists" path to a single syscall.
80
+ */
81
+ function openIOChannel(path: string, mode: IOMode, creat: boolean): GLib.IOChannel {
82
+ try {
83
+ return GLib.IOChannel.new_file(path, mode);
84
+ } catch (err) {
85
+ const gErr = err as { code?: number } | null | undefined;
86
+ if (creat && mode === 'r+' && gErr?.code === GLib.FileError.NOENT) {
87
+ GLib.file_set_contents(path, new Uint8Array(0));
88
+ return GLib.IOChannel.new_file(path, mode);
89
+ }
90
+ throw err;
91
+ }
92
+ }
64
93
 
65
94
  function mapOpenError(err: unknown, path: string): NodeJS.ErrnoException {
66
- const gErr = err as { code?: number; message?: string } | null | undefined;
67
- const msg = gErr?.message ?? '';
68
95
  // GLib.IOChannel.new_file() always throws GLib.FileError (not Gio.IOErrorEnum).
69
- // GLIB_FILE_ERROR_TO_NODE maps GLib.FileError numeric values to Node.js codes.
70
- const code = GLIB_FILE_ERROR_TO_NODE[gErr?.code ?? -1] ?? 'EIO';
71
- const error = new Error(`${code}: ${msg || 'unknown error'}, open '${path}'`) as NodeJS.ErrnoException;
72
- error.code = code;
73
- error.syscall = 'open';
74
- error.path = path;
75
- return error;
96
+ return createGLibFileError(err, 'open', { path }) as NodeJS.ErrnoException;
76
97
  }
77
98
 
78
99
  export class FileHandle implements IFileHandle {
@@ -80,29 +101,104 @@ export class FileHandle implements IFileHandle {
80
101
  /** Not part of the default implementation, used internal by gjsify */
81
102
  private _file: GLib.IOChannel;
82
103
 
104
+ /**
105
+ * Lazily-opened Gio streams for positional read() / write() so each call does
106
+ * not re-load the entire file via Gio.File.load_contents(). The IOStream is
107
+ * used when the handle was opened with write capability (r+, w, w+, a, a+) —
108
+ * it shares seek state between input and output so writes are visible to
109
+ * subsequent reads without a flush. For read-only handles we only open a
110
+ * FileInputStream; trying to open_readwrite on a read-only file can fall
111
+ * back to create_readwrite(REPLACE_DESTINATION) which would truncate it.
112
+ */
113
+ private _ioStream: Gio.FileIOStream | null = null;
114
+ private _readStream: Gio.FileInputStream | null = null;
115
+ private readonly _gFile: Gio.File;
116
+ private readonly _ioMode: IOMode;
117
+ // Serialize async I/O on the shared FileIOStream. Concurrent write_bytes_async
118
+ // calls hit Gio.IOErrorEnum.PENDING ("Datenstrom hat noch einen ausstehenden
119
+ // Vorgang"); overlapping seek()s on the shared cursor also corrupt positions.
120
+ // random-access-file (used by fs-chunk-store / webtorrent) issues many
121
+ // concurrent positional writes, so every async op on this handle chains
122
+ // through _ioLock.
123
+ private _ioLock: Promise<unknown> = Promise.resolve();
124
+
83
125
  /** Not part of the default implementation, used internal by gjsify */
84
126
  private static instances: {[fd: number]: FileHandle} = {};
85
127
 
86
128
  constructor(readonly options: {
87
129
  path: PathLike,
88
- flags?: OpenFlags,
130
+ flags?: OpenFlags | number,
89
131
  mode?: Mode
90
132
  }) {
91
133
  this.options.flags ||= "r";
92
134
  this.options.mode ||= 0o666;
135
+ const pathStr = normalizePath(options.path);
136
+ const creat = typeof options.flags === 'number' && (options.flags & O_CREAT) !== 0;
137
+ const ioMode = resolveIOMode(options.flags);
93
138
  try {
94
- this._file = GLib.IOChannel.new_file(options.path.toString(), this.options.flags);
139
+ this._file = openIOChannel(pathStr, ioMode, creat);
95
140
  } catch (err: unknown) {
96
- throw mapOpenError(err, options.path.toString());
141
+ throw mapOpenError(err, pathStr);
97
142
  }
98
143
  // Binary mode: prevent GLib from doing any character set conversion.
99
144
  this._file.set_encoding(null as unknown as string);
100
145
  this.fd = this._file.unix_get_fd();
146
+ this._gFile = Gio.File.new_for_path(pathStr);
147
+ this._ioMode = ioMode;
101
148
 
102
149
  FileHandle.instances[this.fd] = this;
103
150
  return FileHandle.getInstance(this.fd);
104
151
  }
105
152
 
153
+ /**
154
+ * Lazy-open the read-capable stream and return both the input stream and
155
+ * its seekable view. Both FileInputStream (read-only handle) and
156
+ * FileIOStream (read/write handle) implement Gio.Seekable, but we return
157
+ * both to avoid callers needing to know which concrete type they got.
158
+ */
159
+ private _getReadStream(): { input: Gio.InputStream; seekable: Gio.Seekable } {
160
+ if (this._ioStream) {
161
+ return {
162
+ input: this._ioStream.get_input_stream(),
163
+ seekable: this._ioStream as unknown as Gio.Seekable,
164
+ };
165
+ }
166
+ if (this._ioMode === 'r') {
167
+ if (!this._readStream) this._readStream = this._gFile.read(null);
168
+ return {
169
+ input: this._readStream,
170
+ seekable: this._readStream as unknown as Gio.Seekable,
171
+ };
172
+ }
173
+ // open_readwrite requires the file to exist. For modes that imply
174
+ // create-if-missing (w, w+, a, a+) the IOChannel already created it
175
+ // above; for 'r+' openIOChannel() catches ENOENT and pre-creates it.
176
+ this._ioStream = this._gFile.open_readwrite(null);
177
+ return {
178
+ input: this._ioStream.get_input_stream(),
179
+ seekable: this._ioStream as unknown as Gio.Seekable,
180
+ };
181
+ }
182
+
183
+ /** Lazy-open the write-capable stream (IOStream) for this handle. Only valid
184
+ * when the handle was opened with a write-capable mode. */
185
+ private _getWriteStream(): Gio.FileIOStream {
186
+ if (this._ioStream) return this._ioStream;
187
+ if (this._ioMode === 'r') {
188
+ throw new Error('FileHandle opened read-only; cannot write');
189
+ }
190
+ this._ioStream = this._gFile.open_readwrite(null);
191
+ return this._ioStream;
192
+ }
193
+
194
+ /** Serialize an async operation on the shared FileIOStream. */
195
+ private _serialize<T>(op: () => Promise<T>): Promise<T> {
196
+ const prev = this._ioLock;
197
+ const next = prev.catch(() => {}).then(op);
198
+ this._ioLock = next;
199
+ return next;
200
+ }
201
+
106
202
 
107
203
  /**
108
204
  * The numeric file descriptor managed by the {FileHandle} object.
@@ -150,7 +246,7 @@ export class FileHandle implements IFileHandle {
150
246
  * @return Fulfills with `undefined` upon success.
151
247
  */
152
248
  async chown(uid: number, gid: number): Promise<void> {
153
- warnNotImplemented('fs.FileHandle.chown');
249
+ chownSync(normalizePath(this.options.path), uid, gid);
154
250
  }
155
251
  /**
156
252
  * Modifies the permissions on the file. See [`chmod(2)`](http://man7.org/linux/man-pages/man2/chmod.2.html).
@@ -159,7 +255,7 @@ export class FileHandle implements IFileHandle {
159
255
  * @return Fulfills with `undefined` upon success.
160
256
  */
161
257
  async chmod(mode: Mode): Promise<void> {
162
- warnNotImplemented('fs.FileHandle.chmod');
258
+ chmodSync(normalizePath(this.options.path), mode);
163
259
  }
164
260
  /**
165
261
  * Unlike the 16 kb default `highWaterMark` for a `stream.Readable`, the stream
@@ -245,7 +341,7 @@ export class FileHandle implements IFileHandle {
245
341
  * @return Fulfills with `undefined` upon success.
246
342
  */
247
343
  async datasync(): Promise<void> {
248
- warnNotImplemented('fs.FileHandle.datasync');
344
+ this._file.flush();
249
345
  }
250
346
  /**
251
347
  * Request that all data for the open file descriptor is flushed to the storage
@@ -255,7 +351,7 @@ export class FileHandle implements IFileHandle {
255
351
  * @return Fufills with `undefined` upon success.
256
352
  */
257
353
  async sync(): Promise<void> {
258
- warnNotImplemented('fs.FileHandle.sync');
354
+ this._file.flush();
259
355
  }
260
356
  /**
261
357
  * Reads data from the file and stores that in the given buffer.
@@ -295,24 +391,25 @@ export class FileHandle implements IFileHandle {
295
391
  const bufView = buffer as unknown as Uint8Array;
296
392
  const bufOffset = offset ?? 0;
297
393
  const readLength = length ?? bufView?.byteLength ?? 65536;
298
-
299
- // Use Gio.File.load_contents for reliable position-based reads.
300
- // GLib.IOChannel.read_chars() is not introspectable in GJS (caller-allocated buffer),
301
- // and read_to_end() has stdio-buffer visibility issues after mixed write/read.
302
- const gFile = Gio.File.new_for_path(this.options.path.toString());
303
- const [, fileContents] = gFile.load_contents(null);
304
- const fileData = fileContents as Uint8Array;
305
394
  const startPos = (position as number | null) ?? 0;
306
- const readData = fileData.slice(startPos, startPos + readLength);
307
- const bytesRead = readData.length;
308
- if (bufView && bytesRead > 0) {
309
- bufView.set(readData, bufOffset);
310
- }
311
395
 
312
- return {
313
- bytesRead,
314
- buffer: buffer as T,
315
- };
396
+ // Positional read — seek + read_bytes on the appropriate Gio stream,
397
+ // touching only the requested region. Replaces the old load_contents()
398
+ // path that read the entire file on every call (O(N²) over streamed
399
+ // workloads like WebTorrent piece hashing or random-access-file).
400
+ // Serialized with writes: seek() on the shared FileIOStream cursor must
401
+ // not interleave with a pending write_bytes_async.
402
+ return this._serialize(async () => {
403
+ const { input, seekable } = this._getReadStream();
404
+ seekable.seek(BigInt(startPos), GLib.SeekType.SET, null);
405
+ const bytes = input.read_bytes(readLength, null);
406
+ const data = bytes.get_data() as Uint8Array | null;
407
+ const bytesRead = data?.length ?? 0;
408
+ if (bufView && data && bytesRead > 0) {
409
+ bufView.set(data, bufOffset);
410
+ }
411
+ return { bytesRead, buffer: buffer as T };
412
+ });
316
413
  }
317
414
  /**
318
415
  * Returns a `ReadableStream` that may be used to read the files data.
@@ -426,7 +523,7 @@ export class FileHandle implements IFileHandle {
426
523
  * @param options See `filehandle.createReadStream()` for the options.
427
524
  */
428
525
  readLines(options?: CreateReadStreamOptions): ReadlineInterface {
429
- notImplemented('fs.FileHandle.readLines');
526
+ return createInterface({ input: this.createReadStream(options), crlfDelay: Infinity });
430
527
  }
431
528
  /**
432
529
  * @since v10.0.0
@@ -443,8 +540,15 @@ export class FileHandle implements IFileHandle {
443
540
  }
444
541
  ): Promise<BigIntStats>
445
542
  async stat(opts?: StatOptions): Promise<Stats | BigIntStats> {
446
- warnNotImplemented('fs.FileHandle.stat');
447
- return new Stats(this.options.path.toString());
543
+ const info = await new Promise<Gio.FileInfo>((resolve, reject) => {
544
+ this._gFile.query_info_async(STAT_ATTRIBUTES, Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null, (_s: unknown, res: Gio.AsyncResult) => {
545
+ try { resolve(this._gFile.query_info_finish(res)); } catch (e) { reject(e); }
546
+ });
547
+ });
548
+ const pathStr = normalizePath(this.options.path);
549
+ return opts?.bigint
550
+ ? new BigIntStats(info, pathStr)
551
+ : new Stats(info, pathStr);
448
552
  }
449
553
  /**
450
554
  * Truncates the file.
@@ -477,18 +581,18 @@ export class FileHandle implements IFileHandle {
477
581
  async truncate(len: number = 0): Promise<void> {
478
582
  const effectiveLen = Math.max(0, len);
479
583
  this._file.flush();
480
- const gFile = Gio.File.new_for_path(this.options.path.toString());
481
- const [, currentContent] = gFile.load_contents(null);
482
- const newContent = new Uint8Array(effectiveLen);
483
- newContent.set(currentContent.slice(0, Math.min(effectiveLen, currentContent.length)));
484
- gFile.replace_contents(newContent, null, false, Gio.FileCreateFlags.NONE, null);
584
+ // Gio.FileOutputStream implements Seekable.truncate — extends with zeros
585
+ // when growing, matches POSIX ftruncate(2).
586
+ const out = this._getWriteStream().get_output_stream() as Gio.FileOutputStream;
587
+ out.truncate(effectiveLen, null);
485
588
  }
486
589
  /**
487
590
  * Change the file system timestamps of the object referenced by the `FileHandle` then resolves the promise with no arguments upon success.
488
591
  * @since v10.0.0
489
592
  */
490
593
  async utimes(atime: string | number | Date, mtime: string | number | Date): Promise<void> {
491
- warnNotImplemented('fs.FileHandle.utimes');
594
+ const { utimesSync } = await import('./utimes.js');
595
+ utimesSync(normalizePath(this.options.path), atime, mtime);
492
596
  }
493
597
  /**
494
598
  * Asynchronously writes data to a file, replacing the file if it already exists.`data` can be a string, a buffer, an
@@ -600,25 +704,32 @@ export class FileHandle implements IFileHandle {
600
704
  const writeSlice = writeBuf.slice(bufOffset, bufOffset + writeLength);
601
705
  const writePos = position ?? 0;
602
706
 
603
- // Use Gio.File for reliable position-based writes.
604
- // GLib.IOChannel write_chars + flush does not guarantee data is visible to
605
- // subsequent Gio.File or g_file_get_contents readers (stdio-buffer flushing issue).
606
- const gFile = Gio.File.new_for_path(this.options.path.toString());
607
- let existingData: Uint8Array;
608
- try {
609
- const [, existing] = gFile.load_contents(null);
610
- existingData = existing as Uint8Array;
611
- } catch {
612
- existingData = new Uint8Array(0);
613
- }
614
- const newSize = Math.max(existingData.length, writePos + writeSlice.length);
615
- const newContent = new Uint8Array(newSize);
616
- newContent.set(existingData);
617
- newContent.set(writeSlice, writePos);
618
- gFile.replace_contents(newContent, null, false, Gio.FileCreateFlags.NONE, null);
707
+ // Positional write seek + write_bytes_async on the IOStream, touches
708
+ // only the requested region. Uses async Gio I/O so the GLib main loop
709
+ // (and GTK events) are not blocked during the write. Serialized via
710
+ // _serialize() so concurrent callers (e.g. random-access-file) don't
711
+ // trigger GIO_ERROR_PENDING or corrupt the shared seek cursor.
712
+ const bytesWritten = await this._serialize(async () => {
713
+ const stream = this._getWriteStream();
714
+ stream.seek(BigInt(writePos), GLib.SeekType.SET, null);
715
+ const output = stream.get_output_stream();
716
+ const written = await new Promise<number>((resolve, reject) => {
717
+ output.write_bytes_async(new GLib.Bytes(writeSlice), GLib.PRIORITY_DEFAULT, null, (_source, asyncResult) => {
718
+ try { resolve(output.write_bytes_finish(asyncResult)); }
719
+ catch (err) { reject(err); }
720
+ });
721
+ });
722
+ await new Promise<void>((resolve, reject) => {
723
+ output.flush_async(GLib.PRIORITY_DEFAULT, null, (_source, asyncResult) => {
724
+ try { output.flush_finish(asyncResult); resolve(); }
725
+ catch (err) { reject(err); }
726
+ });
727
+ });
728
+ return written;
729
+ });
619
730
 
620
731
  return {
621
- bytesWritten: writeSlice.length,
732
+ bytesWritten,
622
733
  buffer: data
623
734
  }
624
735
  }
@@ -640,11 +751,16 @@ export class FileHandle implements IFileHandle {
640
751
  * position.
641
752
  */
642
753
  async writev<TBuffers extends readonly NodeJS.ArrayBufferView[]>(buffers: TBuffers, position?: number): Promise<WriteVResult<TBuffers>> {
643
- warnNotImplemented('fs.FileHandle.writev');
644
- return {
645
- bytesWritten: 0,
646
- buffers: buffers as unknown as TBuffers,
754
+ let bytesWritten = 0;
755
+ for (const buf of buffers) {
756
+ const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
757
+ const res = await this.write(
758
+ b, 0, b.byteLength,
759
+ position != null ? position + bytesWritten : null,
760
+ );
761
+ bytesWritten += res.bytesWritten;
647
762
  }
763
+ return { bytesWritten, buffers: buffers as unknown as TBuffers };
648
764
  }
649
765
  /**
650
766
  * Read from a file and write to an array of [ArrayBufferView](https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView) s
@@ -653,12 +769,57 @@ export class FileHandle implements IFileHandle {
653
769
  * @return Fulfills upon success an object containing two properties:
654
770
  */
655
771
  async readv<TBuffers extends readonly NodeJS.ArrayBufferView[]>(buffers: TBuffers, position?: number): Promise<ReadVResult<TBuffers>> {
656
- warnNotImplemented('fs.FileHandle.readv');
657
- return {
658
- bytesRead: 0,
659
- buffers: buffers as unknown as TBuffers,
772
+ let bytesRead = 0;
773
+ for (const buf of buffers) {
774
+ const res = await this.read({
775
+ buffer: Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength),
776
+ position: position != null ? position + bytesRead : null,
777
+ });
778
+ bytesRead += res.bytesRead;
779
+ if (res.bytesRead < buf.byteLength) break;
660
780
  }
781
+ return { bytesRead, buffers: buffers as unknown as TBuffers };
661
782
  }
783
+ /** @internal */ _flushSync(): void {
784
+ this._file.flush();
785
+ }
786
+
787
+ /** @internal */ _closeSync(): void {
788
+ try { this._ioStream?.close(null); } catch { /* best-effort */ }
789
+ try { this._readStream?.close(null); } catch { /* best-effort */ }
790
+ this._ioStream = null;
791
+ this._readStream = null;
792
+ try { this._file.shutdown(true); } catch { /* best-effort */ }
793
+ delete (FileHandle as any).instances[this.fd];
794
+ }
795
+
796
+ /** @internal */ _readSync(buffer: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null): number {
797
+ const stream = this._gFile.read(null);
798
+ try {
799
+ if (position !== null && position >= 0) {
800
+ (stream as unknown as Gio.Seekable).seek(position, GLib.SeekType.SET, null);
801
+ }
802
+ const bytes = stream.read_bytes(length, null);
803
+ const arr = bytes.get_data()!;
804
+ new Uint8Array((buffer as any).buffer, (buffer as any).byteOffset + offset).set(arr.subarray(0, arr.length));
805
+ return arr.length;
806
+ } finally {
807
+ stream.close(null);
808
+ }
809
+ }
810
+
811
+ /** @internal */ _writeSync(data: Uint8Array, position: number | null): number {
812
+ const stream = this._gFile.open_readwrite(null);
813
+ try {
814
+ if (position !== null && position >= 0) {
815
+ (stream as unknown as Gio.Seekable).seek(position, GLib.SeekType.SET, null);
816
+ }
817
+ return stream.get_output_stream().write_bytes(GLib.Bytes.new(data), null);
818
+ } finally {
819
+ stream.close(null);
820
+ }
821
+ }
822
+
662
823
  /**
663
824
  * Closes the file handle after waiting for any pending operation on the handle to
664
825
  * complete.
@@ -677,7 +838,16 @@ export class FileHandle implements IFileHandle {
677
838
  * @return Fulfills with `undefined` upon success.
678
839
  */
679
840
  async close(): Promise<void> {
680
- this._file.shutdown(true);
841
+ // Close the Gio streams first; they own an fd wrapping the same file
842
+ // as the IOChannel. IOChannel.shutdown(true) flushes + closes its own
843
+ // fd — safe to call even if the Gio streams already released theirs,
844
+ // but guarded here so a throw from shutdown doesn't strand the stream
845
+ // references in a "closed but still pinned" state.
846
+ try { this._ioStream?.close(null); } catch { /* best-effort */ }
847
+ try { this._readStream?.close(null); } catch { /* best-effort */ }
848
+ this._ioStream = null;
849
+ this._readStream = null;
850
+ try { this._file.shutdown(true); } catch { /* best-effort */ }
681
851
  }
682
852
 
683
853
  async [Symbol.asyncDispose](): Promise<void> {
package/src/fs-watcher.ts CHANGED
@@ -4,20 +4,22 @@
4
4
  import GLib from '@girs/glib-2.0';
5
5
  import Gio from '@girs/gio-2.0';
6
6
  import { EventEmitter } from 'node:events';
7
+ import { normalizePath } from './utils.js';
7
8
  const privates = new WeakMap;
8
9
 
9
- import type { FSWatcher as IFSWatcher } from 'node:fs';
10
+ import type { FSWatcher as IFSWatcher, PathLike, WatchOptions } from 'node:fs';
10
11
 
11
12
  export class FSWatcher extends EventEmitter implements IFSWatcher {
12
13
 
13
- constructor(filename: string, options, listener) {
14
+ constructor(filename: PathLike, options, listener) {
14
15
  super();
15
16
  if (!options || typeof options !== 'object')
16
17
  options = {persistent: true};
17
18
 
18
19
  const persistent = options.persistent !== false;
19
20
  const cancellable = Gio.Cancellable.new();
20
- const file = Gio.File.new_for_path(filename);
21
+ const pathStr = normalizePath(filename);
22
+ const file = Gio.File.new_for_path(pathStr);
21
23
  const watcher = file.monitor(Gio.FileMonitorFlags.NONE, cancellable);
22
24
  watcher.connect('changed', changed.bind(this));
23
25
 
@@ -28,7 +30,11 @@ export class FSWatcher extends EventEmitter implements IFSWatcher {
28
30
  if (persistent) {
29
31
  // Add a never-firing timeout source to keep the mainloop alive.
30
32
  // This is a lightweight way to hold a ref on the main context.
31
- sourceId = GLib.timeout_add(GLib.PRIORITY_LOW, 2147483647, () => GLib.SOURCE_CONTINUE);
33
+ sourceId = (GLib.timeout_add as unknown as (priority: number, interval: number, fn: () => boolean) => number)(
34
+ GLib.PRIORITY_LOW,
35
+ 2147483647,
36
+ () => GLib.SOURCE_CONTINUE,
37
+ );
32
38
  }
33
39
 
34
40
  privates.set(this, {
@@ -62,7 +68,11 @@ export class FSWatcher extends EventEmitter implements IFSWatcher {
62
68
  const priv = privates.get(this);
63
69
  if (!priv.persistent && !priv.cancellable.is_cancelled()) {
64
70
  priv.persistent = true;
65
- priv.sourceId = GLib.timeout_add(GLib.PRIORITY_LOW, 2147483647, () => GLib.SOURCE_CONTINUE);
71
+ priv.sourceId = (GLib.timeout_add as unknown as (priority: number, interval: number, fn: () => boolean) => number)(
72
+ GLib.PRIORITY_LOW,
73
+ 2147483647,
74
+ () => GLib.SOURCE_CONTINUE,
75
+ );
66
76
  }
67
77
  return this;
68
78
  }
@@ -100,4 +110,89 @@ function changed(watcher, file, otherFile, eventType) {
100
110
  }
101
111
  }
102
112
 
103
- export default FSWatcher;
113
+ export default FSWatcher;
114
+
115
+ // ─── fs.promises.watch ────────────────────────────────────────────────────────
116
+
117
+ type WatchEvent = { eventType: string; filename: string | null };
118
+
119
+ function gioEventToNodeType(eventType: Gio.FileMonitorEvent): string | null {
120
+ switch (eventType) {
121
+ case Gio.FileMonitorEvent.CHANGES_DONE_HINT: return 'change';
122
+ case Gio.FileMonitorEvent.DELETED:
123
+ case Gio.FileMonitorEvent.CREATED:
124
+ case Gio.FileMonitorEvent.RENAMED:
125
+ case Gio.FileMonitorEvent.MOVED_IN:
126
+ case Gio.FileMonitorEvent.MOVED_OUT: return 'rename';
127
+ default: return null;
128
+ }
129
+ }
130
+
131
+ export async function* watchAsync(
132
+ filename: PathLike,
133
+ options?: WatchOptions & { signal?: AbortSignal },
134
+ ): AsyncIterableIterator<WatchEvent> {
135
+ const signal = (options as any)?.signal as AbortSignal | undefined;
136
+
137
+ if (signal?.aborted) return;
138
+
139
+ const pathStr = normalizePath(filename);
140
+ const file = Gio.File.new_for_path(pathStr);
141
+ const cancellable = Gio.Cancellable.new();
142
+
143
+ let watcher: Gio.FileMonitor;
144
+ try {
145
+ watcher = file.monitor(Gio.FileMonitorFlags.NONE, cancellable);
146
+ } catch {
147
+ return;
148
+ }
149
+
150
+ const eventQueue: WatchEvent[] = [];
151
+ const waiterQueue: Array<{ resolve: (r: IteratorResult<WatchEvent>) => void }> = [];
152
+ let finished = false;
153
+
154
+ function enqueue(event: WatchEvent): void {
155
+ if (finished) return;
156
+ if (waiterQueue.length > 0) {
157
+ waiterQueue.shift()!.resolve({ value: event, done: false });
158
+ } else {
159
+ eventQueue.push(event);
160
+ }
161
+ }
162
+
163
+ function terminate(): void {
164
+ if (finished) return;
165
+ finished = true;
166
+ if (!cancellable.is_cancelled()) cancellable.cancel();
167
+ while (waiterQueue.length > 0) {
168
+ waiterQueue.shift()!.resolve({ value: undefined as any, done: true });
169
+ }
170
+ }
171
+
172
+ const signalId = watcher.connect('changed', (_mon: Gio.FileMonitor, changedFile: Gio.File, _otherFile: Gio.File | null, eventType: Gio.FileMonitorEvent) => {
173
+ const type = gioEventToNodeType(eventType);
174
+ if (type === null) return;
175
+ enqueue({ eventType: type, filename: changedFile?.get_basename() ?? null });
176
+ });
177
+
178
+ const abortHandler = () => terminate();
179
+ signal?.addEventListener('abort', abortHandler);
180
+
181
+ try {
182
+ while (!finished) {
183
+ if (eventQueue.length > 0) {
184
+ yield eventQueue.shift()!;
185
+ continue;
186
+ }
187
+ const result = await new Promise<IteratorResult<WatchEvent>>(resolve => {
188
+ waiterQueue.push({ resolve });
189
+ });
190
+ if (result.done) break;
191
+ yield result.value;
192
+ }
193
+ } finally {
194
+ signal?.removeEventListener('abort', abortHandler);
195
+ try { watcher.disconnect(signalId); } catch {}
196
+ if (!cancellable.is_cancelled()) cancellable.cancel();
197
+ }
198
+ }