# FsNotify
[](https://hex.pm/packages/fs_notify)
[](https://hexdocs.pm/fs_notify)
[](https://github.com/fahchen/fs_notify/blob/main/LICENSE)
[](https://github.com/fahchen/fs_notify/actions/workflows/ci.yml)
Cross-platform filesystem watcher for Elixir, backed by
[notify-rs](https://github.com/notify-rs/notify) via
[Rustler](https://github.com/rusterlium/rustler).
Watch files and directories and receive change events as messages in your own
process. Each native watcher runs on its own OS-backed thread (FSEvents on
macOS, inotify on Linux, ReadDirectoryChangesW on Windows) and forwards events
into the BEAM.
## Features
- Native, low-overhead watching via notify-rs (no polling).
- Each event is delivered straight to a subscriber process as
`{:fs_notify_event, %FsNotify.Event{}}` — one message per event.
- Watch one path or many with a single watcher; recursive or not.
- Fine-grained event kinds (`:create`, `:modify`, `:remove`, `:access`,
`:other`) with the notify subtype preserved in `detail`.
- Automatic cleanup: the OS watch stops when the subscriber process dies (or
the returned reference is garbage-collected) — no leaks, even on crash.
## Installation
Add `fs_notify` to your deps in `mix.exs`:
```elixir
def deps do
[
{:fs_notify, "~> 0.1.0"}
]
end
```
FsNotify ships **precompiled NIFs** via
[`rustler_precompiled`](https://hexdocs.pm/rustler_precompiled), so a Rust
toolchain is **not** required for the supported targets (macOS / Linux /
Windows on x86_64 and aarch64). The artifact for your platform is downloaded
and verified against a checksum at compile time.
### Building from source
To compile the NIF locally instead of downloading it (e.g. an unsupported
target, or to hack on the Rust code), force a build — this needs `cargo`:
```bash
FS_NOTIFY_BUILD=1 mix compile
```
You can also force a build for all `rustler_precompiled` packages with
`config :rustler_precompiled, force_build_all: true`. Non-`:prod` Mix
environments build from source by default.
## Quick start
Watch from a process and handle events in `handle_info/2`:
```elixir
defmodule DirWatcher do
use GenServer
def start_link(dir), do: GenServer.start_link(__MODULE__, dir)
@impl true
def init(dir) do
{:ok, _ref} = FsNotify.watch(dir, recursive: true)
{:ok, dir}
end
@impl true
def handle_info({:fs_notify_event, %FsNotify.Event{kind: kind, paths: paths}}, dir) do
IO.puts("#{kind}: #{Enum.join(paths, ", ")}")
{:noreply, dir}
end
end
{:ok, _pid} = DirWatcher.start_link("/path/to/dir")
```
The watch stops automatically when `DirWatcher` stops — no cleanup needed.
## Usage
```elixir
# Start watching a directory (or a list of paths). Events go to the caller by default.
{:ok, ref} = FsNotify.watch("/path/to/dir", recursive: true)
# A single watcher can cover several paths:
{:ok, ref} = FsNotify.watch(["/path/a", "/path/b"])
# Receive events — one message per event, carrying all affected paths.
receive do
{:fs_notify_event, %FsNotify.Event{kind: kind, paths: paths}} ->
IO.inspect({kind, paths})
end
# Stop watching. Optional — the native watch also stops automatically when the
# subscriber process dies or `ref` is garbage-collected.
FsNotify.unwatch(ref)
```
Keep `ref` for as long as you want to watch; the watch is tied to the
subscriber process.
### Message shape
Each notify event arrives as its own message:
```elixir
{:fs_notify_event, %FsNotify.Event{kind: kind, detail: detail, paths: paths}}
```
- `kind` — top-level category: `:any | :access | :create | :modify | :remove | :other`.
- `detail` — notify sub-kind under `kind`: a bare atom (`:file`, `:folder`, `:read`,
`:any`, `:other`) or a `{group, sub}` tuple (`{:data, :content}`, `{:metadata, :write_time}`,
`{:name, :both}`, `{:open, :write}`, `{:close, :read}`). See `FsNotify.Event`.
- `paths` — affected paths as binaries; a reconciled rename carries `[from, to]`.
> notify reports different event kinds per platform, so do not rely on a
> specific `kind`/`detail` for the same operation across OSes.
### Options
| Option | Default | Description |
| ------------- | ---------------- | ---------------------------------------------------------------- |
| `:recursive` | `true` | Watch subdirectories recursively. |
| `:debounce` | `0` | Coalesce events over this many ms (notify-rs debouncer); 0 = off.|
| `:backend` | `:recommended` | `:recommended` (OS-native) or `:poll` (portable polling). |
| `:poll_interval` | `0` | Poll interval (ms) for the `:poll` backend; 0 = notify default. |
| `:subscriber` | calling process | Process that receives `{:fs_notify_event, ...}` messages. |
## How it works
```
notify-rs thread --{:fs_notify_event, %Event{}}--> subscriber process
```
The native `RecommendedWatcher` is held inside a Rustler resource. Events are
mapped to `%FsNotify.Event{}` in Rust and pushed directly to the subscriber,
one message per event. With `:debounce` set, events are coalesced natively via
[`notify-debouncer-full`](https://crates.io/crates/notify-debouncer-full). The
resource monitors the subscriber: when it dies (or when the resource is
garbage-collected), `notify` is dropped and the OS watch stops. There is no
GenServer in the path — no event-kind filtering or supervision; do that in your
own process if you need it.
## Development
```bash
mix deps.get
mix test # runs unit + native integration tests (uses real temp dirs)
mix format
mix docs
```
## Releasing
Publishing a GitHub release triggers `.github/workflows/release.yml`, which
cross-compiles the NIFs, attaches them to the release, generates the
`rustler_precompiled` checksum file, and runs `mix hex.publish`.
Required steps:
1. Bump the version in `mix.exs` and `native/fs_notify/Cargo.toml` (keep them in sync).
2. Update `CHANGELOG.md`.
3. Commit and push to `main`.
4. Create the release: `gh release create v<version> --generate-notes`.
Pushes to `main` and PRs touching `native/**` build the targets too (no upload,
no publish) to catch cross-compile breakage early.
## License
[MIT](LICENSE)