Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afe462990f | |||
| 90275a0f1c | |||
| ef2388b16f | |||
| 6f6868f2ad | |||
| ea57de06dd | |||
| 4894253e48 | |||
| 2f55f628f5 | |||
| 5dda689b4c | |||
| 8677f61da1 | |||
| 0c236d44d3 | |||
| 913c14bfcf | |||
| 2ce056f401 | |||
| 1c77a94b96 | |||
| 097ea96e99 | |||
| 5a6d9a2575 | |||
| 7a7ee041a3 | |||
| 0dc4eaba52 | |||
| 31ab561859 | |||
| a5b4fedf9f | |||
| b84a50f87d | |||
| 2ae297e5fa | |||
| 9130613941 | |||
| fdd6281f0b | |||
| 0f17be179c | |||
| aab3ce213b | |||
| 6d02ef3ddc | |||
| 21827f06e9 | |||
| b5133c2a9c | |||
| 294420e0b2 | |||
| d7a05abfa2 | |||
| 3444b7c74d | |||
| bf76b225c6 | |||
| 3bad722c25 | |||
| 28ba176b62 | |||
| da500d7fd4 | |||
| 6aaf66ac5c | |||
| 34ba87a1fb | |||
| 8cd846e8bf | |||
| d98c54fba9 | |||
| 50b0fd357a | |||
| 42976a32d7 | |||
| b214de9370 | |||
| 3d98c2ba6f | |||
| e4a349c85e | |||
| 2abfa7c47a | |||
| 50aa0a136a | |||
| b98a9be2b7 | |||
| 3314928c5f | |||
| 0b7c29eae7 | |||
| 779e495941 | |||
| 8402deeccb | |||
| 7c4324b3ad | |||
| 268201727c | |||
| 12ae27b2ff | |||
| 3bb9b2229d | |||
| e17e4c376e | |||
| d5f2427aad | |||
| 5715c6fe67 | |||
| 9628e914b5 | |||
| 55746f5d92 | |||
| 90ae869686 | |||
| 771c454411 | |||
| 99395794d8 | |||
| 1763ede6df | |||
| 102b6ac9ae | |||
| cd9ca458f6 | |||
| 2c4fc8b212 | |||
| ce48b6f889 | |||
| 2e851cbabc | |||
| 4c001f5270 | |||
| dfbbe8090f | |||
| 40dd48533d | |||
| 1e252d2cd7 | |||
| 34eb725d40 | |||
| 0298bfe3c6 | |||
| ea26987ca6 | |||
| 8f6a4f4909 | |||
| c7b0722155 | |||
| 6cbd74eb12 | |||
| fe52811dcd | |||
| 9c1e306c99 | |||
| 19ab839853 | |||
| cb6c8f3c8e |
66
.gitea/workflows/default_nottags.yaml
Normal file
66
.gitea/workflows/default_nottags.yaml
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Default (not tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
|
||||
- name: Run npm prepare
|
||||
run: npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm build
|
||||
124
.gitea/workflows/default_tags.yaml
Normal file
124
.gitea/workflows/default_tags.yaml
Normal file
@@ -0,0 +1,124 @@
|
||||
name: Default (tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm build
|
||||
|
||||
release:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm publish
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Code quality
|
||||
run: |
|
||||
npmci command npm install -g typescript
|
||||
npmci npm install
|
||||
|
||||
- name: Trigger
|
||||
run: npmci trigger
|
||||
|
||||
- name: Build docs and upload artifacts
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
npmci command tsdoc
|
||||
continue-on-error: true
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1,4 +1,20 @@
|
||||
node_modules/
|
||||
pages/
|
||||
public/
|
||||
.nogit/
|
||||
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
|
||||
# caches
|
||||
.yarn/
|
||||
.cache/
|
||||
.rpt2_cache
|
||||
|
||||
# builds
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
@@ -1,71 +0,0 @@
|
||||
# gitzone standard
|
||||
image: hosttoday/ht-docker-node:npmci
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .yarn/
|
||||
key: "$CI_BUILD_STAGE"
|
||||
|
||||
stages:
|
||||
- test
|
||||
- release
|
||||
- trigger
|
||||
- pages
|
||||
|
||||
testLEGACY:
|
||||
stage: test
|
||||
script:
|
||||
- npmci test legacy
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
testLTS:
|
||||
stage: test
|
||||
script:
|
||||
- npmci test lts
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
testSTABLE:
|
||||
stage: test
|
||||
script:
|
||||
- npmci test stable
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
release:
|
||||
stage: release
|
||||
script:
|
||||
- npmci publish
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- docker
|
||||
|
||||
trigger:
|
||||
stage: trigger
|
||||
script:
|
||||
- npmci trigger
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- docker
|
||||
|
||||
pages:
|
||||
image: hosttoday/ht-docker-node:npmci
|
||||
stage: pages
|
||||
script:
|
||||
- npmci command yarn global add npmpage
|
||||
- npmci command npmpage --publish gitlab
|
||||
tags:
|
||||
- docker
|
||||
only:
|
||||
- tags
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- public
|
||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "npm test",
|
||||
"name": "Run npm test",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
.vscode/settings.json
vendored
Normal file
26
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"npmci": {
|
||||
"type": "object",
|
||||
"description": "settings for npmci"
|
||||
},
|
||||
"gitzone": {
|
||||
"type": "object",
|
||||
"description": "settings for gitzone",
|
||||
"properties": {
|
||||
"projectType": {
|
||||
"type": "string",
|
||||
"enum": ["website", "element", "service", "npm", "wcc"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
52
README.md
52
README.md
@@ -1,52 +0,0 @@
|
||||
# smartchok
|
||||
smart wrapper for chokidar
|
||||
|
||||
## Availabililty
|
||||
[](https://www.npmjs.com/package/smartchok)
|
||||
[](https://GitLab.com/pushrocks/smartchok)
|
||||
[](https://github.com/pushrocks/smartchok)
|
||||
[](https://pushrocks.gitlab.io/smartchok/)
|
||||
|
||||
## Status for master
|
||||
[](https://GitLab.com/pushrocks/smartchok/commits/master)
|
||||
[](https://GitLab.com/pushrocks/smartchok/commits/master)
|
||||
[](https://www.npmjs.com/package/smartchok)
|
||||
[](https://david-dm.org/pushrocks/smartchok)
|
||||
[](https://www.bithound.io/github/pushrocks/smartchok/master/dependencies/npm)
|
||||
[](https://www.bithound.io/github/pushrocks/smartchok)
|
||||
[](https://nodejs.org/dist/latest-v6.x/docs/api/)
|
||||
[](https://nodejs.org/dist/latest-v6.x/docs/api/)
|
||||
[](http://standardjs.com/)
|
||||
|
||||
## Usage
|
||||
Use TypeScript for best in class instellisense.
|
||||
|
||||
```javascript
|
||||
import { Smartchok } from 'smartchok'
|
||||
|
||||
let mySmartChok = new Smartchok(['some/path/**/*.any','/absolute/*.js'], chokidarOptions)
|
||||
|
||||
mySmartChok.add(['/some/**/*.any']) // add files
|
||||
|
||||
mySmartChok.remove('some/**/*.js')
|
||||
|
||||
mySmartChok.start() // starts the watch process
|
||||
|
||||
mySmartChok.getObservableFor('change').then((observableArg) => {
|
||||
observableArg.subscribe(x => {
|
||||
// do something here when a change is detected
|
||||
// possible events are 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw'
|
||||
// note that the observable is only created once you call .start() on the Smartchok instance
|
||||
// hence the promise construction
|
||||
})
|
||||
})
|
||||
|
||||
mySmartChok.stop()
|
||||
```
|
||||
|
||||
For further information read the linked docs at the top of this README.
|
||||
|
||||
> MIT licensed | **©** [Lossless GmbH](https://lossless.gmbh)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy.html)
|
||||
|
||||
[](https://push.rocks)
|
||||
217
changelog.md
Normal file
217
changelog.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-11 - 6.2.3 - fix(watcher.node)
|
||||
Improve handling of temporary files from atomic editor writes in Node watcher
|
||||
|
||||
- Detect temporary files produced by atomic editor saves and attempt to map them to the real target file instead of silently skipping the event
|
||||
- Add getTempFileTarget() to extract the real file path from temp filenames (supports patterns like file.ts.tmp.PID.TIMESTAMP and generic .tmp.*)
|
||||
- When a temp-file event is seen, queue a corresponding event for the resolved real file after a short delay (50ms) to allow rename/replace to complete
|
||||
- Add logging around temp file detection and real-file checks to aid debugging
|
||||
|
||||
## 2025-12-11 - 6.2.2 - fix(watcher.node)
|
||||
Defer events during initial scan, track full event sequences, and harden watcher shutdown
|
||||
|
||||
- Defer fs.watch events that arrive during the initial directory scan and process them after the scan completes to avoid race conditions where watchedFiles isn't populated.
|
||||
- Debounce now tracks the full sequence of events per file (rename/change) instead of collapsing to the last event, preventing intermediate events from being lost.
|
||||
- Detect delete+recreate via inode changes and emit unlink then add when appropriate; handle rapid create+delete sequences by emitting both events.
|
||||
- During stop(), cancel pending debounced emits before flipping _isWatching and make handleFsEvent return early when watcher is stopped to prevent orphaned timeouts and post-stop emits.
|
||||
- Add verbose logging of event sequences to aid debugging of complex fs event scenarios.
|
||||
- Update tests to expect unlink + add for inode replacement scenarios.
|
||||
- Version bump from 6.2.1 → 6.2.2
|
||||
|
||||
## 2025-12-10 - 6.2.1 - fix(watcher.node)
|
||||
Handle fs.watch close without spurious restarts; add tests and improve test runner
|
||||
|
||||
- Prevent spurious restarts and noisy warnings on fs.watch 'close' by checking the internal isWatching flag before logging and restarting (ts/watchers/watcher.node.ts).
|
||||
- Add comprehensive test suites covering basic operations, inode-change detection, atomic writes and stress scenarios (test/test.basic.ts, test/test.inode.ts, test/test.stress.ts).
|
||||
- Remove outdated test (test/test.ts) and delete the test asset test/assets/hi.txt.
|
||||
- Update test script in package.json to enable verbose logging, write a logfile and increase timeout to 120s to reduce flakiness in test runs.
|
||||
|
||||
## 2025-12-10 - 6.2.0 - feat(watchers)
|
||||
Improve Node watcher robustness: file-level inode tracking, abortable restarts, restart race guards, and untracked-file handling
|
||||
|
||||
- Add file-level inode tracking to detect delete+recreate (editor atomic saves) and emit correct 'change'/'add' events
|
||||
- Make restart delays abortable via AbortController so stop() cancels pending restarts and prevents orphan watchers
|
||||
- Guard against concurrent/dual restarts with restartingPaths to avoid race conditions between health checks and error handlers
|
||||
- Emit 'unlink' for deletions of previously untracked files (files created after initial scan) and clean up inode state
|
||||
- Track file inodes during initial scan and update/cleanup inode state on events
|
||||
- Improve logging for restart/inode/delete+recreate scenarios and update documentation/readme hints to v6.2.0+
|
||||
|
||||
## 2025-12-08 - 6.1.1 - fix(watchers/watcher.node)
|
||||
Improve Node watcher robustness: inode tracking, ENOSPC detection, enhanced health checks and temp-file handling
|
||||
|
||||
- Track directory inodes (watchedInodes) and restart watchers if inode changes are detected (addresses stale watchers when directories are replaced).
|
||||
- Health check now validates inode stability and explicitly detects ENOSPC (inotify max_user_watches) errors, emitting errors and logging a recommended fix command.
|
||||
- Detect ENOSPC in FSWatcher error events and log guidance to increase inotify limits.
|
||||
- Clear inode tracking state on watcher stop to avoid stale state across restarts.
|
||||
- Improve temporary file handling and logging to avoid dropping events for atomic writes (only skip pure temp files and log skipped temp events).
|
||||
- Documentation (readme.hints.md) updated with robustness notes, known fs.watch limitations, and example logs.
|
||||
|
||||
## 2025-12-08 - 6.1.0 - feat(watcher.node)
|
||||
Add automatic restart, periodic health checks, and safe event emission to Node watcher; improve logging and stat handling
|
||||
|
||||
- NodeWatcher: introduced safeEmit() to isolate subscriber errors and prevent watcher crashes
|
||||
- Auto-restart on failure with exponential backoff (1s → 30s) and up to 3 retry attempts per watched base path
|
||||
- Periodic health checks (every 30s) to detect missing watched paths and trigger automatic restarts
|
||||
- Handle unexpected FSWatcher 'close' events and restart watchers when they close silently
|
||||
- Verbose lifecycle logging with `[smartwatch]` prefix for start/stop/health/restart events
|
||||
- Clear restart tracking and stop health checks on watcher.stop() to ensure clean shutdown
|
||||
- Improved statSafe() to normalize followSymlinks logic and log non-ENO errors as warnings
|
||||
- Updated readme.hints.md documenting the new robustness features (v6.1.0+)
|
||||
|
||||
## 2025-12-08 - 6.0.0 - BREAKING CHANGE(watchers)
|
||||
Replace polling-based write stabilization with debounce-based event coalescing and simplify watcher options
|
||||
|
||||
- Remove polling-based WriteStabilizer (ts/utils/write-stabilizer.ts) and related waitForWriteFinish logic
|
||||
- Introduce debounce-based coalescing (debounceMs) for Node and Deno watchers (ts/watchers/watcher.node.ts, ts/watchers/watcher.deno.ts)
|
||||
- Change IWatcherOptions: remove stabilityThreshold/pollInterval/maxWaitTime and add debounceMs
|
||||
- Default watcher options updated to use debounceMs = 100
|
||||
- Node/Deno watchers now skip editor temp files earlier, debounce duplicate events, and emit events directly (no size polling)
|
||||
- Updated default watcher creation in Smartwatch to pass debounceMs
|
||||
- Update package.json build script to run 'tsbuild tsfolders'
|
||||
|
||||
## 2025-12-08 - 5.1.0 - feat(watchers)
|
||||
Improve write stabilization and ignore temporary editor files
|
||||
|
||||
- Add maxWaitTime option (ms) to IWatcherOptions and WriteStabilizer to cap how long stabilization will wait (default: 1000ms).
|
||||
- WriteStabilizer: reduce default stabilityThreshold from 300ms to 100ms and track write start time to enforce maxWaitTime and avoid indefinite polling.
|
||||
- Node and Deno watchers: detect and ignore common temporary/editor files (e.g. *.tmp.*, *.swp, *.swx, trailing ~, .#*) to prevent spurious events from atomic saves.
|
||||
- Node watcher: treat rename (atomic-save) events as already-complete files and emit add/change immediately without stabilization.
|
||||
- Deno watcher: use the configured maxWaitTime and polling-based stabilization for modify events to ensure consistent behavior across runtimes.
|
||||
|
||||
## 2025-11-30 - 5.0.0 - BREAKING CHANGE(@push.rocks/smartwatch)
|
||||
Rename package and update branding/docs: switch from @push.rocks/smartchok to @push.rocks/smartwatch, update repository/homepage/bugs URLs and author, and refresh README examples and install instructions.
|
||||
|
||||
- Package name changed from @push.rocks/smartchok to @push.rocks/smartwatch in package.json
|
||||
- Repository, homepage and issue URLs updated to point to the new smartwatch repository
|
||||
- Author changed to Task Venture Capital GmbH in package metadata
|
||||
- README updated: install commands, import examples and references now use @push.rocks/smartwatch
|
||||
- Documentation text and branding updated throughout README (project name, internal references)
|
||||
|
||||
## 2025-11-30 - 4.0.1 - fix(readme)
|
||||
Update README: refine description and clarify trademark/legal information
|
||||
|
||||
- Refined project description and tagline for clarity and brevity (adds lightweight wording and an emoji).
|
||||
- Updated Trademark section to explicitly mention third-party trademarks, add guidance about usage and approval, and clarify that trademarks are not covered by the MIT license.
|
||||
- Minor legal/company wording and formatting fixes (e.g. 'District Court' capitalization and contact sentence tweaks).
|
||||
- General README wording and formatting improvements for clearer instructions and feature descriptions.
|
||||
|
||||
## 2025-11-30 - 4.0.0 - BREAKING CHANGE(watchers)
|
||||
Replace chokidar with native platform watchers and add cross-runtime support (Node.js, Deno, Bun); introduce write stabilization and internal glob matching
|
||||
|
||||
- Replaced chokidar-based implementation with native file watching APIs (Node.js fs.watch, Deno.watchFs).
|
||||
- Added platform-specific watchers: NodeWatcher and DenoWatcher (Bun uses Node compatibility).
|
||||
- Implemented polling-based write stabilization (awaitWriteFinish replacement) to avoid duplicate events during writes.
|
||||
- Keep glob pattern support by matching events internally using picomatch; base-path extraction used to limit watch scope.
|
||||
- API/runtime requirement increased: Node.js >= 20.0.0 is required for native recursive fs.watch.
|
||||
- Package/documentation name and examples updated to @push.rocks/smartchok and export the Smartwatch class.
|
||||
|
||||
## 2025-11-30 - 3.0.0 - BREAKING CHANGE(smartwatch)
|
||||
Introduce Smartwatch: cross-runtime native file watching for Node.js, Deno and Bun; rename smartchok to smartwatch and bump major version to 2.0.0
|
||||
|
||||
- Rename public API and docs from Smartchok to Smartwatch and update package metadata for the new module name.
|
||||
- Replace chokidar with native watchers and picomatch-based glob filtering to enable cross-runtime support (Node.js, Deno, Bun).
|
||||
- Add watcher factory and runtime-specific implementations: watchers/index.ts, watcher.node.ts, watcher.deno.ts.
|
||||
- Add WriteStabilizer (ts/utils/write-stabilizer.ts) to provide awaitWriteFinish functionality via polling.
|
||||
- Introduce @push.rocks/smartenv for runtime detection and remove direct chokidar dependency; update dependencies accordingly.
|
||||
- Update tests (test/test.ts) and documentation (readme.md, readme.hints.md) to reflect API/name changes and new architecture.
|
||||
- Bump package version to 2.0.0 to mark breaking changes in API and behavior.
|
||||
|
||||
## 2025-11-29 - 1.2.0 - feat(core)
|
||||
Migrate to chokidar 5.x, add picomatch filtering and update test/dev dependencies
|
||||
|
||||
- Upgrade runtime dependencies: chokidar -> ^5.0.0 and picomatch -> ^4.0.3; bumped related @push.rocks packages versions.
|
||||
- Upgrade devDependencies: @git.zone/tsbuild, @git.zone/tsrun and @git.zone/tstest to newer v2/v3 releases; updated @types/node.
|
||||
- Updated README and readme.hints to document migration to chokidar 5.x and dev dependency changes.
|
||||
- Tests updated to use @git.zone/tstest/tapbundle (import change) and test runner start changed to export default tap.start().
|
||||
- Smartchok implementation updated to extract glob base paths, watch base directories and filter events via picomatch matchers (shouldWatchPath + event filtering).
|
||||
- Note: ts/00_commitinfo_data.ts still references chokidar 4.x in the description and should be updated to reflect the migration.
|
||||
|
||||
## 2025-06-26 - 1.1.1 - fix(package.json)
|
||||
Add packageManager field to package.json for pnpm configuration
|
||||
|
||||
- Added "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" to package.json
|
||||
|
||||
## 2025-06-26 - 1.1.0 - feat(Smartchok)
|
||||
Migrate to chokidar 4.x with picomatch glob support, update documentation and tests
|
||||
|
||||
- Removed deprecated @tempfix/watcher and added chokidar and picomatch as dependencies in package.json
|
||||
- Updated Smartchok class to extract base paths and apply custom glob filtering using picomatch
|
||||
- Revised readme and technical hints to reflect migration to chokidar 4.x and clarify glob pattern support
|
||||
- Adjusted tests and commit info to align with the updated code structure
|
||||
|
||||
## 2024-05-29 – 1.0.34 – general
|
||||
This release improves the project description.
|
||||
- update description
|
||||
|
||||
## 2024-05-06 – 1.0.33 – core
|
||||
This release includes a mix of bug fixes and configuration updates.
|
||||
- fix(core): update
|
||||
- update tsconfig
|
||||
- update npmextra.json: githost (recorded multiple times)
|
||||
|
||||
## 2024-02-29 – 1.0.32 to 1.0.28 – core fixes
|
||||
Releases 1.0.32 through 1.0.28 were dedicated to routine core fixes.
|
||||
(This range covers versions that only included “fix(core): update” changes.)
|
||||
|
||||
## 2024-01-28 – 1.0.27 – core
|
||||
This release not only fixed core issues but also adjusted the organization scheme.
|
||||
- fix(core): update
|
||||
- switch to new org scheme (recorded twice)
|
||||
|
||||
## 2021-12-01 – 1.0.26 to 1.0.14 – core fixes
|
||||
Releases 1.0.26 through 1.0.14 were devoted to routine core fixes.
|
||||
(No additional details beyond the core update were recorded.)
|
||||
|
||||
## 2018-02-28 – 1.0.13 to 1.0.11 – ci updates
|
||||
Releases 1.0.13 through 1.0.11 focused on updating the continuous integration configuration.
|
||||
- update ci
|
||||
|
||||
## 2017-06-30 – 1.0.10 – general
|
||||
This release delivered several improvements beyond a simple version bump.
|
||||
- fix Promise issues
|
||||
- update test
|
||||
- update
|
||||
|
||||
## 2017-06-30 – 1.0.9 – general
|
||||
This release addressed module loading and code hygiene.
|
||||
- fix loading of rxjs
|
||||
- clean up code
|
||||
|
||||
## 2017-06-30 – 1.0.8 – general
|
||||
A targeted update to align output with expectations.
|
||||
- update to wirj like expected
|
||||
|
||||
## 2017-04-09 – 1.0.7 – ci
|
||||
An update to the continuous integration configuration.
|
||||
- update ci
|
||||
|
||||
## 2017-04-09 – 1.0.6 – npm
|
||||
This release updated extra npm configuration.
|
||||
- update npmextra.json
|
||||
|
||||
## 2017-02-15 – 1.0.5 – general
|
||||
Standardization work was undertaken with new organizational practices.
|
||||
- update to new gitzone standard
|
||||
|
||||
## 2016-11-18 – 1.0.4 – general
|
||||
This release refreshed dependency settings.
|
||||
- update dependencies
|
||||
|
||||
## 2016-11-18 – 1.0.3 – general
|
||||
Readability and developer guidance were improved.
|
||||
- improve README
|
||||
|
||||
## 2016-11-18 – 1.0.2 – general
|
||||
Minor documentation and CI configuration enhancements were added.
|
||||
- add README
|
||||
- Update .gitlab-ci.yml
|
||||
|
||||
## 2016-09-22 – 1.0.1 – general
|
||||
A fix was applied to ensure the process exits correctly.
|
||||
- fix process not exiting problem
|
||||
|
||||
## 2016-09-22 – 1.0.0 – general
|
||||
The project’s initial setup was established along with CI configuration.
|
||||
- add gitlab-ci
|
||||
- initial
|
||||
1
dist/index.d.ts
vendored
1
dist/index.d.ts
vendored
@@ -1 +0,0 @@
|
||||
export * from './smartchok.classes.smartchok';
|
||||
7
dist/index.js
vendored
7
dist/index.js
vendored
@@ -1,7 +0,0 @@
|
||||
"use strict";
|
||||
function __export(m) {
|
||||
for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];
|
||||
}
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__export(require("./smartchok.classes.smartchok"));
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7OztBQUFBLG1EQUE2QyJ9
|
||||
0
dist/smartchok.child.d.ts
vendored
0
dist/smartchok.child.d.ts
vendored
3
dist/smartchok.child.js
vendored
3
dist/smartchok.child.js
vendored
@@ -1,3 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRjaG9rLmNoaWxkLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvc21hcnRjaG9rLmNoaWxkLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiIifQ==
|
||||
40
dist/smartchok.classes.smartchok.d.ts
vendored
40
dist/smartchok.classes.smartchok.d.ts
vendored
@@ -1,40 +0,0 @@
|
||||
import * as plugins from './smartchok.plugins';
|
||||
import { Stringmap } from 'lik';
|
||||
export declare type TSmartchokStatus = 'idle' | 'starting' | 'watching';
|
||||
export declare type TFsEvent = 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw';
|
||||
/**
|
||||
* Smartchok allows easy wathcing of files
|
||||
*/
|
||||
export declare class Smartchok {
|
||||
watchStringmap: Stringmap;
|
||||
chokidarOptions: plugins.chokidar.WatchOptions;
|
||||
status: TSmartchokStatus;
|
||||
private watcher;
|
||||
private watchingDeferred;
|
||||
private eventObservablemap;
|
||||
/**
|
||||
* constructor of class smartchok
|
||||
*/
|
||||
constructor(watchArrayArg: string[], optionsArg?: plugins.chokidar.WatchOptions);
|
||||
/**
|
||||
* adds files to the list of watched files
|
||||
*/
|
||||
add(pathArrayArg: string[]): void;
|
||||
/**
|
||||
* removes files from the list of watched files
|
||||
*/
|
||||
remove(pathArg: string): void;
|
||||
/**
|
||||
* gets an observable for a certain event
|
||||
*/
|
||||
getObservableFor(fsEvent: TFsEvent): Promise<plugins.rx.Observable<any>>;
|
||||
/**
|
||||
* starts the watcher
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
start(): Promise<void>;
|
||||
/**
|
||||
* stop the watcher process if watching
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
75
dist/smartchok.classes.smartchok.js
vendored
75
dist/smartchok.classes.smartchok.js
vendored
@@ -1,75 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const plugins = require("./smartchok.plugins");
|
||||
const lik_1 = require("lik");
|
||||
/**
|
||||
* Smartchok allows easy wathcing of files
|
||||
*/
|
||||
class Smartchok {
|
||||
/**
|
||||
* constructor of class smartchok
|
||||
*/
|
||||
constructor(watchArrayArg, optionsArg = {}) {
|
||||
this.watchStringmap = new lik_1.Stringmap();
|
||||
this.status = 'idle';
|
||||
this.watchingDeferred = plugins.smartq.defer(); // used to run things when watcher is initialized
|
||||
this.eventObservablemap = new plugins.lik.Observablemap(); // register one observable per event
|
||||
this.watchStringmap.addStringArray(watchArrayArg);
|
||||
this.chokidarOptions = optionsArg;
|
||||
}
|
||||
/**
|
||||
* adds files to the list of watched files
|
||||
*/
|
||||
add(pathArrayArg) {
|
||||
this.watchStringmap.addStringArray(pathArrayArg);
|
||||
}
|
||||
/**
|
||||
* removes files from the list of watched files
|
||||
*/
|
||||
remove(pathArg) {
|
||||
this.watchStringmap.removeString(pathArg);
|
||||
}
|
||||
/**
|
||||
* gets an observable for a certain event
|
||||
*/
|
||||
getObservableFor(fsEvent) {
|
||||
let done = plugins.smartq.defer();
|
||||
this.watchingDeferred.promise.then(() => {
|
||||
let eventObservable = this.eventObservablemap.getObservableForEmitterEvent(this.watcher, fsEvent);
|
||||
done.resolve(eventObservable);
|
||||
});
|
||||
return done.promise;
|
||||
}
|
||||
/**
|
||||
* starts the watcher
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
start() {
|
||||
let done = plugins.smartq.defer();
|
||||
this.status = 'starting';
|
||||
this.watcher = plugins.chokidar.watch(this.watchStringmap.getStringArray(), this.chokidarOptions);
|
||||
this.watcher.on('ready', () => {
|
||||
this.status = 'watching';
|
||||
this.watchingDeferred.resolve();
|
||||
done.resolve();
|
||||
});
|
||||
return done.promise;
|
||||
}
|
||||
/**
|
||||
* stop the watcher process if watching
|
||||
*/
|
||||
stop() {
|
||||
let closeWatcher = () => {
|
||||
this.watcher.close();
|
||||
};
|
||||
if (this.status === 'watching') {
|
||||
console.log('closing while watching');
|
||||
closeWatcher();
|
||||
}
|
||||
else if (this.status === 'starting') {
|
||||
this.watchingDeferred.promise.then(() => { closeWatcher(); });
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Smartchok = Smartchok;
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRjaG9rLmNsYXNzZXMuc21hcnRjaG9rLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvc21hcnRjaG9rLmNsYXNzZXMuc21hcnRjaG9rLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O0FBQUEsK0NBQThDO0FBQzlDLDZCQUErQjtBQUsvQjs7R0FFRztBQUNIO0lBUUU7O09BRUc7SUFDSCxZQUFhLGFBQXVCLEVBQUUsYUFBNEMsRUFBRTtRQVZwRixtQkFBYyxHQUFHLElBQUksZUFBUyxFQUFFLENBQUE7UUFFaEMsV0FBTSxHQUFxQixNQUFNLENBQUE7UUFFekIscUJBQWdCLEdBQUcsT0FBTyxDQUFDLE1BQU0sQ0FBQyxLQUFLLEVBQVEsQ0FBQSxDQUFDLGlEQUFpRDtRQUNqRyx1QkFBa0IsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLENBQUMsYUFBYSxFQUFFLENBQUEsQ0FBQyxvQ0FBb0M7UUFNL0YsSUFBSSxDQUFDLGNBQWMsQ0FBQyxjQUFjLENBQUMsYUFBYSxDQUFDLENBQUE7UUFDakQsSUFBSSxDQUFDLGVBQWUsR0FBRyxVQUFVLENBQUE7SUFDbkMsQ0FBQztJQUVEOztPQUVHO0lBQ0gsR0FBRyxDQUFDLFlBQXNCO1FBQ3hCLElBQUksQ0FBQyxjQUFjLENBQUMsY0FBYyxDQUFDLFlBQVksQ0FBQyxDQUFBO0lBQ2xELENBQUM7SUFFRDs7T0FFRztJQUNILE1BQU0sQ0FBRSxPQUFlO1FBQ3JCLElBQUksQ0FBQyxjQUFjLENBQUMsWUFBWSxDQUFDLE9BQU8sQ0FBQyxDQUFBO0lBQzNDLENBQUM7SUFFRDs7T0FFRztJQUNILGdCQUFnQixDQUFFLE9BQWlCO1FBQ2pDLElBQUksSUFBSSxHQUFHLE9BQU8sQ0FBQyxNQUFNLENBQUMsS0FBSyxFQUE4QixDQUFBO1FBQzdELElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDO1lBQ2pDLElBQUksZUFBZSxHQUFHLElBQUksQ0FBQyxrQkFBa0IsQ0FBQyw0QkFBNEIsQ0FBQyxJQUFJLENBQUMsT0FBTyxFQUFFLE9BQU8sQ0FBQyxDQUFBO1lBQ2pHLElBQUksQ0FBQyxPQUFPLENBQUMsZUFBZSxDQUFDLENBQUE7UUFDL0IsQ0FBQyxDQUFDLENBQUE7UUFDRixNQUFNLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBQTtJQUNyQixDQUFDO0lBRUQ7OztPQUdHO0lBQ0gsS0FBSztRQUNILElBQUksSUFBSSxHQUFHLE9BQU8sQ0FBQyxNQUFNLENBQUMsS0FBSyxFQUFRLENBQUE7UUFDdkMsSUFBSSxDQUFDLE1BQU0sR0FBRyxVQUFVLENBQUE7UUFDeEIsSUFBSSxDQUFDLE9BQU8sR0FBRyxPQUFPLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxJQUFJLENBQUMsY0FBYyxDQUFDLGNBQWMsRUFBRSxFQUFFLElBQUksQ0FBQyxlQUFlLENBQUMsQ0FBQTtRQUNqRyxJQUFJLENBQUMsT0FBTyxDQUFDLEVBQUUsQ0FBQyxPQUFPLEVBQUU7WUFDdkIsSUFBSSxDQUFDLE1BQU0sR0FBRyxVQUFVLENBQUE7WUFDeEIsSUFBSSxDQUFDLGdCQUFnQixDQUFDLE9BQU8sRUFBRSxDQUFBO1lBQy9CLElBQUksQ0FBQyxPQUFPLEVBQUUsQ0FBQTtRQUNoQixDQUFDLENBQUMsQ0FBQTtRQUNGLE1BQU0sQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFBO0lBQ3JCLENBQUM7SUFFRDs7T0FFRztJQUNILElBQUk7UUFDRixJQUFJLFlBQVksR0FBRztZQUNqQixJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssRUFBRSxDQUFBO1FBQ3RCLENBQUMsQ0FBQTtRQUNELEVBQUUsQ0FBQyxDQUFDLElBQUksQ0FBQyxNQUFNLEtBQUssVUFBVSxDQUFDLENBQUMsQ0FBQztZQUMvQixPQUFPLENBQUMsR0FBRyxDQUFDLHdCQUF3QixDQUFDLENBQUE7WUFDckMsWUFBWSxFQUFFLENBQUE7UUFDaEIsQ0FBQztRQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsQ0FBQyxJQUFJLENBQUMsTUFBTSxLQUFLLFVBQVUsQ0FBQyxDQUFDLENBQUM7WUFDdEMsSUFBSSxDQUFDLGdCQUFnQixDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsUUFBUSxZQUFZLEVBQUUsQ0FBQSxDQUFDLENBQUMsQ0FBQyxDQUFBO1FBQzlELENBQUM7SUFDSCxDQUFDO0NBQ0Y7QUF4RUQsOEJBd0VDIn0=
|
||||
0
dist/smartchok.classes.watcher.d.ts
vendored
0
dist/smartchok.classes.watcher.d.ts
vendored
3
dist/smartchok.classes.watcher.js
vendored
3
dist/smartchok.classes.watcher.js
vendored
@@ -1,3 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRjaG9rLmNsYXNzZXMud2F0Y2hlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3NtYXJ0Y2hvay5jbGFzc2VzLndhdGNoZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9
|
||||
13
dist/smartchok.plugins.d.ts
vendored
13
dist/smartchok.plugins.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
import 'typings-global';
|
||||
export import lik = require('lik');
|
||||
export import chokidar = require('chokidar');
|
||||
export import path = require('path');
|
||||
export import smartq = require('smartq');
|
||||
export import rx = require('rxjs/Rx');
|
||||
import { Operator } from 'rxjs/Operator';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
declare module 'rxjs/Subject' {
|
||||
interface Subject<T> {
|
||||
lift<R>(operator: Operator<T, R>): Observable<R>;
|
||||
}
|
||||
}
|
||||
9
dist/smartchok.plugins.js
vendored
9
dist/smartchok.plugins.js
vendored
@@ -1,9 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("typings-global");
|
||||
exports.lik = require("lik");
|
||||
exports.chokidar = require("chokidar");
|
||||
exports.path = require("path");
|
||||
exports.smartq = require("smartq");
|
||||
exports.rx = require("rxjs/Rx");
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRjaG9rLnBsdWdpbnMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9zbWFydGNob2sucGx1Z2lucy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUFBLDBCQUF1QjtBQUN2Qiw2QkFBa0M7QUFDbEMsdUNBQTRDO0FBQzVDLCtCQUFvQztBQUNwQyxtQ0FBd0M7QUFDeEMsZ0NBQXFDIn0=
|
||||
@@ -1,10 +1,32 @@
|
||||
{
|
||||
"npmts": {
|
||||
"mode": "default"
|
||||
},
|
||||
"npmci": {
|
||||
"globalNpmTools": [
|
||||
"npmts"
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartwatch",
|
||||
"description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced file watching features.",
|
||||
"npmPackagename": "@push.rocks/smartwatch",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"file watching",
|
||||
"chokidar",
|
||||
"filesystem",
|
||||
"observable",
|
||||
"typescript",
|
||||
"node.js",
|
||||
"development tool",
|
||||
"file system events",
|
||||
"real-time",
|
||||
"watch files"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
}
|
||||
}
|
||||
81
package.json
81
package.json
@@ -1,34 +1,73 @@
|
||||
{
|
||||
"name": "smartchok",
|
||||
"version": "1.0.11",
|
||||
"description": "smart wrapper for chokidar",
|
||||
"main": "dist/index.js",
|
||||
"typings": "dist/index.d.ts",
|
||||
"name": "@push.rocks/smartwatch",
|
||||
"version": "6.2.3",
|
||||
"private": false,
|
||||
"description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "(yarn run prepareTest && npmts)",
|
||||
"prepareTest": "(rm -f ./test/assets/hi.txt)"
|
||||
"test": "(npm run prepareTest && tstest test/ --verbose --logfile --timeout 120)",
|
||||
"prepareTest": "(rm -f ./test/assets/hi.txt)",
|
||||
"build": "tsbuild tsfolders",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@gitlab.com/pushrocks/smartchok.git"
|
||||
"url": "https://code.foss.global/push.rocks/smartwatch.git"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/pushrocks/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://gitlab.com/pushrocks/smartchok#README",
|
||||
"dependencies": {
|
||||
"@types/chokidar": "^1.7.0",
|
||||
"chokidar": "^1.7.0",
|
||||
"lik": "^1.0.32",
|
||||
"rxjs": "^5.4.1",
|
||||
"smartipc": "^1.0.9",
|
||||
"smartq": "^1.1.1",
|
||||
"typings-global": "^1.0.19"
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"smartfile": "^4.2.17",
|
||||
"tapbundle": "^1.0.14"
|
||||
}
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@push.rocks/smartfile": "^11.0.4",
|
||||
"@types/node": "^24.10.1"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
],
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"file watching",
|
||||
"filesystem",
|
||||
"observable",
|
||||
"typescript",
|
||||
"node.js",
|
||||
"deno",
|
||||
"bun",
|
||||
"cross-runtime",
|
||||
"development tool",
|
||||
"file system events",
|
||||
"real-time",
|
||||
"watch files",
|
||||
"glob patterns"
|
||||
],
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
|
||||
10240
pnpm-lock.yaml
generated
Normal file
10240
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
167
readme.hints.md
Normal file
167
readme.hints.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# smartchok - Technical Hints
|
||||
|
||||
## Native File Watching (v2.0.0+)
|
||||
|
||||
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
|
||||
|
||||
- **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?
|
||||
|
||||
Native file watching APIs don't support glob patterns. Picomatch provides glob pattern matching with:
|
||||
- Zero dependencies
|
||||
- 164M+ weekly downloads
|
||||
- Excellent security profile
|
||||
- Full glob syntax support
|
||||
|
||||
### Event Handling
|
||||
|
||||
Native events are normalized to a consistent interface:
|
||||
|
||||
| Node.js/Bun Event | Deno Event | Normalized Event |
|
||||
|-------------------|------------|------------------|
|
||||
| `rename` (file exists) | `create` | `add` |
|
||||
| `rename` (file gone) | `remove` | `unlink` |
|
||||
| `change` | `modify` | `change` |
|
||||
|
||||
### awaitWriteFinish Implementation
|
||||
|
||||
The `WriteStabilizer` class replaces chokidar's built-in write stabilization:
|
||||
- Polls file size until stable (configurable threshold: 300ms default)
|
||||
- Configurable poll interval (100ms default)
|
||||
- Handles file deletion during write detection
|
||||
|
||||
### 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 based on learnings from [chokidar](https://github.com/paulmillr/chokidar) and known [fs.watch issues](https://github.com/nodejs/node/issues/47058):
|
||||
|
||||
**Auto-restart on failure:**
|
||||
- Watchers automatically restart when errors occur
|
||||
- Exponential backoff (1s → 30s max)
|
||||
- Maximum 3 retry attempts before giving up
|
||||
- **v6.2.0+**: Race condition guards prevent orphan watchers when `stop()` is called during restart
|
||||
|
||||
**Inode tracking (critical for long-running watchers):**
|
||||
- `fs.watch()` watches the **inode**, not the path!
|
||||
- When directories are replaced (git checkout, atomic saves), the inode changes
|
||||
- Health check detects inode changes and restarts the watcher
|
||||
- **v6.2.0+**: File-level inode tracking detects delete+recreate (common editor save pattern)
|
||||
- This is the most common cause of "watcher stops working after some time"
|
||||
|
||||
**Health check monitoring:**
|
||||
- 30-second periodic health checks
|
||||
- Detects when watched paths disappear
|
||||
- Detects inode changes (directory replacement)
|
||||
- Detects ENOSPC errors (inotify limit exceeded)
|
||||
- **v6.2.0+**: Protected against dual-restart race conditions (health check + error handler)
|
||||
|
||||
**ENOSPC detection (Linux inotify limit):**
|
||||
- Detects when `/proc/sys/fs/inotify/max_user_watches` is exceeded
|
||||
- Logs fix command: `echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p`
|
||||
|
||||
**Error isolation:**
|
||||
- Subscriber errors don't crash the watcher
|
||||
- All events emitted via `safeEmit()` with try-catch
|
||||
|
||||
**Untracked file handling (v6.2.0+):**
|
||||
- Files created after initial scan are properly detected
|
||||
- Untracked file deletions emit `unlink` events instead of being silently dropped
|
||||
|
||||
**Event Deferral During Initial Scan (v6.2.2+):**
|
||||
- Events are queued until initial scan completes
|
||||
- Prevents race conditions where events arrive before `watchedFiles` is populated
|
||||
- Deferred events are processed after scan completes
|
||||
|
||||
**Event Sequence Tracking (v6.2.2+):**
|
||||
- Debounce now tracks ALL events in sequence, not just the last one
|
||||
- Prevents losing intermediate events (e.g., add→change→delete no longer loses add)
|
||||
- Intelligent processing of event sequences:
|
||||
- Delete+recreate with inode change → emits `unlink` then `add`
|
||||
- Rapid create+delete → emits both events
|
||||
- Multiple changes → single `change` event (debouncing)
|
||||
|
||||
**Post-Stop Event Guards (v6.2.2+):**
|
||||
- `handleFsEvent()` returns early if watcher is stopped
|
||||
- Pending emits are cleared BEFORE setting `_isWatching = false`
|
||||
- Prevents orphaned timeouts and events after `stop()`
|
||||
|
||||
**Verbose logging:**
|
||||
- All lifecycle events logged with `[smartwatch]` prefix
|
||||
- Event sequences logged for debugging complex scenarios
|
||||
- Helps debug watcher issues in production
|
||||
|
||||
Example log output:
|
||||
```
|
||||
[smartwatch] Starting watcher for 1 base path(s)...
|
||||
[smartwatch] Started watching: ./test/assets/
|
||||
[smartwatch] Starting health check (every 30s)
|
||||
[smartwatch] Watcher started with 1 active watcher(s)
|
||||
[smartwatch] Health check: 1 watchers active
|
||||
[smartwatch] Processing event sequence for ./src/file.ts: [rename, rename, change]
|
||||
[smartwatch] File inode changed (delete+recreate): ./src/file.ts
|
||||
[smartwatch] Previous inode: 12345, current: 67890
|
||||
```
|
||||
|
||||
### Known fs.watch Limitations
|
||||
|
||||
1. **Watches inode, not path** - If a directory is replaced, watcher goes stale
|
||||
2. **inotify limits on Linux** - Default `max_user_watches` (8192) may be too low
|
||||
3. **No events for some atomic writes** - Some editors' save patterns may not trigger events
|
||||
4. **Platform differences** - Linux uses inotify, macOS uses FSEvents/kqueue
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
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`
|
||||
261
readme.md
Normal file
261
readme.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# @push.rocks/smartwatch
|
||||
|
||||
A lightweight, cross-runtime file watcher with glob pattern support for **Node.js**, **Deno**, and **Bun**. Zero heavyweight dependencies — just native file watching APIs for maximum performance. 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install @push.rocks/smartwatch
|
||||
# or
|
||||
pnpm add @push.rocks/smartwatch
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
🌐 **Cross-Runtime** — Works seamlessly on Node.js 20+, Deno, and Bun
|
||||
🔍 **Glob Pattern Support** — Watch files using familiar patterns like `**/*.ts` and `src/**/*.{js,jsx}`
|
||||
📡 **RxJS Observables** — Subscribe to file system events using reactive streams
|
||||
🔄 **Dynamic Watching** — Add or remove watch patterns at runtime
|
||||
⚡ **Native Performance** — Uses `fs.watch()` on Node.js/Bun and `Deno.watchFs()` on Deno
|
||||
✨ **Write Stabilization** — Built-in debouncing prevents duplicate events during file writes
|
||||
🎯 **TypeScript First** — Full TypeScript support with comprehensive type definitions
|
||||
📦 **Minimal Footprint** — No chokidar, no FSEvents bindings — just ~500 lines of focused code
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||
|
||||
// Create a watcher with glob patterns
|
||||
const watcher = new Smartwatch([
|
||||
'./src/**/*.ts', // Watch all TypeScript files in src
|
||||
'./public/assets/**/*' // Watch all files in public/assets
|
||||
]);
|
||||
|
||||
// Start watching
|
||||
await watcher.start();
|
||||
```
|
||||
|
||||
### Subscribing to File Events
|
||||
|
||||
Use RxJS observables to react to file system changes:
|
||||
|
||||
```typescript
|
||||
// Get an observable for file changes
|
||||
const changeObservable = await watcher.getObservableFor('change');
|
||||
|
||||
changeObservable.subscribe({
|
||||
next: ([path, stats]) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
console.log(`New size: ${stats?.size} bytes`);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for new files
|
||||
const addObservable = await watcher.getObservableFor('add');
|
||||
addObservable.subscribe(([path]) => {
|
||||
console.log(`File added: ${path}`);
|
||||
});
|
||||
|
||||
// Watch for deleted files
|
||||
const unlinkObservable = await watcher.getObservableFor('unlink');
|
||||
unlinkObservable.subscribe(([path]) => {
|
||||
console.log(`File deleted: ${path}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event | Description |
|
||||
|-------------|--------------------------------|
|
||||
| `add` | File has been added |
|
||||
| `addDir` | Directory has been added |
|
||||
| `change` | File has been modified |
|
||||
| `unlink` | File has been removed |
|
||||
| `unlinkDir` | Directory has been removed |
|
||||
| `error` | Error occurred |
|
||||
| `ready` | Initial scan complete |
|
||||
|
||||
### Dynamic Watch Management
|
||||
|
||||
Add or remove patterns while the watcher is running:
|
||||
|
||||
```typescript
|
||||
const watcher = new Smartwatch(['./src/**/*.ts']);
|
||||
await watcher.start();
|
||||
|
||||
// Add more patterns to watch
|
||||
watcher.add(['./tests/**/*.spec.ts', './config/*.json']);
|
||||
|
||||
// Remove a pattern
|
||||
watcher.remove('./src/**/*.test.ts');
|
||||
```
|
||||
|
||||
### Stopping the Watcher
|
||||
|
||||
```typescript
|
||||
// Stop watching when done
|
||||
await watcher.stop();
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||
|
||||
async function watchProject() {
|
||||
// Initialize with patterns
|
||||
const watcher = new Smartwatch([
|
||||
'./src/**/*.ts',
|
||||
'./package.json'
|
||||
]);
|
||||
|
||||
// Start the watcher
|
||||
await watcher.start();
|
||||
console.log('👀 Watching for changes...');
|
||||
|
||||
// Subscribe to changes
|
||||
const changes = await watcher.getObservableFor('change');
|
||||
changes.subscribe(([path, stats]) => {
|
||||
console.log(`📝 Modified: ${path}`);
|
||||
console.log(` Size: ${stats?.size ?? 'unknown'} bytes`);
|
||||
});
|
||||
|
||||
// Subscribe to new files
|
||||
const additions = await watcher.getObservableFor('add');
|
||||
additions.subscribe(([path]) => {
|
||||
console.log(`✨ New file: ${path}`);
|
||||
});
|
||||
|
||||
// Subscribe to deletions
|
||||
const deletions = await watcher.getObservableFor('unlink');
|
||||
deletions.subscribe(([path]) => {
|
||||
console.log(`🗑️ Deleted: ${path}`);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n🛑 Stopping watcher...');
|
||||
await watcher.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
watchProject();
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
smartwatch uses native file watching APIs for each runtime:
|
||||
|
||||
| Runtime | API Used |
|
||||
|-----------------|----------------------------------|
|
||||
| **Node.js 20+** | `fs.watch({ recursive: true })` |
|
||||
| **Deno** | `Deno.watchFs()` |
|
||||
| **Bun** | Node.js compatibility layer |
|
||||
|
||||
### Under the Hood
|
||||
|
||||
Native file watching APIs don't support glob patterns directly, so smartwatch handles pattern matching internally:
|
||||
|
||||
1. **Base path extraction** — Extracts the static portion from each glob pattern (e.g., `./src/` from `./src/**/*.ts`)
|
||||
2. **Efficient watching** — Native watchers monitor only the base directories
|
||||
3. **Pattern filtering** — Events are filtered through [picomatch](https://github.com/micromatch/picomatch) matchers before emission
|
||||
4. **Event deduplication** — Built-in throttling prevents duplicate events from rapid file operations
|
||||
|
||||
### Write Stabilization
|
||||
|
||||
smartwatch includes built-in write stabilization (similar to chokidar's `awaitWriteFinish`). When a file is being written, events are held until the file size stabilizes, preventing multiple events for a single write operation.
|
||||
|
||||
Default settings:
|
||||
- **Stability threshold**: 300ms
|
||||
- **Poll interval**: 100ms
|
||||
|
||||
## Requirements
|
||||
|
||||
| Runtime | Version |
|
||||
|-----------------|----------------------------------------|
|
||||
| **Node.js** | 20+ (required for native recursive watching) |
|
||||
| **Deno** | Any version with `Deno.watchFs()` support |
|
||||
| **Bun** | Uses Node.js compatibility |
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Smartwatch`
|
||||
|
||||
#### Constructor
|
||||
|
||||
```typescript
|
||||
new Smartwatch(patterns: string[])
|
||||
```
|
||||
|
||||
Creates a new Smartwatch instance with the given glob patterns.
|
||||
|
||||
**Parameters:**
|
||||
- `patterns` — Array of glob patterns to watch (e.g., `['./src/**/*.ts', './config/*.json']`)
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|----------------------------------------|------------------------------------------------|------------------------------------------------|
|
||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||
| `stop()` | `Promise<void>` | Stops the file watcher and cleans up resources |
|
||||
| `add(patterns: string[])` | `void` | Adds additional patterns to watch |
|
||||
| `remove(pattern: string)` | `void` | Removes a pattern from the watch list |
|
||||
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|-------------------------------------------|--------------------------|
|
||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||
|
||||
### 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
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
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.
|
||||
70
readme.plan.md
Normal file
70
readme.plan.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Migration Plan: smartchok to Chokidar 4.x
|
||||
|
||||
Command to reread CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
|
||||
|
||||
## MIGRATION COMPLETED ✅
|
||||
|
||||
All phases of the migration have been successfully completed:
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
- **Current dependency**: `@tempfix/watcher` v2.3.0 (a fork of fabiospampinato/watcher)
|
||||
- **Target**: Chokidar v4.0.3
|
||||
- **Major challenge**: Chokidar 4.x removed glob support, but smartchok heavily uses glob patterns
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Preparation
|
||||
1. Install chokidar 4.x and glob library for pattern matching
|
||||
- `pnpm install chokidar@^4.0.3`
|
||||
- `pnpm install picomatch` (for glob pattern matching)
|
||||
- `pnpm uninstall @tempfix/watcher`
|
||||
|
||||
### Phase 2: Code Changes
|
||||
|
||||
#### 2.1 Update Plugin Imports (ts/smartchok.plugins.ts)
|
||||
- Remove `@tempfix/watcher` import
|
||||
- Add `chokidar` import
|
||||
- Add `picomatch` for glob pattern matching
|
||||
|
||||
#### 2.2 Update Smartchok Class (ts/smartchok.classes.smartchok.ts)
|
||||
- Replace watcher initialization with chokidar
|
||||
- Implement custom glob filtering using picomatch
|
||||
- Update event mapping to match chokidar's event names
|
||||
- Adjust watcher options to match chokidar's API
|
||||
|
||||
#### 2.3 Handle Glob Patterns
|
||||
Since chokidar 4.x removed glob support, we need to:
|
||||
- Parse glob patterns to extract base directories
|
||||
- Use chokidar to watch base directories
|
||||
- Use picomatch to filter events based on glob patterns
|
||||
- Ensure backward compatibility with existing API
|
||||
|
||||
#### 2.4 Event Mapping
|
||||
Map chokidar events to existing smartchok events:
|
||||
- Keep existing event names for backward compatibility
|
||||
- Ensure all current functionality is preserved
|
||||
|
||||
### Phase 3: Testing
|
||||
1. Run existing tests to ensure backward compatibility
|
||||
2. Add new tests for glob pattern handling
|
||||
3. Test edge cases with complex glob patterns
|
||||
4. Verify performance with large file sets
|
||||
|
||||
### Phase 4: Documentation
|
||||
1. Update readme.md to reflect the change to chokidar 4.x
|
||||
2. Document any API changes (if any)
|
||||
3. Update version number in package.json
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Key Differences to Handle:
|
||||
1. **Glob Support**: Implement custom glob filtering layer
|
||||
2. **API Changes**: Adapt initialization and option passing
|
||||
3. **Event Names**: Map between different event naming conventions
|
||||
4. **Minimum Node Version**: Ensure compatibility with Node 14+
|
||||
|
||||
### Risk Mitigation:
|
||||
- Maintain backward compatibility with existing API
|
||||
- Extensive testing with current test suite
|
||||
- Consider keeping a legacy branch if breaking changes are unavoidable
|
||||
@@ -1 +0,0 @@
|
||||
HI
|
||||
127
test/test.basic.ts
Normal file
127
test/test.basic.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Skip in CI
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const TEST_DIR = './test/assets';
|
||||
|
||||
// Helper to delay
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to wait for an event with timeout
|
||||
async function waitForEvent<T>(
|
||||
observable: smartrx.rxjs.Observable<T>,
|
||||
timeoutMs: number = 5000
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const subscription = observable.subscribe((value) => {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
|
||||
// ===========================================
|
||||
// BASIC TESTS
|
||||
// ===========================================
|
||||
|
||||
tap.test('should create a new instance', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([]);
|
||||
expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch);
|
||||
});
|
||||
|
||||
tap.test('should add paths and start watching', async () => {
|
||||
testSmartwatch.add([`${TEST_DIR}/**/*.txt`]);
|
||||
await testSmartwatch.start();
|
||||
expect(testSmartwatch.status).toEqual('watching');
|
||||
});
|
||||
|
||||
tap.test('should detect ADD event for new files', async () => {
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const eventPromise = waitForEvent(addObservable);
|
||||
|
||||
// Create a new file
|
||||
const testFile = path.join(TEST_DIR, 'add-test.txt');
|
||||
await smartfile.memory.toFs('test content', testFile);
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('add-test.txt');
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('should detect CHANGE event for modified files', async () => {
|
||||
// First create the file
|
||||
const testFile = path.join(TEST_DIR, 'change-test.txt');
|
||||
await smartfile.memory.toFs('initial content', testFile);
|
||||
|
||||
// Wait for add event to complete
|
||||
await delay(200);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const eventPromise = waitForEvent(changeObservable);
|
||||
|
||||
// Modify the file
|
||||
await smartfile.memory.toFs('modified content', testFile);
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('change-test.txt');
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('should detect UNLINK event for deleted files', async () => {
|
||||
// First create the file
|
||||
const testFile = path.join(TEST_DIR, 'unlink-test.txt');
|
||||
await smartfile.memory.toFs('to be deleted', testFile);
|
||||
|
||||
// Wait for add event to complete
|
||||
await delay(200);
|
||||
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
const eventPromise = waitForEvent(unlinkObservable);
|
||||
|
||||
// Delete the file
|
||||
await fs.promises.unlink(testFile);
|
||||
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('unlink-test.txt');
|
||||
});
|
||||
|
||||
tap.test('should stop the watch process', async () => {
|
||||
await testSmartwatch.stop();
|
||||
expect(testSmartwatch.status).toEqual('idle');
|
||||
});
|
||||
|
||||
tap.test('cleanup: remove any remaining test files', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('add-') || file.startsWith('change-') || file.startsWith('unlink-')) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(TEST_DIR, file));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
135
test/test.inode.ts
Normal file
135
test/test.inode.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Skip in CI
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const TEST_DIR = './test/assets';
|
||||
|
||||
// Helper to delay
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to wait for an event with timeout
|
||||
async function waitForEvent<T>(
|
||||
observable: smartrx.rxjs.Observable<T>,
|
||||
timeoutMs: number = 5000
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const subscription = observable.subscribe((value) => {
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
|
||||
// ===========================================
|
||||
// INODE CHANGE DETECTION TESTS
|
||||
// ===========================================
|
||||
|
||||
tap.test('setup: start watcher', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]);
|
||||
await testSmartwatch.start();
|
||||
expect(testSmartwatch.status).toEqual('watching');
|
||||
});
|
||||
|
||||
tap.test('should detect delete+recreate (inode change scenario)', async () => {
|
||||
// This simulates what many editors do: delete file, create new file
|
||||
const testFile = path.join(TEST_DIR, 'inode-test.txt');
|
||||
|
||||
// Create initial file
|
||||
await smartfile.memory.toFs('initial content', testFile);
|
||||
await delay(200);
|
||||
|
||||
// Get the initial inode
|
||||
const initialStats = await fs.promises.stat(testFile);
|
||||
const initialInode = initialStats.ino;
|
||||
console.log(`[test] Initial inode: ${initialInode}`);
|
||||
|
||||
// With event sequence tracking, delete+recreate emits: unlink, then add
|
||||
// This is more accurate than just emitting 'change'
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const unlinkPromise = waitForEvent(unlinkObservable, 3000);
|
||||
const addPromise = waitForEvent(addObservable, 3000);
|
||||
|
||||
// Delete and recreate (this creates a new inode)
|
||||
await fs.promises.unlink(testFile);
|
||||
await smartfile.memory.toFs('recreated content', testFile);
|
||||
|
||||
// Check inode changed
|
||||
const newStats = await fs.promises.stat(testFile);
|
||||
const newInode = newStats.ino;
|
||||
console.log(`[test] New inode: ${newInode}`);
|
||||
expect(newInode).not.toEqual(initialInode);
|
||||
|
||||
// Should detect both unlink and add events for delete+recreate
|
||||
const [[unlinkPath], [addPath]] = await Promise.all([unlinkPromise, addPromise]);
|
||||
expect(unlinkPath).toInclude('inode-test.txt');
|
||||
expect(addPath).toInclude('inode-test.txt');
|
||||
console.log(`[test] Detected unlink + add events for delete+recreate`);
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('should detect atomic write pattern (temp file + rename)', async () => {
|
||||
// This simulates what Claude Code and many editors do:
|
||||
// 1. Write to temp file (file.txt.tmp.12345)
|
||||
// 2. Rename temp file to target file
|
||||
const testFile = path.join(TEST_DIR, 'atomic-test.txt');
|
||||
const tempFile = path.join(TEST_DIR, 'atomic-test.txt.tmp.12345');
|
||||
|
||||
// Create initial file
|
||||
await smartfile.memory.toFs('initial content', testFile);
|
||||
await delay(200);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const eventPromise = waitForEvent(changeObservable, 3000);
|
||||
|
||||
// Atomic write: create temp file then rename
|
||||
await smartfile.memory.toFs('atomic content', tempFile);
|
||||
await fs.promises.rename(tempFile, testFile);
|
||||
|
||||
// Should detect the change to the target file
|
||||
const [filePath] = await eventPromise;
|
||||
expect(filePath).toInclude('atomic-test.txt');
|
||||
expect(filePath).not.toInclude('.tmp.');
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('teardown: stop watcher', async () => {
|
||||
await testSmartwatch.stop();
|
||||
expect(testSmartwatch.status).toEqual('idle');
|
||||
});
|
||||
|
||||
tap.test('cleanup: remove test files', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('inode-') || file.startsWith('atomic-')) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(TEST_DIR, file));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
174
test/test.stress.ts
Normal file
174
test/test.stress.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartwatch from '../ts/index.js';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Skip in CI
|
||||
if (process.env.CI) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const TEST_DIR = './test/assets';
|
||||
|
||||
// Helper to delay
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to collect events
|
||||
function collectEvents<T>(
|
||||
observable: smartrx.rxjs.Observable<T>,
|
||||
durationMs: number
|
||||
): Promise<T[]> {
|
||||
return new Promise((resolve) => {
|
||||
const events: T[] = [];
|
||||
const subscription = observable.subscribe((value) => {
|
||||
events.push(value);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
resolve(events);
|
||||
}, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
let testSmartwatch: smartwatch.Smartwatch;
|
||||
|
||||
// ===========================================
|
||||
// STRESS TESTS
|
||||
// ===========================================
|
||||
|
||||
tap.test('setup: start watcher', async () => {
|
||||
testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]);
|
||||
await testSmartwatch.start();
|
||||
expect(testSmartwatch.status).toEqual('watching');
|
||||
});
|
||||
|
||||
tap.test('STRESS: rapid file modifications', async () => {
|
||||
const testFile = path.join(TEST_DIR, 'stress-rapid.txt');
|
||||
|
||||
// Create initial file
|
||||
await smartfile.memory.toFs('initial', testFile);
|
||||
await delay(200);
|
||||
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
|
||||
// Rapidly modify the file 20 times
|
||||
const RAPID_CHANGES = 20;
|
||||
const eventCollector = collectEvents(changeObservable, 3000);
|
||||
|
||||
for (let i = 0; i < RAPID_CHANGES; i++) {
|
||||
await smartfile.memory.toFs(`content ${i}`, testFile);
|
||||
await delay(10); // 10ms between writes
|
||||
}
|
||||
|
||||
const events = await eventCollector;
|
||||
console.log(`[test] Rapid changes: sent ${RAPID_CHANGES}, received ${events.length} events`);
|
||||
|
||||
// Due to debouncing, we won't get all events, but we should get at least some
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Cleanup
|
||||
await fs.promises.unlink(testFile);
|
||||
});
|
||||
|
||||
tap.test('STRESS: many files created rapidly', async () => {
|
||||
const FILE_COUNT = 20;
|
||||
const files: string[] = [];
|
||||
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const eventCollector = collectEvents(addObservable, 5000);
|
||||
|
||||
// Create many files rapidly
|
||||
for (let i = 0; i < FILE_COUNT; i++) {
|
||||
const file = path.join(TEST_DIR, `stress-many-${i}.txt`);
|
||||
files.push(file);
|
||||
await smartfile.memory.toFs(`content ${i}`, file);
|
||||
await delay(20); // 20ms between creates
|
||||
}
|
||||
|
||||
const events = await eventCollector;
|
||||
console.log(`[test] Many files: created ${FILE_COUNT}, detected ${events.length} events`);
|
||||
|
||||
// Should detect most or all files
|
||||
expect(events.length).toBeGreaterThanOrEqual(FILE_COUNT * 0.8); // Allow 20% tolerance
|
||||
|
||||
// Cleanup all files
|
||||
for (const file of files) {
|
||||
try {
|
||||
await fs.promises.unlink(file);
|
||||
} catch {
|
||||
// Ignore if already deleted
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('STRESS: interleaved add/change/delete operations', async () => {
|
||||
const testFiles = [
|
||||
path.join(TEST_DIR, 'stress-interleave-1.txt'),
|
||||
path.join(TEST_DIR, 'stress-interleave-2.txt'),
|
||||
path.join(TEST_DIR, 'stress-interleave-3.txt'),
|
||||
];
|
||||
|
||||
// Create initial files
|
||||
for (const file of testFiles) {
|
||||
await smartfile.memory.toFs('initial', file);
|
||||
}
|
||||
await delay(300);
|
||||
|
||||
const addObservable = await testSmartwatch.getObservableFor('add');
|
||||
const changeObservable = await testSmartwatch.getObservableFor('change');
|
||||
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
|
||||
|
||||
const addEvents = collectEvents(addObservable, 3000);
|
||||
const changeEvents = collectEvents(changeObservable, 3000);
|
||||
const unlinkEvents = collectEvents(unlinkObservable, 3000);
|
||||
|
||||
// Interleaved operations
|
||||
await smartfile.memory.toFs('changed 1', testFiles[0]); // change
|
||||
await delay(50);
|
||||
await fs.promises.unlink(testFiles[1]); // delete
|
||||
await delay(50);
|
||||
await smartfile.memory.toFs('recreated 1', testFiles[1]); // add (recreate)
|
||||
await delay(50);
|
||||
await smartfile.memory.toFs('changed 2', testFiles[2]); // change
|
||||
await delay(50);
|
||||
|
||||
const [adds, changes, unlinks] = await Promise.all([addEvents, changeEvents, unlinkEvents]);
|
||||
|
||||
console.log(`[test] Interleaved: adds=${adds.length}, changes=${changes.length}, unlinks=${unlinks.length}`);
|
||||
|
||||
// Should have detected some events of each type
|
||||
expect(changes.length).toBeGreaterThan(0);
|
||||
|
||||
// Cleanup
|
||||
for (const file of testFiles) {
|
||||
try {
|
||||
await fs.promises.unlink(file);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('teardown: stop watcher', async () => {
|
||||
await testSmartwatch.stop();
|
||||
expect(testSmartwatch.status).toEqual('idle');
|
||||
});
|
||||
|
||||
tap.test('cleanup: remove stress test files', async () => {
|
||||
const files = await fs.promises.readdir(TEST_DIR);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('stress-')) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(TEST_DIR, file));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
45
test/test.ts
45
test/test.ts
@@ -1,45 +0,0 @@
|
||||
import { tap, expect } from 'tapbundle'
|
||||
import * as smartchok from '../dist/index'
|
||||
import * as smartfile from 'smartfile'
|
||||
import * as smartq from 'smartq'
|
||||
import * as rx from 'rxjs/Rx'
|
||||
|
||||
// the module to test
|
||||
|
||||
let testSmartchok: smartchok.Smartchok
|
||||
let testAddObservable: rx.Observable<any>
|
||||
let testSubscription: rx.Subscription
|
||||
tap.test('should create a new instance', async () => {
|
||||
testSmartchok = new smartchok.Smartchok([])
|
||||
return expect(testSmartchok).to.be.instanceof(smartchok.Smartchok)
|
||||
}).catch(tap.threw)
|
||||
|
||||
tap.test('should add some files to watch and start', async () => {
|
||||
testSmartchok.add([ './test/assets/**/*.txt' ])
|
||||
let localPromise = testSmartchok.start().then(async () => {
|
||||
testSmartchok.add([ './test/assets/**/*.md' ])
|
||||
})
|
||||
return await expect(localPromise).to.eventually.be.fulfilled
|
||||
}).catch(tap.threw)
|
||||
|
||||
tap.test('should get an observable for a certain event', async () => {
|
||||
let localPromise = testSmartchok.getObservableFor('add').then(async (observableArg) => {
|
||||
testAddObservable = observableArg
|
||||
})
|
||||
return await expect(localPromise).to.eventually.be.fulfilled
|
||||
}).catch(tap.threw)
|
||||
|
||||
tap.test('should register an add operation', async () => {
|
||||
let testDeferred = smartq.defer()
|
||||
testSubscription = testAddObservable.subscribe(x => {
|
||||
testDeferred.resolve()
|
||||
})
|
||||
smartfile.memory.toFs('HI', './test/assets/hi.txt')
|
||||
return await expect(testDeferred.promise).to.eventually.be.fulfilled
|
||||
}).catch(tap.threw)
|
||||
|
||||
tap.test('should stop the watch process', async () => {
|
||||
testSmartchok.stop()
|
||||
}).catch(tap.threw)
|
||||
|
||||
tap.start()
|
||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartwatch',
|
||||
version: '6.2.3',
|
||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from './smartchok.classes.smartchok'
|
||||
export * from './smartwatch.classes.smartwatch.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import chokidar = require('chokidar')
|
||||
@@ -1,82 +0,0 @@
|
||||
import * as plugins from './smartchok.plugins'
|
||||
import { Stringmap } from 'lik'
|
||||
|
||||
export type TSmartchokStatus = 'idle' | 'starting' | 'watching'
|
||||
export type TFsEvent = 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw'
|
||||
|
||||
/**
|
||||
* Smartchok allows easy wathcing of files
|
||||
*/
|
||||
export class Smartchok {
|
||||
watchStringmap = new Stringmap()
|
||||
chokidarOptions: plugins.chokidar.WatchOptions
|
||||
status: TSmartchokStatus = 'idle'
|
||||
private watcher
|
||||
private watchingDeferred = plugins.smartq.defer<void>() // used to run things when watcher is initialized
|
||||
private eventObservablemap = new plugins.lik.Observablemap() // register one observable per event
|
||||
|
||||
/**
|
||||
* constructor of class smartchok
|
||||
*/
|
||||
constructor (watchArrayArg: string[], optionsArg: plugins.chokidar.WatchOptions = {}) {
|
||||
this.watchStringmap.addStringArray(watchArrayArg)
|
||||
this.chokidarOptions = optionsArg
|
||||
}
|
||||
|
||||
/**
|
||||
* adds files to the list of watched files
|
||||
*/
|
||||
add(pathArrayArg: string[]) {
|
||||
this.watchStringmap.addStringArray(pathArrayArg)
|
||||
}
|
||||
|
||||
/**
|
||||
* removes files from the list of watched files
|
||||
*/
|
||||
remove (pathArg: string) {
|
||||
this.watchStringmap.removeString(pathArg)
|
||||
}
|
||||
|
||||
/**
|
||||
* gets an observable for a certain event
|
||||
*/
|
||||
getObservableFor (fsEvent: TFsEvent): Promise<plugins.rx.Observable<any>> {
|
||||
let done = plugins.smartq.defer<plugins.rx.Observable<any>>()
|
||||
this.watchingDeferred.promise.then(() => {
|
||||
let eventObservable = this.eventObservablemap.getObservableForEmitterEvent(this.watcher, fsEvent)
|
||||
done.resolve(eventObservable)
|
||||
})
|
||||
return done.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* starts the watcher
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
let done = plugins.smartq.defer<void>()
|
||||
this.status = 'starting'
|
||||
this.watcher = plugins.chokidar.watch(this.watchStringmap.getStringArray(), this.chokidarOptions)
|
||||
this.watcher.on('ready', () => {
|
||||
this.status = 'watching'
|
||||
this.watchingDeferred.resolve()
|
||||
done.resolve()
|
||||
})
|
||||
return done.promise
|
||||
}
|
||||
|
||||
/**
|
||||
* stop the watcher process if watching
|
||||
*/
|
||||
stop() {
|
||||
let closeWatcher = () => {
|
||||
this.watcher.close()
|
||||
}
|
||||
if (this.status === 'watching') {
|
||||
console.log('closing while watching')
|
||||
closeWatcher()
|
||||
} else if (this.status === 'starting') {
|
||||
this.watchingDeferred.promise.then(() => { closeWatcher() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import * as plugins from './smartchok.plugins'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'typings-global'
|
||||
export import lik = require('lik')
|
||||
export import chokidar = require('chokidar')
|
||||
export import path = require('path')
|
||||
export import smartq = require('smartq')
|
||||
export import rx = require('rxjs/Rx')
|
||||
|
||||
// workaround
|
||||
import {Operator} from 'rxjs/Operator'
|
||||
import {Observable} from 'rxjs/Observable'
|
||||
declare module 'rxjs/Subject' {
|
||||
interface Subject<T> {
|
||||
lift<R>(operator: Operator<T, R>): Observable<R>
|
||||
}
|
||||
}
|
||||
236
ts/smartwatch.classes.smartwatch.ts
Normal file
236
ts/smartwatch.classes.smartwatch.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import * as plugins from './smartwatch.plugins.js';
|
||||
import { Stringmap } from '@push.rocks/lik';
|
||||
import { createWatcher, type IWatcher, type IWatchEvent, type TWatchEventType } from './watchers/index.js';
|
||||
|
||||
export type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||
export type TFsEvent =
|
||||
| 'add'
|
||||
| 'addDir'
|
||||
| 'change'
|
||||
| 'error'
|
||||
| 'unlink'
|
||||
| 'unlinkDir'
|
||||
| 'ready'
|
||||
| 'raw';
|
||||
|
||||
/**
|
||||
* Smartwatch allows easy watching of files
|
||||
* Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support
|
||||
*/
|
||||
export class Smartwatch {
|
||||
public watchStringmap = new Stringmap();
|
||||
public status: TSmartwatchStatus = 'idle';
|
||||
private watcher: IWatcher | null = null;
|
||||
private globPatterns: string[] = [];
|
||||
private globMatchers: Map<string, (path: string) => boolean> = new Map();
|
||||
private watchingDeferred = plugins.smartpromise.defer<void>();
|
||||
|
||||
// Event subjects for each event type
|
||||
private eventSubjects: Map<TFsEvent, plugins.smartrx.rxjs.Subject<[string, plugins.fs.Stats | undefined]>> = new Map();
|
||||
|
||||
/**
|
||||
* constructor of class Smartwatch
|
||||
*/
|
||||
constructor(watchArrayArg: string[]) {
|
||||
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) {
|
||||
// Characters that mark the beginning of a glob pattern
|
||||
const globChars = ['*', '?', '[', ']', '{', '}'];
|
||||
|
||||
// Find the index of the first glob character
|
||||
const firstGlobCharIndex = globPattern.split('').findIndex((char) => globChars.includes(char));
|
||||
|
||||
// If no glob characters are found, return the entire string
|
||||
if (firstGlobCharIndex === -1) {
|
||||
return globPattern;
|
||||
}
|
||||
|
||||
// Extract the substring up to the first glob character
|
||||
const basePathPortion = globPattern.substring(0, firstGlobCharIndex);
|
||||
|
||||
// Find the last slash before the glob pattern starts
|
||||
const lastSlashIndex = basePathPortion.lastIndexOf('/');
|
||||
|
||||
// If there is no slash, return the basePathPortion as is
|
||||
if (lastSlashIndex === -1) {
|
||||
return basePathPortion;
|
||||
}
|
||||
|
||||
// Return the base path up to and including the last slash
|
||||
return basePathPortion.substring(0, lastSlashIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* adds files to the list of watched files
|
||||
*/
|
||||
public add(pathArrayArg: string[]) {
|
||||
this.watchStringmap.addStringArray(pathArrayArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* removes files from the list of watched files
|
||||
*/
|
||||
public remove(pathArg: string) {
|
||||
this.watchStringmap.removeString(pathArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* gets an observable for a certain event
|
||||
*/
|
||||
public getObservableFor(
|
||||
fsEvent: TFsEvent
|
||||
): Promise<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
|
||||
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
|
||||
this.watchingDeferred.promise.then(() => {
|
||||
const subject = this.eventSubjects.get(fsEvent);
|
||||
if (subject) {
|
||||
done.resolve(subject.asObservable());
|
||||
} else {
|
||||
done.reject(new Error(`Unknown event type: ${fsEvent}`));
|
||||
}
|
||||
});
|
||||
return done.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* starts the watcher
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
this.status = 'starting';
|
||||
|
||||
// Store original glob patterns and create matchers
|
||||
this.globPatterns = this.watchStringmap.getStringArray();
|
||||
const basePaths = new Set<string>();
|
||||
|
||||
this.globPatterns.forEach((pattern) => {
|
||||
const basePath = this.getGlobBase(pattern);
|
||||
basePaths.add(basePath);
|
||||
|
||||
// Create a picomatch matcher for each glob pattern
|
||||
const matcher = plugins.picomatch(pattern, {
|
||||
dot: true,
|
||||
basename: false
|
||||
});
|
||||
this.globMatchers.set(pattern, matcher);
|
||||
});
|
||||
|
||||
// Convert Set to Array for the watcher
|
||||
const watchPaths = Array.from(basePaths);
|
||||
console.log('Base paths to watch:', watchPaths);
|
||||
|
||||
// Create the platform-appropriate watcher
|
||||
this.watcher = await createWatcher({
|
||||
basePaths: watchPaths,
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100
|
||||
});
|
||||
|
||||
// Subscribe to watcher events and dispatch to appropriate subjects
|
||||
this.watcher.events$.subscribe((event: IWatchEvent) => {
|
||||
this.handleWatchEvent(event);
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stop the watcher process if watching
|
||||
*/
|
||||
public async stop() {
|
||||
const closeWatcher = async () => {
|
||||
if (this.watcher) {
|
||||
await this.watcher.stop();
|
||||
this.watcher = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (this.status === 'watching') {
|
||||
console.log('closing while watching');
|
||||
await closeWatcher();
|
||||
} else if (this.status === 'starting') {
|
||||
await this.watchingDeferred.promise;
|
||||
await closeWatcher();
|
||||
}
|
||||
|
||||
this.status = 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path should be watched based on glob patterns
|
||||
*/
|
||||
private shouldWatchPath(filePath: string): boolean {
|
||||
// Normalize the path - remove leading ./ if present
|
||||
let normalizedPath = filePath.replace(/\\/g, '/');
|
||||
if (normalizedPath.startsWith('./')) {
|
||||
normalizedPath = normalizedPath.substring(2);
|
||||
}
|
||||
|
||||
// Check if the path matches any of our glob patterns
|
||||
for (const [pattern, matcher] of this.globMatchers) {
|
||||
// Also normalize the pattern for comparison
|
||||
let normalizedPattern = pattern;
|
||||
if (normalizedPattern.startsWith('./')) {
|
||||
normalizedPattern = normalizedPattern.substring(2);
|
||||
}
|
||||
|
||||
// Try matching with both the original pattern and normalized
|
||||
if (matcher(normalizedPath) || matcher(filePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also try matching without the leading path
|
||||
const withoutLeading = 'test/' + normalizedPath.split('test/').slice(1).join('test/');
|
||||
if (matcher(withoutLeading)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
28
ts/smartwatch.plugins.ts
Normal file
28
ts/smartwatch.plugins.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// node native
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export {
|
||||
fs,
|
||||
path
|
||||
}
|
||||
|
||||
// @pushrocks scope
|
||||
import * as lik from '@push.rocks/lik';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import { Smartenv } from '@push.rocks/smartenv';
|
||||
|
||||
export {
|
||||
lik,
|
||||
smartpromise,
|
||||
smartrx,
|
||||
Smartenv
|
||||
}
|
||||
|
||||
// thirdparty scope
|
||||
import picomatch from 'picomatch';
|
||||
|
||||
export {
|
||||
picomatch,
|
||||
}
|
||||
32
ts/watchers/index.ts
Normal file
32
ts/watchers/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Smartenv } from '@push.rocks/smartenv';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||
|
||||
export type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType };
|
||||
|
||||
/**
|
||||
* Creates a platform-appropriate file watcher based on the current runtime
|
||||
* Uses @push.rocks/smartenv for runtime detection
|
||||
*/
|
||||
export async function createWatcher(options: IWatcherOptions): Promise<IWatcher> {
|
||||
const env = new Smartenv();
|
||||
|
||||
if (env.isDeno) {
|
||||
// Deno runtime - use Deno.watchFs
|
||||
const { DenoWatcher } = await import('./watcher.deno.js');
|
||||
return new DenoWatcher(options);
|
||||
} else {
|
||||
// Node.js or Bun - both use fs.watch (Bun has Node.js compatibility)
|
||||
const { NodeWatcher } = await import('./watcher.node.js');
|
||||
return new NodeWatcher(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default watcher options
|
||||
*/
|
||||
export const defaultWatcherOptions: IWatcherOptions = {
|
||||
basePaths: [],
|
||||
depth: 4,
|
||||
followSymlinks: false,
|
||||
debounceMs: 100
|
||||
};
|
||||
45
ts/watchers/interfaces.ts
Normal file
45
ts/watchers/interfaces.ts
Normal 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
290
ts/watchers/watcher.deno.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||
|
||||
// Type definitions for Deno APIs (these exist at runtime in Deno)
|
||||
declare const Deno: {
|
||||
watchFs(paths: string | string[], options?: { recursive?: boolean }): AsyncIterable<{
|
||||
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other';
|
||||
paths: string[];
|
||||
flag?: { rescan: boolean };
|
||||
}> & { close(): void };
|
||||
stat(path: string): Promise<{
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
isSymlink: boolean;
|
||||
size: number;
|
||||
mtime: Date | null;
|
||||
atime: Date | null;
|
||||
birthtime: Date | null;
|
||||
mode: number | null;
|
||||
uid: number | null;
|
||||
gid: number | null;
|
||||
}>;
|
||||
lstat(path: string): Promise<{
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
isSymlink: boolean;
|
||||
size: number;
|
||||
mtime: Date | null;
|
||||
atime: Date | null;
|
||||
birthtime: Date | null;
|
||||
mode: number | null;
|
||||
uid: number | null;
|
||||
gid: number | null;
|
||||
}>;
|
||||
readDir(path: string): AsyncIterable<{
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
isSymlink: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Deno stat to Node.js-like Stats object
|
||||
*/
|
||||
function denoStatToNodeStats(denoStat: Awaited<ReturnType<typeof Deno.stat>>): any {
|
||||
return {
|
||||
isFile: () => denoStat.isFile,
|
||||
isDirectory: () => denoStat.isDirectory,
|
||||
isSymbolicLink: () => denoStat.isSymlink,
|
||||
size: denoStat.size,
|
||||
mtime: denoStat.mtime,
|
||||
atime: denoStat.atime,
|
||||
birthtime: denoStat.birthtime,
|
||||
mode: denoStat.mode,
|
||||
uid: denoStat.uid,
|
||||
gid: denoStat.gid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deno file watcher using native Deno.watchFs API
|
||||
*/
|
||||
export class DenoWatcher implements IWatcher {
|
||||
private watcher: ReturnType<typeof Deno.watchFs> | null = null;
|
||||
private watchedFiles: Set<string> = new Set();
|
||||
private _isWatching = false;
|
||||
|
||||
// Debounce: pending emits per file path
|
||||
private pendingEmits: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {}
|
||||
|
||||
/**
|
||||
* Check if a file is a temporary file created by editors
|
||||
*/
|
||||
private isTemporaryFile(filePath: string): boolean {
|
||||
const basename = filePath.split('/').pop() || '';
|
||||
// Editor temp files: *.tmp.*, *.swp, *.swx, *~, .#*
|
||||
if (basename.includes('.tmp.')) return true;
|
||||
if (basename.endsWith('.swp') || basename.endsWith('.swx')) return true;
|
||||
if (basename.endsWith('~')) return true;
|
||||
if (basename.startsWith('.#')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._isWatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start watching all base paths
|
||||
this.watcher = Deno.watchFs(this.options.basePaths, { recursive: true });
|
||||
this._isWatching = true;
|
||||
|
||||
// Perform initial scan
|
||||
for (const basePath of this.options.basePaths) {
|
||||
await this.scanDirectory(basePath, 0);
|
||||
}
|
||||
|
||||
// Emit ready event
|
||||
this.events$.next({ type: 'ready', path: '' });
|
||||
|
||||
// Start processing events
|
||||
this.processEvents();
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: '', error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this._isWatching = false;
|
||||
|
||||
// Cancel all pending debounced emits
|
||||
for (const timeout of this.pendingEmits.values()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.pendingEmits.clear();
|
||||
|
||||
// Close the watcher
|
||||
if (this.watcher) {
|
||||
(this.watcher as any).close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
this.watchedFiles.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process events from the Deno watcher
|
||||
*/
|
||||
private async processEvents(): Promise<void> {
|
||||
if (!this.watcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const event of this.watcher) {
|
||||
if (!this._isWatching) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const filePath of event.paths) {
|
||||
this.handleDenoEvent(event.kind, filePath);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (this._isWatching) {
|
||||
this.events$.next({ type: 'error', path: '', error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Deno file system event - debounce and normalize
|
||||
*/
|
||||
private handleDenoEvent(
|
||||
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other',
|
||||
filePath: string
|
||||
): void {
|
||||
// Ignore 'access' events (just reading the file)
|
||||
if (kind === 'access') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip temporary files created by editors (atomic saves)
|
||||
if (this.isTemporaryFile(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: cancel any pending emit for this file
|
||||
const existing = this.pendingEmits.get(filePath);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
// Schedule debounced emit
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingEmits.delete(filePath);
|
||||
this.emitFileEvent(filePath, kind);
|
||||
}, this.options.debounceMs);
|
||||
|
||||
this.pendingEmits.set(filePath, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the actual file event after debounce
|
||||
*/
|
||||
private async emitFileEvent(
|
||||
filePath: string,
|
||||
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other'
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (kind === 'create') {
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (stats) {
|
||||
this.watchedFiles.add(filePath);
|
||||
const eventType: TWatchEventType = stats.isDirectory() ? 'addDir' : 'add';
|
||||
this.events$.next({ type: eventType, path: filePath, stats });
|
||||
}
|
||||
} else if (kind === 'modify') {
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (stats && !stats.isDirectory()) {
|
||||
this.events$.next({ type: 'change', path: filePath, stats });
|
||||
}
|
||||
} else if (kind === 'remove') {
|
||||
const wasDirectory = this.isKnownDirectory(filePath);
|
||||
this.watchedFiles.delete(filePath);
|
||||
this.events$.next({
|
||||
type: wasDirectory ? 'unlinkDir' : 'unlink',
|
||||
path: filePath
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: filePath, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory and emit 'add' events for existing files
|
||||
*/
|
||||
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
|
||||
if (depth > this.options.depth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const entry of Deno.readDir(dirPath)) {
|
||||
const fullPath = `${dirPath}/${entry.name}`;
|
||||
|
||||
// Skip temp files during initial scan too
|
||||
if (this.isTemporaryFile(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = await this.statSafe(fullPath);
|
||||
|
||||
if (!stats) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
||||
await this.scanDirectory(fullPath, depth + 1);
|
||||
} else if (entry.isFile) {
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.events$.next({ type: 'add', path: fullPath, stats });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
|
||||
this.events$.next({ type: 'error', path: dirPath, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stat a path, returning null if it doesn't exist
|
||||
*/
|
||||
private async statSafe(filePath: string): Promise<any | null> {
|
||||
try {
|
||||
const statFn = this.options.followSymlinks ? Deno.stat : Deno.lstat;
|
||||
const denoStats = await statFn(filePath);
|
||||
return denoStatToNodeStats(denoStats);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path was known to be a directory
|
||||
*/
|
||||
private isKnownDirectory(filePath: string): boolean {
|
||||
for (const watched of this.watchedFiles) {
|
||||
if (watched.startsWith(filePath + '/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
687
ts/watchers/watcher.node.ts
Normal file
687
ts/watchers/watcher.node.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
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
|
||||
// Fix 2: Track event sequence instead of just last event type
|
||||
// This prevents losing intermediate events (add→change→delete should not lose add)
|
||||
private pendingEmits: Map<string, {
|
||||
timeout: NodeJS.Timeout;
|
||||
events: Array<'rename' | 'change'>;
|
||||
}> = new Map();
|
||||
|
||||
// Restart tracking
|
||||
private restartDelays: Map<string, number> = new Map();
|
||||
private restartAttempts: Map<string, number> = new Map();
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Inode tracking - detect when directories are replaced (atomic saves, etc.)
|
||||
// fs.watch watches the inode, not the path. If inode changes, we need to restart.
|
||||
private watchedInodes: Map<string, bigint> = new Map();
|
||||
|
||||
// File inode tracking - detect when individual files are deleted and recreated
|
||||
// This is critical: editors delete+recreate files, fs.watch watches OLD inode!
|
||||
// See: https://github.com/paulmillr/chokidar/issues/972
|
||||
private fileInodes: Map<string, bigint> = new Map();
|
||||
|
||||
// Abort controllers for pending restart delays - prevents orphan watchers on stop()
|
||||
private restartAbortControllers: Map<string, AbortController> = new Map();
|
||||
|
||||
// Prevent concurrent restarts for the same path (health check + error can race)
|
||||
private restartingPaths: Set<string> = new Set();
|
||||
|
||||
// Initial scan state - events are deferred until scan completes to avoid race conditions
|
||||
// Without this, events can arrive before watchedFiles is populated, causing inconsistent state
|
||||
private initialScanComplete: boolean = false;
|
||||
private deferredEvents: Array<{basePath: string; filename: string; eventType: string}> = [];
|
||||
|
||||
// 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
|
||||
* Includes guards against:
|
||||
* - Dual restart race condition (health check + error handler calling simultaneously)
|
||||
* - Orphan watchers when stop() is called during restart delay
|
||||
*/
|
||||
private async restartWatcher(basePath: string, error: Error): Promise<void> {
|
||||
// Guard: Prevent concurrent restarts for the same path
|
||||
if (this.restartingPaths.has(basePath)) {
|
||||
console.log(`[smartwatch] Restart already in progress for ${basePath}, skipping`);
|
||||
return;
|
||||
}
|
||||
this.restartingPaths.add(basePath);
|
||||
|
||||
try {
|
||||
const attempts = (this.restartAttempts.get(basePath) || 0) + 1;
|
||||
this.restartAttempts.set(basePath, attempts);
|
||||
|
||||
console.log(`[smartwatch] Watcher error for ${basePath}: ${error.message}`);
|
||||
console.log(`[smartwatch] Restart attempt ${attempts}/${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 with AbortController (so stop() can cancel)
|
||||
const delay = this.restartDelays.get(basePath) || NodeWatcher.INITIAL_RESTART_DELAY;
|
||||
console.log(`[smartwatch] Waiting ${delay}ms before restart...`);
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.restartAbortControllers.set(basePath, abortController);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, delay);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Restart aborted by stop()'));
|
||||
});
|
||||
});
|
||||
} catch (abortError) {
|
||||
console.log(`[smartwatch] Restart aborted for ${basePath}`);
|
||||
return; // stop() was called, don't continue
|
||||
} finally {
|
||||
this.restartAbortControllers.delete(basePath);
|
||||
}
|
||||
|
||||
// Double-check we're still watching after the delay
|
||||
if (!this._isWatching) {
|
||||
console.log(`[smartwatch] Watcher stopped during restart delay, aborting`);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
// Clear restartingPaths before recursive call
|
||||
this.restartingPaths.delete(basePath);
|
||||
this.restartWatcher(basePath, restartError as Error); // Recursive retry
|
||||
return; // Don't delete from restartingPaths again in finally
|
||||
}
|
||||
} finally {
|
||||
this.restartingPaths.delete(basePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic health checks to detect silent failures
|
||||
* Checks for:
|
||||
* 1. Path no longer exists
|
||||
* 2. Inode changed (directory was replaced - fs.watch watches inode, not path!)
|
||||
*/
|
||||
private startHealthCheck(): void {
|
||||
console.log('[smartwatch] Starting health check (every 30s)');
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
console.log(`[smartwatch] Health check: ${this.watchers.size} watchers active`);
|
||||
for (const [basePath] of this.watchers) {
|
||||
try {
|
||||
const stats = await fs.promises.stat(basePath);
|
||||
const currentInode = stats.ino;
|
||||
const previousInode = this.watchedInodes.get(basePath);
|
||||
|
||||
if (!stats) {
|
||||
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'));
|
||||
} else if (previousInode !== undefined && BigInt(currentInode) !== previousInode) {
|
||||
// CRITICAL: Inode changed! fs.watch is now watching a stale inode.
|
||||
// This happens when the directory is replaced (atomic operations, git checkout, etc.)
|
||||
console.warn(`[smartwatch] Inode changed for ${basePath}: ${previousInode} -> ${currentInode}`);
|
||||
console.warn('[smartwatch] fs.watch watches inode, not path - restarting watcher');
|
||||
this.restartWatcher(basePath, new Error('Inode changed - directory was replaced'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`);
|
||||
this.restartWatcher(basePath, new Error('Watched path disappeared'));
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
// inotify watch limit exceeded - critical system issue
|
||||
console.error(`[smartwatch] ENOSPC: inotify watch limit exceeded!`);
|
||||
console.error('[smartwatch] Fix: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p');
|
||||
this.safeEmit({ type: 'error', path: basePath, error });
|
||||
} else {
|
||||
console.error(`[smartwatch] Health check error for ${basePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the real file path from a temporary file path
|
||||
* Used to detect atomic writes where only the temp file event is emitted
|
||||
*
|
||||
* Patterns:
|
||||
* - Claude Code: file.ts.tmp.PID.TIMESTAMP -> file.ts
|
||||
* - Vim swap: .file.ts.swp -> file.ts (but we don't handle this case)
|
||||
*/
|
||||
private getTempFileTarget(tempFilePath: string): string | null {
|
||||
const basename = path.basename(tempFilePath);
|
||||
|
||||
// Claude Code pattern: file.ts.tmp.PID.TIMESTAMP
|
||||
// Match: anything.tmp.digits.digits
|
||||
const claudeMatch = basename.match(/^(.+)\.tmp\.\d+\.\d+$/);
|
||||
if (claudeMatch) {
|
||||
const realBasename = claudeMatch[1];
|
||||
return path.join(path.dirname(tempFilePath), realBasename);
|
||||
}
|
||||
|
||||
// Generic .tmp. pattern: file.ts.tmp.something -> file.ts
|
||||
const tmpMatch = basename.match(/^(.+)\.tmp\.[^.]+$/);
|
||||
if (tmpMatch) {
|
||||
const realBasename = tmpMatch[1];
|
||||
return path.join(path.dirname(tempFilePath), realBasename);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Reset initial scan state
|
||||
this.initialScanComplete = false;
|
||||
this.deferredEvents = [];
|
||||
|
||||
// Start watching each base path
|
||||
// NOTE: Events may arrive immediately but will be deferred until scan completes
|
||||
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
|
||||
// This populates watchedFiles and fileInodes BEFORE we process events
|
||||
for (const basePath of this.options.basePaths) {
|
||||
await this.scanDirectory(basePath, 0);
|
||||
}
|
||||
|
||||
// Mark scan complete and process any events that arrived during scan
|
||||
this.initialScanComplete = true;
|
||||
if (this.deferredEvents.length > 0) {
|
||||
console.log(`[smartwatch] Processing ${this.deferredEvents.length} deferred events from initial scan window`);
|
||||
for (const event of this.deferredEvents) {
|
||||
this.handleFsEvent(event.basePath, event.filename, event.eventType);
|
||||
}
|
||||
this.deferredEvents = [];
|
||||
}
|
||||
|
||||
// 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...');
|
||||
|
||||
// Fix 4: Cancel pending debounced emits FIRST (before flag changes)
|
||||
// This prevents handleFsEvent from creating new pendingEmits during shutdown
|
||||
for (const pending of this.pendingEmits.values()) {
|
||||
clearTimeout(pending.timeout);
|
||||
}
|
||||
this.pendingEmits.clear();
|
||||
|
||||
// NOW set the flag - handleFsEvent will return early after this
|
||||
this._isWatching = false;
|
||||
|
||||
// Stop health check monitoring
|
||||
this.stopHealthCheck();
|
||||
|
||||
// Abort all pending restart delays (prevents orphan watchers)
|
||||
for (const [path, controller] of this.restartAbortControllers) {
|
||||
console.log(`[smartwatch] Aborting pending restart for: ${path}`);
|
||||
controller.abort();
|
||||
}
|
||||
this.restartAbortControllers.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 all tracking state
|
||||
this.restartDelays.clear();
|
||||
this.restartAttempts.clear();
|
||||
this.watchedInodes.clear();
|
||||
this.fileInodes.clear();
|
||||
this.restartingPaths.clear();
|
||||
|
||||
// Fix 5: Reset initial scan state
|
||||
this.initialScanComplete = false;
|
||||
this.deferredEvents = [];
|
||||
|
||||
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()) {
|
||||
// Store inode for health check - fs.watch watches inode, not path!
|
||||
// If inode changes (directory replaced), watcher becomes stale
|
||||
this.watchedInodes.set(watchPath, BigInt(stats.ino));
|
||||
|
||||
// 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: NodeJS.ErrnoException) => {
|
||||
console.error(`[smartwatch] FSWatcher error event on ${watchPath}:`, error);
|
||||
|
||||
// Detect inotify watch limit exceeded - common cause of "stops working"
|
||||
if (error.code === 'ENOSPC') {
|
||||
console.error('[smartwatch] CRITICAL: inotify watch limit exceeded!');
|
||||
console.error('[smartwatch] Fix with: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p');
|
||||
}
|
||||
|
||||
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', () => {
|
||||
// Only log/restart if we didn't intentionally stop
|
||||
if (this._isWatching) {
|
||||
console.warn(`[smartwatch] FSWatcher closed unexpectedly for ${watchPath}`);
|
||||
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 {
|
||||
// Fix 3: Guard against post-stop events (events queued before watcher closed)
|
||||
if (!this._isWatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix 1: Defer events until initial scan completes
|
||||
// This prevents race conditions where events arrive before watchedFiles is populated
|
||||
if (!this.initialScanComplete) {
|
||||
this.deferredEvents.push({ basePath, filename, eventType });
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.join(basePath, filename);
|
||||
|
||||
// Handle temporary files from atomic writes (Claude Code, editors, etc.)
|
||||
// Pattern: editor writes to file.tmp.xxx then renames to file
|
||||
// Problem: fs.watch on Linux may ONLY emit event for the temp file, not the target!
|
||||
// Solution: When we see a temp file event, also check the corresponding real file
|
||||
if (this.isTemporaryFile(fullPath)) {
|
||||
console.log(`[smartwatch] Detected temp file event: ${filename}`);
|
||||
|
||||
// Extract the real file path from the temp file path
|
||||
// Pattern: file.ts.tmp.PID.TIMESTAMP -> file.ts
|
||||
const realFilePath = this.getTempFileTarget(fullPath);
|
||||
if (realFilePath) {
|
||||
console.log(`[smartwatch] Checking corresponding real file: ${realFilePath}`);
|
||||
// Queue an event for the REAL file - this is the actual file that changed
|
||||
// Use a short delay to let the rename complete
|
||||
setTimeout(() => {
|
||||
if (this._isWatching) {
|
||||
this.handleFsEvent(basePath, path.relative(basePath, realFilePath), 'change');
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix 2: Track event sequence in debounce instead of collapsing to last event
|
||||
// This ensures we don't lose intermediate events (e.g., add→change→delete)
|
||||
const existing = this.pendingEmits.get(fullPath);
|
||||
if (existing) {
|
||||
// Cancel existing timeout but KEEP the event sequence
|
||||
clearTimeout(existing.timeout);
|
||||
// Add this event to the sequence
|
||||
existing.events.push(eventType as 'rename' | 'change');
|
||||
// Reschedule the emit with the accumulated events
|
||||
existing.timeout = setTimeout(() => {
|
||||
const pending = this.pendingEmits.get(fullPath);
|
||||
if (pending) {
|
||||
this.pendingEmits.delete(fullPath);
|
||||
this.emitFileEvent(fullPath, pending.events);
|
||||
}
|
||||
}, this.options.debounceMs);
|
||||
} else {
|
||||
// First event for this file - create new sequence
|
||||
const timeout = setTimeout(() => {
|
||||
const pending = this.pendingEmits.get(fullPath);
|
||||
if (pending) {
|
||||
this.pendingEmits.delete(fullPath);
|
||||
this.emitFileEvent(fullPath, pending.events);
|
||||
}
|
||||
}, this.options.debounceMs);
|
||||
|
||||
this.pendingEmits.set(fullPath, {
|
||||
timeout,
|
||||
events: [eventType as 'rename' | 'change']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the actual file event after debounce
|
||||
*
|
||||
* Fix 2: Now receives event sequence instead of single event type
|
||||
* This allows intelligent processing of rapid event sequences:
|
||||
* - add→change→delete: File was created and deleted rapidly
|
||||
* - rename→rename: File was deleted and recreated (or vice versa)
|
||||
*
|
||||
* Also handles file inode tracking to detect delete+recreate scenarios:
|
||||
* - fs.watch watches the inode, not the path
|
||||
* - When editors delete+recreate files, the inode changes
|
||||
* - Without inode tracking, events for the new file would be missed
|
||||
* - See: https://github.com/paulmillr/chokidar/issues/972
|
||||
*/
|
||||
private async emitFileEvent(
|
||||
fullPath: string,
|
||||
eventSequence: Array<'rename' | 'change'>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stats = await this.statSafe(fullPath);
|
||||
const wasWatched = this.watchedFiles.has(fullPath);
|
||||
const previousInode = this.fileInodes.get(fullPath);
|
||||
|
||||
// Analyze event sequence to understand what happened
|
||||
const hasRename = eventSequence.includes('rename');
|
||||
const hasChange = eventSequence.includes('change');
|
||||
const renameCount = eventSequence.filter(e => e === 'rename').length;
|
||||
|
||||
// Log sequence for debugging complex scenarios
|
||||
if (eventSequence.length > 1) {
|
||||
console.log(`[smartwatch] Processing event sequence for ${fullPath}: [${eventSequence.join(', ')}]`);
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
// File EXISTS now
|
||||
const currentInode = BigInt(stats.ino);
|
||||
const inodeChanged = previousInode !== undefined && previousInode !== currentInode;
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
if (!wasWatched) {
|
||||
this.watchedFiles.add(fullPath);
|
||||
this.safeEmit({ type: 'addDir', path: fullPath, stats });
|
||||
}
|
||||
// Directories don't track inodes at file level
|
||||
} else {
|
||||
// Update tracking
|
||||
this.fileInodes.set(fullPath, currentInode);
|
||||
this.watchedFiles.add(fullPath);
|
||||
|
||||
if (!wasWatched) {
|
||||
// File wasn't tracked before - this is an add
|
||||
// Even if there were multiple events, the end result is a new file
|
||||
this.safeEmit({ type: 'add', path: fullPath, stats });
|
||||
} else if (inodeChanged) {
|
||||
// File was recreated with different inode (delete+recreate)
|
||||
console.log(`[smartwatch] File inode changed (delete+recreate): ${fullPath}`);
|
||||
console.log(`[smartwatch] Previous inode: ${previousInode}, current: ${currentInode}`);
|
||||
// Multiple rename events with inode change = delete+recreate pattern
|
||||
// Emit unlink for the old file, then add for the new one
|
||||
if (renameCount >= 2) {
|
||||
this.safeEmit({ type: 'unlink', path: fullPath });
|
||||
this.safeEmit({ type: 'add', path: fullPath, stats });
|
||||
} else {
|
||||
// Single rename with inode change = atomic save (emit as change)
|
||||
this.safeEmit({ type: 'change', path: fullPath, stats });
|
||||
}
|
||||
} else if (hasChange || hasRename) {
|
||||
// File exists, was tracked, inode same - content changed
|
||||
this.safeEmit({ type: 'change', path: fullPath, stats });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File does NOT exist now - it was deleted
|
||||
const wasDir = this.isKnownDirectory(fullPath);
|
||||
|
||||
if (wasWatched) {
|
||||
// File was tracked and is now gone
|
||||
this.watchedFiles.delete(fullPath);
|
||||
this.fileInodes.delete(fullPath);
|
||||
|
||||
// If there were multiple events, file may have been created then deleted
|
||||
if (renameCount >= 2 && !wasDir) {
|
||||
// add→delete sequence - emit both events
|
||||
console.log(`[smartwatch] File created and deleted rapidly: ${fullPath}`);
|
||||
this.safeEmit({ type: 'add', path: fullPath });
|
||||
this.safeEmit({ type: 'unlink', path: fullPath });
|
||||
} else {
|
||||
this.safeEmit({
|
||||
type: wasDir ? 'unlinkDir' : 'unlink',
|
||||
path: fullPath
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// File wasn't tracked - but events occurred for it
|
||||
this.fileInodes.delete(fullPath);
|
||||
|
||||
if (renameCount >= 2) {
|
||||
// Multiple rename events for untracked file that doesn't exist
|
||||
// Likely: created → deleted rapidly
|
||||
console.log(`[smartwatch] Untracked file created and deleted: ${fullPath}`);
|
||||
this.safeEmit({ type: 'add', path: fullPath });
|
||||
this.safeEmit({ type: 'unlink', path: fullPath });
|
||||
} else if (hasRename) {
|
||||
// Single event for file that doesn't exist and wasn't tracked
|
||||
console.log(`[smartwatch] Untracked file deleted: ${fullPath}`);
|
||||
this.safeEmit({ type: 'unlink', path: fullPath });
|
||||
}
|
||||
// If only 'change' events for non-existent untracked file, ignore
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
// Track file inode for delete+recreate detection
|
||||
this.fileInodes.set(fullPath, BigInt(stats.ino));
|
||||
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;
|
||||
}
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "tslint-config-standard"
|
||||
}
|
||||
Reference in New Issue
Block a user