feat(watchers): add Rust-powered watcher backend with runtime fallback and cross-platform test coverage
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartwatch',
|
||||
version: '6.3.1',
|
||||
version: '6.4.0',
|
||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export {
|
||||
// @pushrocks scope
|
||||
import * as lik from '@push.rocks/lik';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import { Smartenv } from '@push.rocks/smartenv';
|
||||
|
||||
export {
|
||||
lik,
|
||||
smartpromise,
|
||||
smartrust,
|
||||
smartrx,
|
||||
Smartenv
|
||||
}
|
||||
|
||||
@@ -4,18 +4,27 @@ import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './
|
||||
export type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType };
|
||||
|
||||
/**
|
||||
* Creates a platform-appropriate file watcher based on the current runtime
|
||||
* Uses @push.rocks/smartenv for runtime detection
|
||||
* Creates a file watcher, preferring the Rust backend when available.
|
||||
* Falls back to chokidar (Node.js/Bun) or Deno.watchFs based on runtime.
|
||||
*/
|
||||
export async function createWatcher(options: IWatcherOptions): Promise<IWatcher> {
|
||||
// Try Rust watcher first (works on all runtimes via smartrust IPC)
|
||||
try {
|
||||
const { RustWatcher } = await import('./watcher.rust.js');
|
||||
if (await RustWatcher.isAvailable()) {
|
||||
return new RustWatcher(options);
|
||||
}
|
||||
} catch {
|
||||
// Rust watcher not available, fall back
|
||||
}
|
||||
|
||||
// Fall back to runtime-specific watchers
|
||||
const env = new Smartenv();
|
||||
|
||||
if (env.isDeno) {
|
||||
// Deno runtime - use Deno.watchFs
|
||||
const { DenoWatcher } = await import('./watcher.deno.js');
|
||||
return new DenoWatcher(options);
|
||||
} else {
|
||||
// Node.js or Bun - both use fs.watch (Bun has Node.js compatibility)
|
||||
const { NodeWatcher } = await import('./watcher.node.js');
|
||||
return new NodeWatcher(options);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,30 @@ export class DenoWatcher implements IWatcher {
|
||||
type: wasDirectory ? 'unlinkDir' : 'unlink',
|
||||
path: filePath
|
||||
});
|
||||
} else if (kind === 'any' || kind === 'other') {
|
||||
// Deno may emit 'any' for various operations — determine the actual type
|
||||
const stats = await this.statSafe(filePath);
|
||||
if (stats) {
|
||||
if (this.watchedFiles.has(filePath)) {
|
||||
// Known file → treat as change
|
||||
if (!stats.isDirectory()) {
|
||||
this.events$.next({ type: 'change', path: filePath, stats });
|
||||
}
|
||||
} else {
|
||||
// New file → treat as add
|
||||
this.watchedFiles.add(filePath);
|
||||
const eventType: TWatchEventType = stats.isDirectory() ? 'addDir' : 'add';
|
||||
this.events$.next({ type: eventType, path: filePath, stats });
|
||||
}
|
||||
} else {
|
||||
// File no longer exists → treat as remove
|
||||
const wasDirectory = this.isKnownDirectory(filePath);
|
||||
this.watchedFiles.delete(filePath);
|
||||
this.events$.next({
|
||||
type: wasDirectory ? 'unlinkDir' : 'unlink',
|
||||
path: filePath
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.events$.next({ type: 'error', path: filePath, error });
|
||||
|
||||
@@ -17,11 +17,19 @@ import type { IWatcher, IWatcherOptions, IWatchEvent } from './interfaces.js';
|
||||
export class NodeWatcher implements IWatcher {
|
||||
private watcher: chokidar.FSWatcher | null = null;
|
||||
private _isWatching = false;
|
||||
private _preExistingHandles: Set<any> = new Set();
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {}
|
||||
|
||||
/** Collect all current FSWatcher handles from the process */
|
||||
private _getFsWatcherHandles(): any[] {
|
||||
return (process as any)._getActiveHandles().filter(
|
||||
(h: any) => h?.constructor?.name === 'FSWatcher' && typeof h.unref === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
@@ -29,6 +37,9 @@ export class NodeWatcher implements IWatcher {
|
||||
async start(): Promise<void> {
|
||||
if (this._isWatching) return;
|
||||
|
||||
// Snapshot existing FSWatcher handles so we only unref ours on stop
|
||||
this._preExistingHandles = new Set(this._getFsWatcherHandles());
|
||||
|
||||
console.log(`[smartwatch] Starting chokidar watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||
|
||||
try {
|
||||
@@ -90,13 +101,16 @@ export class NodeWatcher implements IWatcher {
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
// Unref any lingering FSWatcher handles from chokidar so they don't prevent process exit.
|
||||
// Chokidar v5's close() resolves before all fs.watch() handles are fully released.
|
||||
for (const handle of (process as any)._getActiveHandles()) {
|
||||
if (handle?.constructor?.name === 'FSWatcher' && typeof handle.unref === 'function') {
|
||||
// Unref only FSWatcher handles created during our watch session.
|
||||
// Chokidar v5 can orphan fs.watch() handles under heavy file churn,
|
||||
// preventing process exit. We only touch handles that didn't exist
|
||||
// before start() to avoid affecting other watchers in the process.
|
||||
for (const handle of this._getFsWatcherHandles()) {
|
||||
if (!this._preExistingHandles.has(handle)) {
|
||||
handle.unref();
|
||||
}
|
||||
}
|
||||
this._preExistingHandles.clear();
|
||||
|
||||
this._isWatching = false;
|
||||
console.log('[smartwatch] Watcher stopped');
|
||||
|
||||
154
ts/watchers/watcher.rust.ts
Normal file
154
ts/watchers/watcher.rust.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as path from 'node:path';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartrust from '@push.rocks/smartrust';
|
||||
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
||||
|
||||
// Resolve the package directory for binary location
|
||||
const packageDir = path.resolve(new URL('.', import.meta.url).pathname, '..', '..');
|
||||
|
||||
/**
|
||||
* Command map for the Rust file watcher binary
|
||||
*/
|
||||
type TWatcherCommands = {
|
||||
watch: {
|
||||
params: {
|
||||
paths: string[];
|
||||
depth: number;
|
||||
followSymlinks: boolean;
|
||||
debounceMs: number;
|
||||
};
|
||||
result: { watching: boolean };
|
||||
};
|
||||
stop: {
|
||||
params: Record<string, never>;
|
||||
result: { stopped: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build local search paths for the Rust binary
|
||||
*/
|
||||
function buildLocalPaths(): string[] {
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform;
|
||||
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch;
|
||||
const platformSuffix = `${platform}_${arch}`;
|
||||
|
||||
return [
|
||||
path.join(packageDir, 'dist_rust', `smartwatch-rust_${platformSuffix}`),
|
||||
path.join(packageDir, 'dist_rust', 'smartwatch-rust'),
|
||||
path.join(packageDir, 'rust', 'target', 'release', 'smartwatch-rust'),
|
||||
path.join(packageDir, 'rust', 'target', 'debug', 'smartwatch-rust'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust-based file watcher using the notify crate via @push.rocks/smartrust
|
||||
*
|
||||
* Uses a Rust binary for native OS-level file watching (inotify/FSEvents/ReadDirectoryChangesW).
|
||||
* Works across Node.js, Deno, and Bun via smartrust's IPC bridge.
|
||||
*/
|
||||
export class RustWatcher implements IWatcher {
|
||||
private bridge: smartrust.RustBridge<TWatcherCommands>;
|
||||
private _isWatching = false;
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {
|
||||
this.bridge = new smartrust.RustBridge<TWatcherCommands>({
|
||||
binaryName: 'smartwatch-rust',
|
||||
localPaths: buildLocalPaths(),
|
||||
searchSystemPath: false,
|
||||
cliArgs: ['--management'],
|
||||
requestTimeoutMs: 30000,
|
||||
readyTimeoutMs: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Rust binary is available on this system
|
||||
*/
|
||||
static async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const locator = new smartrust.RustBinaryLocator({
|
||||
binaryName: 'smartwatch-rust',
|
||||
localPaths: buildLocalPaths(),
|
||||
searchSystemPath: false,
|
||||
});
|
||||
const binaryPath = await locator.findBinary();
|
||||
return binaryPath !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._isWatching) return;
|
||||
|
||||
console.log(`[smartwatch] Starting Rust watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||
|
||||
// Listen for file system events from the Rust binary
|
||||
this.bridge.on('management:fsEvent', (data: { type: string; path: string }) => {
|
||||
const eventType = data.type as TWatchEventType;
|
||||
this.safeEmit({ type: eventType, path: data.path });
|
||||
});
|
||||
|
||||
this.bridge.on('management:error', (data: { message: string }) => {
|
||||
console.error('[smartwatch] Rust watcher error:', data.message);
|
||||
this.safeEmit({ type: 'error', path: '', error: new Error(data.message) });
|
||||
});
|
||||
|
||||
this.bridge.on('management:watchReady', () => {
|
||||
console.log('[smartwatch] Rust watcher ready - initial scan complete');
|
||||
this.safeEmit({ type: 'ready', path: '' });
|
||||
});
|
||||
|
||||
// Spawn the Rust binary
|
||||
const ok = await this.bridge.spawn();
|
||||
if (!ok) {
|
||||
throw new Error('[smartwatch] Failed to spawn Rust watcher binary');
|
||||
}
|
||||
|
||||
// Resolve paths to absolute
|
||||
const absolutePaths = this.options.basePaths.map(p => path.resolve(p));
|
||||
|
||||
// Send watch command
|
||||
await this.bridge.sendCommand('watch', {
|
||||
paths: absolutePaths,
|
||||
depth: this.options.depth,
|
||||
followSymlinks: this.options.followSymlinks,
|
||||
debounceMs: this.options.debounceMs,
|
||||
});
|
||||
|
||||
this._isWatching = true;
|
||||
console.log('[smartwatch] Rust watcher started');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
console.log('[smartwatch] Stopping Rust watcher...');
|
||||
|
||||
if (this._isWatching) {
|
||||
try {
|
||||
await this.bridge.sendCommand('stop', {} as any);
|
||||
} catch {
|
||||
// Binary may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
this.bridge.kill();
|
||||
this._isWatching = false;
|
||||
console.log('[smartwatch] Rust watcher stopped');
|
||||
}
|
||||
|
||||
/** Safely emit an event, isolating subscriber errors */
|
||||
private safeEmit(event: IWatchEvent): void {
|
||||
try {
|
||||
this.events$.next(event);
|
||||
} catch (error) {
|
||||
console.error('[smartwatch] Subscriber threw error (isolated):', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user