Compare commits

..

14 Commits

Author SHA1 Message Date
913c14bfcf v6.1.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 17:48:50 +00:00
2ce056f401 feat(watcher.node): Add automatic restart, periodic health checks, and safe event emission to Node watcher; improve logging and stat handling 2025-12-08 17:48:50 +00:00
1c77a94b96 v6.0.0
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 16:06:18 +00:00
097ea96e99 BREAKING CHANGE(watchers): Replace polling-based write stabilization with debounce-based event coalescing and simplify watcher options 2025-12-08 16:06:18 +00:00
5a6d9a2575 v5.1.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 15:09:16 +00:00
7a7ee041a3 feat(watchers): Improve write stabilization and ignore temporary editor files 2025-12-08 15:09:16 +00:00
0dc4eaba52 v5.0.0
Some checks failed
Default (tags) / security (push) Failing after 17s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-30 18:01:01 +00:00
31ab561859 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. 2025-11-30 18:01:01 +00:00
a5b4fedf9f v4.0.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-30 17:46:19 +00:00
b84a50f87d fix(readme): Update README: refine description and clarify trademark/legal information 2025-11-30 17:46:19 +00:00
2ae297e5fa v4.0.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-30 17:32:45 +00:00
9130613941 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 2025-11-30 17:32:44 +00:00
fdd6281f0b v3.0.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-30 03:04:49 +00:00
0f17be179c 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 2025-11-30 03:04:49 +00:00
15 changed files with 1198 additions and 202 deletions

View File

@@ -1,5 +1,75 @@
# Changelog # Changelog
## 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

View File

@@ -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",

View File

@@ -1,31 +1,34 @@
{ {
"name": "@push.rocks/smartchok", "name": "@push.rocks/smartwatch",
"version": "1.2.0", "version": "6.1.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/)",
"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",
"picomatch": "^4.0.3" "picomatch": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
@@ -53,15 +56,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"
} }

41
pnpm-lock.yaml generated
View File

@@ -11,15 +11,15 @@ 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
'@push.rocks/smartrx': '@push.rocks/smartrx':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
chokidar:
specifier: ^5.0.0
version: 5.0.0
picomatch: picomatch:
specifier: ^4.0.3 specifier: ^4.0.3
version: 4.0.3 version: 4.0.3
@@ -898,9 +898,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==}
@@ -2202,10 +2199,6 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chromium-bidi@5.1.0: chromium-bidi@5.1.0:
resolution: {integrity: sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==} resolution: {integrity: sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==}
peerDependencies: peerDependencies:
@@ -3638,10 +3631,6 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
reflect-metadata@0.2.2: reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -5784,7 +5773,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 +5966,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 +6059,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 +6075,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 +6383,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 +6468,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 +6500,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': {}
@@ -7915,10 +7900,6 @@ snapshots:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chromium-bidi@5.1.0(devtools-protocol@0.0.1452169): chromium-bidi@5.1.0(devtools-protocol@0.0.1452169):
dependencies: dependencies:
devtools-protocol: 0.0.1452169 devtools-protocol: 0.0.1452169
@@ -9646,8 +9627,6 @@ snapshots:
readdirp@4.1.2: {} readdirp@4.1.2: {}
readdirp@5.0.0: {}
reflect-metadata@0.2.2: {} reflect-metadata@0.2.2: {}
regenerator-runtime@0.14.1: {} regenerator-runtime@0.14.1: {}

View File

