diff --git a/README.md b/README.md index 4ab9e5c..635754e 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Methods: | Method | Deno | Node | Bun | Base implementation | | --------- | ---- | ---- | --- | ------------------- | +| FsWatcher | X | X | X | custom | | unlink | X | X | X | node:fs/promises | | dirpath | X | X | X | @cross/dir | | mkdir | X | X | X | node:fs/promises | @@ -111,7 +112,6 @@ Methods: | chmod | X | X | X | node:fs/promises | | chown | X | X | X | node:fs/promises | | rename | X | X | X | node:fs/promises | -| watch | X | X | X | node:fs/promises | | truncate | X | X | X | node:fs/promises | | open | X | X | X | node:fs/promises | | access | X | X | X | node:fs/promises | diff --git a/deno.json b/deno.json index c536dc0..b9004ab 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@cross/fs", - "version": "0.0.10", + "version": "0.1.11", "exports": { ".": "./mod.ts", "./stat": "./src/stat/mod.ts", @@ -12,9 +12,9 @@ "@cross/env": "jsr:@cross/env@^1.0.0", "@cross/runtime": "jsr:@cross/runtime@^1.0.0", "@cross/test": "jsr:@cross/test@^0.0.9", - "@cross/utils": "jsr:@cross/utils@^0.11.0", - "@std/assert": "jsr:@std/assert@^0.223.0", - "@std/path": "jsr:@std/path@^0.223.0" + "@cross/utils": "jsr:@cross/utils@^0.12.0", + "@std/assert": "jsr:@std/assert@^0.224.0", + "@std/path": "jsr:@std/path@^0.224.0" }, "publish": { "exclude": [".github", "*.test.ts"] diff --git a/src/ops/mod.ts b/src/ops/mod.ts index 1630b8d..f94e027 100644 --- a/src/ops/mod.ts +++ b/src/ops/mod.ts @@ -11,7 +11,6 @@ export { rmdir, truncate, unlink, - watch, } from "node:fs/promises"; export type { FSWatcher } from "node:fs"; @@ -20,3 +19,4 @@ export * from "./mktempdir.ts"; export * from "./tempfile.ts"; export * from "./chdir.ts"; export * from "./cwd.ts"; +export * from "./watch.ts"; diff --git a/src/ops/watch.test.ts b/src/ops/watch.test.ts new file mode 100644 index 0000000..5e00932 --- /dev/null +++ b/src/ops/watch.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from "@std/assert"; +import { test } from "@cross/test"; +import { mktempdir, rm } from "./mod.ts"; +import { join } from "@std/path"; +import { type FileSystemEvent, FsWatcher } from "./watch.ts"; +import { writeFile } from "../io/mod.ts"; + +test("FsWatcher watches for file changes", async () => { + const watcher = FsWatcher(); + const tempdir = await mktempdir(); + const filePath = join(tempdir, "test.txt"); + const events: FileSystemEvent[] = []; + setTimeout(async () => { + await writeFile(filePath, "Hello"); + }, 1000); + for await (const event of watcher.watch(tempdir)) { + if (event.kind === "modify" && filePath == event.paths[0]) { + events.push(event); + break; // Stop watching after the creation event + } + } + await new Promise((resolve) => setTimeout(resolve, 1000)); // Allow some time + watcher.close(); + await rm(tempdir, { recursive: true }); + assertEquals(events.length, 1); + assertEquals(events[0].kind, "modify"); + assertEquals(events[0].paths[0], filePath); +}); diff --git a/src/ops/watch.ts b/src/ops/watch.ts new file mode 100644 index 0000000..1dbb907 --- /dev/null +++ b/src/ops/watch.ts @@ -0,0 +1,91 @@ +import { CurrentRuntime, Runtime } from "@cross/runtime"; +import { watch as nodeWatch } from "node:fs/promises"; +import type { WatchOptions } from "node:fs"; +import { join } from "@std/path"; + +export interface FileSystemWatcherOptions { + recursive: boolean; + signal?: AbortSignal; +} +export type FileSystemEventKind = + | "error" + | "any" + | "access" + | "create" + | "modify" + | "remove" + | "other"; +export interface FileSystemEvent { + kind: FileSystemEventKind; + paths: (string | undefined)[]; +} +export interface Watcher { + watch( + path: string, + options?: FileSystemWatcherOptions, + ): AsyncIterable; + close(): void; +} +export function FsWatcher(): Watcher { + let denoWatcher: Deno.FsWatcher | undefined; + let nodeWatcher: AsyncIterable; + const ac = new AbortController(); + return { + async *watch( + path: string, + options?: FileSystemWatcherOptions, + ): AsyncIterable { + try { + if (CurrentRuntime === Runtime.Deno) { + denoWatcher = Deno.watchFs(path, options); + for await (const event of denoWatcher) { + yield event; + } + } else if ( + CurrentRuntime === Runtime.Node || CurrentRuntime === Runtime.Bun + ) { + const usedOptions: FileSystemWatcherOptions = options + ? options + : { recursive: true }; + if (!options?.signal) usedOptions.signal = ac.signal; + nodeWatcher = await nodeWatch(path, usedOptions as WatchOptions); + for await (const event of nodeWatcher) { + //@ts-ignore cross-runtime + if (event.filename) { + const generatedEvent = { + //@ts-ignore cross-runtime + kind: (event.eventType === "change" + ? "modify" + //@ts-ignore cross-runtime + : event.eventType) as FileSystemEventKind, + //@ts-ignore cross-runtime + paths: [join(path, event.filename?.toString())], + }; + yield generatedEvent; + } + } + } else { + throw new Error("cross/watchFs: Runtime not supported."); + } + } catch (err) { + if (err.name === "AbortError") { + /* Ok! */ + } else { + throw new Error( + "Cannot start asynchronous filesystem watcher using current runtime.", + ); + } + } + }, + close() { + if (denoWatcher) { + try { + denoWatcher.close(); + } catch (_e) { /* Ignore */ } + } + if (nodeWatcher) { + ac?.abort(); + } + }, + }; +}