Repository: Effect-TS/effect (TypeScript)

Issue:
Add "recursive" option to `FileSystem.watch`
### What is the problem this feature would solve?

This would make it easier to recursively watch changes in a directory with the Node platform, rather than only changes at the top level of the watched directory.

### What is the feature you are proposing to solve the problem?

Adding the `recursive` option to the FileSystem API. See [the Node.js documentation](https://nodejs.org/docs/latest/api/fs.html#fswatchfilename-options-listener) for information about this option.

### What alternatives have you considered?

Wrapping the Node.js-native `fs.watch` API manually—effectively just copying [the Effect implementation](https://github.com/Effect-TS/effect/blob/13e3dcf9f8a4180c7df3a7eb6e08e8735df18999/packages/platform-node-shared/src/internal/fileSystem.ts#L526-L573) and adding support for the option.


Discussion:

Patch:
diff --git a/.changeset/recursive-watch-option.md b/.changeset/recursive-watch-option.md
new file mode 100644
index 00000000000..003eb83aa8e
--- /dev/null
+++ b/.changeset/recursive-watch-option.md
@@ -0,0 +1,26 @@
+---
+"@effect/platform": patch
+"@effect/platform-node": patch
+"@effect/platform-node-shared": patch
+---
+
+feat(platform): add recursive option to FileSystem.watch
+
+Added a `recursive` option to `FileSystem.watch` that allows watching for changes in subdirectories. When set to `true`, the watcher will monitor changes in all nested directories.
+
+Note: The recursive option is only supported on macOS and Windows. On other platforms, it will be ignored.
+
+Example:
+```ts
+import { FileSystem } from "@effect/platform"
+import { Effect, Stream } from "effect"
+
+Effect.gen(function* () {
+  const fs = yield* FileSystem.FileSystem
+  
+  // Watch directory and all subdirectories
+  yield* fs.watch("src", { recursive: true }).pipe(
+    Stream.runForEach(console.log)
+  )
+})
+```
\ No newline at end of file
diff --git a/packages/platform-node-shared/src/internal/fileSystem.ts b/packages/platform-node-shared/src/internal/fileSystem.ts
index 782f82a8d09..66db46a9951 100644
--- a/packages/platform-node-shared/src/internal/fileSystem.ts
+++ b/packages/platform-node-shared/src/internal/fileSystem.ts
@@ -535,11 +535,11 @@ const utimes = (() => {
 
 // == watch
 
-const watchNode = (path: string) =>
+const watchNode = (path: string, options?: FileSystem.WatchOptions) =>
   Stream.asyncScoped<FileSystem.WatchEvent, Error.PlatformError>((emit) =>
     Effect.acquireRelease(
       Effect.sync(() => {
-        const watcher = NFS.watch(path, {}, (event, path) => {
+        const watcher = NFS.watch(path, { recursive: options?.recursive }, (event, path) => {
           if (!path) return
           switch (event) {
             case "rename": {
@@ -578,12 +578,16 @@ const watchNode = (path: string) =>
     )
   )
 
-const watch = (backend: Option.Option<Context.Tag.Service<FileSystem.WatchBackend>>, path: string) =>
+const watch = (
+  backend: Option.Option<Context.Tag.Service<FileSystem.WatchBackend>>,
+  path: string,
+  options?: FileSystem.WatchOptions
+) =>
   stat(path).pipe(
     Effect.map((stat) =>
       backend.pipe(
-        Option.flatMap((_) => _.register(path, stat)),
-        Option.getOrElse(() => watchNode(path))
+        Option.flatMap((_) => _.register(path, stat, options)),
+        Option.getOrElse(() => watchNode(path, options))
       )
     ),
     Stream.unwrap
@@ -634,8 +638,8 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend),
     symlink,
     truncate,
     utimes,
-    watch(path) {
-      return watch(backend, path)
+    watch(path, options) {
+      return watch(backend, path, options)
     },
     writeFile
   }))
diff --git a/packages/platform-node-shared/src/internal/fileSystem/parcelWatcher.ts b/packages/platform-node-shared/src/internal/fileSystem/parcelWatcher.ts
index c9c40f1d1a2..f5be795401a 100644
--- a/packages/platform-node-shared/src/internal/fileSystem/parcelWatcher.ts
+++ b/packages/platform-node-shared/src/internal/fileSystem/parcelWatcher.ts
@@ -53,7 +53,7 @@ const watchParcel = (path: string) =>
   )
 
 const backend = FileSystem.WatchBackend.of({
-  register(path, stat) {
+  register(path, stat, _options) {
     if (stat.type !== "Directory") {
       return Option.none()
     }
diff --git a/packages/platform-node/examples/fs-watch.ts b/packages/platform-node/examples/fs-watch.ts
index fe033494553..27947357c69 100644
--- a/packages/platform-node/examples/fs-watch.ts
+++ b/packages/platform-node/examples/fs-watch.ts
@@ -8,7 +8,7 @@ const EnvLive = NodeFileSystem.layer.pipe(Layer.provide(ParcelWatcher.layer))
 Effect.gen(function*() {
   const fs = yield* FileSystem.FileSystem
 
-  yield* fs.watch("src").pipe(
+  yield* fs.watch("src", { recursive: true }).pipe(
     Stream.runForEach(Console.log)
   )
 }).pipe(Effect.provide(EnvLive), NodeRuntime.runMain)
diff --git a/packages/platform/src/FileSystem.ts b/packages/platform/src/FileSystem.ts
index 36c142bd4cc..9559e0e5581 100644
--- a/packages/platform/src/FileSystem.ts
+++ b/packages/platform/src/FileSystem.ts
@@ -230,9 +230,15 @@ export interface FileSystem {
     mtime: Date | number
   ) => Effect.Effect<void, PlatformError>
   /**
-   * Watch a directory or file for changes
+   * Watch a directory or file for changes.
+   *
+   * By default, only changes to the direct children of the directory are reported.
+   * Set the `recursive` option to `true` to watch for changes in subdirectories as well.
+   *
+   * Note: The `recursive` option is only supported on macOS and Windows.
+   * On other platforms, it will be ignored.
    */
-  readonly watch: (path: string) => Stream<WatchEvent, PlatformError>
+  readonly watch: (path: string, options?: WatchOptions) => Stream<WatchEvent, PlatformError>
   /**
    * Write data to a file at `path`.
    */
@@ -432,6 +438,17 @@ export interface WriteFileStringOptions {
   readonly mode?: number
 }
 
+/**
+ * @since 1.0.0
+ * @category options
+ */
+export interface WatchOptions {
+  /**
+   * When `true`, the watcher will also watch for changes in subdirectories.
+   */
+  readonly recursive?: boolean
+}
+
 /**
  * @since 1.0.0
  * @category tag
@@ -627,7 +644,11 @@ export const WatchEventRemove: Data.Case.Constructor<WatchEvent.Remove, "_tag">
 export class WatchBackend extends Context.Tag("@effect/platform/FileSystem/WatchBackend")<
   WatchBackend,
   {
-    readonly register: (path: string, stat: File.Info) => Option<Stream<WatchEvent, PlatformError>>
+    readonly register: (
+      path: string,
+      stat: File.Info,
+      options?: WatchOptions
+    ) => Option<Stream<WatchEvent, PlatformError>>
   }
 >() {
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c3a6c4b0e06..b9cd9272859 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5129,7 +5129,6 @@ packages:
 
   glob@7.2.3:
     resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
-    deprecated: Glob versions prior to v9 are no longer supported
 
   globals@11.12.0:
     resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
@@ -5178,7 +5177,6 @@ packages:
 
   gulp-header@1.8.12:
     resolution: {integrity: sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ==}
-    deprecated: Removed event-stream from gulp-header
 
   happy-dom@17.4.7:
     resolution: {integrity: sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==}
@@ -5290,7 +5288,6 @@ packages:
 
   inflight@1.0.6:
     resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
-    deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
 
   inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -5808,7 +5805,6 @@ packages:
 
   lodash.template@4.5.0:
     resolution: {integrity: sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==}
-    deprecated: This package is deprecated. Use https://socket.dev/npm/package/eta instead.
 
   lodash.templatesettings@4.2.0:
     resolution: {integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==}
@@ -6229,7 +6225,6 @@ packages:
   node-domexception@1.0.0:
     resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
     engines: {node: '>=10.5.0'}
-    deprecated: Use your platform's native DOMException instead
 
   node-fetch@2.7.0:
     resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}


Test patch:
diff --git a/packages/platform-node-shared/test/FileSystem.test.ts b/packages/platform-node-shared/test/FileSystem.test.ts
index c68830794ca..89e5d2e307d 100644
--- a/packages/platform-node-shared/test/FileSystem.test.ts
+++ b/packages/platform-node-shared/test/FileSystem.test.ts
@@ -3,6 +3,7 @@ import * as Fs from "@effect/platform/FileSystem"
 import { assert, describe, expect, it } from "@effect/vitest"
 import * as Chunk from "effect/Chunk"
 import * as Effect from "effect/Effect"
+import * as Fiber from "effect/Fiber"
 import * as Stream from "effect/Stream"
 
 const runPromise = <E, A>(self: Effect.Effect<A, E, Fs.FileSystem>) =>
@@ -203,4 +204,164 @@ describe("FileSystem", () => {
         Effect.scoped
       )
     })))
+
+  describe("watch", () => {
+    // Simple test to verify watch API accepts recursive option
+    it("should accept recursive option", () =>
+      runPromise(Effect.gen(function*() {
+        const fs = yield* Fs.FileSystem
+
+        yield* Effect.scoped(Effect.gen(function*() {
+          const dir = yield* fs.makeTempDirectoryScoped()
+
+          // Test that watch accepts no options
+          const fiber1 = yield* fs.watch(dir).pipe(
+            Stream.runDrain,
+            Effect.fork
+          )
+          yield* Fiber.interrupt(fiber1)
+
+          // Test that watch accepts recursive: true
+          const fiber2 = yield* fs.watch(dir, { recursive: true }).pipe(
+            Stream.runDrain,
+            Effect.fork
+          )
+          yield* Fiber.interrupt(fiber2)
+
+          // Test that watch accepts recursive: false
+          const fiber3 = yield* fs.watch(dir, { recursive: false }).pipe(
+            Stream.runDrain,
+            Effect.fork
+          )
+          yield* Fiber.interrupt(fiber3)
+        }))
+      })))
+
+    it("should watch file changes in a directory", () =>
+      runPromise(Effect.gen(function*() {
+        const fs = yield* Fs.FileSystem
+
+        yield* Effect.scoped(Effect.gen(function*() {
+          const dir = yield* fs.makeTempDirectoryScoped()
+          let eventCount = 0
+
+          // Start watching the directory
+          const fiber = yield* fs.watch(dir).pipe(
+            Stream.tap((_event) =>
+              Effect.sync(() => {
+                eventCount++
+              })
+            ),
+            Stream.runDrain,
+            Effect.fork
+          )
+
+          // Wait for the watcher to initialize
+          yield* Effect.sleep("500 millis")
+
+          // Create a file - this should trigger an event
+          const testFile = `${dir}/test.txt`
+          yield* fs.writeFileString(testFile, "hello")
+
+          // Wait for event to be processed
+          yield* Effect.sleep("1000 millis")
+
+          // We should have received at least one event
+          expect(eventCount).toBeGreaterThan(0)
+
+          yield* Fiber.interrupt(fiber)
+        }))
+      })))
+
+    it("should watch file changes recursively in subdirectories", () =>
+      runPromise(Effect.gen(function*() {
+        const fs = yield* Fs.FileSystem
+
+        yield* Effect.scoped(Effect.gen(function*() {
+          const dir = yield* fs.makeTempDirectoryScoped()
+          let eventCount = 0
+
+          // Create a subdirectory
+          const subdir = `${dir}/subdir`
+          yield* fs.makeDirectory(subdir)
+
+          // Start watching with recursive option
+          const fiber = yield* fs.watch(dir, { recursive: true }).pipe(
+            Stream.tap((_event) =>
+              Effect.sync(() => {
+                eventCount++
+              })
+            ),
+            Stream.runDrain,
+            Effect.fork
+          )
+
+          // Wait for the watcher to initialize
+          yield* Effect.sleep("500 millis")
+
+          // Create a file in subdirectory - this should trigger an event
+          const testFile = `${subdir}/test.txt`
+          yield* fs.writeFileString(testFile, "hello from subdir")
+
+          // Wait for event to be processed
+          yield* Effect.sleep("1000 millis")
+
+          // We should have received at least one event
+          expect(eventCount).toBeGreaterThan(0)
+
+          yield* Fiber.interrupt(fiber)
+        }))
+      })))
+
+    it("should not watch subdirectories when recursive is false", () =>
+      runPromise(Effect.gen(function*() {
+        const fs = yield* Fs.FileSystem
+
+        yield* Effect.scoped(Effect.gen(function*() {
+          const dir = yield* fs.makeTempDirectoryScoped()
+          let rootEventCount = 0
+          let subEventCount = 0
+
+          // Create a subdirectory
+          const subdir = `${dir}/subdir`
+          yield* fs.makeDirectory(subdir)
+
+          // Start watching WITHOUT recursive option (default is false)
+          const fiber = yield* fs.watch(dir, { recursive: false }).pipe(
+            Stream.tap((event) =>
+              Effect.sync(() => {
+                if (event.path.includes("root.txt")) {
+                  rootEventCount++
+                }
+                if (event.path.includes("sub.txt")) {
+                  subEventCount++
+                }
+              })
+            ),
+            Stream.runDrain,
+            Effect.fork
+          )
+
+          // Wait for the watcher to initialize
+          yield* Effect.sleep("500 millis")
+
+          // Create a file in root directory - this SHOULD trigger an event
+          const rootFile = `${dir}/root.txt`
+          yield* fs.writeFileString(rootFile, "root file")
+
+          // Create a file in subdirectory - this should NOT trigger an event
+          const subFile = `${subdir}/sub.txt`
+          yield* fs.writeFileString(subFile, "sub file")
+
+          // Wait for events to be processed
+          yield* Effect.sleep("1000 millis")
+
+          // We should have received events for root file but not subdirectory file
+          expect(rootEventCount).toBeGreaterThan(0)
+          expect(subEventCount).toBe(0)
+
+          yield* Fiber.interrupt(fiber)
+        }))
+      })))
+  })
 })