Skip to content

Commit f63089e

Browse files
committed
feat(watcher): add Superwatcher class with debounce and event batching
- Add constructor with multi-path support and file target detection - Add debounced flush with Map-based dedup and atomic write resolution - Add ignore matcher compilation for string, RegExp, and function filters - Add pollWriteStable for file size stability polling before emission - Add start and dispose lifecycle methods with error-isolated onChange
1 parent 6c2ddc1 commit f63089e

1 file changed

Lines changed: 226 additions & 0 deletions

File tree

src/index.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { Utils } from '@app/utils.ts'
2+
import type * as Types from '@app/types.ts'
3+
4+
export class Superwatcher {
5+
private readonly debounceMs: number
6+
private readonly fileTargets: Set<string>
7+
private readonly ignoreMatchers: Types.IgnoreMatcherFn[]
8+
private readonly onChange: (events: Types.WatchEvent[]) => void
9+
private readonly pendingPaths: Map<string, Types.PendingEntry> = new Map()
10+
private readonly recursive: boolean
11+
private readonly sizePollers: Map<string, Types.TimerId> = new Map()
12+
private readonly watchPaths: string[]
13+
private readonly writeStable: Types.WriteStable | undefined
14+
private debounceTimer: Types.TimerId | null = null
15+
private watcher: Deno.FsWatcher | null = null
16+
17+
constructor(options: Types.WatcherOptions) {
18+
Utils.validateOptions(options)
19+
const rawPaths = Array.isArray(options.path)
20+
? (options.path as string[])
21+
: [options.path as string]
22+
this.fileTargets = new Set()
23+
this.watchPaths = []
24+
this.recursive = options.recursive ?? true
25+
for (const targetPath of rawPaths) {
26+
const normalized = Utils.normalize(Deno.realPathSync(targetPath))
27+
const stat = Deno.statSync(normalized)
28+
if (stat.isFile) {
29+
const separatorIndex = normalized.lastIndexOf('/')
30+
const dirPath = separatorIndex > 0 ? normalized.slice(0, separatorIndex) : '.'
31+
this.fileTargets.add(Utils.baseName(normalized))
32+
if (!this.watchPaths.includes(dirPath)) {
33+
this.watchPaths.push(dirPath)
34+
}
35+
} else {
36+
this.watchPaths.push(normalized)
37+
}
38+
}
39+
this.debounceMs = options.debounceMs
40+
this.onChange = options.onChange
41+
this.writeStable = options.writeStable
42+
this.ignoreMatchers = (options.ignore ?? []).map((matcher) => this.compileMatcher(matcher))
43+
}
44+
45+
dispose(): void {
46+
if (this.debounceTimer !== null) {
47+
clearTimeout(this.debounceTimer)
48+
this.debounceTimer = null
49+
}
50+
for (const timer of this.sizePollers.values()) {
51+
clearTimeout(timer)
52+
}
53+
this.sizePollers.clear()
54+
this.pendingPaths.clear()
55+
try {
56+
this.watcher?.close()
57+
} catch {
58+
// no-op
59+
}
60+
this.watcher = null
61+
}
62+
63+
start(): void {
64+
if (this.watchPaths.length === 0) {
65+
return
66+
}
67+
this.dispose()
68+
const recursive = this.fileTargets.size === 0 && this.recursive
69+
const paths = this.watchPaths.length === 1 ? this.watchPaths[0]! : this.watchPaths
70+
try {
71+
this.watcher = Deno.watchFs(paths, { recursive })
72+
this.listen()
73+
} catch {
74+
this.watcher = null
75+
}
76+
}
77+
78+
private compileMatcher(matcher: Types.IgnoreMatcher): Types.IgnoreMatcherFn {
79+
if (typeof matcher === 'function') {
80+
return matcher
81+
}
82+
if (typeof matcher === 'string') {
83+
return (filePath) => filePath.endsWith(matcher) || Utils.baseName(filePath) === matcher
84+
}
85+
if (matcher instanceof RegExp) {
86+
return (filePath) => matcher.test(filePath)
87+
}
88+
return () => false
89+
}
90+
91+
private flush(): void {
92+
this.debounceTimer = null
93+
if (this.pendingPaths.size === 0) {
94+
return
95+
}
96+
const entries = [...this.pendingPaths.values()]
97+
this.pendingPaths.clear()
98+
const resolvedEvents: Types.WatchEvent[] = []
99+
let pendingCount = entries.length
100+
const emitResolved = (): void => {
101+
if (resolvedEvents.length === 0) {
102+
return
103+
}
104+
try {
105+
this.onChange(resolvedEvents)
106+
} catch {
107+
// no-op
108+
}
109+
}
110+
const collectEntry = (entry: Types.PendingEntry): void => {
111+
resolvedEvents.push({ kind: entry.kind, path: entry.path })
112+
pendingCount--
113+
if (pendingCount === 0) {
114+
emitResolved()
115+
}
116+
}
117+
for (const entry of entries) {
118+
this.resolveEntry(entry)
119+
if (this.writeStable && entry.kind !== 'remove') {
120+
this.pollWriteStable(entry.path, () => collectEntry(entry))
121+
} else {
122+
collectEntry(entry)
123+
}
124+
}
125+
}
126+
127+
private isIgnored(filePath: string): boolean {
128+
for (const matchFn of this.ignoreMatchers) {
129+
if (matchFn(filePath)) {
130+
return true
131+
}
132+
}
133+
return false
134+
}
135+
136+
private async listen(): Promise<void> {
137+
if (!this.watcher) {
138+
return
139+
}
140+
try {
141+
for await (const event of this.watcher) {
142+
const kind = Utils.resolveEventKind(event.kind)
143+
if (kind === null) {
144+
continue
145+
}
146+
for (const eventPath of event.paths) {
147+
const normalized = Utils.normalize(eventPath)
148+
if (this.fileTargets.size > 0 && !this.fileTargets.has(Utils.baseName(normalized))) {
149+
continue
150+
}
151+
if (this.isIgnored(normalized)) {
152+
continue
153+
}
154+
this.setPending(normalized, kind)
155+
this.scheduleFlush()
156+
}
157+
}
158+
} catch {
159+
// no-op
160+
}
161+
}
162+
163+
private pollWriteStable(filePath: string, callback: () => void): void {
164+
if (!this.writeStable) {
165+
callback()
166+
return
167+
}
168+
const existing = this.sizePollers.get(filePath)
169+
if (existing) {
170+
clearTimeout(existing)
171+
}
172+
let lastSize = -1
173+
let stableSince = Date.now()
174+
const { threshold, interval } = this.writeStable
175+
const poll = (): void => {
176+
try {
177+
const fileInfo = Deno.statSync(filePath)
178+
const now = Date.now()
179+
if (fileInfo.size !== lastSize) {
180+
lastSize = fileInfo.size
181+
stableSince = now
182+
}
183+
if (now - stableSince >= threshold) {
184+
this.sizePollers.delete(filePath)
185+
callback()
186+
} else {
187+
this.sizePollers.set(filePath, setTimeout(poll, interval))
188+
}
189+
} catch {
190+
this.sizePollers.delete(filePath)
191+
}
192+
}
193+
this.sizePollers.set(filePath, setTimeout(poll, interval))
194+
}
195+
196+
private resolveEntry(entry: Types.PendingEntry): void {
197+
if (entry.kind === 'remove') {
198+
try {
199+
Deno.statSync(entry.path)
200+
entry.kind = 'modify'
201+
} catch {
202+
// no-op
203+
}
204+
}
205+
}
206+
207+
private scheduleFlush(): void {
208+
if (this.debounceTimer !== null) {
209+
clearTimeout(this.debounceTimer)
210+
}
211+
this.debounceTimer = setTimeout(() => {
212+
this.flush()
213+
}, this.debounceMs)
214+
}
215+
216+
private setPending(filePath: string, kind: Types.EventKind): void {
217+
const existing = this.pendingPaths.get(filePath)
218+
if (existing && existing.kind === 'remove' && kind !== 'remove') {
219+
existing.kind = 'modify'
220+
} else {
221+
this.pendingPaths.set(filePath, { path: filePath, kind })
222+
}
223+
}
224+
}
225+
226+
export type * from '@app/types.ts'

0 commit comments

Comments
 (0)