diff options
author | 2023-07-25 23:55:39 -0700 | |
---|---|---|
committer | 2023-07-25 23:55:39 -0700 | |
commit | 57928f7e8061783bb7a4fb41f68d32b4b9c7bca2 (patch) | |
tree | 03c1205d6fb930ed45d41f949ebddf33de51215f | |
parent | 6bfee02301a2e2a0b79339974af0445eb5a2688f (diff) | |
download | bun-fs-watch-file.tar.gz bun-fs-watch-file.tar.zst bun-fs-watch-file.zip |
Implement `fs.watchFile()`fs-watch-file
Closes #3812
-rw-r--r-- | packages/bun-types/fs.d.ts | 156 | ||||
-rw-r--r-- | src/bun.js/bindings/bindings.zig | 21 | ||||
-rw-r--r-- | src/bun.js/node/node_fs_watcher.zig | 5 | ||||
-rw-r--r-- | src/bun.js/node/types.zig | 47 | ||||
-rw-r--r-- | src/js/node/fs.js | 159 | ||||
-rw-r--r-- | src/js/out/modules/node/fs.js | 105 |
6 files changed, 479 insertions, 14 deletions
diff --git a/packages/bun-types/fs.d.ts b/packages/bun-types/fs.d.ts index 5fb552b7c..3338bcb82 100644 --- a/packages/bun-types/fs.d.ts +++ b/packages/bun-types/fs.d.ts @@ -4067,6 +4067,162 @@ declare module "fs" { filename: PathLike, listener?: WatchListener<string>, ): FSWatcher; + + /** + * A successful call to {@link watchFile} will return a new fs.StatWatcher object. + * @since 0.7.1 + */ + export interface StatWatcher extends EventEmitter { + /** + * When called, requests that the Node.js event loop not exit so long as the watcher is active. + * + * Calling watcher.ref() multiple times will have no effect. + */ + ref(): this; + + /** + * When called, the active watcher will not require the Node.js event loop to remain active. + * If there is no other activity keeping the event loop running, the process may exit before + * the watcher's callback is invoked. + * + * Calling watcher.unref() multiple times will have no effect. + */ + unref(): this; + } + + /** + * Watch for changes on `filename`. The callback `listener` will be called each + * time the file is accessed. + * + * The `options` argument may be omitted. If provided, it should be an object. The`options` object may contain a boolean named `persistent` that indicates + * whether the process should continue to run as long as files are being watched. + * The `options` object may specify an `interval` property indicating how often the + * target should be polled in milliseconds. + * + * The `listener` gets two arguments the current stat object and the previous + * stat object: + * + * ```js + * import { watchFile } from "node:fs"; + * + * watchFile("example.txt", (curr, prev) => { + * console.log(`the current mtime is: ${curr.mtime}`); + * console.log(`the previous mtime was: ${prev.mtime}`); + * }); + * ``` + * + * These stat objects are instances of `fs.Stat`. If the `bigint` option is `true`, + * the numeric values in these objects are specified as `BigInt`s. + * + * To be notified when the file was modified, not just accessed, it is necessary + * to compare `curr.mtimeMs` and `prev.mtimeMs`. + * + * When an `fs.watchFile` operation results in an `ENOENT` error, it + * will invoke the listener once, with all the fields zeroed (or, for dates, the + * Unix Epoch). If the file is created later on, the listener will be called + * again, with the latest stat objects. This is a change in functionality since + * v0.10. + * + * Using {@link watch} is more efficient than `fs.watchFile` and`fs.unwatchFile`. + * `fs.watch` should be used instead of `fs.watchFile` and`fs.unwatchFile` when possible. + * + * When a file being watched by `fs.watchFile()` disappears and reappears, + * then the contents of `previous` in the second callback event (the file's + * reappearance) will be the same as the contents of `previous` in the first + * callback event (its disappearance). + * + * This happens when: + * + * * the file is deleted, followed by a restore + * * the file is renamed and then renamed a second time back to its original name + * + * @since 0.7.1 + */ + export type WatchFileOptions = { + bigint?: boolean; + persistent?: boolean; + interval?: number; + }; + + export type StatsListener = (curr: Stats, prev: Stats) => void; + export type BigIntStatsListener = (curr: BigIntStats, prev: BigIntStats) => void; + + /** + * Watch for changes on `filename`. The callback `listener` will be called each + * time the file is accessed. + * + * The `options` argument may be omitted. If provided, it should be an object. The`options` object may contain a boolean named `persistent` that indicates + * whether the process should continue to run as long as files are being watched. + * The `options` object may specify an `interval` property indicating how often the + * target should be polled in milliseconds. + * + * The `listener` gets two arguments the current stat object and the previous + * stat object: + * + * ```js + * import { watchFile } from "node:fs"; + * + * watchFile("example.txt", (curr, prev) => { + * console.log(`the current mtime is: ${curr.mtime}`); + * console.log(`the previous mtime was: ${prev.mtime}`); + * }); + * ``` + * + * These stat objects are instances of `fs.Stat`. If the `bigint` option is `true`, + * the numeric values in these objects are specified as `BigInt`s. + * + * To be notified when the file was modified, not just accessed, it is necessary + * to compare `curr.mtimeMs` and `prev.mtimeMs`. + * + * When an `fs.watchFile` operation results in an `ENOENT` error, it + * will invoke the listener once, with all the fields zeroed (or, for dates, the + * Unix Epoch). If the file is created later on, the listener will be called + * again, with the latest stat objects. This is a change in functionality since + * v0.10. + * + * Using {@link watch} is more efficient than `fs.watchFile` and`fs.unwatchFile`. + * `fs.watch` should be used instead of `fs.watchFile` and`fs.unwatchFile` when possible. + * + * When a file being watched by `fs.watchFile()` disappears and reappears, + * then the contents of `previous` in the second callback event (the file's + * reappearance) will be the same as the contents of `previous` in the first + * callback event (its disappearance). + * + * This happens when: + * + * * the file is deleted, followed by a restore + * * the file is renamed and then renamed a second time back to its original name + * + * @since 0.7.1 + */ + export function watchFile( + filename: PathLike, + options: + | (WatchFileOptions & { + bigint?: false | undefined; + }) + | undefined, + listener: StatsListener, + ): StatWatcher; + + export function watchFile( + filename: PathLike, + options: + | (WatchFileOptions & { + bigint: true; + }) + | undefined, + listener: BigIntStatsListener, + ): StatWatcher; + + /** + * Watch for changes on `filename`. The callback `listener` will be called each time the file is accessed. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + */ + export function watchFile( + filename: PathLike, + listener: StatsListener, + ): StatWatcher; } declare module "node:fs" { diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 924eb27a1..f6b9f3ce1 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -3461,6 +3461,7 @@ pub const JSValue = enum(JSValueReprInt) { i8 => @as(i8, @truncate(toInt32(this))), i32 => @as(i32, @truncate(toInt32(this))), i64 => this.toInt64(), + f64 => this.asNumber(), bool => this.toBoolean(), else => @compileError("Not implemented yet"), }; @@ -3989,6 +3990,10 @@ pub const JSValue = enum(JSValueReprInt) { return FFI.JSVALUE_IS_NUMBER(.{ .asJSValue = this }); } + pub fn isNumeric(this: JSValue) bool { + return this.isNumber() or this.isBigInt(); + } + pub fn isError(this: JSValue) bool { if (!this.isCell()) return false; @@ -5211,15 +5216,21 @@ pub const CallFrame = opaque { var ptr = self.argumentsPtr(); return switch (@as(u4, @min(len, max))) { 0 => .{ .ptr = undefined, .len = 0 }, - 4 => Arguments(max).init(comptime @min(4, max), ptr), + 1 => Arguments(max).init(comptime @min(1, max), ptr), 2 => Arguments(max).init(comptime @min(2, max), ptr), - 6 => Arguments(max).init(comptime @min(6, max), ptr), 3 => Arguments(max).init(comptime @min(3, max), ptr), - 8 => Arguments(max).init(comptime @min(8, max), ptr), + 4 => Arguments(max).init(comptime @min(4, max), ptr), 5 => Arguments(max).init(comptime @min(5, max), ptr), - 1 => Arguments(max).init(comptime @min(1, max), ptr), + 6 => Arguments(max).init(comptime @min(6, max), ptr), 7 => Arguments(max).init(comptime @min(7, max), ptr), - else => unreachable, + 8 => Arguments(max).init(comptime @min(8, max), ptr), + 9 => Arguments(max).init(comptime @min(9, max), ptr), + 10 => Arguments(max).init(comptime @min(10, max), ptr), + 11 => Arguments(max).init(comptime @min(11, max), ptr), + 12 => Arguments(max).init(comptime @min(12, max), ptr), + 13 => Arguments(max).init(comptime @min(13, max), ptr), + 14 => Arguments(max).init(comptime @min(14, max), ptr), + 15 => Arguments(max).init(comptime @min(15, max), ptr), }; } diff --git a/src/bun.js/node/node_fs_watcher.zig b/src/bun.js/node/node_fs_watcher.zig index d0af350c0..4819b61fe 100644 --- a/src/bun.js/node/node_fs_watcher.zig +++ b/src/bun.js/node/node_fs_watcher.zig @@ -364,11 +364,6 @@ pub const FSWatcher = struct { } }, .directory => { - // macOS should use FSEvents for directories - if (comptime Environment.isMac) { - @panic("Unexpected directory watch"); - } - const affected = event.names(changed_files); for (affected) |changed_name_| { diff --git a/src/bun.js/node/types.zig b/src/bun.js/node/types.zig index 23d693d69..1f8964e2a 100644 --- a/src/bun.js/node/types.zig +++ b/src/bun.js/node/types.zig @@ -1302,6 +1302,29 @@ fn StatsDataType(comptime T: type) type { @as(Date, @enumFromInt(@as(u64, @intCast(@max(stat_.birthtime().tv_sec, 0))))), }; } + + pub fn fromJS(args: []JSC.JSValue) @This() { + return @This(){ + .dev = if (args.len > 0 and args[0].isNumeric()) args[0].to(T) else 0, + .ino = if (args.len > 1 and args[1].isNumeric()) args[1].to(T) else 0, + .mode = if (args.len > 2 and args[2].isNumeric()) args[2].to(T) else 0, + .nlink = if (args.len > 3 and args[3].isNumeric()) args[3].to(T) else 0, + .uid = if (args.len > 4 and args[4].isNumeric()) args[4].to(T) else 0, + .gid = if (args.len > 5 and args[5].isNumeric()) args[5].to(T) else 0, + .rdev = if (args.len > 6 and args[6].isNumeric()) args[6].to(T) else 0, + .size = if (args.len > 7 and args[7].isNumeric()) args[7].to(T) else 0, + .blksize = if (args.len > 8 and args[8].isNumeric()) args[8].to(T) else 0, + .blocks = if (args.len > 9 and args[9].isNumeric()) args[9].to(T) else 0, + .atime_ms = if (args.len > 10 and args[10].isNumeric()) args[10].to(f64) else 0, + .mtime_ms = if (args.len > 11 and args[11].isNumeric()) args[11].to(f64) else 0, + .ctime_ms = if (args.len > 12 and args[12].isNumeric()) args[12].to(f64) else 0, + .birthtime_ms = if (args.len > 13 and args[13].isNumeric()) args[13].to(T) else 0, + .atime = @as(Date, @enumFromInt(if (args.len > 10 and args[10].isNumeric()) args[10].to(u64) else 0)), + .mtime = @as(Date, @enumFromInt(if (args.len > 11 and args[11].isNumeric()) args[11].to(u64) else 0)), + .ctime = @as(Date, @enumFromInt(if (args.len > 12 and args[12].isNumeric()) args[12].to(u64) else 0)), + .birthtime = @as(Date, @enumFromInt(if (args.len > 13 and args[13].isNumeric()) args[13].to(u64) else 0)), + }; + } }; } @@ -1431,10 +1454,26 @@ pub const Stats = union(enum) { return this; } - pub fn constructor(globalThis: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) ?*Stats { - globalThis.throw("Stats is not constructable. use fs.stat()", .{}); - - return null; + pub fn constructor(_: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) ?*This { + var this = bun.default_allocator.create(Stats) catch unreachable; + var arguments = callframe.arguments(15); + var args = arguments.ptr[0..arguments.len]; + if (args.len > 0 and args[0].isBoolean()) { + if (args[0].toBoolean()) { + this.* = .{ + .big = StatsDataType(i64).fromJS(args[1..]), + }; + } else { + this.* = .{ + .small = StatsDataType(i32).fromJS(args[1..]), + }; + } + } else { + this.* = .{ + .small = StatsDataType(i32).fromJS(args), + }; + } + return this; } comptime { diff --git a/src/js/node/fs.js b/src/js/node/fs.js index 5e72d6e27..37f109a33 100644 --- a/src/js/node/fs.js +++ b/src/js/node/fs.js @@ -8,6 +8,7 @@ var { direct, isPromise, isCallable } = $lazy("primordials"); import promises from "node:fs/promises"; export { default as promises } from "node:fs/promises"; import * as Stream from "node:stream"; +import { resolve } from "node:path"; var fs = Bun.fs(); var debug = process.env.DEBUG ? console.log : () => {}; @@ -68,6 +69,124 @@ class FSWatcher extends EventEmitter { this.#watcher?.unref(); } } + +/** @type {Map<string, Array<[Function, StatWatcher]>>} */ +const statWatchers = new Map(); + +/** @link https://nodejs.org/api/fs.html#class-fsstatwatcher */ +class StatWatcher extends EventEmitter { + #filename; + #options; + #listener; + #watcher; + #timer; + #stat; + + constructor(filename, options, listener) { + super(); + this.#filename = filename; + if (typeof options === "function") { + listener = options; + options = undefined; + } else if (typeof listener !== "function") { + listener = () => {}; + } + this.#listener = listener; + this.#options = options; + const watchKey = resolve(filename); + const watchers = statWatchers.get(watchKey); + if (watchers === undefined) { + statWatchers.set(watchKey, [[this.#listener, this]]); + } else { + watchers.push([this.#listener, this]); + } + this.#watch(); + } + + #watch() { + let previous = this.#stat; + let current; + try { + current = this.#stat = fs.statSync(this.#filename); + debug("fs.watchFile mtime", current.mtime); + + if (this.#watcher === undefined) { + this.#watcher = fs.watch(this.#filename, this.#options, this.#onEvent.bind(this)); + } + } catch (error) { + debug("fs.watchFile error", error); + if (error.code !== "ENOENT") { + throw error; + } + + // When an `fs.watchFile` operation results in an ENOENT error, + // it will invoke the listener once, with all the fields zeroed (or, for dates, the Unix Epoch). + // If the file is created later on, the listener will be called again, with the latest stat objects. + if (previous === undefined) { + current = this.#stat = new fs.Stats(this.#options?.bigint === true); + this.#listener?.(current, current); + } + + if (this.#timer === undefined) { + this.#timer = setInterval( + this.#watch.bind(this), + this.#options?.interval ?? 5007, // libuv default + ); + } + return; + } + if (previous !== undefined && previous.mtimeMs !== current.mtimeMs) { + this.#listener?.(current, previous); + } + this.#clear(); + } + + #onEvent(eventType, filename) { + debug("fs.watchFile event", eventType, filename); + switch (eventType) { + case "close": + this.close(); + break; + case "error": + this.close(); + // fallthrough + case "rename": + case "change": + this.#watch(); + break; + } + } + + #clear() { + if (this.#timer !== undefined) { + debug("fs.watchFile clear timer"); + clearInterval(this.#timer); + this.#timer = undefined; + } + } + + close() { + debug("fs.watchFile close"); + this.#watcher?.close(); + this.#watcher = undefined; + this.#clear(); + } + + ref() { + debug("fs.watchFile ref"); + this.#watcher?.ref(); + this.#timer?.ref(); + return this; + } + + unref() { + debug("fs.watchFile unref"); + this.#watcher?.unref(); + this.#timer?.unref(); + return this; + } +} + export var access = function access(...args) { callbackify(fs.accessSync, args); }, @@ -250,6 +369,43 @@ export var access = function access(...args) { Stats = fs.Stats, watch = function watch(path, options, listener) { return new FSWatcher(path, options, listener); + }, + watchFile = function watchFile(path, options, listener) { + return new StatWatcher(path, options, listener); + }, + unwatchFile = function unwatchFile(path, listener) { + const watchKey = resolve(path); + const watchers = statWatchers.get(watchKey); + if (watchers === undefined) { + return; + } + if (typeof listener === "function") { + const deleted = new Set(); + for (const [func, watcher] of watchers) { + if (listener !== func) { + continue; + } + try { + watcher.close(); + } finally { + deleted.add(watcher); + } + } + const remaining = watchers.filter(([_, watcher]) => !deleted.has(watcher)); + if (remaining.length) { + statWatchers.set(watchKey, remaining); + } else { + statWatchers.delete(watchKey); + } + return; + } + try { + for (const [_, watcher] of watchers) { + watcher.close(); + } + } finally { + statWatchers.delete(watchKey); + } }; function callbackify(fsFunction, args) { @@ -1102,6 +1258,9 @@ export default { ReadStream, watch, FSWatcher, + watchFile, + unwatchFile, + StatWatcher, writev, writevSync, readv, diff --git a/src/js/out/modules/node/fs.js b/src/js/out/modules/node/fs.js index b7457f104..5c67f3e0c 100644 --- a/src/js/out/modules/node/fs.js +++ b/src/js/out/modules/node/fs.js @@ -2,6 +2,7 @@ import {EventEmitter} from "node:events"; import promises2 from "node:fs/promises"; import {default as default2} from "node:fs/promises"; import * as Stream from "node:stream"; +import {resolve} from "node:path"; var callbackify = function(fsFunction, args) { try { const result = fsFunction.apply(fs, args.slice(0, args.length - 1)), callback = args[args.length - 1]; @@ -61,6 +62,75 @@ class FSWatcher extends EventEmitter { this.#watcher?.unref(); } } +var statWatchers = new Map; + +class StatWatcher extends EventEmitter { + #filename; + #options; + #listener; + #watcher; + #timer; + #stat; + constructor(filename, options, listener) { + super(); + if (this.#filename = filename, typeof options === "function") + listener = options, options = void 0; + else if (typeof listener !== "function") + listener = () => { + }; + this.#listener = listener, this.#options = options; + const watchKey = resolve(filename), watchers = statWatchers.get(watchKey); + if (watchers === void 0) + statWatchers.set(watchKey, [[this.#listener, this]]); + else + watchers.push([this.#listener, this]); + this.#watch(); + } + #watch() { + let previous = this.#stat, current; + try { + if (current = this.#stat = fs.statSync(this.#filename), debug("fs.watchFile mtime", current.mtime), this.#watcher === void 0) + this.#watcher = fs.watch(this.#filename, this.#options, this.#onEvent.bind(this)); + } catch (error) { + if (debug("fs.watchFile error", error), error.code !== "ENOENT") + throw error; + if (previous === void 0) + current = this.#stat = new fs.Stats(this.#options?.bigint === !0), this.#listener?.(current, current); + if (this.#timer === void 0) + this.#timer = setInterval(this.#watch.bind(this), this.#options?.interval ?? 5007); + return; + } + if (previous !== void 0 && previous.mtimeMs !== current.mtimeMs) + this.#listener?.(current, previous); + this.#clear(); + } + #onEvent(eventType, filename) { + switch (debug("fs.watchFile event", eventType, filename), eventType) { + case "close": + this.close(); + break; + case "error": + this.close(); + case "rename": + case "change": + this.#watch(); + break; + } + } + #clear() { + if (this.#timer !== void 0) + debug("fs.watchFile clear timer"), clearInterval(this.#timer), this.#timer = void 0; + } + close() { + debug("fs.watchFile close"), this.#watcher?.close(), this.#watcher = void 0, this.#clear(); + } + ref() { + return debug("fs.watchFile ref"), this.#watcher?.ref(), this.#timer?.ref(), this; + } + unref() { + return debug("fs.watchFile unref"), this.#watcher?.unref(), this.#timer?.unref(), this; + } +} var access = function access2(...args) { callbackify(fs.accessSync, args); }, appendFile = function appendFile2(...args) { @@ -157,6 +227,36 @@ var access = function access2(...args) { }); }, readvSync = fs.readvSync.bind(fs), Dirent = fs.Dirent, Stats = fs.Stats, watch = function watch2(path, options, listener) { return new FSWatcher(path, options, listener); +}, watchFile = function watchFile2(path, options, listener) { + return new StatWatcher(path, options, listener); +}, unwatchFile = function unwatchFile2(path, listener) { + const watchKey = resolve(path), watchers = statWatchers.get(watchKey); + if (watchers === void 0) + return; + if (typeof listener === "function") { + const deleted = new Set; + for (let [func, watcher] of watchers) { + if (listener !== func) + continue; + try { + watcher.close(); + } finally { + deleted.add(watcher); + } + } + const remaining = watchers.filter(([_, watcher]) => !deleted.has(watcher)); + if (remaining.length) + statWatchers.set(watchKey, remaining); + else + statWatchers.delete(watchKey); + return; + } + try { + for (let [_, watcher] of watchers) + watcher.close(); + } finally { + statWatchers.delete(watchKey); + } }, readStreamPathFastPathSymbol = Symbol.for("Bun.Node.readStreamPathFastPath"), readStreamSymbol = Symbol.for("Bun.NodeReadStream"), readStreamPathOrFdSymbol = Symbol.for("Bun.NodeReadStreamPathOrFd"), writeStreamSymbol = Symbol.for("Bun.NodeWriteStream"), writeStreamPathFastPathSymbol = Symbol.for("Bun.NodeWriteStreamFastPath"), writeStreamPathFastPathCallSymbol = Symbol.for("Bun.NodeWriteStreamFastPathCall"), kIoDone = Symbol.for("kIoDone"), defaultReadStreamOptions = { file: void 0, fd: void 0, @@ -664,6 +764,9 @@ var fs_default = { ReadStream, watch, FSWatcher, + watchFile, + unwatchFile, + StatWatcher, writev, writevSync, readv, @@ -680,9 +783,11 @@ export { writeFileSync, writeFile, write, + watchFile, watch, utimesSync, utimes, + unwatchFile, unlinkSync, unlink, truncateSync, |