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
This commit is contained in:
11
changelog.md
11
changelog.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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",
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartchok",
|
"name": "@push.rocks/smartchok",
|
||||||
"version": "1.2.0",
|
"version": "2.0.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": {
|
||||||
@@ -21,11 +21,14 @@
|
|||||||
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartchok",
|
"homepage": "https://code.foss.global/push.rocks/smartchok",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"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
41
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -1,33 +1,82 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
### 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`
|
||||||
|
|||||||
26
readme.md
26
readme.md
@@ -1,4 +1,4 @@
|
|||||||
# @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 smart wrapper for chokidar 5.x with glob pattern support, RxJS observable integration, and enhanced file watching features.
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@ 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
|
||||||
@@ -27,10 +27,10 @@ pnpm add @push.rocks/smartchok
|
|||||||
### 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
|
||||||
]);
|
]);
|
||||||
@@ -87,7 +87,7 @@ unlinkObservable.subscribe(([path]) => {
|
|||||||
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 +107,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,24 +152,24 @@ 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.
|
Since chokidar 4.x+ no longer supports glob patterns natively, smartwatch 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.
|
||||||
|
|
||||||
When you provide glob patterns:
|
When you provide glob patterns:
|
||||||
1. **Base path extraction** - smartchok extracts the static base path from each pattern
|
1. **Base path extraction** - smartwatch extracts the static base path from each pattern
|
||||||
2. **Efficient watching** - chokidar watches the base directories
|
2. **Efficient watching** - chokidar watches the base directories
|
||||||
3. **Pattern filtering** - Events are filtered through picomatch matchers before being emitted
|
3. **Pattern filtering** - Events are filtered through picomatch matchers before being emitted
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
#### Methods
|
#### Methods
|
||||||
|
|
||||||
|
|||||||
18
test/test.ts
18
test/test.ts
@@ -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();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartchok',
|
name: '@push.rocks/smartchok',
|
||||||
version: '1.2.0',
|
version: '3.0.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,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,
|
stabilityThreshold: 300,
|
||||||
pollInterval: 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 +186,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 +199,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,
|
||||||
}
|
}
|
||||||
97
ts/utils/write-stabilizer.ts
Normal file
97
ts/utils/write-stabilizer.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
interface IPendingWrite {
|
||||||
|
lastSize: number;
|
||||||
|
lastChange: number;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
resolve: (stats: fs.Stats) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements awaitWriteFinish functionality by polling file size until stable.
|
||||||
|
* This replaces chokidar's built-in write stabilization.
|
||||||
|
*/
|
||||||
|
export class WriteStabilizer {
|
||||||
|
private pendingWrites = new Map<string, IPendingWrite>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private stabilityThreshold: number = 300,
|
||||||
|
private pollInterval: number = 100
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a file write to complete by polling until size is stable
|
||||||
|
*/
|
||||||
|
async waitForWriteFinish(filePath: string): Promise<fs.Stats> {
|
||||||
|
// Cancel any existing pending check for this file
|
||||||
|
this.cancel(filePath);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const stats = await fs.promises.stat(filePath);
|
||||||
|
const pending = this.pendingWrites.get(filePath);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
// Was cancelled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (stats.size !== pending.lastSize) {
|
||||||
|
// Size changed - file is still being written, reset timer
|
||||||
|
pending.lastSize = stats.size;
|
||||||
|
pending.lastChange = now;
|
||||||
|
pending.timeoutId = setTimeout(poll, this.pollInterval);
|
||||||
|
} else if (now - pending.lastChange >= this.stabilityThreshold) {
|
||||||
|
// Size has been stable for the threshold duration
|
||||||
|
this.pendingWrites.delete(filePath);
|
||||||
|
resolve(stats);
|
||||||
|
} else {
|
||||||
|
// Size is the same but not yet past threshold
|
||||||
|
pending.timeoutId = setTimeout(poll, this.pollInterval);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.pendingWrites.delete(filePath);
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
// File was deleted during polling
|
||||||
|
reject(new Error(`File was deleted: ${filePath}`));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pendingWrites.set(filePath, {
|
||||||
|
lastSize: -1,
|
||||||
|
lastChange: Date.now(),
|
||||||
|
timeoutId: setTimeout(poll, this.pollInterval),
|
||||||
|
resolve,
|
||||||
|
reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any pending write stabilization for a file
|
||||||
|
*/
|
||||||
|
cancel(filePath: string): void {
|
||||||
|
const pending = this.pendingWrites.get(filePath);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
this.pendingWrites.delete(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all pending write stabilizations
|
||||||
|
*/
|
||||||
|
cancelAll(): void {
|
||||||
|
for (const [filePath, pending] of this.pendingWrites) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
}
|
||||||
|
this.pendingWrites.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ts/watchers/index.ts
Normal file
33
ts/watchers/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
stabilityThreshold: 300,
|
||||||
|
pollInterval: 100
|
||||||
|
};
|
||||||
47
ts/watchers/interfaces.ts
Normal file
47
ts/watchers/interfaces.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
/** Stability threshold for write detection (ms) */
|
||||||
|
stabilityThreshold: number;
|
||||||
|
/** Poll interval for 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>;
|
||||||
|
}
|
||||||
329
ts/watchers/watcher.deno.ts
Normal file
329
ts/watchers/watcher.deno.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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;
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
private recentEvents: Map<string, number> = new Map();
|
||||||
|
private throttleMs = 50;
|
||||||
|
private pendingWrites: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
|
// 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 write stabilizations
|
||||||
|
for (const timeout of this.pendingWrites.values()) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
this.pendingWrites.clear();
|
||||||
|
|
||||||
|
// Close the watcher
|
||||||
|
if (this.watcher) {
|
||||||
|
(this.watcher as any).close();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.watchedFiles.clear();
|
||||||
|
this.recentEvents.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) {
|
||||||
|
await this.handleDenoEvent(event.kind, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (this._isWatching) {
|
||||||
|
this.events$.next({ type: 'error', path: '', error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a Deno file system event
|
||||||
|
*/
|
||||||
|
private async handleDenoEvent(
|
||||||
|
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other',
|
||||||
|
filePath: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Ignore 'access' events (just reading the file)
|
||||||
|
if (kind === 'access') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle duplicate events
|
||||||
|
if (!this.shouldEmit(filePath, kind)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (kind === 'create') {
|
||||||
|
const stats = await this.statSafe(filePath);
|
||||||
|
if (stats) {
|
||||||
|
// Wait for write to stabilize
|
||||||
|
await this.waitForWriteFinish(filePath);
|
||||||
|
const finalStats = await this.statSafe(filePath);
|
||||||
|
|
||||||
|
if (finalStats) {
|
||||||
|
this.watchedFiles.add(filePath);
|
||||||
|
const eventType: TWatchEventType = finalStats.isDirectory() ? 'addDir' : 'add';
|
||||||
|
this.events$.next({ type: eventType, path: filePath, stats: finalStats });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'modify') {
|
||||||
|
const stats = await this.statSafe(filePath);
|
||||||
|
if (stats && !stats.isDirectory()) {
|
||||||
|
// Wait for write to stabilize
|
||||||
|
await this.waitForWriteFinish(filePath);
|
||||||
|
const finalStats = await this.statSafe(filePath);
|
||||||
|
|
||||||
|
if (finalStats) {
|
||||||
|
this.events$.next({ type: 'change', path: filePath, stats: finalStats });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for file write to complete (polling-based)
|
||||||
|
*/
|
||||||
|
private async waitForWriteFinish(filePath: string): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let lastSize = -1;
|
||||||
|
let lastChange = Date.now();
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const stats = await this.statSafe(filePath);
|
||||||
|
if (!stats) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (stats.size !== lastSize) {
|
||||||
|
lastSize = stats.size;
|
||||||
|
lastChange = now;
|
||||||
|
this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval));
|
||||||
|
} else if (now - lastChange >= this.options.stabilityThreshold) {
|
||||||
|
this.pendingWrites.delete(filePath);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.pendingWrites.delete(filePath);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan directory and emit 'add' events for existing files
|
||||||
|
*/
|
||||||
|
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}`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle duplicate events
|
||||||
|
*/
|
||||||
|
private shouldEmit(filePath: string, eventType: string): boolean {
|
||||||
|
const key = `${filePath}:${eventType}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const lastEmit = this.recentEvents.get(key);
|
||||||
|
|
||||||
|
if (lastEmit && now - lastEmit < this.throttleMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentEvents.set(key, now);
|
||||||
|
|
||||||
|
// Clean up old entries periodically
|
||||||
|
if (this.recentEvents.size > 1000) {
|
||||||
|
const cutoff = now - this.throttleMs * 2;
|
||||||
|
for (const [k, time] of this.recentEvents) {
|
||||||
|
if (time < cutoff) {
|
||||||
|
this.recentEvents.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
281
ts/watchers/watcher.node.ts
Normal file
281
ts/watchers/watcher.node.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||||
|
import { WriteStabilizer } from '../utils/write-stabilizer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
private writeStabilizer: WriteStabilizer;
|
||||||
|
private recentEvents: Map<string, number> = new Map();
|
||||||
|
private throttleMs = 50;
|
||||||
|
|
||||||
|
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||||
|
|
||||||
|
constructor(private options: IWatcherOptions) {
|
||||||
|
this.writeStabilizer = new WriteStabilizer(
|
||||||
|
options.stabilityThreshold,
|
||||||
|
options.pollInterval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isWatching(): boolean {
|
||||||
|
return this._isWatching;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._isWatching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start watching each base path
|
||||||
|
for (const basePath of this.options.basePaths) {
|
||||||
|
await this.watchPath(basePath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isWatching = true;
|
||||||
|
|
||||||
|
// Perform initial scan to emit 'add' events for existing files
|
||||||
|
for (const basePath of this.options.basePaths) {
|
||||||
|
await this.scanDirectory(basePath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit ready event
|
||||||
|
this.events$.next({ type: 'ready', path: '' });
|
||||||
|
} catch (error: any) {
|
||||||
|
this.events$.next({ type: 'error', path: '', error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this._isWatching = false;
|
||||||
|
this.writeStabilizer.cancelAll();
|
||||||
|
|
||||||
|
// Close all watchers
|
||||||
|
for (const [watchPath, watcher] of this.watchers) {
|
||||||
|
watcher.close();
|
||||||
|
}
|
||||||
|
this.watchers.clear();
|
||||||
|
this.watchedFiles.clear();
|
||||||
|
this.recentEvents.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => {
|
||||||
|
this.events$.next({ type: 'error', path: watchPath, error });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.watchers.set(watchPath, watcher);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.events$.next({ type: 'error', path: watchPath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle raw fs.watch events and normalize them
|
||||||
|
*/
|
||||||
|
private async handleFsEvent(
|
||||||
|
basePath: string,
|
||||||
|
filename: string,
|
||||||
|
eventType: 'rename' | 'change' | string
|
||||||
|
): Promise<void> {
|
||||||
|
const fullPath = path.join(basePath, filename);
|
||||||
|
|
||||||
|
// Throttle duplicate events
|
||||||
|
if (!this.shouldEmit(fullPath, eventType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await this.statSafe(fullPath);
|
||||||
|
|
||||||
|
if (eventType === 'rename') {
|
||||||
|
// 'rename' can mean add or unlink - check if file exists
|
||||||
|
if (stats) {
|
||||||
|
// File exists - it's either a new file or was renamed to this location
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
if (!this.watchedFiles.has(fullPath)) {
|
||||||
|
this.watchedFiles.add(fullPath);
|
||||||
|
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wait for write to stabilize before emitting
|
||||||
|
try {
|
||||||
|
const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath);
|
||||||
|
const wasWatched = this.watchedFiles.has(fullPath);
|
||||||
|
this.watchedFiles.add(fullPath);
|
||||||
|
this.events$.next({
|
||||||
|
type: wasWatched ? 'change' : 'add',
|
||||||
|
path: fullPath,
|
||||||
|
stats: stableStats
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// File was deleted during stabilization
|
||||||
|
if (this.watchedFiles.has(fullPath)) {
|
||||||
|
this.watchedFiles.delete(fullPath);
|
||||||
|
this.events$.next({ type: 'unlink', path: fullPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist - it was deleted
|
||||||
|
if (this.watchedFiles.has(fullPath)) {
|
||||||
|
const wasDir = this.isKnownDirectory(fullPath);
|
||||||
|
this.watchedFiles.delete(fullPath);
|
||||||
|
this.events$.next({
|
||||||
|
type: wasDir ? 'unlinkDir' : 'unlink',
|
||||||
|
path: fullPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (eventType === 'change') {
|
||||||
|
// File was modified
|
||||||
|
if (stats && !stats.isDirectory()) {
|
||||||
|
try {
|
||||||
|
const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath);
|
||||||
|
// Check if this is a new file (not yet in watchedFiles)
|
||||||
|
const wasWatched = this.watchedFiles.has(fullPath);
|
||||||
|
if (!wasWatched) {
|
||||||
|
// This is actually an 'add' - file wasn't being watched before
|
||||||
|
this.watchedFiles.add(fullPath);
|
||||||
|
this.events$.next({ type: 'add', path: fullPath, stats: stableStats });
|
||||||
|
} else {
|
||||||
|
this.events$.next({ type: 'change', path: fullPath, stats: stableStats });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File was deleted during write
|
||||||
|
if (this.watchedFiles.has(fullPath)) {
|
||||||
|
this.watchedFiles.delete(fullPath);
|
||||||
|
this.events$.next({ type: 'unlink', path: fullPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.events$.next({ type: 'error', path: fullPath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan directory and emit 'add' events for existing files
|
||||||
|
*/
|
||||||
|
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
|
||||||
|
if (depth > this.options.depth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
|
const stats = await this.statSafe(fullPath);
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
this.watchedFiles.add(fullPath);
|
||||||
|
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
||||||
|
await this.scanDirectory(fullPath, depth + 1);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
this.watchedFiles.add(fullPath);
|
||||||
|
this.events$.next({ type: 'add', path: fullPath, stats });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
|
||||||
|
this.events$.next({ type: 'error', path: dirPath, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely stat a path, returning null if it doesn't exist
|
||||||
|
*/
|
||||||
|
private async statSafe(filePath: string): Promise<fs.Stats | null> {
|
||||||
|
try {
|
||||||
|
if (this.options.followSymlinks) {
|
||||||
|
return await fs.promises.stat(filePath);
|
||||||
|
} else {
|
||||||
|
return await fs.promises.lstat(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path was known to be a directory (for proper unlink event type)
|
||||||
|
*/
|
||||||
|
private isKnownDirectory(filePath: string): boolean {
|
||||||
|
// Check if any watched files are children of this path
|
||||||
|
for (const watched of this.watchedFiles) {
|
||||||
|
if (watched.startsWith(filePath + path.sep)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle duplicate events
|
||||||
|
*/
|
||||||
|
private shouldEmit(filePath: string, eventType: string): boolean {
|
||||||
|
const key = `${filePath}:${eventType}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const lastEmit = this.recentEvents.get(key);
|
||||||
|
|
||||||
|
if (lastEmit && now - lastEmit < this.throttleMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentEvents.set(key, now);
|
||||||
|
|
||||||
|
// Clean up old entries periodically
|
||||||
|
if (this.recentEvents.size > 1000) {
|
||||||
|
const cutoff = now - this.throttleMs * 2;
|
||||||
|
for (const [k, time] of this.recentEvents) {
|
||||||
|
if (time < cutoff) {
|
||||||
|
this.recentEvents.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user