Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adb2e5a1db | |||
| 7def7020c6 | |||
| ca9a66e03e | |||
| 48081302c8 | |||
| 09485e20d9 | |||
| 61a8222c9b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,4 +17,8 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# rust
|
||||
rust/target/
|
||||
dist_rust/
|
||||
|
||||
# custom
|
||||
24
changelog.md
24
changelog.md
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-23 - 6.4.0 - feat(watchers)
|
||||
add Rust-powered watcher backend with runtime fallback and cross-platform test coverage
|
||||
|
||||
- introduces a new Rust watcher binary and TypeScript bridge using @push.rocks/smartrust
|
||||
- updates watcher selection to prefer the Rust backend when available and fall back to Node.js or Deno implementations
|
||||
- improves Deno event classification for any/other file system events
|
||||
- prevents Node.js watcher shutdown from affecting unrelated FSWatcher handles
|
||||
- adds platform-specific tests for Node.js, Deno, Bun, and Rust-backed watchers
|
||||
|
||||
## 2026-03-23 - 6.3.1 - fix(watcher)
|
||||
unref lingering FSWatcher handles after stopping the node watcher
|
||||
|
||||
- Ensures chokidar file watcher handles do not keep the process running after watcher shutdown
|
||||
- Works around chokidar v5 behavior where close() can resolve before all fs.watch() handles are fully released
|
||||
|
||||
## 2025-12-11 - 6.3.0 - feat(watchers)
|
||||
Integrate chokidar-based Node watcher, expose awaitWriteFinish options, and update docs/tests
|
||||
|
||||
- Add chokidar dependency and implement NodeWatcher as a chokidar wrapper for Node.js/Bun
|
||||
- Expose awaitWriteFinish, stabilityThreshold and pollInterval in IWatcherOptions and wire them into the NodeWatcher
|
||||
- Update watcher factory to return NodeWatcher for Node/Bun and DenoWatcher for Deno
|
||||
- Adjust tests to wait for chokidar readiness and to expect chokidar's atomic handling (delete+recreate -> change)
|
||||
- Revise README and technical hints to document chokidar usage and cross-runtime behavior
|
||||
|
||||
## 2025-12-11 - 6.2.5 - fix(watcher.node)
|
||||
Normalize paths and improve Node watcher robustness: restart/rescan on errors (including ENOSPC), clear stale state, and remove legacy throttler
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -24,9 +20,22 @@
|
||||
"real-time",
|
||||
"watch files"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/tsrust": {
|
||||
"targets": ["linux_amd64", "linux_arm64"]
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartwatch",
|
||||
"version": "6.2.5",
|
||||
"version": "6.4.0",
|
||||
"private": false,
|
||||
"description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.",
|
||||
"main": "dist_ts/index.js",
|
||||
@@ -25,18 +25,20 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"chokidar": "^5.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@push.rocks/smartfile": "^11.0.4",
|
||||
"@types/node": "^24.10.1"
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tsrust": "^1.3.0",
|
||||
"@git.zone/tstest": "^3.5.0",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
4517
pnpm-lock.yaml
generated
4517
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
226
readme.hints.md
226
readme.hints.md
@@ -1,14 +1,16 @@
|
||||
# smartchok - Technical Hints
|
||||
# smartwatch - Technical Hints
|
||||
|
||||
## Native File Watching (v2.0.0+)
|
||||
## Native File Watching (v6.0.0+)
|
||||
|
||||
The module now uses native file watching APIs instead of chokidar, providing cross-runtime support for Node.js, Deno, and Bun.
|
||||
The module provides cross-runtime file watching support:
|
||||
- **Node.js/Bun**: Uses [chokidar](https://github.com/paulmillr/chokidar) v5
|
||||
- **Deno**: Uses native `Deno.watchFs()`
|
||||
|
||||
### Exported Class
|
||||
|
||||
The package exports the `Smartwatch` class (not `Smartchok`):
|
||||
The package exports the `Smartwatch` class:
|
||||
```typescript
|
||||
import { Smartwatch } from '@push.rocks/smartchok';
|
||||
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||
```
|
||||
|
||||
### Architecture
|
||||
@@ -16,198 +18,74 @@ import { Smartwatch } from '@push.rocks/smartchok';
|
||||
```
|
||||
ts/
|
||||
├── smartwatch.classes.smartwatch.ts # Main Smartwatch class
|
||||
├── smartwatch.plugins.ts # Dependencies (smartenv, picomatch, etc.)
|
||||
├── smartwatch.plugins.ts # Dependencies
|
||||
├── watchers/
|
||||
│ ├── index.ts # Factory with runtime detection
|
||||
│ ├── interfaces.ts # IWatcher interface and types
|
||||
│ ├── watcher.node.ts # Node.js/Bun implementation (fs.watch)
|
||||
│ └── watcher.deno.ts # Deno implementation (Deno.watchFs)
|
||||
│ ├── watcher.node.ts # Node.js/Bun: chokidar wrapper
|
||||
│ └── watcher.deno.ts # Deno: Deno.watchFs wrapper
|
||||
└── utils/
|
||||
└── write-stabilizer.ts # awaitWriteFinish polling implementation
|
||||
```
|
||||
|
||||
### Runtime Detection
|
||||
|
||||
Uses `@push.rocks/smartenv` v6.x for runtime detection:
|
||||
- **Node.js/Bun**: Uses native `fs.watch()` with `{ recursive: true }`
|
||||
Uses `@push.rocks/smartenv` for runtime detection:
|
||||
- **Node.js/Bun**: Uses chokidar (battle-tested file watcher)
|
||||
- **Deno**: Uses `Deno.watchFs()` async iterable
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **picomatch**: Glob pattern matching (zero deps, well-maintained)
|
||||
- **@push.rocks/smartenv**: Runtime detection (Node.js, Deno, Bun)
|
||||
- **chokidar**: Battle-tested file watcher for Node.js/Bun
|
||||
- **picomatch**: Glob pattern matching (zero deps)
|
||||
- **@push.rocks/smartenv**: Runtime detection
|
||||
- **@push.rocks/smartrx**: RxJS Subject/Observable management
|
||||
- **@push.rocks/smartpromise**: Deferred promise utilities
|
||||
- **@push.rocks/lik**: Stringmap for pattern storage
|
||||
|
||||
### Why picomatch?
|
||||
### Chokidar Features (Node.js/Bun)
|
||||
|
||||
Native file watching APIs don't support glob patterns. Picomatch provides glob pattern matching with:
|
||||
- Zero dependencies
|
||||
- 164M+ weekly downloads
|
||||
- Excellent security profile
|
||||
- Full glob syntax support
|
||||
The Node.js watcher (`ts/watchers/watcher.node.ts`) is a thin ~100 line wrapper around chokidar v5:
|
||||
|
||||
```typescript
|
||||
chokidar.watch(paths, {
|
||||
persistent: true,
|
||||
ignoreInitial: false,
|
||||
followSymlinks: options.followSymlinks,
|
||||
depth: options.depth,
|
||||
atomic: true, // Handles atomic writes (delete+recreate, temp+rename)
|
||||
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
||||
});
|
||||
```
|
||||
|
||||
**Chokidar handles all edge cases:**
|
||||
- Atomic writes (temp file + rename pattern) → emits single 'change' event
|
||||
- Delete + recreate detection → emits single 'change' event
|
||||
- Inode tracking
|
||||
- Cross-platform differences (inotify, FSEvents, etc.)
|
||||
- Debouncing
|
||||
- Write stabilization
|
||||
- ENOSPC (inotify limit) errors
|
||||
|
||||
### Event Handling
|
||||
|
||||
Native events are normalized to a consistent interface:
|
||||
Events are normalized across all runtimes:
|
||||
|
||||
| Node.js/Bun Event | Deno Event | Normalized Event |
|
||||
|-------------------|------------|------------------|
|
||||
| `rename` (file exists) | `create` | `add` |
|
||||
| `rename` (file gone) | `remove` | `unlink` |
|
||||
| `change` | `modify` | `change` |
|
||||
|
||||
### awaitWriteFinish Implementation
|
||||
|
||||
The `WriteStabilizer` class replaces chokidar's built-in write stabilization:
|
||||
- Polls file size until stable (configurable threshold: 300ms default)
|
||||
- Configurable poll interval (100ms default)
|
||||
- Handles file deletion during write detection
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `add` | File added |
|
||||
| `change` | File modified |
|
||||
| `unlink` | File removed |
|
||||
| `addDir` | Directory added |
|
||||
| `unlinkDir` | Directory removed |
|
||||
| `ready` | Initial scan complete |
|
||||
| `error` | Error occurred |
|
||||
|
||||
### Platform Requirements
|
||||
|
||||
- **Node.js 20+**: Required for native recursive watching on all platforms
|
||||
- **Node.js 20+**: Required for chokidar v5
|
||||
- **Deno**: Works on all versions with `Deno.watchFs()`
|
||||
- **Bun**: Uses Node.js compatibility layer
|
||||
|
||||
### Architecture (v6.3.0+) - Chokidar-Inspired
|
||||
|
||||
The Node.js watcher has been refactored with elegant patterns inspired by [chokidar](https://github.com/paulmillr/chokidar):
|
||||
|
||||
**DirEntry Class:**
|
||||
- Tracks directory contents with proper disposal
|
||||
- Encapsulates file tracking and inode management
|
||||
- `dispose()` method freezes object to catch use-after-cleanup bugs
|
||||
|
||||
**Path Normalization (v6.3.1+):**
|
||||
- ALL paths are normalized to absolute at entry points
|
||||
- Prevents relative/absolute path mismatch bugs
|
||||
- `watchPath()`, `handleFsEvent()`, `scanDirectory()` all resolve paths
|
||||
|
||||
**Restart Rescan (v6.3.1+):**
|
||||
- When watcher restarts, it rescans the directory
|
||||
- Catches files created during the restart window
|
||||
- Clears stale DirEntry data before rescan
|
||||
- Clears pending unlink timeouts to prevent stale events
|
||||
|
||||
**ENOSPC Handling (v6.3.1+):**
|
||||
- inotify limit errors now trigger watcher restart
|
||||
- Previously only logged error without recovery
|
||||
|
||||
**Atomic Write Handling:**
|
||||
- Unlink events are queued with 100ms delay
|
||||
- If add event arrives for same path within delay, unlink is cancelled
|
||||
- Emits single `change` event instead of `unlink` + `add`
|
||||
- Handles editor atomic saves elegantly
|
||||
|
||||
**Closer Registry:**
|
||||
- Maps watch paths to cleanup functions
|
||||
- Ensures proper resource cleanup on stop
|
||||
- `addCloser()` / `runClosers()` pattern
|
||||
|
||||
**Event Constants Object:**
|
||||
```typescript
|
||||
const EV = {
|
||||
ADD: 'add',
|
||||
CHANGE: 'change',
|
||||
UNLINK: 'unlink',
|
||||
ADD_DIR: 'addDir',
|
||||
UNLINK_DIR: 'unlinkDir',
|
||||
READY: 'ready',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Configuration Constants:**
|
||||
```typescript
|
||||
const CONFIG = {
|
||||
MAX_RETRIES: 3,
|
||||
INITIAL_RESTART_DELAY: 1000,
|
||||
MAX_RESTART_DELAY: 30000,
|
||||
HEALTH_CHECK_INTERVAL: 30000,
|
||||
ATOMIC_DELAY: 100,
|
||||
TEMP_FILE_DELAY: 50,
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Robustness Features (v6.1.0+)
|
||||
|
||||
The Node.js watcher includes automatic recovery mechanisms based on learnings from [chokidar](https://github.com/paulmillr/chokidar) and known [fs.watch issues](https://github.com/nodejs/node/issues/47058):
|
||||
|
||||
**Auto-restart on failure:**
|
||||
- Watchers automatically restart when errors occur
|
||||
- Exponential backoff (1s → 30s max)
|
||||
- Maximum 3 retry attempts before giving up
|
||||
- **v6.2.0+**: Race condition guards prevent orphan watchers when `stop()` is called during restart
|
||||
|
||||
**Inode tracking (critical for long-running watchers):**
|
||||
- `fs.watch()` watches the **inode**, not the path!
|
||||
- When directories are replaced (git checkout, atomic saves), the inode changes
|
||||
- Health check detects inode changes and restarts the watcher
|
||||
- **v6.2.0+**: File-level inode tracking detects delete+recreate (common editor save pattern)
|
||||
- This is the most common cause of "watcher stops working after some time"
|
||||
|
||||
**Health check monitoring:**
|
||||
- 30-second periodic health checks
|
||||
- Detects when watched paths disappear
|
||||
- Detects inode changes (directory replacement)
|
||||
- Detects ENOSPC errors (inotify limit exceeded)
|
||||
- **v6.2.0+**: Protected against dual-restart race conditions (health check + error handler)
|
||||
|
||||
**ENOSPC detection (Linux inotify limit):**
|
||||
- Detects when `/proc/sys/fs/inotify/max_user_watches` is exceeded
|
||||
- Logs fix command: `echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p`
|
||||
|
||||
**Error isolation:**
|
||||
- Subscriber errors don't crash the watcher
|
||||
- All events emitted via `safeEmit()` with try-catch
|
||||
|
||||
**Untracked file handling (v6.2.0+):**
|
||||
- Files created after initial scan are properly detected
|
||||
- Untracked file deletions emit `unlink` events instead of being silently dropped
|
||||
|
||||
**Event Deferral During Initial Scan (v6.2.2+):**
|
||||
- Events are queued until initial scan completes
|
||||
- Prevents race conditions where events arrive before `watchedFiles` is populated
|
||||
- Deferred events are processed after scan completes
|
||||
|
||||
**Event Sequence Tracking (v6.2.2+):**
|
||||
- Debounce now tracks ALL events in sequence, not just the last one
|
||||
- Prevents losing intermediate events (e.g., add→change→delete no longer loses add)
|
||||
- Intelligent processing of event sequences:
|
||||
- Delete+recreate with inode change → emits `unlink` then `add`
|
||||
- Rapid create+delete → emits both events
|
||||
- Multiple changes → single `change` event (debouncing)
|
||||
|
||||
**Post-Stop Event Guards (v6.2.2+):**
|
||||
- `handleFsEvent()` returns early if watcher is stopped
|
||||
- Pending emits are cleared BEFORE setting `_isWatching = false`
|
||||
- Prevents orphaned timeouts and events after `stop()`
|
||||
|
||||
**Verbose logging:**
|
||||
- All lifecycle events logged with `[smartwatch]` prefix
|
||||
- Event sequences logged for debugging complex scenarios
|
||||
- Helps debug watcher issues in production
|
||||
|
||||
Example log output:
|
||||
```
|
||||
[smartwatch] Starting watcher for 1 base path(s)...
|
||||
[smartwatch] Started watching: ./test/assets/
|
||||
[smartwatch] Starting health check (every 30s)
|
||||
[smartwatch] Watcher started with 1 active watcher(s)
|
||||
[smartwatch] Health check: 1 watchers active
|
||||
[smartwatch] Processing event sequence for ./src/file.ts: [rename, rename, change]
|
||||
[smartwatch] File inode changed (delete+recreate): ./src/file.ts
|
||||
[smartwatch] Previous inode: 12345, current: 67890
|
||||
```
|
||||
|
||||
### Known fs.watch Limitations
|
||||
|
||||
1. **Watches inode, not path** - If a directory is replaced, watcher goes stale
|
||||
2. **inotify limits on Linux** - Default `max_user_watches` (8192) may be too low
|
||||
3. **No events for some atomic writes** - Some editors' save patterns may not trigger events
|
||||
4. **Platform differences** - Linux uses inotify, macOS uses FSEvents/kqueue
|
||||
- **Bun**: Uses Node.js compatibility layer with chokidar
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -217,15 +95,15 @@ pnpm test
|
||||
|
||||
Test files:
|
||||
- **test.basic.ts** - Core functionality (add, change, unlink events)
|
||||
- **test.inode.ts** - Inode change detection, atomic writes
|
||||
- **test.inode.ts** - Atomic write detection (delete+recreate, temp+rename)
|
||||
- **test.stress.ts** - Rapid modifications, many files, interleaved operations
|
||||
|
||||
Tests verify:
|
||||
- Creating Smartwatch instance
|
||||
- Adding glob patterns
|
||||
- Receiving 'add', 'change', 'unlink' events
|
||||
- Inode change detection (delete+recreate pattern)
|
||||
- Atomic write pattern (temp file + rename)
|
||||
- Atomic write detection (delete+recreate → change event)
|
||||
- Temp file + rename pattern detection
|
||||
- Rapid file modifications (debouncing)
|
||||
- Many files created rapidly
|
||||
- Interleaved add/change/delete operations
|
||||
|
||||
113
readme.md
113
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartwatch
|
||||
|
||||
A lightweight, cross-runtime file watcher with glob pattern support for **Node.js**, **Deno**, and **Bun**. Zero heavyweight dependencies — just native file watching APIs for maximum performance. 🚀
|
||||
A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -16,14 +16,25 @@ pnpm add @push.rocks/smartwatch
|
||||
|
||||
## Features
|
||||
|
||||
🌐 **Cross-Runtime** — Works seamlessly on Node.js 20+, Deno, and Bun
|
||||
🔍 **Glob Pattern Support** — Watch files using familiar patterns like `**/*.ts` and `src/**/*.{js,jsx}`
|
||||
📡 **RxJS Observables** — Subscribe to file system events using reactive streams
|
||||
🔄 **Dynamic Watching** — Add or remove watch patterns at runtime
|
||||
⚡ **Native Performance** — Uses `fs.watch()` on Node.js/Bun and `Deno.watchFs()` on Deno
|
||||
✨ **Write Stabilization** — Built-in debouncing prevents duplicate events during file writes
|
||||
🎯 **TypeScript First** — Full TypeScript support with comprehensive type definitions
|
||||
📦 **Minimal Footprint** — No chokidar, no FSEvents bindings — just ~500 lines of focused code
|
||||
- **Cross-Runtime** — Works on Node.js 20+, Deno, and Bun
|
||||
- **Glob Pattern Support** — Watch files using patterns like `**/*.ts` and `src/**/*.{js,jsx}`
|
||||
- **RxJS Observables** — Subscribe to file system events using reactive streams
|
||||
- **Dynamic Watching** — Add or remove watch patterns at runtime
|
||||
- **Write Stabilization** — Built-in debouncing and awaitWriteFinish support for atomic writes
|
||||
- **TypeScript First** — Full TypeScript support with comprehensive type definitions
|
||||
|
||||
## How It Works
|
||||
|
||||
smartwatch selects the best file watching backend for the current runtime:
|
||||
|
||||
| Runtime | Backend |
|
||||
|-----------------|----------------------------------|
|
||||
| **Node.js/Bun** | [chokidar](https://github.com/paulmillr/chokidar) v5 (uses `fs.watch()` internally) |
|
||||
| **Deno** | Native `Deno.watchFs()` API |
|
||||
|
||||
On Node.js and Bun, chokidar provides robust cross-platform file watching with features like atomic write detection, inode tracking, and write stabilization. On Deno, native APIs are used directly with built-in debouncing and temporary file filtering.
|
||||
|
||||
Glob patterns are handled through [picomatch](https://github.com/micromatch/picomatch) — base paths are extracted from patterns and watched natively, while events are filtered through matchers before emission.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -34,8 +45,8 @@ import { Smartwatch } from '@push.rocks/smartwatch';
|
||||
|
||||
// Create a watcher with glob patterns
|
||||
const watcher = new Smartwatch([
|
||||
'./src/**/*.ts', // Watch all TypeScript files in src
|
||||
'./public/assets/**/*' // Watch all files in public/assets
|
||||
'./src/**/*.ts',
|
||||
'./public/assets/**/*'
|
||||
]);
|
||||
|
||||
// Start watching
|
||||
@@ -49,15 +60,9 @@ Use RxJS observables to react to file system changes:
|
||||
```typescript
|
||||
// Get an observable for file changes
|
||||
const changeObservable = await watcher.getObservableFor('change');
|
||||
|
||||
changeObservable.subscribe({
|
||||
next: ([path, stats]) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
console.log(`New size: ${stats?.size} bytes`);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error: ${err}`);
|
||||
}
|
||||
changeObservable.subscribe(([path, stats]) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
console.log(`New size: ${stats?.size} bytes`);
|
||||
});
|
||||
|
||||
// Watch for new files
|
||||
@@ -103,7 +108,6 @@ watcher.remove('./src/**/*.test.ts');
|
||||
### Stopping the Watcher
|
||||
|
||||
```typescript
|
||||
// Stop watching when done
|
||||
await watcher.stop();
|
||||
```
|
||||
|
||||
@@ -113,38 +117,31 @@ await watcher.stop();
|
||||
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||
|
||||
async function watchProject() {
|
||||
// Initialize with patterns
|
||||
const watcher = new Smartwatch([
|
||||
'./src/**/*.ts',
|
||||
'./package.json'
|
||||
]);
|
||||
|
||||
// Start the watcher
|
||||
await watcher.start();
|
||||
console.log('👀 Watching for changes...');
|
||||
console.log('Watching for changes...');
|
||||
|
||||
// Subscribe to changes
|
||||
const changes = await watcher.getObservableFor('change');
|
||||
changes.subscribe(([path, stats]) => {
|
||||
console.log(`📝 Modified: ${path}`);
|
||||
console.log(` Size: ${stats?.size ?? 'unknown'} bytes`);
|
||||
console.log(`Modified: ${path} (${stats?.size ?? 'unknown'} bytes)`);
|
||||
});
|
||||
|
||||
// Subscribe to new files
|
||||
const additions = await watcher.getObservableFor('add');
|
||||
additions.subscribe(([path]) => {
|
||||
console.log(`✨ New file: ${path}`);
|
||||
console.log(`New file: ${path}`);
|
||||
});
|
||||
|
||||
// Subscribe to deletions
|
||||
const deletions = await watcher.getObservableFor('unlink');
|
||||
deletions.subscribe(([path]) => {
|
||||
console.log(`🗑️ Deleted: ${path}`);
|
||||
console.log(`Deleted: ${path}`);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n🛑 Stopping watcher...');
|
||||
await watcher.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -153,41 +150,6 @@ async function watchProject() {
|
||||
watchProject();
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
smartwatch uses native file watching APIs for each runtime:
|
||||
|
||||
| Runtime | API Used |
|
||||
|-----------------|----------------------------------|
|
||||
| **Node.js 20+** | `fs.watch({ recursive: true })` |
|
||||
| **Deno** | `Deno.watchFs()` |
|
||||
| **Bun** | Node.js compatibility layer |
|
||||
|
||||
### Under the Hood
|
||||
|
||||
Native file watching APIs don't support glob patterns directly, so smartwatch handles pattern matching internally:
|
||||
|
||||
1. **Base path extraction** — Extracts the static portion from each glob pattern (e.g., `./src/` from `./src/**/*.ts`)
|
||||
2. **Efficient watching** — Native watchers monitor only the base directories
|
||||
3. **Pattern filtering** — Events are filtered through [picomatch](https://github.com/micromatch/picomatch) matchers before emission
|
||||
4. **Event deduplication** — Built-in throttling prevents duplicate events from rapid file operations
|
||||
|
||||
### Write Stabilization
|
||||
|
||||
smartwatch includes built-in write stabilization (similar to chokidar's `awaitWriteFinish`). When a file is being written, events are held until the file size stabilizes, preventing multiple events for a single write operation.
|
||||
|
||||
Default settings:
|
||||
- **Stability threshold**: 300ms
|
||||
- **Poll interval**: 100ms
|
||||
|
||||
## Requirements
|
||||
|
||||
| Runtime | Version |
|
||||
|-----------------|----------------------------------------|
|
||||
| **Node.js** | 20+ (required for native recursive watching) |
|
||||
| **Deno** | Any version with `Deno.watchFs()` support |
|
||||
| **Bun** | Uses Node.js compatibility |
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Smartwatch`
|
||||
@@ -226,18 +188,13 @@ type TFsEvent = 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' |
|
||||
type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||
```
|
||||
|
||||
## Why smartwatch?
|
||||
## Requirements
|
||||
|
||||
| Feature | smartwatch | chokidar |
|
||||
|-------------------------|----------------------|--------------------|
|
||||
| Native API | ✅ Direct `fs.watch` | ❌ FSEvents bindings |
|
||||
| Cross-runtime | ✅ Node, Deno, Bun | ❌ Node only |
|
||||
| Dependencies | 4 small packages | ~20 packages |
|
||||
| Write stabilization | ✅ Built-in | ✅ Built-in |
|
||||
| Glob support | ✅ picomatch | ✅ anymatch |
|
||||
| Bundle size | ~15KB | ~200KB+ |
|
||||
|
||||
If you need a lightweight file watcher without native compilation headaches, smartwatch has you covered.
|
||||
| Runtime | Version |
|
||||
|-----------------|----------------------------------------|
|
||||
| **Node.js** | 20+ |
|
||||
| **Deno** | Any version with `Deno.watchFs()` support |
|
||||
| **Bun** | Uses Node.js compatibility layer |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
493
rust/Cargo.lock
generated
Normal file
493
rust/Cargo.lock
generated
Normal file
@@ -0,0 +1,493 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dcf855483228259b2353f89e99df35fc639b2b2510d1166e4858e3f67ec1afb"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smartwatch-rust"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
19
rust/Cargo.toml
Normal file
19
rust/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "smartwatch-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "smartwatch-rust"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
notify = "7"
|
||||
notify-debouncer-full = "0.4"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
strip = true
|
||||
289
rust/src/main.rs
Normal file
289
rust/src/main.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use notify_debouncer_full::{new_debouncer, DebounceEventResult, DebouncedEvent, RecommendedCache};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, EventKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
// --- IPC message types ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Request {
|
||||
id: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Response {
|
||||
id: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Event {
|
||||
event: String,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
// --- Watch command params ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WatchParams {
|
||||
paths: Vec<String>,
|
||||
#[serde(default = "default_depth")]
|
||||
depth: u32,
|
||||
#[serde(default)]
|
||||
follow_symlinks: bool,
|
||||
#[serde(default = "default_debounce")]
|
||||
debounce_ms: u64,
|
||||
}
|
||||
|
||||
fn default_depth() -> u32 { 4 }
|
||||
fn default_debounce() -> u64 { 100 }
|
||||
|
||||
// --- Output helpers (thread-safe via stdout lock) ---
|
||||
|
||||
fn send_line(line: &str) {
|
||||
let stdout = io::stdout();
|
||||
let mut handle = stdout.lock();
|
||||
let _ = handle.write_all(line.as_bytes());
|
||||
let _ = handle.write_all(b"\n");
|
||||
let _ = handle.flush();
|
||||
}
|
||||
|
||||
fn send_response(resp: &Response) {
|
||||
if let Ok(json) = serde_json::to_string(resp) {
|
||||
send_line(&json);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_event(name: &str, data: serde_json::Value) {
|
||||
let evt = Event { event: name.to_string(), data };
|
||||
if let Ok(json) = serde_json::to_string(&evt) {
|
||||
send_line(&json);
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_response(id: String, result: serde_json::Value) -> Response {
|
||||
Response { id, success: true, result: Some(result), error: None }
|
||||
}
|
||||
|
||||
fn err_response(id: String, msg: String) -> Response {
|
||||
Response { id, success: false, result: None, error: Some(msg) }
|
||||
}
|
||||
|
||||
// --- Map notify EventKind to our event type strings ---
|
||||
|
||||
fn event_kind_to_type(kind: &EventKind) -> Option<&'static str> {
|
||||
match kind {
|
||||
EventKind::Create(_) => Some("create"),
|
||||
EventKind::Modify(_) => Some("change"),
|
||||
EventKind::Remove(_) => Some("remove"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if a path is a directory
|
||||
fn classify_path(path: &Path) -> &'static str {
|
||||
if path.is_dir() { "dir" } else { "file" }
|
||||
}
|
||||
|
||||
/// Walk a directory and emit add/addDir events for initial scan
|
||||
fn scan_directory(dir: &Path, depth: u32, max_depth: u32) {
|
||||
if depth > max_depth {
|
||||
return;
|
||||
}
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if path.is_dir() {
|
||||
send_event("fsEvent", serde_json::json!({
|
||||
"type": "addDir",
|
||||
"path": path_str,
|
||||
}));
|
||||
scan_directory(&path, depth + 1, max_depth);
|
||||
} else if path.is_file() {
|
||||
send_event("fsEvent", serde_json::json!({
|
||||
"type": "add",
|
||||
"path": path_str,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Messages between threads ---
|
||||
|
||||
enum MainMessage {
|
||||
StdinLine(String),
|
||||
StdinClosed,
|
||||
FsEvents(Vec<DebouncedEvent>),
|
||||
FsError(Vec<notify::Error>),
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if !args.contains(&"--management".to_string()) {
|
||||
eprintln!("smartwatch-rust: use --management flag for IPC mode");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Signal ready
|
||||
send_event("ready", serde_json::json!({}));
|
||||
|
||||
// Single channel for all messages to the main thread
|
||||
let (main_tx, main_rx) = mpsc::channel::<MainMessage>();
|
||||
|
||||
// Spawn stdin reader thread
|
||||
let stdin_tx = main_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
match line {
|
||||
Ok(l) => {
|
||||
let trimmed = l.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
if stdin_tx.send(MainMessage::StdinLine(trimmed)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = stdin_tx.send(MainMessage::StdinClosed);
|
||||
});
|
||||
|
||||
// State: active debouncer
|
||||
let mut active_debouncer: Option<notify_debouncer_full::Debouncer<
|
||||
RecommendedWatcher,
|
||||
RecommendedCache,
|
||||
>> = None;
|
||||
|
||||
// Main event loop — receives both stdin lines and FS events
|
||||
for msg in &main_rx {
|
||||
match msg {
|
||||
MainMessage::StdinClosed => break,
|
||||
|
||||
MainMessage::FsEvents(events) => {
|
||||
for event in events {
|
||||
let Some(event_type) = event_kind_to_type(&event.kind) else {
|
||||
continue;
|
||||
};
|
||||
for path in &event.paths {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let path_kind = classify_path(path);
|
||||
let fs_type = match (event_type, path_kind) {
|
||||
("create", "dir") => "addDir",
|
||||
("create", _) => "add",
|
||||
("change", _) => "change",
|
||||
("remove", "dir") => "unlinkDir",
|
||||
("remove", _) => "unlink",
|
||||
_ => continue,
|
||||
};
|
||||
send_event("fsEvent", serde_json::json!({
|
||||
"type": fs_type,
|
||||
"path": path_str,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainMessage::FsError(errors) => {
|
||||
for err in errors {
|
||||
send_event("error", serde_json::json!({
|
||||
"message": format!("{}", err),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
MainMessage::StdinLine(line) => {
|
||||
let request: Request = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
send_response(&err_response(
|
||||
"unknown".to_string(),
|
||||
format!("Failed to parse request: {}", e),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match request.method.as_str() {
|
||||
"watch" => {
|
||||
let params: WatchParams = match serde_json::from_value(request.params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
send_response(&err_response(request.id, format!("Invalid params: {}", e)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Stop any existing watcher
|
||||
active_debouncer.take();
|
||||
|
||||
let debounce_duration = Duration::from_millis(params.debounce_ms);
|
||||
let fs_tx = main_tx.clone();
|
||||
|
||||
let debouncer = new_debouncer(
|
||||
debounce_duration,
|
||||
None,
|
||||
move |result: DebounceEventResult| {
|
||||
match result {
|
||||
Ok(events) => { let _ = fs_tx.send(MainMessage::FsEvents(events)); }
|
||||
Err(errors) => { let _ = fs_tx.send(MainMessage::FsError(errors)); }
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
match debouncer {
|
||||
Ok(mut debouncer) => {
|
||||
for path_str in ¶ms.paths {
|
||||
let path = Path::new(path_str);
|
||||
if let Err(e) = debouncer.watch(path, RecursiveMode::Recursive) {
|
||||
eprintln!("Watch error for {}: {}", path_str, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scan
|
||||
for path_str in ¶ms.paths {
|
||||
scan_directory(Path::new(path_str), 0, params.depth);
|
||||
}
|
||||
|
||||
send_event("watchReady", serde_json::json!({}));
|
||||
active_debouncer = Some(debouncer);
|
||||
send_response(&ok_response(request.id, serde_json::json!({ "watching": true })));
|
||||
}
|
||||
Err(e) => {
|
||||
send_response(&err_response(request.id, format!("Failed to create watcher: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"stop" => {
|
||||
active_debouncer.take();
|
||||
send_response(&ok_response(request.id, serde_json::json!({ "stopped": true })));
|
||||
}
|
||||
other => {
|
||||
send_response(&err_response(request.id, format!("Unknown method: {}", other)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
active_debouncer.take();
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -74,53 +73,57 @@ tap.test('should add paths and start watching', async () => {
|
||||
testSmartwatch.add([`${TEST_DIR}/**/*.txt`]);
|
||||
await testSmartwatch.start();
|
||||
expect(testSmartwatch.status).toEqual('watching');
|
||||
// Wait for chokidar to be ready
|
||||
await delay(500);
|
||||
});
|
||||
|
||||
tap.test('should detect ADD event for new files', async () => {
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const eventPromise = waitForEvent(addObservable);
|
||||
|
||||
// Subscribe FIRST, then create file
|
||||
const eventPromise = waitForFileEvent(addObservable, 'add-test.txt');
|
||||
|
||||
// Create a new file
|
||||
const testFile = path.join(TEST_DIR, 'add-test.txt');
|
||||
await smartfile.memory.toFs('test content', testFile);
|
||||
await fs.promises.writeFile(testFile, 'test content');
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('add-test.txt');
|
||||
|
||||
// Cleanup - wait for atomic delay to complete (100ms debounce + 100ms atomic)
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
await delay(250);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('should detect CHANGE event for modified files', async () => {
|
||||
// First create the file
|
||||
const testFile = path.join(TEST_DIR, 'change-test.txt');
|
||||
await smartfile.memory.toFs('initial content', testFile);
|
||||
await fs.promises.writeFile(testFile, 'initial content');
|
||||
|
||||
// Wait for add event to complete
|
||||
await delay(200);
|
||||
await delay(300);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const eventPromise = waitForEvent(changeObservable);
|
||||
const eventPromise = waitForFileEvent(changeObservable, 'change-test.txt');
|
||||
|
||||
// Modify the file
|
||||
await smartfile.memory.toFs('modified content', testFile);
|
||||
await fs.promises.writeFile(testFile, 'modified content');
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('change-test.txt');
|
||||
|
||||
// Cleanup - wait for atomic delay to complete
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
await delay(250);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('should detect UNLINK event for deleted files', async () => {
|
||||
// First create the file
|
||||
const testFile = path.join(TEST_DIR, 'unlink-test.txt');
|
||||
await smartfile.memory.toFs('to be deleted', testFile);
|
||||
await fs.promises.writeFile(testFile, 'to be deleted');
|
||||
|
||||
// Wait for add event to complete
|
||||
await delay(200);
|
||||
await delay(300);
|
||||
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
|
||||
|
||||
86
test/test.fswatcher-linger.node.ts
Normal file
86
test/test.fswatcher-linger.node.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as chokidar from 'chokidar';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const TEST_DIR = './test/assets';
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Count active FSWatcher handles in the process
|
||||
*/
|
||||
function countFSWatcherHandles(): number {
|
||||
const handles = (process as any)._getActiveHandles();
|
||||
return handles.filter((h: any) => h?.constructor?.name === 'FSWatcher').length;
|
||||
}
|
||||
|
||||
tap.test('should not leave lingering FSWatcher handles after chokidar close', async () => {
|
||||
const handlesBefore = countFSWatcherHandles();
|
||||
console.log(`FSWatcher handles before: ${handlesBefore}`);
|
||||
|
||||
// Start a chokidar watcher
|
||||
const watcher = chokidar.watch(path.resolve(TEST_DIR), {
|
||||
persistent: true,
|
||||
ignoreInitial: false,
|
||||
});
|
||||
|
||||
// Wait for ready
|
||||
await new Promise<void>((resolve) => watcher.on('ready', resolve));
|
||||
|
||||
const handlesDuring = countFSWatcherHandles();
|
||||
console.log(`FSWatcher handles during watch: ${handlesDuring}`);
|
||||
expect(handlesDuring).toBeGreaterThan(handlesBefore);
|
||||
|
||||
// Close the watcher
|
||||
await watcher.close();
|
||||
console.log('chokidar.close() resolved');
|
||||
|
||||
// Check immediately after close
|
||||
const handlesAfterClose = countFSWatcherHandles();
|
||||
console.log(`FSWatcher handles immediately after close: ${handlesAfterClose}`);
|
||||
|
||||
// Wait a bit and check again to see if handles are cleaned up asynchronously
|
||||
await delay(500);
|
||||
const handlesAfterDelay500 = countFSWatcherHandles();
|
||||
console.log(`FSWatcher handles after 500ms: ${handlesAfterDelay500}`);
|
||||
|
||||
await delay(1500);
|
||||
const handlesAfterDelay2000 = countFSWatcherHandles();
|
||||
console.log(`FSWatcher handles after 2000ms: ${handlesAfterDelay2000}`);
|
||||
|
||||
const lingeringHandles = handlesAfterDelay2000 - handlesBefore;
|
||||
console.log(`Lingering FSWatcher handles: ${lingeringHandles}`);
|
||||
|
||||
if (lingeringHandles > 0) {
|
||||
console.log('WARNING: chokidar left lingering FSWatcher handles after close()');
|
||||
} else {
|
||||
console.log('OK: all FSWatcher handles were cleaned up');
|
||||
}
|
||||
|
||||
expect(lingeringHandles).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should not leave handles after multiple open/close cycles', async () => {
|
||||
const handlesBefore = countFSWatcherHandles();
|
||||
console.log(`\nMulti-cycle test - handles before: ${handlesBefore}`);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const watcher = chokidar.watch(path.resolve(TEST_DIR), {
|
||||
persistent: true,
|
||||
ignoreInitial: false,
|
||||
});
|
||||
await new Promise<void>((resolve) => watcher.on('ready', resolve));
|
||||
const during = countFSWatcherHandles();
|
||||
console.log(` Cycle ${i + 1} - handles during: ${during}`);
|
||||
await watcher.close();
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
const handlesAfter = countFSWatcherHandles();
|
||||
const leaked = handlesAfter - handlesBefore;
|
||||
console.log(`Handles after 3 cycles: ${handlesAfter} (leaked: ${leaked})`);
|
||||
|
||||
expect(leaked).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -16,21 +15,25 @@ const TEST_DIR = './test/assets';
|
||||
// Helper to delay
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to wait for an event with timeout
|
||||
async function waitForEvent<T>(
|
||||
// Helper to wait for an event with timeout (filters by filename)
|
||||
async function waitForFileEvent<T extends [string, ...any[]]>(
|
||||
observable: smartrx.rxjs.Observable<T>,
|
||||
expectedFile: string,
|
||||
timeoutMs: number = 5000
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
reject(new Error(`Timeout waiting for event on ${expectedFile} after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const subscription = observable.subscribe((value) => {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
const [filePath] = value;
|
||||
if (filePath.includes(expectedFile)) {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -45,31 +48,35 @@ tap.test('setup: start watcher', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]);
|
||||
await testSmartwatch.start();
|
||||
expect(testSmartwatch.status).toEqual('watching');
|
||||
// Wait for chokidar to be ready
|
||||
await delay(500);
|
||||
});
|
||||
|
||||
tap.test('should detect delete+recreate (inode change scenario)', async () => {
|
||||
// This simulates what many editors do: delete file, create new file
|
||||
tap.test('should detect delete+recreate as change event (atomic handling)', async () => {
|
||||
// Chokidar with atomic: true handles delete+recreate as a single change event
|
||||
// This is the expected behavior for editor save patterns
|
||||
const testFile = path.join(TEST_DIR, 'inode-test.txt');
|
||||
|
||||
// Clean up any leftover file from previous runs
|
||||
try { await fs.promises.unlink(testFile); } catch {}
|
||||
await delay(100);
|
||||
|
||||
// Create initial file
|
||||
await smartfile.memory.toFs('initial content', testFile);
|
||||
await delay(200);
|
||||
await fs.promises.writeFile(testFile, 'initial content');
|
||||
await delay(300);
|
||||
|
||||
// Get the initial inode
|
||||
const initialStats = await fs.promises.stat(testFile);
|
||||
const initialInode = initialStats.ino;
|
||||
console.log(`[test] Initial inode: ${initialInode}`);
|
||||
|
||||
// With event sequence tracking, delete+recreate emits: unlink, then add
|
||||
// This is more accurate than just emitting 'change'
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const unlinkPromise = waitForEvent(unlinkObservable, 3000);
|
||||
const addPromise = waitForEvent(addObservable, 3000);
|
||||
// Chokidar's atomic handling will emit a single 'change' event
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const eventPromise = waitForFileEvent(changeObservable, 'inode-test.txt', 3000);
|
||||
|
||||
// Delete and recreate (this creates a new inode)
|
||||
await fs.promises.unlink(testFile);
|
||||
await smartfile.memory.toFs('recreated content', testFile);
|
||||
await fs.promises.writeFile(testFile, 'recreated content');
|
||||
|
||||
// Check inode changed
|
||||
const newStats = await fs.promises.stat(testFile);
|
||||
@@ -77,14 +84,14 @@ tap.test('should detect delete+recreate (inode change scenario)', async () => {
|
||||
console.log(`[test] New inode: ${newInode}`);
|
||||
expect(newInode).not.toEqual(initialInode);
|
||||
|
||||
// Should detect both unlink and add events for delete+recreate
|
||||
const [[unlinkPath], [addPath]] = await Promise.all([unlinkPromise, addPromise]);
|
||||
expect(unlinkPath).toInclude('inode-test.txt');
|
||||
expect(addPath).toInclude('inode-test.txt');
|
||||
console.log(`[test] Detected unlink + add events for delete+recreate`);
|
||||
// Chokidar detects this as a change (atomic write pattern)
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('inode-test.txt');
|
||||
console.log(`[test] Detected change event for delete+recreate (atomic handling)`);
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('should detect atomic write pattern (temp file + rename)', async () => {
|
||||
@@ -95,17 +102,24 @@ tap.test('should detect atomic write pattern (temp file + rename)', async () =>
|
||||
const tempFile = path.join(TEST_DIR, 'atomic-test.txt.tmp.12345');
|
||||
|
||||
// Create initial file
|
||||
await smartfile.memory.toFs('initial content', testFile);
|
||||
await delay(200);
|
||||
await fs.promises.writeFile(testFile, 'initial content');
|
||||
await delay(300);
|
||||
|
||||
// Listen for both change and add events — different watcher backends
|
||||
// may report a rename-over-existing as either a change or an add
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const eventPromise = waitForEvent(changeObservable, 3000);
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
|
||||
const eventPromise = Promise.race([
|
||||
waitForFileEvent(changeObservable, 'atomic-test.txt', 3000),
|
||||
waitForFileEvent(addObservable, 'atomic-test.txt', 3000),
|
||||
]);
|
||||
|
||||
// Atomic write: create temp file then rename
|
||||
await smartfile.memory.toFs('atomic content', tempFile);
|
||||
await fs.promises.writeFile(tempFile, 'atomic content');
|
||||
await fs.promises.rename(tempFile, testFile);
|
||||
|
||||
// Should detect the change to the target file
|
||||
// Should detect the event on the target file
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('atomic-test.txt');
|
||||
expect(filePath).not.toInclude('.tmp.');
|
||||
|
||||
115
test/test.platform.bun.ts
Normal file
115
test/test.platform.bun.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { NodeWatcher } from '../ts/watchers/watcher.node.js';
|
||||
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Bun uses NodeWatcher (Node.js compatibility layer).
|
||||
// This test validates that the chokidar-based watcher works under Bun.
|
||||
const isBun = typeof (globalThis as any).Bun !== 'undefined';
|
||||
|
||||
const TEST_DIR = path.resolve('./test/assets');
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function waitForEvent(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
timeoutMs = 5000
|
||||
): Promise<IWatchEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let watcher: NodeWatcher;
|
||||
|
||||
tap.test('BunNodeWatcher: should create and start', async () => {
|
||||
if (!isBun) { console.log('Skipping: not Bun runtime'); return; }
|
||||
watcher = new NodeWatcher({
|
||||
basePaths: [TEST_DIR],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100,
|
||||
});
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
await watcher.start();
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
await delay(500);
|
||||
});
|
||||
|
||||
tap.test('BunNodeWatcher: should detect file creation', async () => {
|
||||
if (!isBun) return;
|
||||
const file = path.join(TEST_DIR, 'bun-add-test.txt');
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('bun-add-test.txt'));
|
||||
await fs.promises.writeFile(file, 'bun watcher test');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('add');
|
||||
expect(event.path).toInclude('bun-add-test.txt');
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('BunNodeWatcher: should detect file modification', async () => {
|
||||
if (!isBun) return;
|
||||
const file = path.join(TEST_DIR, 'bun-change-test.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('bun-change-test.txt'));
|
||||
await fs.promises.writeFile(file, 'modified');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('change');
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('BunNodeWatcher: should detect file deletion', async () => {
|
||||
if (!isBun) return;
|
||||
const file = path.join(TEST_DIR, 'bun-unlink-test.txt');
|
||||
await fs.promises.writeFile(file, 'to delete');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('bun-unlink-test.txt'));
|
||||
await fs.promises.unlink(file);
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('unlink');
|
||||
});
|
||||
|
||||
tap.test('BunNodeWatcher: should detect directory creation', async () => {
|
||||
if (!isBun) return;
|
||||
const dir = path.join(TEST_DIR, 'bun-test-subdir');
|
||||
const addDirPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('bun-test-subdir'));
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const event = await addDirPromise;
|
||||
expect(event.type).toEqual('addDir');
|
||||
await delay(200);
|
||||
await fs.promises.rmdir(dir);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('BunNodeWatcher: should not be watching after stop', async () => {
|
||||
if (!isBun) return;
|
||||
await watcher.stop();
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('BunNodeWatcher: cleanup', async () => {
|
||||
if (!isBun) return;
|
||||
for (const name of ['bun-add-test.txt', 'bun-change-test.txt', 'bun-unlink-test.txt']) {
|
||||
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
|
||||
}
|
||||
try { await fs.promises.rmdir(path.join(TEST_DIR, 'bun-test-subdir')); } catch {}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
119
test/test.platform.deno.ts
Normal file
119
test/test.platform.deno.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// tstest:deno:allowAll
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
// This test requires the Deno runtime
|
||||
const isDeno = typeof (globalThis as any).Deno !== 'undefined';
|
||||
|
||||
const TEST_DIR = path.resolve('./test/assets');
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function waitForEvent(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
timeoutMs = 5000
|
||||
): Promise<IWatchEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let watcher: any;
|
||||
|
||||
tap.test('DenoWatcher: should create and start', async () => {
|
||||
if (!isDeno) { console.log('Skipping: not Deno runtime'); return; }
|
||||
const { DenoWatcher } = await import('../ts/watchers/watcher.deno.js');
|
||||
watcher = new DenoWatcher({
|
||||
basePaths: [TEST_DIR],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100,
|
||||
});
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
await watcher.start();
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
await delay(500);
|
||||
});
|
||||
|
||||
tap.test('DenoWatcher: should detect file creation', async () => {
|
||||
if (!isDeno) return;
|
||||
const file = path.join(TEST_DIR, 'deno-add-test.txt');
|
||||
// Deno.watchFs may report new files as 'create', 'any', or 'modify' depending on platform
|
||||
const eventPromise = waitForEvent(
|
||||
watcher,
|
||||
(e) => (e.type === 'add' || e.type === 'change') && e.path.includes('deno-add-test.txt'),
|
||||
10000,
|
||||
);
|
||||
await fs.promises.writeFile(file, 'deno watcher test');
|
||||
const event = await eventPromise;
|
||||
expect(event.path).toInclude('deno-add-test.txt');
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('DenoWatcher: should detect file modification', async () => {
|
||||
if (!isDeno) return;
|
||||
const file = path.join(TEST_DIR, 'deno-change-test.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('deno-change-test.txt'));
|
||||
await fs.promises.writeFile(file, 'modified');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('change');
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('DenoWatcher: should detect file deletion', async () => {
|
||||
if (!isDeno) return;
|
||||
const file = path.join(TEST_DIR, 'deno-unlink-test.txt');
|
||||
await fs.promises.writeFile(file, 'to delete');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('deno-unlink-test.txt'));
|
||||
await fs.promises.unlink(file);
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('unlink');
|
||||
});
|
||||
|
||||
tap.test('DenoWatcher: should detect directory creation', async () => {
|
||||
if (!isDeno) return;
|
||||
const dir = path.join(TEST_DIR, 'deno-test-subdir');
|
||||
const addDirPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('deno-test-subdir'));
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const event = await addDirPromise;
|
||||
expect(event.type).toEqual('addDir');
|
||||
await delay(200);
|
||||
await fs.promises.rmdir(dir);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('DenoWatcher: should not be watching after stop', async () => {
|
||||
if (!isDeno) return;
|
||||
await watcher.stop();
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('DenoWatcher: cleanup', async () => {
|
||||
if (!isDeno) return;
|
||||
for (const name of ['deno-add-test.txt', 'deno-change-test.txt', 'deno-unlink-test.txt']) {
|
||||
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
|
||||
}
|
||||
try { await fs.promises.rmdir(path.join(TEST_DIR, 'deno-test-subdir')); } catch {}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
114
test/test.platform.node.ts
Normal file
114
test/test.platform.node.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { NodeWatcher } from '../ts/watchers/watcher.node.js';
|
||||
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const TEST_DIR = path.resolve('./test/assets');
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function waitForEvent(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
timeoutMs = 5000
|
||||
): Promise<IWatchEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let watcher: NodeWatcher;
|
||||
|
||||
tap.test('NodeWatcher: should create and start', async () => {
|
||||
watcher = new NodeWatcher({
|
||||
basePaths: [TEST_DIR],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100,
|
||||
});
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
await watcher.start();
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
await delay(500);
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: should emit ready event', async () => {
|
||||
// Ready event fires during start, so we test isWatching as proxy
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: should detect file creation', async () => {
|
||||
const file = path.join(TEST_DIR, 'node-add-test.txt');
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('node-add-test.txt'));
|
||||
await fs.promises.writeFile(file, 'node watcher test');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('add');
|
||||
expect(event.path).toInclude('node-add-test.txt');
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: should detect file modification', async () => {
|
||||
const file = path.join(TEST_DIR, 'node-change-test.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('node-change-test.txt'));
|
||||
await fs.promises.writeFile(file, 'modified');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('change');
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: should detect file deletion', async () => {
|
||||
const file = path.join(TEST_DIR, 'node-unlink-test.txt');
|
||||
await fs.promises.writeFile(file, 'to delete');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('node-unlink-test.txt'));
|
||||
await fs.promises.unlink(file);
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('unlink');
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: should detect directory creation and removal', async () => {
|
||||
const dir = path.join(TEST_DIR, 'node-test-subdir');
|
||||
|
||||
const addDirPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('node-test-subdir'));
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const addEvent = await addDirPromise;
|
||||
expect(addEvent.type).toEqual('addDir');
|
||||
|
||||
await delay(200);
|
||||
|
||||
const unlinkDirPromise = waitForEvent(watcher, (e) => e.type === 'unlinkDir' && e.path.includes('node-test-subdir'));
|
||||
await fs.promises.rmdir(dir);
|
||||
const unlinkEvent = await unlinkDirPromise;
|
||||
expect(unlinkEvent.type).toEqual('unlinkDir');
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: should not be watching after stop', async () => {
|
||||
await watcher.stop();
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('NodeWatcher: cleanup', async () => {
|
||||
for (const name of ['node-add-test.txt', 'node-change-test.txt', 'node-unlink-test.txt']) {
|
||||
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
|
||||
}
|
||||
try { await fs.promises.rmdir(path.join(TEST_DIR, 'node-test-subdir')); } catch {}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
112
test/test.platform.rust.bun.ts
Normal file
112
test/test.platform.rust.bun.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustWatcher } from '../ts/watchers/watcher.rust.js';
|
||||
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// This test validates the Rust watcher running under the Bun runtime.
|
||||
const isBun = typeof (globalThis as any).Bun !== 'undefined';
|
||||
|
||||
const TEST_DIR = path.resolve('./test/assets');
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function waitForEvent(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
timeoutMs = 5000
|
||||
): Promise<IWatchEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let available = false;
|
||||
|
||||
tap.test('RustWatcher (Bun): check availability', async () => {
|
||||
if (!isBun) { console.log('Skipping: not Bun runtime'); return; }
|
||||
available = await RustWatcher.isAvailable();
|
||||
console.log(`[test] Rust binary available: ${available}`);
|
||||
if (!available) {
|
||||
console.log('[test] Skipping Rust watcher tests — binary not found');
|
||||
}
|
||||
});
|
||||
|
||||
let watcher: RustWatcher;
|
||||
|
||||
tap.test('RustWatcher (Bun): should create and start', async () => {
|
||||
if (!isBun || !available) return;
|
||||
watcher = new RustWatcher({
|
||||
basePaths: [TEST_DIR],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100,
|
||||
});
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
await watcher.start();
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
await delay(300);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Bun): should detect file creation', async () => {
|
||||
if (!isBun || !available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-bun-add.txt');
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('rust-bun-add.txt'));
|
||||
await fs.promises.writeFile(file, 'rust bun add test');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('add');
|
||||
expect(event.path).toInclude('rust-bun-add.txt');
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Bun): should detect file modification', async () => {
|
||||
if (!isBun || !available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-bun-change.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('rust-bun-change.txt'));
|
||||
await fs.promises.writeFile(file, 'modified');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('change');
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Bun): should detect file deletion', async () => {
|
||||
if (!isBun || !available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-bun-unlink.txt');
|
||||
await fs.promises.writeFile(file, 'to delete');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('rust-bun-unlink.txt'));
|
||||
await fs.promises.unlink(file);
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('unlink');
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Bun): should not be watching after stop', async () => {
|
||||
if (!isBun || !available) return;
|
||||
await watcher.stop();
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Bun): cleanup', async () => {
|
||||
if (!isBun) return;
|
||||
for (const name of ['rust-bun-add.txt', 'rust-bun-change.txt', 'rust-bun-unlink.txt']) {
|
||||
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
122
test/test.platform.rust.deno.ts
Normal file
122
test/test.platform.rust.deno.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// tstest:deno:allowAll
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustWatcher } from '../ts/watchers/watcher.rust.js';
|
||||
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
// This test validates the Rust watcher running under the Deno runtime.
|
||||
const isDeno = typeof (globalThis as any).Deno !== 'undefined';
|
||||
|
||||
const TEST_DIR = path.resolve('./test/assets');
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function waitForEvent(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
timeoutMs = 5000
|
||||
): Promise<IWatchEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let available = false;
|
||||
|
||||
tap.test('RustWatcher (Deno): check availability', async () => {
|
||||
if (!isDeno) { console.log('Skipping: not Deno runtime'); return; }
|
||||
available = await RustWatcher.isAvailable();
|
||||
console.log(`[test] Rust binary available: ${available}`);
|
||||
if (!available) {
|
||||
console.log('[test] Skipping Rust watcher tests — binary not found');
|
||||
}
|
||||
});
|
||||
|
||||
let watcher: RustWatcher;
|
||||
|
||||
let started = false;
|
||||
|
||||
tap.test('RustWatcher (Deno): should create and start', async () => {
|
||||
if (!isDeno || !available) return;
|
||||
watcher = new RustWatcher({
|
||||
basePaths: [TEST_DIR],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100,
|
||||
});
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
try {
|
||||
await watcher.start();
|
||||
started = true;
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
await delay(300);
|
||||
} catch (err) {
|
||||
// Deno may block child_process.spawn without --allow-run permission
|
||||
console.log(`[test] RustWatcher spawn failed (likely Deno permission): ${err}`);
|
||||
available = false;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Deno): should detect file creation', async () => {
|
||||
if (!isDeno || !available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-deno-add.txt');
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('rust-deno-add.txt'));
|
||||
await fs.promises.writeFile(file, 'rust deno add test');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('add');
|
||||
expect(event.path).toInclude('rust-deno-add.txt');
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Deno): should detect file modification', async () => {
|
||||
if (!isDeno || !available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-deno-change.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('rust-deno-change.txt'));
|
||||
await fs.promises.writeFile(file, 'modified');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('change');
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Deno): should detect file deletion', async () => {
|
||||
if (!isDeno || !available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-deno-unlink.txt');
|
||||
await fs.promises.writeFile(file, 'to delete');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('rust-deno-unlink.txt'));
|
||||
await fs.promises.unlink(file);
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('unlink');
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Deno): should not be watching after stop', async () => {
|
||||
if (!isDeno || !available) return;
|
||||
await watcher.stop();
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Deno): cleanup', async () => {
|
||||
if (!isDeno) return;
|
||||
for (const name of ['rust-deno-add.txt', 'rust-deno-change.txt', 'rust-deno-unlink.txt']) {
|
||||
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
157
test/test.platform.rust.node.ts
Normal file
157
test/test.platform.rust.node.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { RustWatcher } from '../ts/watchers/watcher.rust.js';
|
||||
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const TEST_DIR = path.resolve('./test/assets');
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function waitForEvent(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
timeoutMs = 5000
|
||||
): Promise<IWatchEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collectEvents(
|
||||
watcher: { events$: { subscribe: Function } },
|
||||
filter: (e: IWatchEvent) => boolean,
|
||||
durationMs: number
|
||||
): Promise<IWatchEvent[]> {
|
||||
return new Promise((resolve) => {
|
||||
const events: IWatchEvent[] = [];
|
||||
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
if (filter(event)) events.push(event);
|
||||
});
|
||||
setTimeout(() => { sub.unsubscribe(); resolve(events); }, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
let available = false;
|
||||
|
||||
tap.test('RustWatcher (Node): check availability', async () => {
|
||||
available = await RustWatcher.isAvailable();
|
||||
console.log(`[test] Rust binary available: ${available}`);
|
||||
if (!available) {
|
||||
console.log('[test] Skipping Rust watcher tests — binary not found');
|
||||
}
|
||||
});
|
||||
|
||||
let watcher: RustWatcher;
|
||||
|
||||
tap.test('RustWatcher (Node): should create and start', async () => {
|
||||
if (!available) return;
|
||||
watcher = new RustWatcher({
|
||||
basePaths: [TEST_DIR],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100,
|
||||
});
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
await watcher.start();
|
||||
expect(watcher.isWatching).toBeTrue();
|
||||
await delay(300);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): should emit initial add events', async () => {
|
||||
if (!available) return;
|
||||
// The initial scan should have emitted add events for existing files.
|
||||
// We verify by creating a file and checking it gets an add event
|
||||
const file = path.join(TEST_DIR, 'rust-node-add.txt');
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('rust-node-add.txt'));
|
||||
await fs.promises.writeFile(file, 'rust node add test');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('add');
|
||||
expect(event.path).toInclude('rust-node-add.txt');
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): should detect file modification', async () => {
|
||||
if (!available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-node-change.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('rust-node-change.txt'));
|
||||
await fs.promises.writeFile(file, 'modified');
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('change');
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): should detect file deletion', async () => {
|
||||
if (!available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-node-unlink.txt');
|
||||
await fs.promises.writeFile(file, 'to delete');
|
||||
await delay(300);
|
||||
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('rust-node-unlink.txt'));
|
||||
await fs.promises.unlink(file);
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('unlink');
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): should detect directory creation', async () => {
|
||||
if (!available) return;
|
||||
const dir = path.join(TEST_DIR, 'rust-node-subdir');
|
||||
const eventPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('rust-node-subdir'));
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const event = await eventPromise;
|
||||
expect(event.type).toEqual('addDir');
|
||||
await delay(200);
|
||||
await fs.promises.rmdir(dir);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): should handle rapid modifications', async () => {
|
||||
if (!available) return;
|
||||
const file = path.join(TEST_DIR, 'rust-node-rapid.txt');
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
await delay(200);
|
||||
|
||||
const collector = collectEvents(watcher, (e) => e.type === 'change' && e.path.includes('rust-node-rapid.txt'), 3000);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await fs.promises.writeFile(file, `content ${i}`);
|
||||
await delay(10);
|
||||
}
|
||||
|
||||
const events = await collector;
|
||||
console.log(`[test] Rapid mods: 10 writes, ${events.length} events received`);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
await fs.promises.unlink(file);
|
||||
await delay(200);
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): should not be watching after stop', async () => {
|
||||
if (!available) return;
|
||||
await watcher.stop();
|
||||
expect(watcher.isWatching).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('RustWatcher (Node): cleanup', async () => {
|
||||
for (const name of ['rust-node-add.txt', 'rust-node-change.txt', 'rust-node-unlink.txt', 'rust-node-rapid.txt']) {
|
||||
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
|
||||
}
|
||||
try { await fs.promises.rmdir(path.join(TEST_DIR, 'rust-node-subdir')); } catch {}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -44,13 +43,15 @@ tap.test('setup: start watcher', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]);
|
||||
await testSmartwatch.start();
|
||||
expect(testSmartwatch.status).toEqual('watching');
|
||||
// Wait for chokidar to be ready
|
||||
await delay(500);
|
||||
});
|
||||
|
||||
tap.test('STRESS: rapid file modifications', async () => {
|
||||
const testFile = path.join(TEST_DIR, 'stress-rapid.txt');
|
||||
|
||||
// Create initial file
|
||||
await smartfile.memory.toFs('initial', testFile);
|
||||
await fs.promises.writeFile(testFile, 'initial');
|
||||
await delay(200);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
@@ -60,7 +61,7 @@ tap.test('STRESS: rapid file modifications', async () => {
|
||||
const eventCollector = collectEvents(changeObservable, 3000);
|
||||
|
||||
for (let i = 0; i < RAPID_CHANGES; i++) {
|
||||
await smartfile.memory.toFs(`content ${i}`, testFile);
|
||||
await fs.promises.writeFile(testFile, `content ${i}`);
|
||||
await delay(10); // 10ms between writes
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ tap.test('STRESS: many files created rapidly', async () => {
|
||||
for (let i = 0; i < FILE_COUNT; i++) {
|
||||
const file = path.join(TEST_DIR, `stress-many-${i}.txt`);
|
||||
files.push(file);
|
||||
await smartfile.memory.toFs(`content ${i}`, file);
|
||||
await fs.promises.writeFile(file, `content ${i}`);
|
||||
await delay(20); // 20ms between creates
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ tap.test('STRESS: interleaved add/change/delete operations', async () => {
|
||||
|
||||
// Create initial files
|
||||
for (const file of testFiles) {
|
||||
await smartfile.memory.toFs('initial', file);
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
}
|
||||
await delay(300);
|
||||
|
||||
@@ -127,13 +128,13 @@ tap.test('STRESS: interleaved add/change/delete operations', async () => {
|
||||
const unlinkEvents = collectEvents(unlinkObservable, 3000);
|
||||
|
||||
// Interleaved operations
|
||||
await smartfile.memory.toFs('changed 1', testFiles[0]); // change
|
||||
await fs.promises.writeFile(testFiles[0], 'changed 1'); // change
|
||||
await delay(50);
|
||||
await fs.promises.unlink(testFiles[1]); // delete
|
||||
await delay(50);
|
||||
await smartfile.memory.toFs('recreated 1', testFiles[1]); // add (recreate)
|
||||
await fs.promises.writeFile(testFiles[1], 'recreated 1'); // add (recreate)
|
||||
await delay(50);
|
||||
await smartfile.memory.toFs('changed 2', testFiles[2]); // change
|
||||
await fs.promises.writeFile(testFiles[2], 'changed 2'); // change
|
||||
await delay(50);
|
||||
|
||||
const [adds, changes, unlinks] = await Promise.all([addEvents, changeEvents, unlinkEvents]);
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartwatch',
|
||||
version: '6.2.5',
|
||||
version: '6.4.0',
|
||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export {
|
||||
// @pushrocks scope
|
||||
import * as lik from '@push.rocks/lik';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import { Smartenv } from '@push.rocks/smartenv';
|
||||
|
||||
export {
|
||||
lik,
|
||||
smartpromise,
|
||||
smartrust,
|
||||
smartrx,
|
||||
Smartenv
|
||||
}
|
||||
|
||||
@@ -4,18 +4,27 @@ import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './
|
||||
export type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType };
|
||||
|
||||
/**
|
||||
* Creates a platform-appropriate file watcher based on the current runtime
|
||||
* Uses @push.rocks/smartenv for runtime detection
|
||||
* Creates a file watcher, preferring the Rust backend when available.
|
||||
* Falls back to chokidar (Node.js/Bun) or Deno.watchFs based on runtime.
|
||||
*/
|
||||
export async function createWatcher(options: IWatcherOptions): Promise<IWatcher> {
|
||||
// Try Rust watcher first (works on all runtimes via smartrust IPC)
|
||||
try {
|
||||
const { RustWatcher } = await import('./watcher.rust.js');
|
||||
if (await RustWatcher.isAvailable()) {
|
||||
return new RustWatcher(options);
|
||||
}
|
||||
} catch {
|
||||
// Rust watcher not available, fall back
|
||||
}
|
||||
|
||||
// Fall back to runtime-specific watchers
|
||||
const env = new Smartenv();
|
||||
|
||||
if (env.isDeno) {
|
||||
// Deno runtime - use Deno.watchFs
|
||||
const { DenoWatcher } = await import('./watcher.deno.js');
|
||||
return new DenoWatcher(options);
|
||||
} else {
|
||||
// Node.js or Bun - both use fs.watch (Bun has Node.js compatibility)
|
||||
const { NodeWatcher } = await import('./watcher.node.js');
|
||||
return new NodeWatcher(options);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface IWatcherOptions {
|
||||
followSymlinks: boolean;
|
||||
/** Debounce time in ms - events for the same file within this window are coalesced */
|
||||
debounceMs: number;
|
||||
/** Whether to wait for writes to stabilize before emitting events */
|
||||
awaitWriteFinish?: boolean;
|
||||
/** How long file size must remain constant before emitting event (ms) */
|
||||
stabilityThreshold?: number;
|
||||
/** How often to poll file size during write detection (ms) */
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -218,6 +218,30 @@ export class DenoWatcher implements IWatcher {
|
||||
type: wasDirectory ? 'unlinkDir' : 'unlink',
|
||||
path: filePath
|
||||
});
|
||||
} else if (kind === 'any' || kind === 'other') {
|
||||
// Deno may emit 'any' for various operations — determine the actual type
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (stats) {
|
||||
if (this.watchedFiles.has(filePath)) {
|
||||
// Known file → treat as change
|
||||
if (!stats.isDirectory()) {
|
||||
this.events$.next({ type: 'change', path: filePath, stats });
|
||||
}
|
||||
} else {
|
||||
// New file → treat as add
|
||||
this.watchedFiles.add(filePath);
|
||||
const eventType: TWatchEventType = stats.isDirectory() ? 'addDir' : 'add';
|
||||
this.events$.next({ type: eventType, path: filePath, stats });
|
||||
}
|
||||
} else {
|
||||
// File no longer exists → treat as remove
|
||||
const wasDirectory = this.isKnownDirectory(filePath);
|
||||
this.watchedFiles.delete(filePath);
|
||||
this.events$.next({
|
||||
type: wasDirectory ? 'unlinkDir' : 'unlink',
|
||||
path: filePath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: filePath, error });
|
||||
|
||||
@@ -1,196 +1,94 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/** Event type constants - inspired by chokidar's pattern */
|
||||
const EV = {
|
||||
ADD: 'add',
|
||||
CHANGE: 'change',
|
||||
UNLINK: 'unlink',
|
||||
ADD_DIR: 'addDir',
|
||||
UNLINK_DIR: 'unlinkDir',
|
||||
READY: 'ready',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
/** Configuration constants */
|
||||
const CONFIG = {
|
||||
MAX_RETRIES: 3,
|
||||
INITIAL_RESTART_DELAY: 1000,
|
||||
MAX_RESTART_DELAY: 30000,
|
||||
HEALTH_CHECK_INTERVAL: 30000,
|
||||
ATOMIC_DELAY: 100,
|
||||
TEMP_FILE_DELAY: 50,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// DirEntry Class - Elegant directory content tracking (inspired by chokidar)
|
||||
// =============================================================================
|
||||
|
||||
import * as chokidar from 'chokidar';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent } from './interfaces.js';
|
||||
|
||||
/**
|
||||
* Tracks contents of a watched directory with proper disposal
|
||||
*/
|
||||
class DirEntry {
|
||||
private _path: string;
|
||||
private _items: Set<string>;
|
||||
private _inodes: Map<string, bigint>;
|
||||
|
||||
constructor(dirPath: string) {
|
||||
this._path = dirPath;
|
||||
this._items = new Set();
|
||||
this._inodes = new Map();
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
add(item: string, inode?: bigint): void {
|
||||
if (item === '.' || item === '..') return;
|
||||
this._items.add(item);
|
||||
if (inode !== undefined) {
|
||||
this._inodes.set(item, inode);
|
||||
}
|
||||
}
|
||||
|
||||
remove(item: string): void {
|
||||
this._items.delete(item);
|
||||
this._inodes.delete(item);
|
||||
}
|
||||
|
||||
has(item: string): boolean {
|
||||
return this._items.has(item);
|
||||
}
|
||||
|
||||
getInode(item: string): bigint | undefined {
|
||||
return this._inodes.get(item);
|
||||
}
|
||||
|
||||
setInode(item: string, inode: bigint): void {
|
||||
this._inodes.set(item, inode);
|
||||
}
|
||||
|
||||
getChildren(): string[] {
|
||||
return [...this._items];
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._items.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._items.clear();
|
||||
this._inodes.clear();
|
||||
this._path = '';
|
||||
// Freeze to catch accidental use after disposal
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NodeWatcher Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Node.js/Bun file watcher using native fs.watch API
|
||||
* Node.js/Bun file watcher using chokidar
|
||||
*
|
||||
* Architecture inspired by chokidar with additional robustness features:
|
||||
* - Event deferral during initial scan
|
||||
* - Event sequence tracking for debounce
|
||||
* - Atomic write handling (unlink→add becomes change)
|
||||
* - Inode tracking for delete+recreate detection
|
||||
* - Health check monitoring
|
||||
* - Auto-restart with exponential backoff
|
||||
* Chokidar handles all the edge cases:
|
||||
* - Atomic writes (temp file + rename)
|
||||
* - Inode tracking
|
||||
* - Cross-platform differences
|
||||
* - Debouncing
|
||||
* - Write stabilization
|
||||
*/
|
||||
export class NodeWatcher implements IWatcher {
|
||||
// Core state
|
||||
private watchers: Map<string, fs.FSWatcher> = new Map();
|
||||
private watched: Map<string, DirEntry> = new Map();
|
||||
private watcher: chokidar.FSWatcher | null = null;
|
||||
private _isWatching = false;
|
||||
private _preExistingHandles: Set<any> = new Set();
|
||||
|
||||
// Event stream
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
// Atomic write handling - pending unlinks that may become changes
|
||||
private pendingUnlinks: Map<string, { timeout: NodeJS.Timeout; event: IWatchEvent }> = new Map();
|
||||
|
||||
// Debounce with event sequence tracking
|
||||
private pendingEmits: Map<string, {
|
||||
timeout: NodeJS.Timeout;
|
||||
events: Array<'rename' | 'change'>;
|
||||
}> = new Map();
|
||||
|
||||
// Restart management
|
||||
private restartDelays: Map<string, number> = new Map();
|
||||
private restartAttempts: Map<string, number> = new Map();
|
||||
private restartAbortControllers: Map<string, AbortController> = new Map();
|
||||
private restartingPaths: Set<string> = new Set();
|
||||
|
||||
// Health monitoring
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
private watchedInodes: Map<string, bigint> = new Map();
|
||||
|
||||
// Initial scan state
|
||||
private initialScanComplete = false;
|
||||
private deferredEvents: Array<{ basePath: string; filename: string; eventType: string }> = [];
|
||||
|
||||
// Closer registry - inspired by chokidar for clean resource management
|
||||
private closers: Map<string, Array<() => void>> = new Map();
|
||||
|
||||
constructor(private options: IWatcherOptions) {}
|
||||
|
||||
/** Collect all current FSWatcher handles from the process */
|
||||
private _getFsWatcherHandles(): any[] {
|
||||
return (process as any)._getActiveHandles().filter(
|
||||
(h: any) => h?.constructor?.name === 'FSWatcher' && typeof h.unref === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API
|
||||
// ===========================================================================
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._isWatching) return;
|
||||
|
||||
console.log(`[smartwatch] Starting watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||
// Snapshot existing FSWatcher handles so we only unref ours on stop
|
||||
this._preExistingHandles = new Set(this._getFsWatcherHandles());
|
||||
|
||||
console.log(`[smartwatch] Starting chokidar watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||
|
||||
try {
|
||||
// Reset state
|
||||
this.initialScanComplete = false;
|
||||
this.deferredEvents = [];
|
||||
// Resolve all paths to absolute
|
||||
const absolutePaths = this.options.basePaths.map(p => path.resolve(p));
|
||||
|
||||
// Start watching each base path (events will be deferred)
|
||||
for (const basePath of this.options.basePaths) {
|
||||
await this.watchPath(basePath);
|
||||
}
|
||||
this.watcher = chokidar.watch(absolutePaths, {
|
||||
persistent: true,
|
||||
ignoreInitial: false,
|
||||
followSymlinks: this.options.followSymlinks,
|
||||
depth: this.options.depth,
|
||||
atomic: true, // Handle atomic writes
|
||||
awaitWriteFinish: this.options.awaitWriteFinish ? {
|
||||
stabilityThreshold: this.options.stabilityThreshold || 300,
|
||||
pollInterval: this.options.pollInterval || 100,
|
||||
} : false,
|
||||
});
|
||||
|
||||
// Wire up all events
|
||||
this.watcher
|
||||
.on('add', (filePath: string, stats?: fs.Stats) => {
|
||||
this.safeEmit({ type: 'add', path: filePath, stats });
|
||||
})
|
||||
.on('change', (filePath: string, stats?: fs.Stats) => {
|
||||
this.safeEmit({ type: 'change', path: filePath, stats });
|
||||
})
|
||||
.on('unlink', (filePath: string) => {
|
||||
this.safeEmit({ type: 'unlink', path: filePath });
|
||||
})
|
||||
.on('addDir', (filePath: string, stats?: fs.Stats) => {
|
||||
this.safeEmit({ type: 'addDir', path: filePath, stats });
|
||||
})
|
||||
.on('unlinkDir', (filePath: string) => {
|
||||
this.safeEmit({ type: 'unlinkDir', path: filePath });
|
||||
})
|
||||
.on('error', (error: Error) => {
|
||||
console.error('[smartwatch] Chokidar error:', error);
|
||||
this.safeEmit({ type: 'error', path: '', error });
|
||||
})
|
||||
.on('ready', () => {
|
||||
console.log('[smartwatch] Chokidar ready - initial scan complete');
|
||||
this.safeEmit({ type: 'ready', path: '' });
|
||||
});
|
||||
|
||||
this._isWatching = true;
|
||||
this.startHealthCheck();
|
||||
|
||||
// Initial scan populates watched entries
|
||||
for (const basePath of this.options.basePaths) {
|
||||
await this.scanDirectory(basePath, 0);
|
||||
}
|
||||
|
||||
// Process deferred events
|
||||
this.initialScanComplete = true;
|
||||
if (this.deferredEvents.length > 0) {
|
||||
console.log(`[smartwatch] Processing ${this.deferredEvents.length} deferred events`);
|
||||
for (const event of this.deferredEvents) {
|
||||
this.handleFsEvent(event.basePath, event.filename, event.eventType);
|
||||
}
|
||||
this.deferredEvents = [];
|
||||
}
|
||||
|
||||
this.safeEmit({ type: EV.READY, path: '' });
|
||||
console.log(`[smartwatch] Watcher started with ${this.watchers.size} active watcher(s)`);
|
||||
console.log('[smartwatch] Watcher started');
|
||||
} catch (error: any) {
|
||||
console.error('[smartwatch] Failed to start watcher:', error);
|
||||
this.safeEmit({ type: EV.ERROR, path: '', error });
|
||||
this.safeEmit({ type: 'error', path: '', error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -198,58 +96,26 @@ export class NodeWatcher implements IWatcher {
|
||||
async stop(): Promise<void> {
|
||||
console.log('[smartwatch] Stopping watcher...');
|
||||
|
||||
// Cancel pending emits first (before flag changes)
|
||||
for (const pending of this.pendingEmits.values()) {
|
||||
clearTimeout(pending.timeout);
|
||||
if (this.watcher) {
|
||||
await this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
this.pendingEmits.clear();
|
||||
|
||||
// Cancel pending unlinks
|
||||
for (const pending of this.pendingUnlinks.values()) {
|
||||
clearTimeout(pending.timeout);
|
||||
// Unref only FSWatcher handles created during our watch session.
|
||||
// Chokidar v5 can orphan fs.watch() handles under heavy file churn,
|
||||
// preventing process exit. We only touch handles that didn't exist
|
||||
// before start() to avoid affecting other watchers in the process.
|
||||
for (const handle of this._getFsWatcherHandles()) {
|
||||
if (!this._preExistingHandles.has(handle)) {
|
||||
handle.unref();
|
||||
}
|
||||
}
|
||||
this.pendingUnlinks.clear();
|
||||
this._preExistingHandles.clear();
|
||||
|
||||
// Now set flag
|
||||
this._isWatching = false;
|
||||
|
||||
this.stopHealthCheck();
|
||||
|
||||
// Abort pending restarts
|
||||
for (const [watchPath, controller] of this.restartAbortControllers) {
|
||||
console.log(`[smartwatch] Aborting pending restart for: ${watchPath}`);
|
||||
controller.abort();
|
||||
}
|
||||
this.restartAbortControllers.clear();
|
||||
|
||||
// Close all watchers and run closers
|
||||
for (const [watchPath, watcher] of this.watchers) {
|
||||
console.log(`[smartwatch] Closing watcher for: ${watchPath}`);
|
||||
watcher.close();
|
||||
this.runClosers(watchPath);
|
||||
}
|
||||
|
||||
// Clear all state
|
||||
this.watchers.clear();
|
||||
this.watched.forEach(entry => entry.dispose());
|
||||
this.watched.clear();
|
||||
this.restartDelays.clear();
|
||||
this.restartAttempts.clear();
|
||||
this.watchedInodes.clear();
|
||||
this.restartingPaths.clear();
|
||||
this.closers.clear();
|
||||
|
||||
// Reset scan state
|
||||
this.initialScanComplete = false;
|
||||
this.deferredEvents = [];
|
||||
|
||||
console.log('[smartwatch] Watcher stopped');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event Emission
|
||||
// ===========================================================================
|
||||
|
||||
/** Safely emit an event, isolating subscriber errors */
|
||||
private safeEmit(event: IWatchEvent): void {
|
||||
try {
|
||||
@@ -258,548 +124,4 @@ export class NodeWatcher implements IWatcher {
|
||||
console.error('[smartwatch] Subscriber threw error (isolated):', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Closer Registry - Clean resource management
|
||||
// ===========================================================================
|
||||
|
||||
private addCloser(watchPath: string, closer: () => void): void {
|
||||
let list = this.closers.get(watchPath);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.closers.set(watchPath, list);
|
||||
}
|
||||
list.push(closer);
|
||||
}
|
||||
|
||||
private runClosers(watchPath: string): void {
|
||||
const list = this.closers.get(watchPath);
|
||||
if (list) {
|
||||
list.forEach(closer => closer());
|
||||
this.closers.delete(watchPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Directory Entry Management
|
||||
// ===========================================================================
|
||||
|
||||
private getWatchedDir(dirPath: string): DirEntry {
|
||||
const resolved = path.resolve(dirPath);
|
||||
let entry = this.watched.get(resolved);
|
||||
if (!entry) {
|
||||
entry = new DirEntry(resolved);
|
||||
this.watched.set(resolved, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private isTracked(filePath: string): boolean {
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
const entry = this.watched.get(path.resolve(dir));
|
||||
return entry?.has(base) ?? false;
|
||||
}
|
||||
|
||||
private trackFile(filePath: string, inode?: bigint): void {
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
this.getWatchedDir(dir).add(base, inode);
|
||||
}
|
||||
|
||||
private untrackFile(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
const entry = this.watched.get(path.resolve(dir));
|
||||
entry?.remove(base);
|
||||
}
|
||||
|
||||
private getFileInode(filePath: string): bigint | undefined {
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
const entry = this.watched.get(path.resolve(dir));
|
||||
return entry?.getInode(base);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Temp File Handling
|
||||
// ===========================================================================
|
||||
|
||||
private isTemporaryFile(filePath: string): boolean {
|
||||
const basename = path.basename(filePath);
|
||||
return (
|
||||
basename.includes('.tmp.') ||
|
||||
basename.endsWith('.swp') ||
|
||||
basename.endsWith('.swx') ||
|
||||
basename.endsWith('~') ||
|
||||
basename.startsWith('.#')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract real file path from temp file (Claude Code atomic writes)
|
||||
* Pattern: file.ts.tmp.PID.TIMESTAMP -> file.ts
|
||||
*/
|
||||
private getTempFileTarget(tempPath: string): string | null {
|
||||
const basename = path.basename(tempPath);
|
||||
|
||||
// Claude Code: file.ts.tmp.PID.TIMESTAMP
|
||||
const claudeMatch = basename.match(/^(.+)\.tmp\.\d+\.\d+$/);
|
||||
if (claudeMatch) {
|
||||
return path.join(path.dirname(tempPath), claudeMatch[1]);
|
||||
}
|
||||
|
||||
// Generic: file.ts.tmp.something
|
||||
const genericMatch = basename.match(/^(.+)\.tmp\.[^.]+$/);
|
||||
if (genericMatch) {
|
||||
return path.join(path.dirname(tempPath), genericMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Watch Path Setup
|
||||
// ===========================================================================
|
||||
|
||||
private async watchPath(watchPath: string): Promise<void> {
|
||||
// Normalize path to absolute - critical for consistent lookups
|
||||
watchPath = path.resolve(watchPath);
|
||||
|
||||
try {
|
||||
const stats = await this.statSafe(watchPath);
|
||||
if (!stats?.isDirectory()) return;
|
||||
|
||||
// Store inode for health check (fs.watch watches inode, not path!)
|
||||
this.watchedInodes.set(watchPath, BigInt(stats.ino));
|
||||
|
||||
const watcher = fs.watch(
|
||||
watchPath,
|
||||
{ recursive: true, persistent: true },
|
||||
(eventType, filename) => {
|
||||
if (filename) {
|
||||
this.handleFsEvent(watchPath, filename, eventType);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watcher.on('error', (error: NodeJS.ErrnoException) => {
|
||||
console.error(`[smartwatch] FSWatcher error on ${watchPath}:`, error);
|
||||
|
||||
if (error.code === 'ENOSPC') {
|
||||
console.error('[smartwatch] CRITICAL: inotify watch limit exceeded!');
|
||||
console.error('[smartwatch] Fix: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p');
|
||||
}
|
||||
|
||||
this.safeEmit({ type: EV.ERROR, path: watchPath, error });
|
||||
if (this._isWatching) {
|
||||
this.restartWatcher(watchPath, error);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('close', () => {
|
||||
if (this._isWatching) {
|
||||
console.warn(`[smartwatch] FSWatcher closed unexpectedly for ${watchPath}`);
|
||||
this.restartWatcher(watchPath, new Error('Watcher closed unexpectedly'));
|
||||
}
|
||||
});
|
||||
|
||||
this.watchers.set(watchPath, watcher);
|
||||
|
||||
// Register closer
|
||||
this.addCloser(watchPath, () => {
|
||||
try { watcher.close(); } catch {}
|
||||
});
|
||||
|
||||
console.log(`[smartwatch] Started watching: ${watchPath}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[smartwatch] Failed to watch path ${watchPath}:`, error);
|
||||
this.safeEmit({ type: EV.ERROR, path: watchPath, error });
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Event Handling
|
||||
// ===========================================================================
|
||||
|
||||
private handleFsEvent(
|
||||
basePath: string,
|
||||
filename: string,
|
||||
eventType: 'rename' | 'change' | string
|
||||
): void {
|
||||
// Guard against post-stop events
|
||||
if (!this._isWatching) return;
|
||||
|
||||
// Defer events until initial scan completes
|
||||
if (!this.initialScanComplete) {
|
||||
this.deferredEvents.push({ basePath, filename, eventType });
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize to absolute path - critical for consistent lookups
|
||||
const fullPath = path.resolve(path.join(basePath, filename));
|
||||
|
||||
// Handle temp files from atomic writes
|
||||
if (this.isTemporaryFile(fullPath)) {
|
||||
console.log(`[smartwatch] Detected temp file event: ${filename}`);
|
||||
const realPath = this.getTempFileTarget(fullPath);
|
||||
if (realPath) {
|
||||
console.log(`[smartwatch] Checking corresponding real file: ${realPath}`);
|
||||
setTimeout(() => {
|
||||
if (this._isWatching) {
|
||||
this.handleFsEvent(basePath, path.relative(basePath, realPath), 'change');
|
||||
}
|
||||
}, CONFIG.TEMP_FILE_DELAY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track event sequence for intelligent debouncing
|
||||
const existing = this.pendingEmits.get(fullPath);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timeout);
|
||||
existing.events.push(eventType as 'rename' | 'change');
|
||||
existing.timeout = setTimeout(() => {
|
||||
const pending = this.pendingEmits.get(fullPath);
|
||||
if (pending) {
|
||||
this.pendingEmits.delete(fullPath);
|
||||
this.emitFileEvent(fullPath, pending.events);
|
||||
}
|
||||
}, this.options.debounceMs);
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
const pending = this.pendingEmits.get(fullPath);
|
||||
if (pending) {
|
||||
this.pendingEmits.delete(fullPath);
|
||||
this.emitFileEvent(fullPath, pending.events);
|
||||
}
|
||||
}, this.options.debounceMs);
|
||||
|
||||
this.pendingEmits.set(fullPath, {
|
||||
timeout,
|
||||
events: [eventType as 'rename' | 'change'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit file event after debounce with atomic write handling
|
||||
*
|
||||
* Atomic write pattern (inspired by chokidar):
|
||||
* - unlink event queued with delay
|
||||
* - if add arrives for same path, transform to change
|
||||
*/
|
||||
private async emitFileEvent(
|
||||
fullPath: string,
|
||||
eventSequence: Array<'rename' | 'change'>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stats = await this.statSafe(fullPath);
|
||||
const wasTracked = this.isTracked(fullPath);
|
||||
const previousInode = this.getFileInode(fullPath);
|
||||
|
||||
// Analyze event sequence
|
||||
const hasRename = eventSequence.includes('rename');
|
||||
const renameCount = eventSequence.filter(e => e === 'rename').length;
|
||||
|
||||
if (eventSequence.length > 1) {
|
||||
console.log(`[smartwatch] Processing event sequence for ${fullPath}: [${eventSequence.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
// File EXISTS
|
||||
const currentInode = BigInt(stats.ino);
|
||||
const inodeChanged = previousInode !== undefined && previousInode !== currentInode;
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (!wasTracked) {
|
||||
this.trackFile(fullPath);
|
||||
this.safeEmit({ type: EV.ADD_DIR, path: fullPath, stats });
|
||||
}
|
||||
} else {
|
||||
// Update tracking
|
||||
this.trackFile(fullPath, currentInode);
|
||||
|
||||
// Check for pending unlink → transform to change (atomic write pattern)
|
||||
const pendingUnlink = this.pendingUnlinks.get(fullPath);
|
||||
if (pendingUnlink) {
|
||||
clearTimeout(pendingUnlink.timeout);
|
||||
this.pendingUnlinks.delete(fullPath);
|
||||
console.log(`[smartwatch] Atomic write detected (unlink→add→change): ${fullPath}`);
|
||||
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasTracked) {
|
||||
this.safeEmit({ type: EV.ADD, path: fullPath, stats });
|
||||
} else if (inodeChanged) {
|
||||
console.log(`[smartwatch] File inode changed (delete+recreate): ${fullPath}`);
|
||||
console.log(`[smartwatch] Previous inode: ${previousInode}, current: ${currentInode}`);
|
||||
|
||||
if (renameCount >= 2) {
|
||||
// Multiple renames with inode change = delete+recreate
|
||||
this.safeEmit({ type: EV.UNLINK, path: fullPath });
|
||||
this.safeEmit({ type: EV.ADD, path: fullPath, stats });
|
||||
} else {
|
||||
// Single rename with inode change = atomic save
|
||||
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
||||
}
|
||||
} else {
|
||||
// Debounce already handles rapid events - no extra throttle needed
|
||||
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File does NOT exist - handle unlink
|
||||
const wasDir = this.isKnownDirectory(fullPath);
|
||||
|
||||
if (wasTracked) {
|
||||
this.untrackFile(fullPath);
|
||||
|
||||
if (renameCount >= 2 && !wasDir) {
|
||||
// Rapid create+delete
|
||||
console.log(`[smartwatch] File created and deleted rapidly: ${fullPath}`);
|
||||
this.safeEmit({ type: EV.ADD, path: fullPath });
|
||||
this.safeEmit({ type: EV.UNLINK, path: fullPath });
|
||||
} else {
|
||||
// Queue unlink with delay for atomic write detection
|
||||
this.queueUnlink(fullPath, wasDir);
|
||||
}
|
||||
} else {
|
||||
if (renameCount >= 2) {
|
||||
console.log(`[smartwatch] Untracked file created and deleted: ${fullPath}`);
|
||||
this.safeEmit({ type: EV.ADD, path: fullPath });
|
||||
this.safeEmit({ type: EV.UNLINK, path: fullPath });
|
||||
} else if (hasRename) {
|
||||
console.log(`[smartwatch] Untracked file deleted: ${fullPath}`);
|
||||
this.queueUnlink(fullPath, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.safeEmit({ type: EV.ERROR, path: fullPath, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an unlink event with delay for atomic write detection
|
||||
* If add event arrives within delay, unlink is cancelled and change is emitted
|
||||
*/
|
||||
private queueUnlink(fullPath: string, isDir: boolean): void {
|
||||
const event: IWatchEvent = {
|
||||
type: isDir ? EV.UNLINK_DIR : EV.UNLINK,
|
||||
path: fullPath,
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const pending = this.pendingUnlinks.get(fullPath);
|
||||
if (pending) {
|
||||
this.pendingUnlinks.delete(fullPath);
|
||||
this.safeEmit(pending.event);
|
||||
}
|
||||
}, CONFIG.ATOMIC_DELAY);
|
||||
|
||||
this.pendingUnlinks.set(fullPath, { timeout, event });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Directory Scanning
|
||||
// ===========================================================================
|
||||
|
||||
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
|
||||
// Normalize path to absolute - critical for consistent lookups
|
||||
dirPath = path.resolve(dirPath);
|
||||
|
||||
if (depth > this.options.depth) return;
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (this.isTemporaryFile(fullPath)) continue;
|
||||
|
||||
const stats = await this.statSafe(fullPath);
|
||||
if (!stats) continue;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
this.trackFile(fullPath);
|
||||
this.safeEmit({ type: EV.ADD_DIR, path: fullPath, stats });
|
||||
await this.scanDirectory(fullPath, depth + 1);
|
||||
} else if (entry.isFile()) {
|
||||
this.trackFile(fullPath, BigInt(stats.ino));
|
||||
this.safeEmit({ type: EV.ADD, path: fullPath, stats });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
|
||||
this.safeEmit({ type: EV.ERROR, path: dirPath, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Health Check & Auto-Restart
|
||||
// ===========================================================================
|
||||
|
||||
private startHealthCheck(): void {
|
||||
console.log('[smartwatch] Starting health check (every 30s)');
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
console.log(`[smartwatch] Health check: ${this.watchers.size} watchers active`);
|
||||
|
||||
for (const [basePath] of this.watchers) {
|
||||
try {
|
||||
const stats = await fs.promises.stat(basePath);
|
||||
const currentInode = BigInt(stats.ino);
|
||||
const previousInode = this.watchedInodes.get(basePath);
|
||||
|
||||
if (previousInode !== undefined && currentInode !== previousInode) {
|
||||
console.warn(`[smartwatch] Inode changed for ${basePath}: ${previousInode} -> ${currentInode}`);
|
||||
console.warn('[smartwatch] fs.watch watches inode, not path - restarting watcher');
|
||||
this.restartWatcher(basePath, new Error('Inode changed - directory was replaced'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`);
|
||||
this.restartWatcher(basePath, new Error('Watched path disappeared'));
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
console.error('[smartwatch] ENOSPC: inotify watch limit exceeded!');
|
||||
console.error('[smartwatch] Fix: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p');
|
||||
this.safeEmit({ type: EV.ERROR, path: basePath, error });
|
||||
// Trigger restart - watcher may be broken after ENOSPC
|
||||
this.restartWatcher(basePath, error);
|
||||
} else {
|
||||
console.error(`[smartwatch] Health check error for ${basePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, CONFIG.HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
private stopHealthCheck(): void {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
this.healthCheckInterval = null;
|
||||
console.log('[smartwatch] Stopped health check');
|
||||
}
|
||||
}
|
||||
|
||||
private async restartWatcher(basePath: string, error: Error): Promise<void> {
|
||||
// Guard against concurrent restarts
|
||||
if (this.restartingPaths.has(basePath)) {
|
||||
console.log(`[smartwatch] Restart already in progress for ${basePath}, skipping`);
|
||||
return;
|
||||
}
|
||||
this.restartingPaths.add(basePath);
|
||||
|
||||
try {
|
||||
const attempts = (this.restartAttempts.get(basePath) || 0) + 1;
|
||||
this.restartAttempts.set(basePath, attempts);
|
||||
|
||||
console.log(`[smartwatch] Watcher error for ${basePath}: ${error.message}`);
|
||||
console.log(`[smartwatch] Restart attempt ${attempts}/${CONFIG.MAX_RETRIES}`);
|
||||
|
||||
if (attempts > CONFIG.MAX_RETRIES) {
|
||||
console.error(`[smartwatch] Max retries exceeded for ${basePath}, giving up`);
|
||||
this.safeEmit({
|
||||
type: EV.ERROR,
|
||||
path: basePath,
|
||||
error: new Error(`Max restart retries (${CONFIG.MAX_RETRIES}) exceeded`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close old watcher
|
||||
const oldWatcher = this.watchers.get(basePath);
|
||||
if (oldWatcher) {
|
||||
try { oldWatcher.close(); } catch {}
|
||||
this.watchers.delete(basePath);
|
||||
}
|
||||
|
||||
// Clear pending unlinks for this base path (prevent stale events)
|
||||
for (const [unlinkedPath, pending] of this.pendingUnlinks) {
|
||||
if (unlinkedPath.startsWith(basePath)) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingUnlinks.delete(unlinkedPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear stale DirEntry data (will be repopulated by rescan)
|
||||
for (const [dirPath, entry] of this.watched) {
|
||||
if (dirPath === basePath || dirPath.startsWith(basePath + path.sep)) {
|
||||
entry.dispose();
|
||||
this.watched.delete(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential backoff with abort support
|
||||
const delay = this.restartDelays.get(basePath) || CONFIG.INITIAL_RESTART_DELAY;
|
||||
console.log(`[smartwatch] Waiting ${delay}ms before restart...`);
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.restartAbortControllers.set(basePath, abortController);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, delay);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Restart aborted by stop()'));
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
console.log(`[smartwatch] Restart aborted for ${basePath}`);
|
||||
return;
|
||||
} finally {
|
||||
this.restartAbortControllers.delete(basePath);
|
||||
}
|
||||
|
||||
if (!this._isWatching) {
|
||||
console.log('[smartwatch] Watcher stopped during restart delay, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartDelays.set(basePath, Math.min(delay * 2, CONFIG.MAX_RESTART_DELAY));
|
||||
|
||||
try {
|
||||
await this.watchPath(basePath);
|
||||
// Rescan to catch files created during restart window
|
||||
await this.scanDirectory(basePath, 0);
|
||||
console.log(`[smartwatch] Successfully restarted watcher for ${basePath}`);
|
||||
this.restartDelays.set(basePath, CONFIG.INITIAL_RESTART_DELAY);
|
||||
this.restartAttempts.set(basePath, 0);
|
||||
} catch (restartError) {
|
||||
console.error(`[smartwatch] Restart failed for ${basePath}:`, restartError);
|
||||
this.restartingPaths.delete(basePath);
|
||||
this.restartWatcher(basePath, restartError as Error);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
this.restartingPaths.delete(basePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utilities
|
||||
// ===========================================================================
|
||||
|
||||
private async statSafe(filePath: string): Promise<fs.Stats | null> {
|
||||
try {
|
||||
return await (this.options.followSymlinks
|
||||
? fs.promises.stat(filePath)
|
||||
: fs.promises.lstat(filePath));
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
return null;
|
||||
}
|
||||
console.warn(`[smartwatch] statSafe warning for ${filePath}: ${error.code} - ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private isKnownDirectory(filePath: string): boolean {
|
||||
const resolved = path.resolve(filePath);
|
||||
return this.watched.has(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
154
ts/watchers/watcher.rust.ts
Normal file
154
ts/watchers/watcher.rust.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as path from 'node:path';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||
|
||||
// Resolve the package directory for binary location
|
||||
const packageDir = path.resolve(new URL('.', import.meta.url).pathname, '..', '..');
|
||||
|
||||
/**
|
||||
* Command map for the Rust file watcher binary
|
||||
*/
|
||||
type TWatcherCommands = {
|
||||
watch: {
|
||||
params: {
|
||||
paths: string[];
|
||||
depth: number;
|
||||
followSymlinks: boolean;
|
||||
debounceMs: number;
|
||||
};
|
||||
result: { watching: boolean };
|
||||
};
|
||||
stop: {
|
||||
params: Record<string, never>;
|
||||
result: { stopped: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build local search paths for the Rust binary
|
||||
*/
|
||||
function buildLocalPaths(): string[] {
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform;
|
||||
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch;
|
||||
const platformSuffix = `${platform}_${arch}`;
|
||||
|
||||
return [
|
||||
path.join(packageDir, 'dist_rust', `smartwatch-rust_${platformSuffix}`),
|
||||
path.join(packageDir, 'dist_rust', 'smartwatch-rust'),
|
||||
path.join(packageDir, 'rust', 'target', 'release', 'smartwatch-rust'),
|
||||
path.join(packageDir, 'rust', 'target', 'debug', 'smartwatch-rust'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust-based file watcher using the notify crate via @push.rocks/smartrust
|
||||
*
|
||||
* Uses a Rust binary for native OS-level file watching (inotify/FSEvents/ReadDirectoryChangesW).
|
||||
* Works across Node.js, Deno, and Bun via smartrust's IPC bridge.
|
||||
*/
|
||||
export class RustWatcher implements IWatcher {
|
||||
private bridge: smartrust.RustBridge<TWatcherCommands>;
|
||||
private _isWatching = false;
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {
|
||||
this.bridge = new smartrust.RustBridge<TWatcherCommands>({
|
||||
binaryName: 'smartwatch-rust',
|
||||
localPaths: buildLocalPaths(),
|
||||
searchSystemPath: false,
|
||||
cliArgs: ['--management'],
|
||||
requestTimeoutMs: 30000,
|
||||
readyTimeoutMs: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Rust binary is available on this system
|
||||
*/
|
||||
static async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const locator = new smartrust.RustBinaryLocator({
|
||||
binaryName: 'smartwatch-rust',
|
||||
localPaths: buildLocalPaths(),
|
||||
searchSystemPath: false,
|
||||
});
|
||||
const binaryPath = await locator.findBinary();
|
||||
return binaryPath !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._isWatching) return;
|
||||
|
||||
console.log(`[smartwatch] Starting Rust watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||
|
||||
// Listen for file system events from the Rust binary
|
||||
this.bridge.on('management:fsEvent', (data: { type: string; path: string }) => {
|
||||
const eventType = data.type as TWatchEventType;
|
||||
this.safeEmit({ type: eventType, path: data.path });
|
||||
});
|
||||
|
||||
this.bridge.on('management:error', (data: { message: string }) => {
|
||||
console.error('[smartwatch] Rust watcher error:', data.message);
|
||||
this.safeEmit({ type: 'error', path: '', error: new Error(data.message) });
|
||||
});
|
||||
|
||||
this.bridge.on('management:watchReady', () => {
|
||||
console.log('[smartwatch] Rust watcher ready - initial scan complete');
|
||||
this.safeEmit({ type: 'ready', path: '' });
|
||||
});
|
||||
|
||||
// Spawn the Rust binary
|
||||
const ok = await this.bridge.spawn();
|
||||
if (!ok) {
|
||||
throw new Error('[smartwatch] Failed to spawn Rust watcher binary');
|
||||
}
|
||||
|
||||
// Resolve paths to absolute
|
||||
const absolutePaths = this.options.basePaths.map(p => path.resolve(p));
|
||||
|
||||
// Send watch command
|
||||
await this.bridge.sendCommand('watch', {
|
||||
paths: absolutePaths,
|
||||
depth: this.options.depth,
|
||||
followSymlinks: this.options.followSymlinks,
|
||||
debounceMs: this.options.debounceMs,
|
||||
});
|
||||
|
||||
this._isWatching = true;
|
||||
console.log('[smartwatch] Rust watcher started');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
console.log('[smartwatch] Stopping Rust watcher...');
|
||||
|
||||
if (this._isWatching) {
|
||||
try {
|
||||
await this.bridge.sendCommand('stop', {} as any);
|
||||
} catch {
|
||||
// Binary may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
this.bridge.kill();
|
||||
this._isWatching = false;
|
||||
console.log('[smartwatch] Rust watcher stopped');
|
||||
}
|
||||
|
||||
/** Safely emit an event, isolating subscriber errors */
|
||||
private safeEmit(event: IWatchEvent): void {
|
||||
try {
|
||||
this.events$.next(event);
|
||||
} catch (error) {
|
||||
console.error('[smartwatch] Subscriber threw error (isolated):', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user