@@ -1,33 +1,120 @@
# smartchok - Technical Hints # smartchok - Technical Hints
## Chokidar 5.x Migration (2024) ## Native File Watching (v2.0.0+)
The module has been migrated to `chokidar` 5.x (from 4.x). Key changes: The module now uses native file watching APIs instead of chokidar, providing cross-runtime support for Node.js, Deno, and Bun.
### Exported Class
The package exports the `Smartwatch` class (not `Smartchok`):
```typescript
import { Smartwatch } from '@push.rocks/smartchok';
```
### Architecture
```
ts/
├── smartwatch.classes.smartwatch.ts # Main Smartwatch class
├── smartwatch.plugins.ts # Dependencies (smartenv, picomatch, etc.)
├── watchers/
│ ├── index.ts # Factory with runtime detection
│ ├── interfaces.ts # IWatcher interface and types
│ ├── watcher.node.ts # Node.js/Bun implementation (fs.watch)
│ └── watcher.deno.ts # Deno implementation (Deno.watchFs)
└── utils/
└── write-stabilizer.ts # awaitWriteFinish polling implementation
```
### Runtime Detection
Uses `@push.rocks/smartenv` v6.x for runtime detection:
- **Node.js/Bun**: Uses native `fs.watch()` with `{ recursive: true }`
- **Deno**: Uses `Deno.watchFs()` async iterable
### Dependencies ### Dependencies
- **Current**: `chokidar` 5.x and `picomatch`
- **Historical**: Was previously using `@tempfix/watcher` before chokidar 4.x - **picomatch**: Glob pattern matching (zero deps, well-maintained)
- **@push.rocks/smartenv**: Runtime detection (Node.js, Deno, Bun)
- **@push.rocks/smartrx**: RxJS Subject/Observable management
- **@push.rocks/smartpromise**: Deferred promise utilities
- **@push.rocks/lik**: Stringmap for pattern storage
### Why picomatch? ### Why picomatch?
Chokidar 4.x+ removed built-in glob pattern support. We use `picomatch` to maintain backward compatibility and provide glob pattern matching functionality.
### Implementation Details Native file watching APIs don't support glob patterns. Picomatch provides glob pattern matching with:
1. **Glob pattern extraction**: The `getGlobBase()` method extracts base directories from glob patterns - Zero dependencies
2. **Pattern matching**: Each glob pattern is compiled to a picomatch matcher function - 164M+ weekly downloads
3. **Event filtering**: File system events are filtered based on glob patterns before being emitted - Excellent security profile
4. **Path normalization**: Paths are normalized to handle different formats (with/without leading ./) - Full glob syntax support
### Event Handling ### Event Handling
Chokidar 5.x events are mapped 1:1 with smartchok events:
- `add`, `change`, `unlink`: File events Native events are normalized to a consistent interface:
- `addDir`, `unlinkDir`: Directory events
- `error`: Error events | Node.js/Bun Event | Deno Event | Normalized Event |
- `raw`: Raw events from underlying watchers |-------------------|------------|------------------|
- `ready`: Emitted when initial scan is complete | `rename` (file exists) | `create` | `add` |
| `rename` (file gone) | `remove` | `unlink` |
| `change` | `modify` | `change` |
### awaitWriteFinish Implementation
The `WriteStabilizer` class replaces chokidar's built-in write stabilization:
- Polls file size until stable (configurable threshold: 300ms default)
- Configurable poll interval (100ms default)
- Handles file deletion during write detection
### Platform Requirements
- **Node.js 20+**: Required for native recursive watching on all platforms
- **Deno**: Works on all versions with `Deno.watchFs()`
- **Bun**: Uses Node.js compatibility layer
### Robustness Features (v6.1.0+)
The Node.js watcher includes automatic recovery mechanisms:
**Auto-restart on failure:**
- Watchers automatically restart when errors occur
- Exponential backoff (1s → 30s max)
- Maximum 3 retry attempts before giving up
**Health check monitoring:**
- 30-second periodic health checks
- Detects when watched paths disappear
- Triggers automatic restart when issues detected
**Error isolation:**
- Subscriber errors don't crash the watcher
- All events emitted via `safeEmit()` with try-catch
**Verbose logging:**
- All lifecycle events logged with `[smartwatch]` prefix
- Helps debug watcher issues in production
Example log output:
```
[smartwatch] Starting watcher for 1 base path(s)...
[smartwatch] Started watching: ./test/assets/
[smartwatch] Starting health check (every 30s)
[smartwatch] Watcher started with 1 active watcher(s)
[smartwatch] Health check: 1 watchers active
```
### 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` ```
Tests verify:
- Creating Smartwatch instance
- Adding glob patterns
- Receiving 'add' events for new files
- Graceful shutdown
## Dev Dependencies
- Using `@git.zone/tstest` v3.x with tapbundle
- Import from `@git.zone/tstest/tapbundle`

146
readme.md
View File

