import { fromFileUrl, join, relative, toFileUrl } from "../deps/path.ts"; import { normalizePath } from "./utils/path.ts"; import Events from "./events.ts"; import type Site from "./site.ts"; import type { Event, EventListener, EventOptions } from "./events.ts"; import { updateDependencies, updateVersion } from "./utils/hmr.ts"; /** The options to configure the local server */ export interface Options { /** The root folder */ root: string; /** The src folder to watch */ src: string; /** Extra files to watch */ paths?: string[]; /** Paths ignored by the watcher */ ignore?: (string | ((path: string) => boolean))[]; /** The debounce waiting time */ debounce?: number; /** File dependencies */ dependencies: Record; } /** Custom events for server */ export interface WatchEvent extends Event { /** The event type */ type: WatchEventType; /** The list of changed files (only for "change" events) */ files?: Set; /** The error object (only for "error" events) */ error?: Error; } /** The available event types */ export type WatchEventType = | "start" | "change" | "error"; export interface Watcher { /** Add a listener to an event */ addEventListener( type: WatchEventType, listener: EventListener, options?: EventOptions, ): this; /** Dispatch an event */ dispatchEvent(event: WatchEvent): Promise; /** Start the watcher */ start(): Promise; } export default class FSWatcher implements Watcher { events: Events = new Events(); options: Options; paused = false; constructor(options: Options) { this.options = options; } /** Add a listener to an event */ addEventListener( type: WatchEventType, listener: EventListener, options?: EventOptions, ) { this.events.addEventListener(type, listener, options); return this; } /** Dispatch an event */ dispatchEvent(event: WatchEvent) { return this.events.dispatchEvent(event); } /** Start the file watcher */ async start() { const { root, src, paths, ignore, debounce, dependencies } = this.options; const watcher = Deno.watchFs([src, ...paths ?? []]); const changes = new Set(); let timer: ReturnType | undefined = undefined; let runningCallback = false; await this.dispatchEvent({ type: "start" }); const callback = async () => { // If the callback is already running, debounce the next call if (runningCallback) { clearTimeout(timer); setTimeout(callback, debounce ?? 100); return; } runningCallback = true; const files = new Set(changes); changes.clear(); if (!files.size) { runningCallback = false; return; } try { const result = await this.dispatchEvent({ type: "change", files: files, }); if (false === result) { runningCallback = false; return watcher.close(); } } catch (err) { await this.dispatchEvent({ type: "error", error: err as Error }); } finally { runningCallback = false; } // New changes detected while running the callback if (changes.size) { callback(); } }; for await (const event of watcher) { const paths = new Set(); updateVersion(); for (const path of event.paths.map((p) => normalizePath(p))) { // Filter ignored paths const isIgnored = ignore?.some((condition) => typeof condition === "string" ? (path.startsWith(normalizePath(join(root, condition, "/"))) || path === normalizePath(join(root, condition))) : condition(path) ); if (isIgnored) continue; paths.add(path); for (const dep of updateDependencies(toFileUrl(path).href)) { paths.add(normalizePath(fromFileUrl(dep))); } } if (!paths.size) { continue; } paths.forEach((path) => { const normalized = normalizePath(relative(root, path)); changes.add(normalized); // The file has dependencies for (const [file, deps] of Object.entries(dependencies ?? {})) { const isDependent = deps.some((dep) => normalizePath(dep) === normalized ); if (isDependent) { changes.add(normalizePath(file)); } } }); // Only run the callback if it is not already running if (!runningCallback) { // Debounce clearTimeout(timer); timer = setTimeout(callback, debounce ?? 100); } } } } export class SiteWatcher implements Watcher { site: Site; events: Events = new Events(); constructor(site: Site) { this.site = site; } /** Add a listener to an event */ addEventListener( type: WatchEventType, listener: EventListener, options?: EventOptions, ) { this.events.addEventListener(type, listener, options); return this; } /** Dispatch an event */ dispatchEvent(event: WatchEvent) { return this.events.dispatchEvent(event); } /** Start the watcher */ async start() { await this.dispatchEvent({ type: "start" }); this.site.addEventListener("afterUpdate", (event) => { const files = new Set([ ...event.pages.map((page) => page.outputPath), ...event.staticFiles.map((file) => file.outputPath), ]); this.dispatchEvent({ type: "change", files }); }); } }