Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2ae297e5fa | |||
| 9130613941 | |||
| fdd6281f0b | |||
| 0f17be179c |
143
changelog.md
143
changelog.md
@@ -1,5 +1,148 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Replaced chokidar-based implementation with native file watching APIs (Node.js fs.watch, Deno.watchFs).
|
||||||
|
- Added platform-specific watchers: NodeWatcher and DenoWatcher (Bun uses Node compatibility).
|
||||||
|
- Implemented polling-based write stabilization (awaitWriteFinish replacement) to avoid duplicate events during writes.
|
||||||
|
- Keep glob pattern support by matching events internally using picomatch; base-path extraction used to limit watch scope.
|
||||||
|
- API/runtime requirement increased: Node.js >= 20.0.0 is required for native recursive fs.watch.
|
||||||
|
- Package/documentation name and examples updated to @push.rocks/smartchok and export the Smartwatch class.
|
||||||
|
|
||||||
|
## 2025-11-30 - 3.0.0 - BREAKING CHANGE(smartwatch)
|
||||||
|
Introduce Smartwatch: cross-runtime native file watching for Node.js, Deno and Bun; rename smartchok to smartwatch and bump major version to 2.0.0
|
||||||
|
|
||||||
|
- Rename public API and docs from Smartchok to Smartwatch and update package metadata for the new module name.
|
||||||
|
- Replace chokidar with native watchers and picomatch-based glob filtering to enable cross-runtime support (Node.js, Deno, Bun).
|
||||||
|
- Add watcher factory and runtime-specific implementations: watchers/index.ts, watcher.node.ts, watcher.deno.ts.
|
||||||
|
- Add WriteStabilizer (ts/utils/write-stabilizer.ts) to provide awaitWriteFinish functionality via polling.
|
||||||
|
- Introduce @push.rocks/smartenv for runtime detection and remove direct chokidar dependency; update dependencies accordingly.
|
||||||
|
- Update tests (test/test.ts) and documentation (readme.md, readme.hints.md) to reflect API/name changes and new architecture.
|
||||||
|
- Bump package version to 2.0.0 to mark breaking changes in API and behavior.
|
||||||
|
|
||||||
## 2025-11-29 - 1.2.0 - feat(core)
|
## 2025-11-29 - 1.2.0 - feat(core)
|
||||||
Migrate to chokidar 5.x, add picomatch filtering and update test/dev dependencies
|
Migrate to chokidar 5.x, add picomatch filtering and update test/dev dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "push.rocks",
|
"gitscope": "push.rocks",
|
||||||
"gitrepo": "smartchok",
|
"gitrepo": "smartwatch",
|
||||||
"description": "A smart wrapper for chokidar to facilitate file watching with enhanced features.",
|
"description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced file watching features.",
|
||||||
"npmPackagename": "@push.rocks/smartchokidar",
|
"npmPackagename": "@push.rocks/smartwatch",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"file watching",
|
"file watching",
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -1,28 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartchok",
|
"name": "@push.rocks/smartwatch",
|
||||||
"version": "1.2.0",
|
"version": "6.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced features.",
|
"description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(npm run prepareTest && tstest test/)",
|
"test": "(npm run prepareTest && tstest test/ --verbose --logfile --timeout 120)",
|
||||||
"prepareTest": "(rm -f ./test/assets/hi.txt)",
|
"prepareTest": "(rm -f ./test/assets/hi.txt)",
|
||||||
"build": "tsbuild",
|
"build": "tsbuild tsfolders",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"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",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
"url": "https://gitlab.com/push.rocks/smartwatch/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://code.foss.global/push.rocks/smartwatch",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartchok",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
|
"@push.rocks/smartenv": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
@@ -53,15 +57,18 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"file watching",
|
"file watching",
|
||||||
"chokidar",
|
|
||||||
"filesystem",
|
"filesystem",
|
||||||
"observable",
|
"observable",
|
||||||
"typescript",
|
"typescript",
|
||||||
"node.js",
|
"node.js",
|
||||||
|
"deno",
|
||||||
|
"bun",
|
||||||
|
"cross-runtime",
|
||||||
"development tool",
|
"development tool",
|
||||||
"file system events",
|
"file system events",
|
||||||
"real-time",
|
"real-time",
|
||||||
"watch files"
|
"watch files",
|
||||||
|
"glob patterns"
|
||||||
],
|
],
|
||||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||||
}
|
}
|
||||||
|
|||||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@push.rocks/lik':
|
'@push.rocks/lik':
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
|
'@push.rocks/smartenv':
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
'@push.rocks/smartpromise':
|
'@push.rocks/smartpromise':
|
||||||
specifier: ^4.2.3
|
specifier: ^4.2.3
|
||||||
version: 4.2.3
|
version: 4.2.3
|
||||||
@@ -898,9 +901,6 @@ packages:
|
|||||||
'@push.rocks/smartdns@7.6.1':
|
'@push.rocks/smartdns@7.6.1':
|
||||||
resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==}
|
resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==}
|
||||||
|
|
||||||
'@push.rocks/smartenv@5.0.12':
|
|
||||||
resolution: {integrity: sha512-tDEFwywzq0FNzRYc9qY2dRl2pgQuZG0G2/yml2RLWZWSW+Fn1EHshnKOGHz8o77W7zvu4hTgQQX42r/JY5XHTg==}
|
|
||||||
|
|
||||||
'@push.rocks/smartenv@5.0.13':
|
'@push.rocks/smartenv@5.0.13':
|
||||||
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
|
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
|
||||||
|
|
||||||
@@ -5784,7 +5784,7 @@ snapshots:
|
|||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartbucket': 3.3.7
|
'@push.rocks/smartbucket': 3.3.7
|
||||||
'@push.rocks/smartcache': 1.0.16
|
'@push.rocks/smartcache': 1.0.16
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
'@push.rocks/smartexit': 1.0.23
|
'@push.rocks/smartexit': 1.0.23
|
||||||
'@push.rocks/smartfile': 11.2.7
|
'@push.rocks/smartfile': 11.2.7
|
||||||
'@push.rocks/smartjson': 5.0.20
|
'@push.rocks/smartjson': 5.0.20
|
||||||
@@ -5977,10 +5977,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@push.rocks/smartenv@5.0.12':
|
|
||||||
dependencies:
|
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
|
||||||
|
|
||||||
'@push.rocks/smartenv@5.0.13':
|
'@push.rocks/smartenv@5.0.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
@@ -6074,7 +6070,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smarthash@3.2.0':
|
'@push.rocks/smarthash@3.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
'@push.rocks/smartjson': 5.0.20
|
'@push.rocks/smartjson': 5.0.20
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@types/through2': 2.0.41
|
'@types/through2': 2.0.41
|
||||||
@@ -6090,7 +6086,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smartjson@5.0.20':
|
'@push.rocks/smartjson@5.0.20':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
'@push.rocks/smartstring': 4.0.15
|
'@push.rocks/smartstring': 4.0.15
|
||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
lodash.clonedeep: 4.5.0
|
lodash.clonedeep: 4.5.0
|
||||||
@@ -6398,14 +6394,14 @@ snapshots:
|
|||||||
'@push.rocks/smartstream@3.2.5':
|
'@push.rocks/smartstream@3.2.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartrx': 3.0.10
|
'@push.rocks/smartrx': 3.0.10
|
||||||
|
|
||||||
'@push.rocks/smartstring@4.0.15':
|
'@push.rocks/smartstring@4.0.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/isounique': 1.0.5
|
'@push.rocks/isounique': 1.0.5
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
'@types/randomatic': 3.1.5
|
'@types/randomatic': 3.1.5
|
||||||
crypto-random-string: 5.0.0
|
crypto-random-string: 5.0.0
|
||||||
js-base64: 3.7.7
|
js-base64: 3.7.7
|
||||||
@@ -6483,7 +6479,7 @@ snapshots:
|
|||||||
'@push.rocks/webrequest@3.0.37':
|
'@push.rocks/webrequest@3.0.37':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
'@push.rocks/smartjson': 5.0.20
|
'@push.rocks/smartjson': 5.0.20
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/webstore': 2.0.20
|
'@push.rocks/webstore': 2.0.20
|
||||||
@@ -6515,7 +6511,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/webstream@1.0.10':
|
'@push.rocks/webstream@1.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartenv': 5.0.12
|
'@push.rocks/smartenv': 5.0.13
|
||||||
|
|
||||||
'@pushrocks/isounique@1.0.5': {}
|
'@pushrocks/isounique@1.0.5': {}
|
||||||
|
|
||||||
|
|||||||
126
readme.hints.md
126
readme.hints.md
@@ -1,33 +1,115 @@
|
|||||||
# smartchok - Technical Hints
|
# smartwatch - Technical Hints
|
||||||
|
|
||||||
## Chokidar 5.x Migration (2024)
|
## Native File Watching (v6.0.0+)
|
||||||
|
|
||||||
The module has been migrated to `chokidar` 5.x (from 4.x). Key changes:
|
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:
|
||||||
|
```typescript
|
||||||
|
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ts/
|
||||||
|
├── smartwatch.classes.smartwatch.ts # Main Smartwatch class
|
||||||
|
├── smartwatch.plugins.ts # Dependencies
|
||||||
|
├── watchers/
|
||||||
|
│ ├── index.ts # Factory with runtime detection
|
||||||
|
│ ├── interfaces.ts # IWatcher interface and types
|
||||||
|
│ ├── 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` for runtime detection:
|
||||||
|
- **Node.js/Bun**: Uses chokidar (battle-tested file watcher)
|
||||||
|
- **Deno**: Uses `Deno.watchFs()` async iterable
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
- **Current**: `chokidar` 5.x and `picomatch`
|
|
||||||
- **Historical**: Was previously using `@tempfix/watcher` before chokidar 4.x
|
|
||||||
|
|
||||||
### Why picomatch?
|
- **chokidar**: Battle-tested file watcher for Node.js/Bun
|
||||||
Chokidar 4.x+ removed built-in glob pattern support. We use `picomatch` to maintain backward compatibility and provide glob pattern matching functionality.
|
- **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
|
||||||
|
|
||||||
### Implementation Details
|
### Chokidar Features (Node.js/Bun)
|
||||||
1. **Glob pattern extraction**: The `getGlobBase()` method extracts base directories from glob patterns
|
|
||||||
2. **Pattern matching**: Each glob pattern is compiled to a picomatch matcher function
|
The Node.js watcher (`ts/watchers/watcher.node.ts`) is a thin ~100 line wrapper around chokidar v5:
|
||||||
3. **Event filtering**: File system events are filtered based on glob patterns before being emitted
|
|
||||||
4. **Path normalization**: Paths are normalized to handle different formats (with/without leading ./)
|
```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
|
### Event Handling
|
||||||
Chokidar 5.x events are mapped 1:1 with smartchok events:
|
|
||||||
- `add`, `change`, `unlink`: File events
|
Events are normalized across all runtimes:
|
||||||
- `addDir`, `unlinkDir`: Directory events
|
|
||||||
- `error`: Error events
|
| Event | Description |
|
||||||
- `raw`: Raw events from underlying watchers
|
|-------|-------------|
|
||||||
- `ready`: Emitted when initial scan is complete
|
| `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 chokidar v5
|
||||||
|
- **Deno**: Works on all versions with `Deno.watchFs()`
|
||||||
|
- **Bun**: Uses Node.js compatibility layer with chokidar
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
All existing tests pass without modification, confirming backward compatibility is maintained.
|
|
||||||
|
|
||||||
## Dev Dependencies (2024)
|
```bash
|
||||||
- Using `@git.zone/tstest` v3.x with tapbundle (`import { tap, expect } from '@git.zone/tstest/tapbundle'`)
|
pnpm test
|
||||||
- Removed deprecated `@push.rocks/tapbundle`
|
```
|
||||||
|
|
||||||
|
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', '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
|
||||||
|
|
||||||
|
- Using `@git.zone/tstest` v3.x with tapbundle
|
||||||
|
- Import from `@git.zone/tstest/tapbundle`
|
||||||
|
|||||||
122
readme.md
122
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartchok
|
# @push.rocks/smartwatch
|
||||||
|
|
||||||
A smart wrapper for chokidar 5.x with glob pattern support, RxJS observable integration, and enhanced file watching features.
|
A lightweight, cross-runtime file watcher with glob pattern support for **Node.js**, **Deno**, and **Bun**. Zero heavyweight dependencies — just native file watching APIs for maximum performance. 🚀
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -9,28 +9,31 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install @push.rocks/smartchok
|
npm install @push.rocks/smartwatch
|
||||||
# or
|
# or
|
||||||
pnpm add @push.rocks/smartchok
|
pnpm add @push.rocks/smartwatch
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
🔍 **Glob Pattern Support** - Watch files using glob patterns like `**/*.ts` or `src/**/*.js`
|
🌐 **Cross-Runtime** — Works seamlessly on Node.js 20+, Deno, and Bun
|
||||||
📡 **RxJS Observables** - Subscribe to file system events using reactive streams
|
🔍 **Glob Pattern Support** — Watch files using familiar patterns like `**/*.ts` and `src/**/*.{js,jsx}`
|
||||||
🔄 **Dynamic Watching** - Add or remove watch patterns at runtime
|
📡 **RxJS Observables** — Subscribe to file system events using reactive streams
|
||||||
⚡ **Chokidar 5.x** - Built on the latest chokidar with improved performance
|
🔄 **Dynamic Watching** — Add or remove watch patterns at runtime
|
||||||
🎯 **TypeScript First** - Full TypeScript support with comprehensive type definitions
|
⚡ **Native Performance** — Uses `fs.watch()` on Node.js/Bun and `Deno.watchFs()` on Deno
|
||||||
|
✨ **Write Stabilization** — Built-in debouncing prevents duplicate events during file writes
|
||||||
|
🎯 **TypeScript First** — Full TypeScript support with comprehensive type definitions
|
||||||
|
📦 **Minimal Footprint** — No chokidar, no FSEvents bindings — just ~500 lines of focused code
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic Setup
|
### Basic Setup
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Smartchok } from '@push.rocks/smartchok';
|
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||||
|
|
||||||
// Create a watcher with glob patterns
|
// Create a watcher with glob patterns
|
||||||
const watcher = new Smartchok([
|
const watcher = new Smartwatch([
|
||||||
'./src/**/*.ts', // Watch all TypeScript files in src
|
'./src/**/*.ts', // Watch all TypeScript files in src
|
||||||
'./public/assets/**/*' // Watch all files in public/assets
|
'./public/assets/**/*' // Watch all files in public/assets
|
||||||
]);
|
]);
|
||||||
@@ -50,6 +53,7 @@ const changeObservable = await watcher.getObservableFor('change');
|
|||||||
changeObservable.subscribe({
|
changeObservable.subscribe({
|
||||||
next: ([path, stats]) => {
|
next: ([path, stats]) => {
|
||||||
console.log(`File changed: ${path}`);
|
console.log(`File changed: ${path}`);
|
||||||
|
console.log(`New size: ${stats?.size} bytes`);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error(`Error: ${err}`);
|
console.error(`Error: ${err}`);
|
||||||
@@ -72,22 +76,21 @@ unlinkObservable.subscribe(([path]) => {
|
|||||||
### Supported Events
|
### Supported Events
|
||||||
|
|
||||||
| Event | Description |
|
| Event | Description |
|
||||||
|-------|-------------|
|
|-------------|--------------------------------|
|
||||||
| `add` | File has been added |
|
| `add` | File has been added |
|
||||||
| `addDir` | Directory has been added |
|
| `addDir` | Directory has been added |
|
||||||
| `change` | File has been changed |
|
| `change` | File has been modified |
|
||||||
| `unlink` | File has been removed |
|
| `unlink` | File has been removed |
|
||||||
| `unlinkDir` | Directory has been removed |
|
| `unlinkDir` | Directory has been removed |
|
||||||
| `error` | Error occurred |
|
| `error` | Error occurred |
|
||||||
| `ready` | Initial scan complete |
|
| `ready` | Initial scan complete |
|
||||||
| `raw` | Raw event from the underlying watcher |
|
|
||||||
|
|
||||||
### Dynamic Watch Management
|
### Dynamic Watch Management
|
||||||
|
|
||||||
Add or remove patterns while the watcher is running:
|
Add or remove patterns while the watcher is running:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const watcher = new Smartchok(['./src/**/*.ts']);
|
const watcher = new Smartwatch(['./src/**/*.ts']);
|
||||||
await watcher.start();
|
await watcher.start();
|
||||||
|
|
||||||
// Add more patterns to watch
|
// Add more patterns to watch
|
||||||
@@ -107,11 +110,11 @@ await watcher.stop();
|
|||||||
### Complete Example
|
### Complete Example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Smartchok } from '@push.rocks/smartchok';
|
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||||
|
|
||||||
async function watchProject() {
|
async function watchProject() {
|
||||||
// Initialize with patterns
|
// Initialize with patterns
|
||||||
const watcher = new Smartchok([
|
const watcher = new Smartwatch([
|
||||||
'./src/**/*.ts',
|
'./src/**/*.ts',
|
||||||
'./package.json'
|
'./package.json'
|
||||||
]);
|
]);
|
||||||
@@ -152,56 +155,107 @@ watchProject();
|
|||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
Since chokidar 4.x+ no longer supports glob patterns natively, smartchok handles glob pattern matching internally using [picomatch](https://github.com/micromatch/picomatch). This means you get the familiar glob syntax while benefiting from chokidar's efficient file watching capabilities.
|
smartwatch uses native file watching APIs for each runtime:
|
||||||
|
|
||||||
When you provide glob patterns:
|
| Runtime | API Used |
|
||||||
1. **Base path extraction** - smartchok extracts the static base path from each pattern
|
|-----------------|----------------------------------|
|
||||||
2. **Efficient watching** - chokidar watches the base directories
|
| **Node.js 20+** | `fs.watch({ recursive: true })` |
|
||||||
3. **Pattern filtering** - Events are filtered through picomatch matchers before being emitted
|
| **Deno** | `Deno.watchFs()` |
|
||||||
|
| **Bun** | Node.js compatibility layer |
|
||||||
|
|
||||||
|
### Under the Hood
|
||||||
|
|
||||||
|
Native file watching APIs don't support glob patterns directly, so smartwatch handles pattern matching internally:
|
||||||
|
|
||||||
|
1. **Base path extraction** — Extracts the static portion from each glob pattern (e.g., `./src/` from `./src/**/*.ts`)
|
||||||
|
2. **Efficient watching** — Native watchers monitor only the base directories
|
||||||
|
3. **Pattern filtering** — Events are filtered through [picomatch](https://github.com/micromatch/picomatch) matchers before emission
|
||||||
|
4. **Event deduplication** — Built-in throttling prevents duplicate events from rapid file operations
|
||||||
|
|
||||||
|
### Write Stabilization
|
||||||
|
|
||||||
|
smartwatch includes built-in write stabilization (similar to chokidar's `awaitWriteFinish`). When a file is being written, events are held until the file size stabilizes, preventing multiple events for a single write operation.
|
||||||
|
|
||||||
|
Default settings:
|
||||||
|
- **Stability threshold**: 300ms
|
||||||
|
- **Poll interval**: 100ms
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Runtime | Version |
|
||||||
|
|-----------------|----------------------------------------|
|
||||||
|
| **Node.js** | 20+ (required for native recursive watching) |
|
||||||
|
| **Deno** | Any version with `Deno.watchFs()` support |
|
||||||
|
| **Bun** | Uses Node.js compatibility |
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### `Smartchok`
|
### `Smartwatch`
|
||||||
|
|
||||||
#### Constructor
|
#### Constructor
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
new Smartchok(patterns: string[])
|
new Smartwatch(patterns: string[])
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates a new Smartchok instance with the given glob patterns.
|
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
|
#### Methods
|
||||||
|
|
||||||
| Method | Returns | Description |
|
| Method | Returns | Description |
|
||||||
|--------|---------|-------------|
|
|----------------------------------------|------------------------------------------------|------------------------------------------------|
|
||||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||||
| `stop()` | `Promise<void>` | Stops the file watcher |
|
| `stop()` | `Promise<void>` | Stops the file watcher and cleans up resources |
|
||||||
| `add(patterns: string[])` | `void` | Adds patterns to watch |
|
| `add(patterns: string[])` | `void` | Adds additional patterns to watch |
|
||||||
| `remove(pattern: string)` | `void` | Removes a pattern from watching |
|
| `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 |
|
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||||
|
|
||||||
#### Properties
|
#### Properties
|
||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|-------------------------------------------|--------------------------|
|
||||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type TFsEvent = 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw';
|
||||||
|
type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why smartwatch?
|
||||||
|
|
||||||
|
| Feature | smartwatch | chokidar |
|
||||||
|
|-------------------------|----------------------|--------------------|
|
||||||
|
| Native API | ✅ Direct `fs.watch` | ❌ FSEvents bindings |
|
||||||
|
| Cross-runtime | ✅ Node, Deno, Bun | ❌ Node only |
|
||||||
|
| Dependencies | 4 small packages | ~20 packages |
|
||||||
|
| Write stabilization | ✅ Built-in | ✅ Built-in |
|
||||||
|
| Glob support | ✅ picomatch | ✅ anymatch |
|
||||||
|
| Bundle size | ~15KB | ~200KB+ |
|
||||||
|
|
||||||
|
If you need a lightweight file watcher without native compilation headaches, smartwatch has you covered.
|
||||||
|
|
||||||
## License and Legal Information
|
## 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.
|
**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
|
### 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
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
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.
|
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.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
HI
|
|
||||||
159
test/test.basic.ts
Normal file
159
test/test.basic.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartwatch from '../ts/index.js';
|
||||||
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
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 smartfile.memory.toFs('test content', testFile);
|
||||||
|
|
||||||
|
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 smartfile.memory.toFs('initial content', testFile);
|
||||||
|
|
||||||
|
// 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 smartfile.memory.toFs('modified content', testFile);
|
||||||
|
|
||||||
|
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 smartfile.memory.toFs('to be deleted', testFile);
|
||||||
|
|
||||||
|
// 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();
|
||||||
143
test/test.inode.ts
Normal file
143
test/test.inode.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartwatch from '../ts/index.js';
|
||||||
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
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 smartfile.memory.toFs('initial content', testFile);
|
||||||
|
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 smartfile.memory.toFs('recreated content', testFile);
|
||||||
|
|
||||||
|
// 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 smartfile.memory.toFs('initial content', testFile);
|
||||||
|
await delay(300);
|
||||||
|
|
||||||
|
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||||
|
const eventPromise = waitForFileEvent(changeObservable, 'atomic-test.txt', 3000);
|
||||||
|
|
||||||
|
// Atomic write: create temp file then rename
|
||||||
|
await smartfile.memory.toFs('atomic content', tempFile);
|
||||||
|
await fs.promises.rename(tempFile, testFile);
|
||||||
|
|
||||||
|
// Should detect the change to 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();
|
||||||
176
test/test.stress.ts
Normal file
176
test/test.stress.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartwatch from '../ts/index.js';
|
||||||
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
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 smartfile.memory.toFs('initial', testFile);
|
||||||
|
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 smartfile.memory.toFs(`content ${i}`, testFile);
|
||||||
|
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 smartfile.memory.toFs(`content ${i}`, file);
|
||||||
|
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 smartfile.memory.toFs('initial', file);
|
||||||
|
}
|
||||||
|
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 smartfile.memory.toFs('changed 1', testFiles[0]); // change
|
||||||
|
await delay(50);
|
||||||
|
await fs.promises.unlink(testFiles[1]); // delete
|
||||||
|
await delay(50);
|
||||||
|
await smartfile.memory.toFs('recreated 1', testFiles[1]); // add (recreate)
|
||||||
|
await delay(50);
|
||||||
|
await smartfile.memory.toFs('changed 2', testFiles[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 smartchok 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 testSmartchok: smartchok.Smartchok;
|
|
||||||
let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>;
|
|
||||||
let testSubscription: smartrx.rxjs.Subscription;
|
|
||||||
tap.test('should create a new instance', async () => {
|
|
||||||
testSmartchok = new smartchok.Smartchok([]);
|
|
||||||
expect(testSmartchok).toBeInstanceOf(smartchok.Smartchok);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should add some files to watch and start', async () => {
|
|
||||||
testSmartchok.add(['./test/**/*.txt']);
|
|
||||||
await testSmartchok.start()
|
|
||||||
testSmartchok.add(['./test/**/*.md']);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should get an observable for a certain event', async () => {
|
|
||||||
await testSmartchok.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);
|
|
||||||
testSmartchok.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartchok',
|
name: '@push.rocks/smartwatch',
|
||||||
version: '1.2.0',
|
version: '6.3.0',
|
||||||
description: 'A smart wrapper for chokidar 5.x with glob pattern support and enhanced features.'
|
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './smartchok.classes.smartchok.js';
|
export * from './smartwatch.classes.smartwatch.js';
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as plugins from './smartchok.plugins.js';
|
import * as plugins from './smartwatch.plugins.js';
|
||||||
import { Stringmap } from '@push.rocks/lik';
|
import { Stringmap } from '@push.rocks/lik';
|
||||||
|
import { createWatcher, type IWatcher, type IWatchEvent, type TWatchEventType } from './watchers/index.js';
|
||||||
|
|
||||||
export type TSmartchokStatus = 'idle' | 'starting' | 'watching';
|
export type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||||
export type TFsEvent =
|
export type TFsEvent =
|
||||||
| 'add'
|
| 'add'
|
||||||
| 'addDir'
|
| 'addDir'
|
||||||
@@ -13,22 +14,31 @@ export type TFsEvent =
|
|||||||
| 'raw';
|
| 'raw';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smartchok allows easy wathcing of files
|
* Smartwatch allows easy watching of files
|
||||||
|
* Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support
|
||||||
*/
|
*/
|
||||||
export class Smartchok {
|
export class Smartwatch {
|
||||||
public watchStringmap = new Stringmap();
|
public watchStringmap = new Stringmap();
|
||||||
public status: TSmartchokStatus = 'idle';
|
public status: TSmartwatchStatus = 'idle';
|
||||||
private watcher: plugins.chokidar.FSWatcher;
|
private watcher: IWatcher | null = null;
|
||||||
private globPatterns: string[] = [];
|
private globPatterns: string[] = [];
|
||||||
private globMatchers: Map<string, (path: string) => boolean> = new Map();
|
private globMatchers: Map<string, (path: string) => boolean> = new Map();
|
||||||
private watchingDeferred = plugins.smartpromise.defer<void>(); // used to run things when watcher is initialized
|
private watchingDeferred = plugins.smartpromise.defer<void>();
|
||||||
private eventObservablemap = new plugins.smartrx.Observablemap(); // register one observable per event
|
|
||||||
|
// Event subjects for each event type
|
||||||
|
private eventSubjects: Map<TFsEvent, plugins.smartrx.rxjs.Subject<[string, plugins.fs.Stats | undefined]>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* constructor of class smartchok
|
* constructor of class Smartwatch
|
||||||
*/
|
*/
|
||||||
constructor(watchArrayArg: string[]) {
|
constructor(watchArrayArg: string[]) {
|
||||||
this.watchStringmap.addStringArray(watchArrayArg);
|
this.watchStringmap.addStringArray(watchArrayArg);
|
||||||
|
|
||||||
|
// Initialize subjects for each event type
|
||||||
|
const eventTypes: TFsEvent[] = ['add', 'addDir', 'change', 'error', 'unlink', 'unlinkDir', 'ready', 'raw'];
|
||||||
|
for (const eventType of eventTypes) {
|
||||||
|
this.eventSubjects.set(eventType, new plugins.smartrx.rxjs.Subject());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getGlobBase(globPattern: string) {
|
private getGlobBase(globPattern: string) {
|
||||||
@@ -80,11 +90,12 @@ export class Smartchok {
|
|||||||
): Promise<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
|
): Promise<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
|
||||||
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
|
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
|
||||||
this.watchingDeferred.promise.then(() => {
|
this.watchingDeferred.promise.then(() => {
|
||||||
const eventObservable = this.eventObservablemap.getSubjectForEmitterEvent(
|
const subject = this.eventSubjects.get(fsEvent);
|
||||||
this.watcher,
|
if (subject) {
|
||||||
fsEvent
|
done.resolve(subject.asObservable());
|
||||||
);
|
} else {
|
||||||
done.resolve(eventObservable);
|
done.reject(new Error(`Unknown event type: ${fsEvent}`));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return done.promise;
|
return done.promise;
|
||||||
}
|
}
|
||||||
@@ -93,8 +104,7 @@ export class Smartchok {
|
|||||||
* starts the watcher
|
* starts the watcher
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
*/
|
*/
|
||||||
public start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
const done = plugins.smartpromise.defer<void>();
|
|
||||||
this.status = 'starting';
|
this.status = 'starting';
|
||||||
|
|
||||||
// Store original glob patterns and create matchers
|
// Store original glob patterns and create matchers
|
||||||
@@ -113,61 +123,61 @@ export class Smartchok {
|
|||||||
this.globMatchers.set(pattern, matcher);
|
this.globMatchers.set(pattern, matcher);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert Set to Array for chokidar
|
// Convert Set to Array for the watcher
|
||||||
const watchPaths = Array.from(basePaths);
|
const watchPaths = Array.from(basePaths);
|
||||||
console.log('Base paths to watch:', watchPaths);
|
console.log('Base paths to watch:', watchPaths);
|
||||||
|
|
||||||
this.watcher = plugins.chokidar.watch(watchPaths, {
|
// Create the platform-appropriate watcher
|
||||||
persistent: true,
|
this.watcher = await createWatcher({
|
||||||
ignoreInitial: false,
|
basePaths: watchPaths,
|
||||||
followSymlinks: false,
|
|
||||||
depth: 4,
|
depth: 4,
|
||||||
awaitWriteFinish: {
|
followSymlinks: false,
|
||||||
stabilityThreshold: 300,
|
debounceMs: 100
|
||||||
pollInterval: 100
|
|
||||||
},
|
|
||||||
ignored: (path: string, stats?: plugins.fs.Stats) => {
|
|
||||||
// Don't filter during initialization - let chokidar watch everything
|
|
||||||
// We'll filter events as they come in
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up event handlers with glob filtering
|
// Subscribe to watcher events and dispatch to appropriate subjects
|
||||||
const fileEvents: Array<'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'> =
|
this.watcher.events$.subscribe((event: IWatchEvent) => {
|
||||||
['add', 'addDir', 'change', 'unlink', 'unlinkDir'];
|
this.handleWatchEvent(event);
|
||||||
|
|
||||||
// Handle file events
|
|
||||||
fileEvents.forEach(eventName => {
|
|
||||||
this.watcher.on(eventName, (path: string, stats?: plugins.fs.Stats) => {
|
|
||||||
// Only emit event if the path matches our glob patterns
|
|
||||||
if (this.shouldWatchPath(path)) {
|
|
||||||
this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, eventName)
|
|
||||||
.next([path, stats]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle error events
|
// Start the watcher
|
||||||
this.watcher.on('error', (error: Error) => {
|
await this.watcher.start();
|
||||||
this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, 'error')
|
|
||||||
.next([error, undefined]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle raw events
|
|
||||||
this.watcher.on('raw', (eventType: string, path: string, details: any) => {
|
|
||||||
if (this.shouldWatchPath(path)) {
|
|
||||||
this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, 'raw')
|
|
||||||
.next([path, undefined]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.watcher.on('ready', () => {
|
|
||||||
this.status = 'watching';
|
this.status = 'watching';
|
||||||
this.watchingDeferred.resolve();
|
this.watchingDeferred.resolve();
|
||||||
done.resolve();
|
}
|
||||||
});
|
|
||||||
return done.promise;
|
/**
|
||||||
|
* Handle events from the native watcher
|
||||||
|
*/
|
||||||
|
private handleWatchEvent(event: IWatchEvent): void {
|
||||||
|
// Handle ready event
|
||||||
|
if (event.type === 'ready') {
|
||||||
|
const subject = this.eventSubjects.get('ready');
|
||||||
|
if (subject) {
|
||||||
|
subject.next(['', undefined]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error event
|
||||||
|
if (event.type === 'error') {
|
||||||
|
const subject = this.eventSubjects.get('error');
|
||||||
|
if (subject) {
|
||||||
|
subject.next([event.error?.message || 'Unknown error', undefined]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter file/directory events by glob patterns
|
||||||
|
if (!this.shouldWatchPath(event.path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = this.eventSubjects.get(event.type as TFsEvent);
|
||||||
|
if (subject) {
|
||||||
|
subject.next([event.path, event.stats]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,8 +185,12 @@ export class Smartchok {
|
|||||||
*/
|
*/
|
||||||
public async stop() {
|
public async stop() {
|
||||||
const closeWatcher = async () => {
|
const closeWatcher = async () => {
|
||||||
await this.watcher.close();
|
if (this.watcher) {
|
||||||
|
await this.watcher.stop();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.status === 'watching') {
|
if (this.status === 'watching') {
|
||||||
console.log('closing while watching');
|
console.log('closing while watching');
|
||||||
await closeWatcher();
|
await closeWatcher();
|
||||||
@@ -184,6 +198,8 @@ export class Smartchok {
|
|||||||
await this.watchingDeferred.promise;
|
await this.watchingDeferred.promise;
|
||||||
await closeWatcher();
|
await closeWatcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.status = 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,18 +11,18 @@ export {
|
|||||||
import * as lik from '@push.rocks/lik';
|
import * as lik from '@push.rocks/lik';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import { Smartenv } from '@push.rocks/smartenv';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
lik,
|
lik,
|
||||||
smartpromise,
|
smartpromise,
|
||||||
smartrx
|
smartrx,
|
||||||
|
Smartenv
|
||||||
}
|
}
|
||||||
|
|
||||||
// thirdparty scope
|
// thirdparty scope
|
||||||
import * as chokidar from 'chokidar';
|
|
||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
chokidar,
|
|
||||||
picomatch,
|
picomatch,
|
||||||
}
|
}
|
||||||
32
ts/watchers/index.ts
Normal file
32
ts/watchers/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Smartenv } from '@push.rocks/smartenv';
|
||||||
|
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||||
|
|
||||||
|
export type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a platform-appropriate file watcher based on the current runtime
|
||||||
|
* Uses @push.rocks/smartenv for runtime detection
|
||||||
|
*/
|
||||||
|
export async function createWatcher(options: IWatcherOptions): Promise<IWatcher> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default watcher options
|
||||||
|
*/
|
||||||
|
export const defaultWatcherOptions: IWatcherOptions = {
|
||||||
|
basePaths: [],
|
||||||
|
depth: 4,
|
||||||
|
followSymlinks: false,
|
||||||
|
debounceMs: 100
|
||||||
|
};
|
||||||
51
ts/watchers/interfaces.ts
Normal file
51
ts/watchers/interfaces.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type * as fs from 'fs';
|
||||||
|
import type * as smartrx from '@push.rocks/smartrx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File system event types that the watcher emits
|
||||||
|
*/
|
||||||
|
export type TWatchEventType = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' | 'ready' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data structure for watch events
|
||||||
|
*/
|
||||||
|
export interface IWatchEvent {
|
||||||
|
type: TWatchEventType;
|
||||||
|
path: string;
|
||||||
|
stats?: fs.Stats;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a watcher
|
||||||
|
*/
|
||||||
|
export interface IWatcherOptions {
|
||||||
|
/** Base paths to watch (extracted from glob patterns) */
|
||||||
|
basePaths: string[];
|
||||||
|
/** Maximum directory depth to watch */
|
||||||
|
depth: number;
|
||||||
|
/** Whether to follow symbolic links */
|
||||||
|
followSymlinks: boolean;
|
||||||
|
/** Debounce time in ms - events for the same file within this window are coalesced */
|
||||||
|
debounceMs: number;
|
||||||
|
/** Whether to wait for writes to stabilize before emitting events */
|
||||||
|
awaitWriteFinish?: boolean;
|
||||||
|
/** How long file size must remain constant before emitting event (ms) */
|
||||||
|
stabilityThreshold?: number;
|
||||||
|
/** How often to poll file size during write detection (ms) */
|
||||||
|
pollInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common interface for file watchers across runtimes
|
||||||
|
*/
|
||||||
|
export interface IWatcher {
|
||||||
|
/** Start watching files */
|
||||||
|
start(): Promise<void>;
|
||||||
|
/** Stop watching and clean up */
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/** Whether the watcher is currently active */
|
||||||
|
readonly isWatching: boolean;
|
||||||
|
/** Subject that emits watch events */
|
||||||
|
readonly events$: smartrx.rxjs.Subject<IWatchEvent>;
|
||||||
|
}
|
||||||
290
ts/watchers/watcher.deno.ts
Normal file
290
ts/watchers/watcher.deno.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||||
|
|
||||||
|
// Type definitions for Deno APIs (these exist at runtime in Deno)
|
||||||
|
declare const Deno: {
|
||||||
|
watchFs(paths: string | string[], options?: { recursive?: boolean }): AsyncIterable<{
|
||||||
|
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other';
|
||||||
|
paths: string[];
|
||||||
|
flag?: { rescan: boolean };
|
||||||
|
}> & { close(): void };
|
||||||
|
stat(path: string): Promise<{
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
isSymlink: boolean;
|
||||||
|
size: number;
|
||||||
|
mtime: Date | null;
|
||||||
|
atime: Date | null;
|
||||||
|
birthtime: Date | null;
|
||||||
|
mode: number | null;
|
||||||
|
uid: number | null;
|
||||||
|
gid: number | null;
|
||||||
|
}>;
|
||||||
|
lstat(path: string): Promise<{
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
isSymlink: boolean;
|
||||||
|
size: number;
|
||||||
|
mtime: Date | null;
|
||||||
|
atime: Date | null;
|
||||||
|
birthtime: Date | null;
|
||||||
|
mode: number | null;
|
||||||
|
uid: number | null;
|
||||||
|
gid: number | null;
|
||||||
|
}>;
|
||||||
|
readDir(path: string): AsyncIterable<{
|
||||||
|
name: string;
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
isSymlink: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Deno stat to Node.js-like Stats object
|
||||||
|
*/
|
||||||
|
function denoStatToNodeStats(denoStat: Awaited<ReturnType<typeof Deno.stat>>): any {
|
||||||
|
return {
|
||||||
|
isFile: () => denoStat.isFile,
|
||||||
|
isDirectory: () => denoStat.isDirectory,
|
||||||
|
isSymbolicLink: () => denoStat.isSymlink,
|
||||||
|
size: denoStat.size,
|
||||||
|
mtime: denoStat.mtime,
|
||||||
|
atime: denoStat.atime,
|
||||||
|
birthtime: denoStat.birthtime,
|
||||||
|
mode: denoStat.mode,
|
||||||
|
uid: denoStat.uid,
|
||||||
|
gid: denoStat.gid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deno file watcher using native Deno.watchFs API
|
||||||
|
*/
|
||||||
|
export class DenoWatcher implements IWatcher {
|
||||||
|
private watcher: ReturnType<typeof Deno.watchFs> | null = null;
|
||||||
|
private watchedFiles: Set<string> = new Set();
|
||||||
|
private _isWatching = false;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._isWatching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start watching all base paths
|
||||||
|
this.watcher = Deno.watchFs(this.options.basePaths, { recursive: true });
|
||||||
|
this._isWatching = true;
|
||||||
|
|
||||||
|
// Perform initial scan
|
||||||
|
for (const basePath of this.options.basePaths) {
|
||||||
|
await this.scanDirectory(basePath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit ready event
|
||||||
|
this.events$.next({ type: 'ready', path: '' });
|
||||||
|
|
||||||
|
// Start processing events
|
||||||
|
this.processEvents();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.events$.next({ type: 'error', path: '', error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this._isWatching = false;
|
||||||
|
|
||||||
|
// Cancel all pending debounced emits
|
||||||
|
for (const timeout of this.pendingEmits.values()) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
this.pendingEmits.clear();
|
||||||
|
|
||||||
|
// Close the watcher
|
||||||
|
if (this.watcher) {
|
||||||
|
(this.watcher as any).close();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.watchedFiles.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process events from the Deno watcher
|
||||||
|
*/
|
||||||
|
private async processEvents(): Promise<void> {
|
||||||
|
if (!this.watcher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const event of this.watcher) {
|
||||||
|
if (!this._isWatching) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePath of event.paths) {
|
||||||
|
this.handleDenoEvent(event.kind, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (this._isWatching) {
|
||||||
|
this.events$.next({ type: 'error', path: '', error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a Deno file system event - debounce and normalize
|
||||||
|
*/
|
||||||
|
private handleDenoEvent(
|
||||||
|
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other',
|
||||||
|
filePath: string
|
||||||
|
): void {
|
||||||
|
// Ignore 'access' events (just reading the file)
|
||||||
|
if (kind === 'access') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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()) {
|
||||||
|
this.events$.next({ type: 'change', path: filePath, stats });
|
||||||
|
}
|
||||||
|
} else if (kind === '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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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) {
|
||||||
|
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<any | null> {
|
||||||
|
try {
|
||||||
|
const statFn = this.options.followSymlinks ? Deno.stat : Deno.lstat;
|
||||||
|
const denoStats = await statFn(filePath);
|
||||||
|
return denoStatToNodeStats(denoStats);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path was known to be a directory
|
||||||
|
*/
|
||||||
|
private isKnownDirectory(filePath: string): boolean {
|
||||||
|
for (const watched of this.watchedFiles) {
|
||||||
|
if (watched.startsWith(filePath + '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
ts/watchers/watcher.node.ts
Normal file
105
ts/watchers/watcher.node.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import * as chokidar from 'chokidar';
|
||||||
|
import type { IWatcher, IWatcherOptions, IWatchEvent } from './interfaces.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 watcher: chokidar.FSWatcher | null = null;
|
||||||
|
private _isWatching = false;
|
||||||
|
|
||||||
|
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||||
|
|
||||||
|
constructor(private options: IWatcherOptions) {}
|
||||||
|
|
||||||
|
get isWatching(): boolean {
|
||||||
|
return this._isWatching;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._isWatching) return;
|
||||||
|
|
||||||
|
console.log(`[smartwatch] Starting chokidar watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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;
|
||||||
|
console.log('[smartwatch] Watcher started');
|
||||||
|
} catch (error: any) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isWatching = false;
|
||||||
|
console.log('[smartwatch] 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