@@ -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}`);
@@ -71,23 +75,22 @@ 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.

View File

@@ -1,5 +1,5 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartchok from '../ts/index.js'; import * as smartwatch from '../ts/index.js';
import * as smartfile from '@push.rocks/smartfile'; import * as smartfile from '@push.rocks/smartfile';
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';
@@ -11,22 +11,22 @@ if (process.env.CI) {
process.exit(0); process.exit(0);
} }
let testSmartchok: smartchok.Smartchok; let testSmartwatch: smartwatch.Smartwatch;
let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>; let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>;
let testSubscription: smartrx.rxjs.Subscription; let testSubscription: smartrx.rxjs.Subscription;
tap.test('should create a new instance', async () => { tap.test('should create a new instance', async () => {
testSmartchok = new smartchok.Smartchok([]); testSmartwatch = new smartwatch.Smartwatch([]);
expect(testSmartchok).toBeInstanceOf(smartchok.Smartchok); expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch);
}); });
tap.test('should add some files to watch and start', async () => { tap.test('should add some files to watch and start', async () => {
testSmartchok.add(['./test/**/*.txt']); testSmartwatch.add(['./test/**/*.txt']);
await testSmartchok.start() await testSmartwatch.start()
testSmartchok.add(['./test/**/*.md']); testSmartwatch.add(['./test/**/*.md']);
}); });
tap.test('should get an observable for a certain event', async () => { tap.test('should get an observable for a certain event', async () => {
await testSmartchok.getObservableFor('add').then(async (observableArg) => { await testSmartwatch.getObservableFor('add').then(async (observableArg) => {
testAddObservable = observableArg; testAddObservable = observableArg;
}); });
}); });
@@ -44,7 +44,7 @@ tap.test('should register an add operation', async () => {
tap.test('should stop the watch process', async (tools) => { tap.test('should stop the watch process', async (tools) => {
await tools.delayFor(10000); await tools.delayFor(10000);
testSmartchok.stop(); testSmartwatch.stop();
}); });
export default tap.start(); export default tap.start();

View File

@@ -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.1.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.'
} }

View File

@@ -1 +1 @@
export * from './smartchok.classes.smartchok.js'; export * from './smartwatch.classes.smartwatch.js';

View File

@@ -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,18 +104,17 @@ 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
this.globPatterns = this.watchStringmap.getStringArray(); this.globPatterns = this.watchStringmap.getStringArray();
const basePaths = new Set<string>(); const basePaths = new Set<string>();
this.globPatterns.forEach((pattern) => { this.globPatterns.forEach((pattern) => {
const basePath = this.getGlobBase(pattern); const basePath = this.getGlobBase(pattern);
basePaths.add(basePath); basePaths.add(basePath);
// Create a picomatch matcher for each glob pattern // Create a picomatch matcher for each glob pattern
const matcher = plugins.picomatch(pattern, { const matcher = plugins.picomatch(pattern, {
dot: true, dot: true,
@@ -112,62 +122,62 @@ 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) => { // Subscribe to watcher events and dispatch to appropriate subjects
// Don't filter during initialization - let chokidar watch everything this.watcher.events$.subscribe((event: IWatchEvent) => {
// We'll filter events as they come in this.handleWatchEvent(event);
return false; });
// Start the watcher
await this.watcher.start();
this.status = 'watching';
this.watchingDeferred.resolve();
}
/**
* 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;
}
// Set up event handlers with glob filtering
const fileEvents: Array<'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'> = // Handle error event
['add', 'addDir', 'change', 'unlink', 'unlinkDir']; if (event.type === 'error') {
const subject = this.eventSubjects.get('error');
// Handle file events if (subject) {
fileEvents.forEach(eventName => { subject.next([event.error?.message || 'Unknown error', undefined]);
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
this.watcher.on('error', (error: Error) => {
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]);
} }
}); return;
}
this.watcher.on('ready', () => {
this.status = 'watching'; // Filter file/directory events by glob patterns
this.watchingDeferred.resolve(); if (!this.shouldWatchPath(event.path)) {
done.resolve(); return;
}); }
return done.promise;
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,8 +198,10 @@ export class Smartchok {
await this.watchingDeferred.promise; await this.watchingDeferred.promise;
await closeWatcher(); await closeWatcher();
} }
this.status = 'idle';
} }
/** /**
* Checks if a path should be watched based on glob patterns * Checks if a path should be watched based on glob patterns
*/ */
@@ -195,7 +211,7 @@ export class Smartchok {
if (normalizedPath.startsWith('./')) { if (normalizedPath.startsWith('./')) {
normalizedPath = normalizedPath.substring(2); normalizedPath = normalizedPath.substring(2);
} }
// Check if the path matches any of our glob patterns // Check if the path matches any of our glob patterns
for (const [pattern, matcher] of this.globMatchers) { for (const [pattern, matcher] of this.globMatchers) {
// Also normalize the pattern for comparison // Also normalize the pattern for comparison
@@ -203,12 +219,12 @@ export class Smartchok {
if (normalizedPattern.startsWith('./')) { if (normalizedPattern.startsWith('./')) {
normalizedPattern = normalizedPattern.substring(2); normalizedPattern = normalizedPattern.substring(2);
} }
// Try matching with both the original pattern and normalized // Try matching with both the original pattern and normalized
if (matcher(normalizedPath) || matcher(filePath)) { if (matcher(normalizedPath) || matcher(filePath)) {
return true; return true;
} }
// Also try matching without the leading path // Also try matching without the leading path
const withoutLeading = 'test/' + normalizedPath.split('test/').slice(1).join('test/'); const withoutLeading = 'test/' + normalizedPath.split('test/').slice(1).join('test/');
if (matcher(withoutLeading)) { if (matcher(withoutLeading)) {

View File

@@ -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
View 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
};

45
ts/watchers/interfaces.ts Normal file
View File

@@ -0,0 +1,45 @@
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;
}
/**
* 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
View 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;
}
}

417
ts/watchers/watcher.node.ts Normal file
View File

@@ -0,0 +1,417 @@
import * as fs from 'fs';
import * as path from 'path';
import * as smartrx from '@push.rocks/smartrx';
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
/**
* Node.js/Bun file watcher using native fs.watch API
*/
export class NodeWatcher implements IWatcher {
private watchers: Map<string, fs.FSWatcher> = new Map();
private watchedFiles: Set<string> = new Set();
private _isWatching = false;
// Debounce: pending emits per file path
private pendingEmits: Map<string, NodeJS.Timeout> = new Map();
// Restart tracking
private restartDelays: Map<string, number> = new Map();
private restartAttempts: Map<string, number> = new Map();
private healthCheckInterval: NodeJS.Timeout | null = null;
// Configuration constants
private static readonly MAX_RETRIES = 3;
private static readonly INITIAL_RESTART_DELAY = 1000;
private static readonly MAX_RESTART_DELAY = 30000;
private static readonly HEALTH_CHECK_INTERVAL = 30000;
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
constructor(private options: IWatcherOptions) {}
/**
* Safely emit an event, catching any subscriber errors
*/
private safeEmit(event: IWatchEvent): void {
try {
this.events$.next(event);
} catch (error) {
console.error('[smartwatch] Subscriber threw error (isolated):', error);
// Don't let subscriber errors kill the watcher
}
}
/**
* Restart a watcher after an error with exponential backoff
*/
private async restartWatcher(basePath: string, error: Error): Promise<void> {
const attempts = (this.restartAttempts.get(basePath) || 0) + 1;
this.restartAttempts.set(basePath, attempts);
console.log(`[smartwatch] Watcher error for ${basePath}: ${error.message}`);
console.log(`[smartwatch] Restart attempt ${attempts}/${NodeWatcher.MAX_RETRIES}`);
if (attempts > NodeWatcher.MAX_RETRIES) {
console.error(`[smartwatch] Max retries exceeded for ${basePath}, giving up`);
this.safeEmit({
type: 'error',
path: basePath,
error: new Error(`Max restart retries (${NodeWatcher.MAX_RETRIES}) exceeded`)
});
return;
}
// Close failed watcher
const oldWatcher = this.watchers.get(basePath);
if (oldWatcher) {
try {
oldWatcher.close();
} catch {
// Ignore close errors
}
this.watchers.delete(basePath);
}
// Exponential backoff
const delay = this.restartDelays.get(basePath) || NodeWatcher.INITIAL_RESTART_DELAY;
console.log(`[smartwatch] Waiting ${delay}ms before restart...`);
await new Promise((resolve) => setTimeout(resolve, delay));
this.restartDelays.set(basePath, Math.min(delay * 2, NodeWatcher.MAX_RESTART_DELAY));
try {
await this.watchPath(basePath, 0);
console.log(`[smartwatch] Successfully restarted watcher for ${basePath}`);
this.restartDelays.set(basePath, NodeWatcher.INITIAL_RESTART_DELAY);
this.restartAttempts.set(basePath, 0);
} catch (restartError) {
console.error(`[smartwatch] Restart failed for ${basePath}:`, restartError);
this.restartWatcher(basePath, restartError as Error); // Recursive retry
}
}
/**
* Start periodic health checks to detect silent failures
*/
private startHealthCheck(): void {
console.log('[smartwatch] Starting health check (every 30s)');
this.healthCheckInterval = setInterval(async () => {
console.log(`[smartwatch] Health check: ${this.watchers.size} watchers active`);
for (const [basePath] of this.watchers) {
const stats = await this.statSafe(basePath);
if (!stats && this._isWatching) {
console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`);
this.safeEmit({
type: 'error',
path: basePath,
error: new Error('Watched path no longer exists')
});
this.restartWatcher(basePath, new Error('Watched path disappeared'));
}
}
}, NodeWatcher.HEALTH_CHECK_INTERVAL);
}
/**
* Stop health check interval
*/
private stopHealthCheck(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
console.log('[smartwatch] Stopped health check');
}
}
/**
* Check if a file is a temporary file created by editors
*/
private isTemporaryFile(filePath: string): boolean {
const basename = path.basename(filePath);
// 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;
}
console.log(`[smartwatch] Starting watcher for ${this.options.basePaths.length} base path(s)...`);
try {
// Start watching each base path
for (const basePath of this.options.basePaths) {
await this.watchPath(basePath, 0);
}
this._isWatching = true;
// Start health check monitoring
this.startHealthCheck();
// Perform initial scan to emit 'add' events for existing files
for (const basePath of this.options.basePaths) {
await this.scanDirectory(basePath, 0);
}
// Emit ready event
this.safeEmit({ type: 'ready', path: '' });
console.log(`[smartwatch] Watcher started with ${this.watchers.size} active watcher(s)`);
} 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...');
this._isWatching = false;
// Stop health check monitoring
this.stopHealthCheck();
// Cancel all pending debounced emits
for (const timeout of this.pendingEmits.values()) {
clearTimeout(timeout);
}
this.pendingEmits.clear();
// Close all watchers
for (const [watchPath, watcher] of this.watchers) {
console.log(`[smartwatch] Closing watcher for: ${watchPath}`);
watcher.close();
}
this.watchers.clear();
this.watchedFiles.clear();
// Clear restart tracking state
this.restartDelays.clear();
this.restartAttempts.clear();
console.log('[smartwatch] Watcher stopped');
}
/**
* Start watching a path (file or directory)
*/
private async watchPath(watchPath: string, depth: number): Promise<void> {
if (depth > this.options.depth) {
return;
}
try {
const stats = await this.statSafe(watchPath);
if (!stats) {
return;
}
if (stats.isDirectory()) {
// Watch the directory with recursive option (Node.js 20+ supports this on all platforms)
const watcher = fs.watch(
watchPath,
{ recursive: true, persistent: true },
(eventType, filename) => {
if (filename) {
this.handleFsEvent(watchPath, filename, eventType);
}
}
);
watcher.on('error', (error) => {
console.error(`[smartwatch] FSWatcher error event on ${watchPath}:`, error);
this.safeEmit({ type: 'error', path: watchPath, error });
if (this._isWatching) {
this.restartWatcher(watchPath, error);
}
});
// Handle 'close' event - fs.watch can close without error
watcher.on('close', () => {
console.warn(`[smartwatch] FSWatcher closed unexpectedly for ${watchPath}`);
if (this._isWatching) {
this.restartWatcher(watchPath, new Error('Watcher closed unexpectedly'));
}
});
this.watchers.set(watchPath, watcher);
console.log(`[smartwatch] Started watching: ${watchPath}`);
}
} catch (error: any) {
console.error(`[smartwatch] Failed to watch path ${watchPath}:`, error);
this.safeEmit({ type: 'error', path: watchPath, error });
}
}
/**
* Handle raw fs.watch events - debounce and normalize them
*/
private handleFsEvent(
basePath: string,
filename: string,
eventType: 'rename' | 'change' | string
): void {
const fullPath = path.join(basePath, filename);
// Skip temporary files created by editors (atomic saves)
if (this.isTemporaryFile(fullPath)) {
return;
}
// Debounce: cancel any pending emit for this file
const existing = this.pendingEmits.get(fullPath);
if (existing) {
clearTimeout(existing);
}
// Schedule debounced emit
const timeout = setTimeout(() => {
this.pendingEmits.delete(fullPath);
this.emitFileEvent(fullPath, eventType);
}, this.options.debounceMs);
this.pendingEmits.set(fullPath, timeout);
}
/**
* Emit the actual file event after debounce
*/
private async emitFileEvent(
fullPath: string,
eventType: 'rename' | 'change' | string
): Promise<void> {
try {
const stats = await this.statSafe(fullPath);
if (eventType === 'rename') {
// 'rename' can mean add or unlink - check if file exists
if (stats) {
// File exists - it's either a new file or was renamed to this location
if (stats.isDirectory()) {
if (!this.watchedFiles.has(fullPath)) {
this.watchedFiles.add(fullPath);
this.safeEmit({ type: 'addDir', path: fullPath, stats });
}
} else {
const wasWatched = this.watchedFiles.has(fullPath);
this.watchedFiles.add(fullPath);
this.safeEmit({
type: wasWatched ? 'change' : 'add',
path: fullPath,
stats
});
}
} else {
// File doesn't exist - it was deleted
if (this.watchedFiles.has(fullPath)) {
const wasDir = this.isKnownDirectory(fullPath);
this.watchedFiles.delete(fullPath);
this.safeEmit({
type: wasDir ? 'unlinkDir' : 'unlink',
path: fullPath
});
}
}
} else if (eventType === 'change') {
// File was modified
if (stats && !stats.isDirectory()) {
const wasWatched = this.watchedFiles.has(fullPath);
if (!wasWatched) {
// This is actually an 'add' - file wasn't being watched before
this.watchedFiles.add(fullPath);
this.safeEmit({ type: 'add', path: fullPath, stats });
} else {
this.safeEmit({ type: 'change', path: fullPath, stats });
}
} else if (!stats && this.watchedFiles.has(fullPath)) {
// File was deleted
this.watchedFiles.delete(fullPath);
this.safeEmit({ type: 'unlink', path: fullPath });
}
}
} catch (error: any) {
this.safeEmit({ type: 'error', path: fullPath, error });
}
}
/**
* Scan directory and emit 'add' events for existing files
*/
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
if (depth > this.options.depth) {
return;
}
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
// 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.safeEmit({ type: 'addDir', path: fullPath, stats });
await this.scanDirectory(fullPath, depth + 1);
} else if (entry.isFile()) {
this.watchedFiles.add(fullPath);
this.safeEmit({ type: 'add', path: fullPath, stats });
}
}
} catch (error: any) {
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
this.safeEmit({ type: 'error', path: dirPath, error });
}
}
}
/**
* Safely stat a path, returning null if it doesn't exist
*/
private async statSafe(filePath: string): Promise<fs.Stats | null> {
try {
return await (this.options.followSymlinks
? fs.promises.stat(filePath)
: fs.promises.lstat(filePath));
} catch (error: any) {
// Only silently return null for expected "file doesn't exist" errors
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return null;
}
// Log other errors (permission, I/O) but still return null
console.warn(`[smartwatch] statSafe warning for ${filePath}: ${error.code} - ${error.message}`);
return null;
}
}
/**
* Check if a path was known to be a directory (for proper unlink event type)
*/
private isKnownDirectory(filePath: string): boolean {
// Check if any watched files are children of this path
for (const watched of this.watchedFiles) {
if (watched.startsWith(filePath + path.sep)) {
return true;
}
}
return false;
}
}