Files
smartwatch/ts/smartwatch.classes.smartwatch.ts

238 lines
6.8 KiB
TypeScript
Raw Normal View History

import * as plugins from './smartwatch.plugins.js';
2024-01-28 01:18:39 +01:00
import { Stringmap } from '@push.rocks/lik';
import { createWatcher, type IWatcher, type IWatchEvent, type TWatchEventType } from './watchers/index.js';
2017-06-30 18:05:55 +02:00
export type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
2018-03-01 00:08:08 +01:00
export type TFsEvent =
| 'add'
| 'addDir'
| 'change'
| 'error'
| 'unlink'
| 'unlinkDir'
| 'ready'
| 'raw';
2017-06-30 18:05:55 +02:00
/**
* Smartwatch allows easy watching of files
* Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support
2017-06-30 18:05:55 +02:00
*/
export class Smartwatch {
2019-09-29 16:35:12 +02:00
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();
2017-06-30 18:05:55 +02:00
/**
* constructor of class Smartwatch
2017-06-30 18:05:55 +02:00
*/
2024-01-28 01:18:39 +01:00
constructor(watchArrayArg: string[]) {
2018-03-01 00:08:08 +01:00
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());
}
2024-01-28 01:18:39 +01:00
}
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);
2017-06-30 18:05:55 +02:00
}
/**
* adds files to the list of watched files
*/
2019-09-29 16:35:12 +02:00
public add(pathArrayArg: string[]) {
2018-03-01 00:08:08 +01:00
this.watchStringmap.addStringArray(pathArrayArg);
2017-06-30 18:05:55 +02:00
}
/**
* removes files from the list of watched files
*/
2019-09-29 16:35:12 +02:00
public remove(pathArg: string) {
2018-03-01 00:08:08 +01:00
this.watchStringmap.removeString(pathArg);
2017-06-30 18:05:55 +02:00
}
/**
* gets an observable for a certain event
*/
2024-01-28 01:18:39 +01:00
public getObservableFor(
fsEvent: TFsEvent
): Promise<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
2019-09-29 16:35:12 +02:00
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
2017-06-30 18:05:55 +02:00
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}`));
}
2018-03-01 00:08:08 +01:00
});
return done.promise;
2017-06-30 18:05:55 +02:00
}
/**
* starts the watcher
* @returns Promise<void>
*/
public async start(): Promise<void> {
2018-03-01 00:08:08 +01:00
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,
stabilityThreshold: 300,
pollInterval: 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]);
}
2017-06-30 18:05:55 +02:00
}
/**
* stop the watcher process if watching
*/
2021-11-29 20:26:59 +01:00
public async stop() {
const closeWatcher = async () => {
if (this.watcher) {
await this.watcher.stop();
this.watcher = null;
}
2018-03-01 00:08:08 +01:00
};
2017-06-30 18:05:55 +02:00
if (this.status === 'watching') {
2018-03-01 00:08:08 +01:00
console.log('closing while watching');
2021-11-29 20:26:59 +01:00
await closeWatcher();
2017-06-30 18:05:55 +02:00
} else if (this.status === 'starting') {
2021-11-29 20:26:59 +01:00
await this.watchingDeferred.promise;
await closeWatcher();
2017-06-30 18:05:55 +02:00
}
this.status = 'idle';
2017-06-30 18:05:55 +02:00
}
/**
* 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;
}
2017-06-30 18:05:55 +02:00
}