Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adb2e5a1db | |||
| 7def7020c6 | |||
| ca9a66e03e | |||
| 48081302c8 | |||
| 09485e20d9 | |||
| 61a8222c9b | |||
| 696d454b00 | |||
| da77d8a608 | |||
| 0bab7e0296 | |||
| f4243f190b | |||
| afe462990f | |||
| 90275a0f1c | |||
| ef2388b16f | |||
| 6f6868f2ad | |||
| ea57de06dd | |||
| 4894253e48 | |||
| 2f55f628f5 | |||
| 5dda689b4c | |||
| 8677f61da1 | |||
| 0c236d44d3 | |||
| 913c14bfcf | |||
| 2ce056f401 | |||
| 1c77a94b96 | |||
| 097ea96e99 | |||
| 5a6d9a2575 | |||
| 7a7ee041a3 | |||
| 0dc4eaba52 | |||
| 31ab561859 | |||
| a5b4fedf9f | |||
| b84a50f87d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,4 +17,8 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# rust
|
||||
rust/target/
|
||||
dist_rust/
|
||||
|
||||
# custom
|
||||
137
changelog.md
137
changelog.md
@@ -1,5 +1,142 @@
|
||||
# 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
|
||||
|
||||
- Normalize all paths to absolute at watcher entry points (watchPath, handleFsEvent, scanDirectory) to avoid relative/absolute mismatch bugs
|
||||
- On watcher restart: clear pending unlink timeouts, dispose stale DirEntry data, and perform a rescan to catch files created during the restart window
|
||||
- Trigger watcher restart on ENOSPC (inotify limit) errors instead of only logging the error
|
||||
- Remove the previous Throttler implementation and rely on the existing debounce + event-sequence tracking to handle rapid events
|
||||
- Atomic write handling and queued unlink behavior preserved; pending unlinks are cleared for restarted base paths to avoid stale events
|
||||
|
||||
## 2025-12-11 - 6.2.4 - fix(tests)
|
||||
Stabilize tests and document chokidar-inspired Node watcher architecture
|
||||
|
||||
- test: add waitForFileEvent helper to wait for events for a specific file (reduces test flakiness)
|
||||
- test: add small delays after unlink cleanup to account for atomic/temp-file debounce windows
|
||||
- docs: expand readme.hints.md with a detailed Node watcher architecture section (DirEntry, Throttler, atomic write handling, closer registry, constants and config)
|
||||
- docs: list updated test files and coverage scenarios (inode detection, atomic writes, stress tests)
|
||||
|
||||
## 2025-12-11 - 6.2.3 - fix(watcher.node)
|
||||
Improve handling of temporary files from atomic editor writes in Node watcher
|
||||
|
||||
- Detect temporary files produced by atomic editor saves and attempt to map them to the real target file instead of silently skipping the event
|
||||
- Add getTempFileTarget() to extract the real file path from temp filenames (supports patterns like file.ts.tmp.PID.TIMESTAMP and generic .tmp.*)
|
||||
- When a temp-file event is seen, queue a corresponding event for the resolved real file after a short delay (50ms) to allow rename/replace to complete
|
||||
- Add logging around temp file detection and real-file checks to aid debugging
|
||||
|
||||
## 2025-12-11 - 6.2.2 - fix(watcher.node)
|
||||
Defer events during initial scan, track full event sequences, and harden watcher shutdown
|
||||
|
||||
- Defer fs.watch events that arrive during the initial directory scan and process them after the scan completes to avoid race conditions where watchedFiles isn't populated.
|
||||
- Debounce now tracks the full sequence of events per file (rename/change) instead of collapsing to the last event, preventing intermediate events from being lost.
|
||||
- Detect delete+recreate via inode changes and emit unlink then add when appropriate; handle rapid create+delete sequences by emitting both events.
|
||||
- During stop(), cancel pending debounced emits before flipping _isWatching and make handleFsEvent return early when watcher is stopped to prevent orphaned timeouts and post-stop emits.
|
||||
- Add verbose logging of event sequences to aid debugging of complex fs event scenarios.
|
||||
- Update tests to expect unlink + add for inode replacement scenarios.
|
||||
- Version bump from 6.2.1 → 6.2.2
|
||||
|
||||
## 2025-12-10 - 6.2.1 - fix(watcher.node)
|
||||
Handle fs.watch close without spurious restarts; add tests and improve test runner
|
||||
|
||||
- Prevent spurious restarts and noisy warnings on fs.watch 'close' by checking the internal isWatching flag before logging and restarting (ts/watchers/watcher.node.ts).
|
||||
- Add comprehensive test suites covering basic operations, inode-change detection, atomic writes and stress scenarios (test/test.basic.ts, test/test.inode.ts, test/test.stress.ts).
|
||||
- Remove outdated test (test/test.ts) and delete the test asset test/assets/hi.txt.
|
||||
- Update test script in package.json to enable verbose logging, write a logfile and increase timeout to 120s to reduce flakiness in test runs.
|
||||
|
||||
## 2025-12-10 - 6.2.0 - feat(watchers)
|
||||
Improve Node watcher robustness: file-level inode tracking, abortable restarts, restart race guards, and untracked-file handling
|
||||
|
||||
- Add file-level inode tracking to detect delete+recreate (editor atomic saves) and emit correct 'change'/'add' events
|
||||
- Make restart delays abortable via AbortController so stop() cancels pending restarts and prevents orphan watchers
|
||||
- Guard against concurrent/dual restarts with restartingPaths to avoid race conditions between health checks and error handlers
|
||||
- Emit 'unlink' for deletions of previously untracked files (files created after initial scan) and clean up inode state
|
||||
- Track file inodes during initial scan and update/cleanup inode state on events
|
||||
- Improve logging for restart/inode/delete+recreate scenarios and update documentation/readme hints to v6.2.0+
|
||||
|
||||
## 2025-12-08 - 6.1.1 - fix(watchers/watcher.node)
|
||||
Improve Node watcher robustness: inode tracking, ENOSPC detection, enhanced health checks and temp-file handling
|
||||
|
||||
- Track directory inodes (watchedInodes) and restart watchers if inode changes are detected (addresses stale watchers when directories are replaced).
|
||||
- Health check now validates inode stability and explicitly detects ENOSPC (inotify max_user_watches) errors, emitting errors and logging a recommended fix command.
|
||||
- Detect ENOSPC in FSWatcher error events and log guidance to increase inotify limits.
|
||||
- Clear inode tracking state on watcher stop to avoid stale state across restarts.
|
||||
- Improve temporary file handling and logging to avoid dropping events for atomic writes (only skip pure temp files and log skipped temp events).
|
||||
- Documentation (readme.hints.md) updated with robustness notes, known fs.watch limitations, and example logs.
|
||||
|
||||
## 2025-12-08 - 6.1.0 - feat(watcher.node)
|
||||
Add automatic restart, periodic health checks, and safe event emission to Node watcher; improve logging and stat handling
|
||||
|
||||
- NodeWatcher: introduced safeEmit() to isolate subscriber errors and prevent watcher crashes
|
||||
- Auto-restart on failure with exponential backoff (1s → 30s) and up to 3 retry attempts per watched base path
|
||||
- Periodic health checks (every 30s) to detect missing watched paths and trigger automatic restarts
|
||||
- Handle unexpected FSWatcher 'close' events and restart watchers when they close silently
|
||||
- Verbose lifecycle logging with `[smartwatch]` prefix for start/stop/health/restart events
|
||||
- Clear restart tracking and stop health checks on watcher.stop() to ensure clean shutdown
|
||||
- Improved statSafe() to normalize followSymlinks logic and log non-ENO errors as warnings
|
||||
- Updated readme.hints.md documenting the new robustness features (v6.1.0+)
|
||||
|
||||
## 2025-12-08 - 6.0.0 - BREAKING CHANGE(watchers)
|
||||
Replace polling-based write stabilization with debounce-based event coalescing and simplify watcher options
|
||||
|
||||
- Remove polling-based WriteStabilizer (ts/utils/write-stabilizer.ts) and related waitForWriteFinish logic
|
||||
- Introduce debounce-based coalescing (debounceMs) for Node and Deno watchers (ts/watchers/watcher.node.ts, ts/watchers/watcher.deno.ts)
|
||||
- Change IWatcherOptions: remove stabilityThreshold/pollInterval/maxWaitTime and add debounceMs
|
||||
- Default watcher options updated to use debounceMs = 100
|
||||
- Node/Deno watchers now skip editor temp files earlier, debounce duplicate events, and emit events directly (no size polling)
|
||||
- Updated default watcher creation in Smartwatch to pass debounceMs
|
||||
- Update package.json build script to run 'tsbuild tsfolders'
|
||||
|
||||
## 2025-12-08 - 5.1.0 - feat(watchers)
|
||||
Improve write stabilization and ignore temporary editor files
|
||||
|
||||
- Add maxWaitTime option (ms) to IWatcherOptions and WriteStabilizer to cap how long stabilization will wait (default: 1000ms).
|
||||
- WriteStabilizer: reduce default stabilityThreshold from 300ms to 100ms and track write start time to enforce maxWaitTime and avoid indefinite polling.
|
||||
- Node and Deno watchers: detect and ignore common temporary/editor files (e.g. *.tmp.*, *.swp, *.swx, trailing ~, .#*) to prevent spurious events from atomic saves.
|
||||
- Node watcher: treat rename (atomic-save) events as already-complete files and emit add/change immediately without stabilization.
|
||||
- Deno watcher: use the configured maxWaitTime and polling-based stabilization for modify events to ensure consistent behavior across runtimes.
|
||||
|
||||
## 2025-11-30 - 5.0.0 - BREAKING CHANGE(@push.rocks/smartwatch)
|
||||
Rename package and update branding/docs: switch from @push.rocks/smartchok to @push.rocks/smartwatch, update repository/homepage/bugs URLs and author, and refresh README examples and install instructions.
|
||||
|
||||
- Package name changed from @push.rocks/smartchok to @push.rocks/smartwatch in package.json
|
||||
- Repository, homepage and issue URLs updated to point to the new smartwatch repository
|
||||
- Author changed to Task Venture Capital GmbH in package metadata
|
||||
- README updated: install commands, import examples and references now use @push.rocks/smartwatch
|
||||
- Documentation text and branding updated throughout README (project name, internal references)
|
||||
|
||||
## 2025-11-30 - 4.0.1 - fix(readme)
|
||||
Update README: refine description and clarify trademark/legal information
|
||||
|
||||
- Refined project description and tagline for clarity and brevity (adds lightweight wording and an emoji).
|
||||
- Updated Trademark section to explicitly mention third-party trademarks, add guidance about usage and approval, and clarify that trademarks are not covered by the MIT license.
|
||||
- Minor legal/company wording and formatting fixes (e.g. 'District Court' capitalization and contact sentence tweaks).
|
||||
- General README wording and formatting improvements for clearer instructions and feature descriptions.
|
||||
|
||||
## 2025-11-30 - 4.0.0 - BREAKING CHANGE(watchers)
|
||||
Replace chokidar with native platform watchers and add cross-runtime support (Node.js, Deno, Bun); introduce write stabilization and internal glob matching
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
30
package.json
30
package.json
@@ -1,42 +1,44 @@
|
||||
{
|
||||
"name": "@push.rocks/smartchok",
|
||||
"version": "4.0.0",
|
||||
"name": "@push.rocks/smartwatch",
|
||||
"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",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "(npm run prepareTest && tstest test/)",
|
||||
"test": "(npm run prepareTest && tstest test/ --verbose --logfile --timeout 120)",
|
||||
"prepareTest": "(rm -f ./test/assets/hi.txt)",
|
||||
"build": "tsbuild",
|
||||
"build": "tsbuild tsfolders",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smartchok.git"
|
||||
"url": "https://code.foss.global/push.rocks/smartwatch.git"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
||||
"url": "https://gitlab.com/push.rocks/smartwatch/issues"
|
||||
},
|
||||
"homepage": "https://code.foss.global/push.rocks/smartchok",
|
||||
"homepage": "https://code.foss.global/push.rocks/smartwatch",
|
||||
"engines": {
|
||||
"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
@@ -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,60 +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
|
||||
- **Bun**: Uses Node.js compatibility layer with chokidar
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -77,10 +93,20 @@ The `WriteStabilizer` class replaces chokidar's built-in write stabilization:
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Test files:
|
||||
- **test.basic.ts** - Core functionality (add, change, unlink events)
|
||||
- **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' events for new files
|
||||
- Receiving 'add', 'change', 'unlink' events
|
||||
- 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
|
||||
- Graceful shutdown
|
||||
|
||||
## Dev Dependencies
|
||||
|
||||
161
readme.md
161
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartchok
|
||||
# @push.rocks/smartwatch
|
||||
|
||||
A cross-runtime file watcher with glob pattern support for **Node.js**, **Deno**, and **Bun**. Built with native file watching APIs for maximum performance and zero heavyweight dependencies.
|
||||
A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -9,32 +9,44 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install @push.rocks/smartchok
|
||||
npm install @push.rocks/smartwatch
|
||||
# or
|
||||
pnpm add @push.rocks/smartchok
|
||||
pnpm add @push.rocks/smartwatch
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
🌐 **Cross-Runtime** - Works on Node.js 20+, Deno, and Bun
|
||||
🔍 **Glob Pattern Support** - Watch files using familiar patterns like `**/*.ts`
|
||||
📡 **RxJS Observables** - Subscribe to file system events using reactive streams
|
||||
🔄 **Dynamic Watching** - Add or remove watch patterns at runtime
|
||||
⚡ **Native Performance** - Uses `fs.watch()` and `Deno.watchFs()` directly
|
||||
🎯 **TypeScript First** - Full TypeScript support with comprehensive type definitions
|
||||
📦 **Minimal Dependencies** - No heavyweight watcher libraries required
|
||||
- **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
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import { Smartwatch } from '@push.rocks/smartchok';
|
||||
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
|
||||
@@ -48,14 +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}`);
|
||||
},
|
||||
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
|
||||
@@ -73,15 +80,15 @@ unlinkObservable.subscribe(([path]) => {
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `add` | File has been added |
|
||||
| `addDir` | Directory has been added |
|
||||
| `change` | File has been changed |
|
||||
| `unlink` | File has been removed |
|
||||
| `unlinkDir` | Directory has been removed |
|
||||
| `error` | Error occurred |
|
||||
| `ready` | Initial scan complete |
|
||||
| Event | Description |
|
||||
|-------------|--------------------------------|
|
||||
| `add` | File has been added |
|
||||
| `addDir` | Directory has been added |
|
||||
| `change` | File has been modified |
|
||||
| `unlink` | File has been removed |
|
||||
| `unlinkDir` | Directory has been removed |
|
||||
| `error` | Error occurred |
|
||||
| `ready` | Initial scan complete |
|
||||
|
||||
### Dynamic Watch Management
|
||||
|
||||
@@ -101,48 +108,40 @@ watcher.remove('./src/**/*.test.ts');
|
||||
### Stopping the Watcher
|
||||
|
||||
```typescript
|
||||
// Stop watching when done
|
||||
await watcher.stop();
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
import { Smartwatch } from '@push.rocks/smartchok';
|
||||
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);
|
||||
});
|
||||
@@ -151,32 +150,6 @@ async function watchProject() {
|
||||
watchProject();
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
smartchok 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 |
|
||||
|
||||
Since native APIs don't support glob patterns, smartchok handles pattern matching internally using [picomatch](https://github.com/micromatch/picomatch):
|
||||
|
||||
1. **Base path extraction** - Extracts the static base path from each glob pattern
|
||||
2. **Efficient watching** - Native watchers monitor the base directories
|
||||
3. **Pattern filtering** - Events are filtered through picomatch matchers before being emitted
|
||||
|
||||
### Write Stabilization
|
||||
|
||||
smartchok 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.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js 20+** - Required for native recursive watching
|
||||
- **Deno** - Any version with `Deno.watchFs()` support
|
||||
- **Bun** - Uses Node.js compatibility
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Smartwatch`
|
||||
@@ -189,37 +162,57 @@ new Smartwatch(patterns: string[])
|
||||
|
||||
Creates a new Smartwatch instance with the given glob patterns.
|
||||
|
||||
**Parameters:**
|
||||
- `patterns` — Array of glob patterns to watch (e.g., `['./src/**/*.ts', './config/*.json']`)
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||
| `stop()` | `Promise<void>` | Stops the file watcher |
|
||||
| `add(patterns: string[])` | `void` | Adds patterns to watch |
|
||||
| `remove(pattern: string)` | `void` | Removes a pattern from watching |
|
||||
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||
| Method | Returns | Description |
|
||||
|----------------------------------------|------------------------------------------------|------------------------------------------------|
|
||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||
| `stop()` | `Promise<void>` | Stops the file watcher and cleans up resources |
|
||||
| `add(patterns: string[])` | `void` | Adds additional patterns to watch |
|
||||
| `remove(pattern: string)` | `void` | Removes a pattern from the watch list |
|
||||
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||
| Property | Type | Description |
|
||||
|----------|-------------------------------------------|--------------------------|
|
||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
type TFsEvent = 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw';
|
||||
type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
| Runtime | Version |
|
||||
|-----------------|----------------------------------------|
|
||||
| **Node.js** | 20+ |
|
||||
| **Deno** | Any version with `Deno.watchFs()` support |
|
||||
| **Bun** | Uses Node.js compatibility layer |
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This 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.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**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.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This 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.
|
||||
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By 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.
|
||||
|
||||
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 +0,0 @@
|
||||
HI
|
||||
158
test/test.basic.ts
Normal file
158
test/test.basic.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Skip in CI
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
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>(
|
||||
observable: smartrx.rxjs.Observable<T>,
|
||||
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`));
|
||||
}, timeoutMs);
|
||||
|
||||
const subscription = observable.subscribe((value) => {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to wait for a specific file's event (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 on ${expectedFile} after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const subscription = observable.subscribe((value) => {
|
||||
const [filePath] = value;
|
||||
if (filePath.includes(expectedFile)) {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
}
|
||||
// Otherwise keep waiting for the right file
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
|
||||
// ===========================================
|
||||
// BASIC TESTS
|
||||
// ===========================================
|
||||
|
||||
tap.test('should create a new instance', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([]);
|
||||
expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// 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 fs.promises.writeFile(testFile, 'test content');
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('add-test.txt');
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
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 fs.promises.writeFile(testFile, 'initial content');
|
||||
|
||||
// Wait for add event to complete
|
||||
await delay(300);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const eventPromise = waitForFileEvent(changeObservable, 'change-test.txt');
|
||||
|
||||
// Modify the file
|
||||
await fs.promises.writeFile(testFile, 'modified content');
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('change-test.txt');
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
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 fs.promises.writeFile(testFile, 'to be deleted');
|
||||
|
||||
// Wait for add event to complete
|
||||
await delay(300);
|
||||
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
|
||||
// Use file-specific wait to handle any pending unlinks from other tests
|
||||
const eventPromise = waitForFileEvent(unlinkObservable, 'unlink-test.txt');
|
||||
|
||||
// Delete the file
|
||||
await fs.promises.unlink(testFile);
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('unlink-test.txt');
|
||||
});
|
||||
|
||||
tap.test('should stop the watch process', async () => {
|
||||
await testSmartwatch.stop();
|
||||
expect(testSmartwatch.status).toEqual('idle');
|
||||
});
|
||||
|
||||
tap.test('cleanup: remove any remaining test files', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('add-') || file.startsWith('change-') || file.startsWith('unlink-')) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(TEST_DIR, file));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
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();
|
||||
149
test/test.inode.ts
Normal file
149
test/test.inode.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Skip in CI
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
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 (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 on ${expectedFile} after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const subscription = observable.subscribe((value) => {
|
||||
const [filePath] = value;
|
||||
if (filePath.includes(expectedFile)) {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
|
||||
// ===========================================
|
||||
// INODE CHANGE DETECTION TESTS
|
||||
// ===========================================
|
||||
|
||||
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 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 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}`);
|
||||
|
||||
// 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 fs.promises.writeFile(testFile, 'recreated content');
|
||||
|
||||
// Check inode changed
|
||||
const newStats = await fs.promises.stat(testFile);
|
||||
const newInode = newStats.ino;
|
||||
console.log(`[test] New inode: ${newInode}`);
|
||||
expect(newInode).not.toEqual(initialInode);
|
||||
|
||||
// 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 () => {
|
||||
// This simulates what Claude Code and many editors do:
|
||||
// 1. Write to temp file (file.txt.tmp.12345)
|
||||
// 2. Rename temp file to target file
|
||||
const testFile = path.join(TEST_DIR, 'atomic-test.txt');
|
||||
const tempFile = path.join(TEST_DIR, 'atomic-test.txt.tmp.12345');
|
||||
|
||||
// Create initial file
|
||||
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 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 fs.promises.writeFile(tempFile, 'atomic content');
|
||||
await fs.promises.rename(tempFile, testFile);
|
||||
|
||||
// Should detect the event on the target file
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('atomic-test.txt');
|
||||
expect(filePath).not.toInclude('.tmp.');
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('teardown: stop watcher', async () => {
|
||||
await testSmartwatch.stop();
|
||||
expect(testSmartwatch.status).toEqual('idle');
|
||||
});
|
||||
|
||||
tap.test('cleanup: remove test files', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('inode-') || file.startsWith('atomic-')) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(TEST_DIR, file));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
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();
|
||||
175
test/test.stress.ts
Normal file
175
test/test.stress.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Skip in CI
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const TEST_DIR = './test/assets';
|
||||
|
||||
// Helper to delay
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to collect events
|
||||
function collectEvents<T>(
|
||||
observable: smartrx.rxjs.Observable<T>,
|
||||
durationMs: number
|
||||
): Promise<T[]> {
|
||||
return new Promise((resolve) => {
|
||||
const events: T[] = [];
|
||||
const subscription = observable.subscribe((value) => {
|
||||
events.push(value);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
resolve(events);
|
||||
}, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
|
||||
// ===========================================
|
||||
// STRESS TESTS
|
||||
// ===========================================
|
||||
|
||||
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 fs.promises.writeFile(testFile, 'initial');
|
||||
await delay(200);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
|
||||
// Rapidly modify the file 20 times
|
||||
const RAPID_CHANGES = 20;
|
||||
const eventCollector = collectEvents(changeObservable, 3000);
|
||||
|
||||
for (let i = 0; i < RAPID_CHANGES; i++) {
|
||||
await fs.promises.writeFile(testFile, `content ${i}`);
|
||||
await delay(10); // 10ms between writes
|
||||
}
|
||||
|
||||
const events = await eventCollector;
|
||||
console.log(`[test] Rapid changes: sent ${RAPID_CHANGES}, received ${events.length} events`);
|
||||
|
||||
// Due to debouncing, we won't get all events, but we should get at least some
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('STRESS: many files created rapidly', async () => {
|
||||
const FILE_COUNT = 20;
|
||||
const files: string[] = [];
|
||||
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const eventCollector = collectEvents(addObservable, 5000);
|
||||
|
||||
// Create many files rapidly
|
||||
for (let i = 0; i < FILE_COUNT; i++) {
|
||||
const file = path.join(TEST_DIR, `stress-many-${i}.txt`);
|
||||
files.push(file);
|
||||
await fs.promises.writeFile(file, `content ${i}`);
|
||||
await delay(20); // 20ms between creates
|
||||
}
|
||||
|
||||
const events = await eventCollector;
|
||||
console.log(`[test] Many files: created ${FILE_COUNT}, detected ${events.length} events`);
|
||||
|
||||
// Should detect most or all files
|
||||
expect(events.length).toBeGreaterThanOrEqual(FILE_COUNT * 0.8); // Allow 20% tolerance
|
||||
|
||||
// Cleanup all files
|
||||
for (const file of files) {
|
||||
try {
|
||||
await fs.promises.unlink(file);
|
||||
} catch {
|
||||
// Ignore if already deleted
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('STRESS: interleaved add/change/delete operations', async () => {
|
||||
const testFiles = [
|
||||
path.join(TEST_DIR, 'stress-interleave-1.txt'),
|
||||
path.join(TEST_DIR, 'stress-interleave-2.txt'),
|
||||
path.join(TEST_DIR, 'stress-interleave-3.txt'),
|
||||
];
|
||||
|
||||
// Create initial files
|
||||
for (const file of testFiles) {
|
||||
await fs.promises.writeFile(file, 'initial');
|
||||
}
|
||||
await delay(300);
|
||||
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
|
||||
const addEvents = collectEvents(addObservable, 3000);
|
||||
const changeEvents = collectEvents(changeObservable, 3000);
|
||||
const unlinkEvents = collectEvents(unlinkObservable, 3000);
|
||||
|
||||
// Interleaved operations
|
||||
await fs.promises.writeFile(testFiles[0], 'changed 1'); // change
|
||||
await delay(50);
|
||||
await fs.promises.unlink(testFiles[1]); // delete
|
||||
await delay(50);
|
||||
await fs.promises.writeFile(testFiles[1], 'recreated 1'); // add (recreate)
|
||||
await delay(50);
|
||||
await fs.promises.writeFile(testFiles[2], 'changed 2'); // change
|
||||
await delay(50);
|
||||
|
||||
const [adds, changes, unlinks] = await Promise.all([addEvents, changeEvents, unlinkEvents]);
|
||||
|
||||
console.log(`[test] Interleaved: adds=${adds.length}, changes=${changes.length}, unlinks=${unlinks.length}`);
|
||||
|
||||
// Should have detected some events of each type
|
||||
expect(changes.length).toBeGreaterThan(0);
|
||||
|
||||
// Cleanup
|
||||
for (const file of testFiles) {
|
||||
try {
|
||||
await fs.promises.unlink(file);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('teardown: stop watcher', async () => {
|
||||
await testSmartwatch.stop();
|
||||
expect(testSmartwatch.status).toEqual('idle');
|
||||
});
|
||||
|
||||
tap.test('cleanup: remove stress test files', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('stress-')) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(TEST_DIR, file));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
50
test/test.ts
50
test/test.ts
@@ -1,50 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
// the module to test
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>;
|
||||
let testSubscription: smartrx.rxjs.Subscription;
|
||||
tap.test('should create a new instance', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([]);
|
||||
expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch);
|
||||
});
|
||||
|
||||
tap.test('should add some files to watch and start', async () => {
|
||||
testSmartwatch.add(['./test/**/*.txt']);
|
||||
await testSmartwatch.start()
|
||||
testSmartwatch.add(['./test/**/*.md']);
|
||||
});
|
||||
|
||||
tap.test('should get an observable for a certain event', async () => {
|
||||
await testSmartwatch.getObservableFor('add').then(async (observableArg) => {
|
||||
testAddObservable = observableArg;
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should register an add operation', async () => {
|
||||
let testDeferred = smartpromise.defer();
|
||||
testSubscription = testAddObservable.subscribe(pathArg => {
|
||||
const pathResult = pathArg[0];
|
||||
console.log(pathResult);
|
||||
testDeferred.resolve();
|
||||
});
|
||||
smartfile.memory.toFs('HI', './test/assets/hi.txt');
|
||||
await testDeferred.promise;
|
||||
});
|
||||
|
||||
tap.test('should stop the watch process', async (tools) => {
|
||||
await tools.delayFor(10000);
|
||||
testSmartwatch.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -2,7 +2,7 @@
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartchok',
|
||||
version: '4.0.0',
|
||||
name: '@push.rocks/smartwatch',
|
||||
version: '6.4.0',
|
||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||
}
|
||||
|
||||
@@ -132,8 +132,7 @@ export class Smartwatch {
|
||||
basePaths: watchPaths,
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
stabilityThreshold: 300,
|
||||
pollInterval: 100
|
||||
debounceMs: 100
|
||||
});
|
||||
|
||||
// Subscribe to watcher events and dispatch to appropriate subjects
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
interface IPendingWrite {
|
||||
lastSize: number;
|
||||
lastChange: number;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
resolve: (stats: fs.Stats) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements awaitWriteFinish functionality by polling file size until stable.
|
||||
* This replaces chokidar's built-in write stabilization.
|
||||
*/
|
||||
export class WriteStabilizer {
|
||||
private pendingWrites = new Map<string, IPendingWrite>();
|
||||
|
||||
constructor(
|
||||
private stabilityThreshold: number = 300,
|
||||
private pollInterval: number = 100
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Wait for a file write to complete by polling until size is stable
|
||||
*/
|
||||
async waitForWriteFinish(filePath: string): Promise<fs.Stats> {
|
||||
// Cancel any existing pending check for this file
|
||||
this.cancel(filePath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
|
||||
if (!pending) {
|
||||
// Was cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (stats.size !== pending.lastSize) {
|
||||
// Size changed - file is still being written, reset timer
|
||||
pending.lastSize = stats.size;
|
||||
pending.lastChange = now;
|
||||
pending.timeoutId = setTimeout(poll, this.pollInterval);
|
||||
} else if (now - pending.lastChange >= this.stabilityThreshold) {
|
||||
// Size has been stable for the threshold duration
|
||||
this.pendingWrites.delete(filePath);
|
||||
resolve(stats);
|
||||
} else {
|
||||
// Size is the same but not yet past threshold
|
||||
pending.timeoutId = setTimeout(poll, this.pollInterval);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.pendingWrites.delete(filePath);
|
||||
if (error.code === 'ENOENT') {
|
||||
// File was deleted during polling
|
||||
reject(new Error(`File was deleted: ${filePath}`));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.pendingWrites.set(filePath, {
|
||||
lastSize: -1,
|
||||
lastChange: Date.now(),
|
||||
timeoutId: setTimeout(poll, this.pollInterval),
|
||||
resolve,
|
||||
reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending write stabilization for a file
|
||||
*/
|
||||
cancel(filePath: string): void {
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingWrites.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending write stabilizations
|
||||
*/
|
||||
cancelAll(): void {
|
||||
for (const [filePath, pending] of this.pendingWrites) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
this.pendingWrites.clear();
|
||||
}
|
||||
}
|
||||
@@ -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 +37,5 @@ export const defaultWatcherOptions: IWatcherOptions = {
|
||||
basePaths: [],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
stabilityThreshold: 300,
|
||||
pollInterval: 100
|
||||
debounceMs: 100
|
||||
};
|
||||
|
||||
@@ -26,10 +26,14 @@ export interface IWatcherOptions {
|
||||
depth: number;
|
||||
/** Whether to follow symbolic links */
|
||||
followSymlinks: boolean;
|
||||
/** Stability threshold for write detection (ms) */
|
||||
stabilityThreshold: number;
|
||||
/** Poll interval for write detection (ms) */
|
||||
pollInterval: number;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,15 +65,27 @@ export class DenoWatcher implements IWatcher {
|
||||
private watcher: ReturnType<typeof Deno.watchFs> | null = null;
|
||||
private watchedFiles: Set<string> = new Set();
|
||||
private _isWatching = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private recentEvents: Map<string, number> = new Map();
|
||||
private throttleMs = 50;
|
||||
private pendingWrites: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
// Debounce: pending emits per file path
|
||||
private pendingEmits: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {}
|
||||
|
||||
/**
|
||||
* Check if a file is a temporary file created by editors
|
||||
*/
|
||||
private isTemporaryFile(filePath: string): boolean {
|
||||
const basename = filePath.split('/').pop() || '';
|
||||
// Editor temp files: *.tmp.*, *.swp, *.swx, *~, .#*
|
||||
if (basename.includes('.tmp.')) return true;
|
||||
if (basename.endsWith('.swp') || basename.endsWith('.swx')) return true;
|
||||
if (basename.endsWith('~')) return true;
|
||||
if (basename.startsWith('.#')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
@@ -84,8 +96,6 @@ export class DenoWatcher implements IWatcher {
|
||||
}
|
||||
|
||||
try {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
// Start watching all base paths
|
||||
this.watcher = Deno.watchFs(this.options.basePaths, { recursive: true });
|
||||
this._isWatching = true;
|
||||
@@ -109,11 +119,11 @@ export class DenoWatcher implements IWatcher {
|
||||
async stop(): Promise<void> {
|
||||
this._isWatching = false;
|
||||
|
||||
// Cancel all pending write stabilizations
|
||||
for (const timeout of this.pendingWrites.values()) {
|
||||
// Cancel all pending debounced emits
|
||||
for (const timeout of this.pendingEmits.values()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.pendingWrites.clear();
|
||||
this.pendingEmits.clear();
|
||||
|
||||
// Close the watcher
|
||||
if (this.watcher) {
|
||||
@@ -122,7 +132,6 @@ export class DenoWatcher implements IWatcher {
|
||||
}
|
||||
|
||||
this.watchedFiles.clear();
|
||||
this.recentEvents.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +149,7 @@ export class DenoWatcher implements IWatcher {
|
||||
}
|
||||
|
||||
for (const filePath of event.paths) {
|
||||
await this.handleDenoEvent(event.kind, filePath);
|
||||
this.handleDenoEvent(event.kind, filePath);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -151,46 +160,56 @@ export class DenoWatcher implements IWatcher {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Deno file system event
|
||||
* Handle a Deno file system event - debounce and normalize
|
||||
*/
|
||||
private async handleDenoEvent(
|
||||
private handleDenoEvent(
|
||||
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other',
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
): void {
|
||||
// Ignore 'access' events (just reading the file)
|
||||
if (kind === 'access') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle duplicate events
|
||||
if (!this.shouldEmit(filePath, kind)) {
|
||||
// Skip temporary files created by editors (atomic saves)
|
||||
if (this.isTemporaryFile(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: cancel any pending emit for this file
|
||||
const existing = this.pendingEmits.get(filePath);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
// Schedule debounced emit
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingEmits.delete(filePath);
|
||||
this.emitFileEvent(filePath, kind);
|
||||
}, this.options.debounceMs);
|
||||
|
||||
this.pendingEmits.set(filePath, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the actual file event after debounce
|
||||
*/
|
||||
private async emitFileEvent(
|
||||
filePath: string,
|
||||
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other'
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (kind === 'create') {
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (stats) {
|
||||
// Wait for write to stabilize
|
||||
await this.waitForWriteFinish(filePath);
|
||||
const finalStats = await this.statSafe(filePath);
|
||||
|
||||
if (finalStats) {
|
||||
this.watchedFiles.add(filePath);
|
||||
const eventType: TWatchEventType = finalStats.isDirectory() ? 'addDir' : 'add';
|
||||
this.events$.next({ type: eventType, path: filePath, stats: finalStats });
|
||||
}
|
||||
this.watchedFiles.add(filePath);
|
||||
const eventType: TWatchEventType = stats.isDirectory() ? 'addDir' : 'add';
|
||||
this.events$.next({ type: eventType, path: filePath, stats });
|
||||
}
|
||||
} else if (kind === 'modify') {
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (stats && !stats.isDirectory()) {
|
||||
// Wait for write to stabilize
|
||||
await this.waitForWriteFinish(filePath);
|
||||
const finalStats = await this.statSafe(filePath);
|
||||
|
||||
if (finalStats) {
|
||||
this.events$.next({ type: 'change', path: filePath, stats: finalStats });
|
||||
}
|
||||
this.events$.next({ type: 'change', path: filePath, stats });
|
||||
}
|
||||
} else if (kind === 'remove') {
|
||||
const wasDirectory = this.isKnownDirectory(filePath);
|
||||
@@ -199,49 +218,36 @@ 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for file write to complete (polling-based)
|
||||
*/
|
||||
private async waitForWriteFinish(filePath: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let lastSize = -1;
|
||||
let lastChange = Date.now();
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (!stats) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (stats.size !== lastSize) {
|
||||
lastSize = stats.size;
|
||||
lastChange = now;
|
||||
this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval));
|
||||
} else if (now - lastChange >= this.options.stabilityThreshold) {
|
||||
this.pendingWrites.delete(filePath);
|
||||
resolve();
|
||||
} else {
|
||||
this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval));
|
||||
}
|
||||
} catch {
|
||||
this.pendingWrites.delete(filePath);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory and emit 'add' events for existing files
|
||||
*/
|
||||
@@ -253,6 +259,12 @@ export class DenoWatcher implements IWatcher {
|
||||
try {
|
||||
for await (const entry of Deno.readDir(dirPath)) {
|
||||
const fullPath = `${dirPath}/${entry.name}`;
|
||||
|
||||
// Skip temp files during initial scan too
|
||||
if (this.isTemporaryFile(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = await this.statSafe(fullPath);
|
||||
|
||||
if (!stats) {
|
||||
@@ -299,31 +311,4 @@ export class DenoWatcher implements IWatcher {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle duplicate events
|
||||
*/
|
||||
private shouldEmit(filePath: string, eventType: string): boolean {
|
||||
const key = `${filePath}:${eventType}`;
|
||||
const now = Date.now();
|
||||
const lastEmit = this.recentEvents.get(key);
|
||||
|
||||
if (lastEmit && now - lastEmit < this.throttleMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.recentEvents.set(key, now);
|
||||
|
||||
// Clean up old entries periodically
|
||||
if (this.recentEvents.size > 1000) {
|
||||
const cutoff = now - this.throttleMs * 2;
|
||||
for (const [k, time] of this.recentEvents) {
|
||||
if (time < cutoff) {
|
||||
this.recentEvents.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
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';
|
||||
import { WriteStabilizer } from '../utils/write-stabilizer.js';
|
||||
import * as chokidar from 'chokidar';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent } from './interfaces.js';
|
||||
|
||||
/**
|
||||
* Node.js/Bun file watcher using native fs.watch API
|
||||
* Node.js/Bun file watcher using chokidar
|
||||
*
|
||||
* Chokidar handles all the edge cases:
|
||||
* - Atomic writes (temp file + rename)
|
||||
* - Inode tracking
|
||||
* - Cross-platform differences
|
||||
* - Debouncing
|
||||
* - Write stabilization
|
||||
*/
|
||||
export class NodeWatcher implements IWatcher {
|
||||
private watchers: Map<string, fs.FSWatcher> = new Map();
|
||||
private watchedFiles: Set<string> = new Set();
|
||||
private watcher: chokidar.FSWatcher | null = null;
|
||||
private _isWatching = false;
|
||||
private writeStabilizer: WriteStabilizer;
|
||||
private recentEvents: Map<string, number> = new Map();
|
||||
private throttleMs = 50;
|
||||
private _preExistingHandles: Set<any> = new Set();
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {
|
||||
this.writeStabilizer = new WriteStabilizer(
|
||||
options.stabilityThreshold,
|
||||
options.pollInterval
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,253 +35,93 @@ export class NodeWatcher implements IWatcher {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._isWatching) {
|
||||
return;
|
||||
}
|
||||
if (this._isWatching) return;
|
||||
|
||||
// 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 {
|
||||
// Start watching each base path
|
||||
for (const basePath of this.options.basePaths) {
|
||||
await this.watchPath(basePath, 0);
|
||||
}
|
||||
// Resolve all paths to absolute
|
||||
const absolutePaths = this.options.basePaths.map(p => path.resolve(p));
|
||||
|
||||
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;
|
||||
|
||||
// Perform initial scan to emit 'add' events for existing files
|
||||
for (const basePath of this.options.basePaths) {
|
||||
await this.scanDirectory(basePath, 0);
|
||||
}
|
||||
|
||||
// Emit ready event
|
||||
this.events$.next({ type: 'ready', path: '' });
|
||||
console.log('[smartwatch] Watcher started');
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: '', error });
|
||||
console.error('[smartwatch] Failed to start watcher:', error);
|
||||
this.safeEmit({ type: 'error', path: '', error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
console.log('[smartwatch] Stopping watcher...');
|
||||
|
||||
if (this.watcher) {
|
||||
await this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
// 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._preExistingHandles.clear();
|
||||
|
||||
this._isWatching = false;
|
||||
this.writeStabilizer.cancelAll();
|
||||
|
||||
// Close all watchers
|
||||
for (const [watchPath, watcher] of this.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers.clear();
|
||||
this.watchedFiles.clear();
|
||||
this.recentEvents.clear();
|
||||
console.log('[smartwatch] Watcher stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a path (file or directory)
|
||||
*/
|
||||
private async watchPath(watchPath: string, depth: number): Promise<void> {
|
||||
if (depth > this.options.depth) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Safely emit an event, isolating subscriber errors */
|
||||
private safeEmit(event: IWatchEvent): void {
|
||||
try {
|
||||
const stats = await this.statSafe(watchPath);
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Watch the directory with recursive option (Node.js 20+ supports this on all platforms)
|
||||
const watcher = fs.watch(
|
||||
watchPath,
|
||||
{ recursive: true, persistent: true },
|
||||
(eventType, filename) => {
|
||||
if (filename) {
|
||||
this.handleFsEvent(watchPath, filename, eventType);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watcher.on('error', (error) => {
|
||||
this.events$.next({ type: 'error', path: watchPath, error });
|
||||
});
|
||||
|
||||
this.watchers.set(watchPath, watcher);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: watchPath, error });
|
||||
this.events$.next(event);
|
||||
} catch (error) {
|
||||
console.error('[smartwatch] Subscriber threw error (isolated):', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle raw fs.watch events and normalize them
|
||||
*/
|
||||
private async handleFsEvent(
|
||||
basePath: string,
|
||||
filename: string,
|
||||
eventType: 'rename' | 'change' | string
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(basePath, filename);
|
||||
|
||||
// Throttle duplicate events
|
||||
if (!this.shouldEmit(fullPath, eventType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await this.statSafe(fullPath);
|
||||
|
||||
if (eventType === 'rename') {
|
||||
// 'rename' can mean add or unlink - check if file exists
|
||||
if (stats) {
|
||||
// File exists - it's either a new file or was renamed to this location
|
||||
if (stats.isDirectory()) {
|
||||
if (!this.watchedFiles.has(fullPath)) {
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
||||
}
|
||||
} else {
|
||||
// Wait for write to stabilize before emitting
|
||||
try {
|
||||
const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath);
|
||||
const wasWatched = this.watchedFiles.has(fullPath);
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({
|
||||
type: wasWatched ? 'change' : 'add',
|
||||
path: fullPath,
|
||||
stats: stableStats
|
||||
});
|
||||
} catch {
|
||||
// File was deleted during stabilization
|
||||
if (this.watchedFiles.has(fullPath)) {
|
||||
this.watchedFiles.delete(fullPath);
|
||||
this.events$.next({ type: 'unlink', path: fullPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist - it was deleted
|
||||
if (this.watchedFiles.has(fullPath)) {
|
||||
const wasDir = this.isKnownDirectory(fullPath);
|
||||
this.watchedFiles.delete(fullPath);
|
||||
this.events$.next({
|
||||
type: wasDir ? 'unlinkDir' : 'unlink',
|
||||
path: fullPath
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (eventType === 'change') {
|
||||
// File was modified
|
||||
if (stats && !stats.isDirectory()) {
|
||||
try {
|
||||
const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath);
|
||||
// Check if this is a new file (not yet in watchedFiles)
|
||||
const wasWatched = this.watchedFiles.has(fullPath);
|
||||
if (!wasWatched) {
|
||||
// This is actually an 'add' - file wasn't being watched before
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({ type: 'add', path: fullPath, stats: stableStats });
|
||||
} else {
|
||||
this.events$.next({ type: 'change', path: fullPath, stats: stableStats });
|
||||
}
|
||||
} catch {
|
||||
// File was deleted during write
|
||||
if (this.watchedFiles.has(fullPath)) {
|
||||
this.watchedFiles.delete(fullPath);
|
||||
this.events$.next({ type: 'unlink', path: fullPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: fullPath, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory and emit 'add' events for existing files
|
||||
*/
|
||||
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
|
||||
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);
|
||||
const stats = await this.statSafe(fullPath);
|
||||
|
||||
if (!stats) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
||||
await this.scanDirectory(fullPath, depth + 1);
|
||||
} else if (entry.isFile()) {
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({ type: 'add', path: fullPath, stats });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
|
||||
this.events$.next({ type: 'error', path: dirPath, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stat a path, returning null if it doesn't exist
|
||||
*/
|
||||
private async statSafe(filePath: string): Promise<fs.Stats | null> {
|
||||
try {
|
||||
if (this.options.followSymlinks) {
|
||||
return await fs.promises.stat(filePath);
|
||||
} else {
|
||||
return await fs.promises.lstat(filePath);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path was known to be a directory (for proper unlink event type)
|
||||
*/
|
||||
private isKnownDirectory(filePath: string): boolean {
|
||||
// Check if any watched files are children of this path
|
||||
for (const watched of this.watchedFiles) {
|
||||
if (watched.startsWith(filePath + path.sep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle duplicate events
|
||||
*/
|
||||
private shouldEmit(filePath: string, eventType: string): boolean {
|
||||
const key = `${filePath}:${eventType}`;
|
||||
const now = Date.now();
|
||||
const lastEmit = this.recentEvents.get(key);
|
||||
|
||||
if (lastEmit && now - lastEmit < this.throttleMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.recentEvents.set(key, now);
|
||||
|
||||
// Clean up old entries periodically
|
||||
if (this.recentEvents.size > 1000) {
|
||||
const cutoff = now - this.throttleMs * 2;
|
||||
for (const [k, time] of this.recentEvents) {
|
||||
if (time < cutoff) {
|
||||
this.recentEvents.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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