@tuongaz/seeflow 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/watcher.ts ADDED
@@ -0,0 +1,383 @@
1
+ import { type FSWatcher, existsSync, readFileSync, watch } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join } from 'node:path';
3
+ import type { EventBus } from './events.ts';
4
+ import type { Registry } from './registry.ts';
5
+ import { type Demo, DemoSchema } from './schema.ts';
6
+
7
+ const DEFAULT_DEBOUNCE_MS = 100;
8
+
9
+ export interface DemoSnapshot {
10
+ /** Last successfully parsed demo, if we ever saw one. */
11
+ demo: Demo | null;
12
+ /** Result of the most recent parse attempt. */
13
+ valid: boolean;
14
+ /** Human-readable error from the most recent parse, when `valid: false`. */
15
+ error: string | null;
16
+ /** Absolute path on disk this snapshot was read from. */
17
+ filePath: string;
18
+ /** Server timestamp of the most recent parse attempt. */
19
+ parsedAt: number;
20
+ }
21
+
22
+ export interface WatcherDeps {
23
+ registry: Registry;
24
+ events: EventBus;
25
+ /** Override for tests. */
26
+ debounceMs?: number;
27
+ }
28
+
29
+ export interface DemoWatcher {
30
+ /** Read the current snapshot for a demo, or null if unknown. */
31
+ snapshot(demoId: string): DemoSnapshot | null;
32
+ /** Begin watching the file backing the given demo id. Idempotent. */
33
+ watch(demoId: string): void;
34
+ /** Stop watching a single demo. */
35
+ unwatch(demoId: string): void;
36
+ /** Start watchers for every entry currently in the registry. */
37
+ watchAll(): void;
38
+ /** Stop everything (used in tests + on shutdown). */
39
+ closeAll(): void;
40
+ /** Force a reparse synchronously. Useful for tests + initial load. */
41
+ reparse(demoId: string): DemoSnapshot | null;
42
+ /**
43
+ * Relative paths (under `<project>/.seeflow/`) currently being watched
44
+ * because they're referenced by a node's `data.htmlPath` or `data.path`.
45
+ * Sorted for stable assertion order. Used by tests.
46
+ */
47
+ referencedPaths(demoId: string): string[];
48
+ }
49
+
50
+ interface FileWatchEntry {
51
+ fsWatcher: FSWatcher;
52
+ /** basename → relative path (rooted at `<project>/.seeflow/`) */
53
+ files: Map<string, string>;
54
+ /** basename → pending debounce timer for the next broadcast */
55
+ timers: Map<string, ReturnType<typeof setTimeout>>;
56
+ }
57
+
58
+ interface WatchHandle {
59
+ fsWatcher: FSWatcher;
60
+ debounceTimer: ReturnType<typeof setTimeout> | null;
61
+ filePath: string;
62
+ /**
63
+ * Per-directory file watchers for files referenced by node data
64
+ * (`htmlPath`, imageNode `path`). Each directory watcher dispatches to
65
+ * specific basenames in its `files` map.
66
+ */
67
+ fileWatchers: Map<string, FileWatchEntry>;
68
+ }
69
+
70
+ const resolveFilePath = (repoPath: string, demoPath: string): string =>
71
+ isAbsolute(demoPath) ? demoPath : join(repoPath, demoPath);
72
+
73
+ const isCleanRelativePath = (p: string): boolean => {
74
+ if (!p) return false;
75
+ // Reject data URLs early — the pre-launch hard-cut (US-004) replaces
76
+ // imageNode.data.image with data.path, but defensively skip any lingering
77
+ // base64 payloads so we don't try to fs.watch a 5MB string.
78
+ if (p.startsWith('data:')) return false;
79
+ if (isAbsolute(p) || p.startsWith('/') || p.startsWith('\\')) return false;
80
+ const segments = p.split(/[\\/]/);
81
+ if (segments.some((s) => s === '..')) return false;
82
+ return true;
83
+ };
84
+
85
+ /**
86
+ * Walk raw demo JSON (pre-schema-parse) collecting referenced file paths:
87
+ * `nodes[].data.htmlPath` (htmlNode) and `nodes[].data.path` (imageNode after
88
+ * US-004). Operates on the raw JSON so the watcher works before those fields
89
+ * are formally declared in the schema — Zod's default-strip would drop them
90
+ * during validation, but the file watcher still needs to know about them.
91
+ */
92
+ const collectReferencedPaths = (raw: unknown): string[] => {
93
+ if (!raw || typeof raw !== 'object') return [];
94
+ const nodes = (raw as { nodes?: unknown }).nodes;
95
+ if (!Array.isArray(nodes)) return [];
96
+ const out = new Set<string>();
97
+ for (const node of nodes) {
98
+ if (!node || typeof node !== 'object') continue;
99
+ const data = (node as { data?: unknown }).data;
100
+ if (!data || typeof data !== 'object') continue;
101
+ const d = data as { htmlPath?: unknown; path?: unknown };
102
+ for (const candidate of [d.htmlPath, d.path]) {
103
+ if (typeof candidate !== 'string') continue;
104
+ if (!isCleanRelativePath(candidate)) continue;
105
+ out.add(candidate);
106
+ }
107
+ }
108
+ return [...out];
109
+ };
110
+
111
+ const closeFileWatchers = (handle: WatchHandle): void => {
112
+ for (const entry of handle.fileWatchers.values()) {
113
+ entry.fsWatcher.close();
114
+ for (const t of entry.timers.values()) clearTimeout(t);
115
+ }
116
+ handle.fileWatchers.clear();
117
+ };
118
+
119
+ export function createWatcher(deps: WatcherDeps): DemoWatcher {
120
+ const { registry, events } = deps;
121
+ const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
122
+
123
+ const handles = new Map<string, WatchHandle>();
124
+ const snapshots = new Map<string, DemoSnapshot>();
125
+
126
+ // Reconcile the file-watch set for `demoId` against the desired referenced
127
+ // paths. Closes watchers for dirs that disappeared, updates the basename
128
+ // map for dirs that survived, opens new fs.watch handles for new dirs.
129
+ const reconcileFileWatchers = (
130
+ demoId: string,
131
+ handle: WatchHandle,
132
+ seeflowRoot: string,
133
+ refs: string[],
134
+ ): void => {
135
+ const desired = new Map<string, Map<string, string>>();
136
+ for (const relPath of refs) {
137
+ const abs = join(seeflowRoot, relPath);
138
+ const dir = dirname(abs);
139
+ const base = basename(abs);
140
+ let dirMap = desired.get(dir);
141
+ if (!dirMap) {
142
+ dirMap = new Map();
143
+ desired.set(dir, dirMap);
144
+ }
145
+ dirMap.set(base, relPath);
146
+ }
147
+
148
+ // Close watchers for directories no longer referenced.
149
+ for (const [dir, entry] of handle.fileWatchers) {
150
+ if (!desired.has(dir)) {
151
+ entry.fsWatcher.close();
152
+ for (const t of entry.timers.values()) clearTimeout(t);
153
+ handle.fileWatchers.delete(dir);
154
+ }
155
+ }
156
+
157
+ // Add or update watchers for desired directories.
158
+ for (const [dir, files] of desired) {
159
+ const existing = handle.fileWatchers.get(dir);
160
+ if (existing) {
161
+ existing.files = files;
162
+ // Drop pending timers for basenames no longer in scope.
163
+ for (const base of [...existing.timers.keys()]) {
164
+ if (!files.has(base)) {
165
+ const t = existing.timers.get(base);
166
+ if (t) clearTimeout(t);
167
+ existing.timers.delete(base);
168
+ }
169
+ }
170
+ continue;
171
+ }
172
+
173
+ if (!existsSync(dir)) {
174
+ // Directory hasn't been created on disk yet (e.g. blocks/ before any
175
+ // htmlNode is dropped). Skip silently — next reparse will retry.
176
+ continue;
177
+ }
178
+
179
+ let fsWatcher: FSWatcher;
180
+ try {
181
+ fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
182
+ if (!changed) return;
183
+ const cur = handle.fileWatchers.get(dir);
184
+ if (!cur) return;
185
+ const rel = cur.files.get(changed);
186
+ if (!rel) return;
187
+ const existingTimer = cur.timers.get(changed);
188
+ if (existingTimer) clearTimeout(existingTimer);
189
+ const timer = setTimeout(() => {
190
+ cur.timers.delete(changed);
191
+ events.broadcast({
192
+ type: 'file:changed',
193
+ demoId,
194
+ payload: { path: rel },
195
+ });
196
+ }, debounceMs);
197
+ cur.timers.set(changed, timer);
198
+ });
199
+ } catch (err) {
200
+ console.error(`[watcher] failed to watch ${dir} for demo ${demoId}:`, err);
201
+ continue;
202
+ }
203
+
204
+ handle.fileWatchers.set(dir, {
205
+ fsWatcher,
206
+ files,
207
+ timers: new Map(),
208
+ });
209
+ }
210
+ };
211
+
212
+ const reparse = (demoId: string): DemoSnapshot | null => {
213
+ const entry = registry.getById(demoId);
214
+ if (!entry) return null;
215
+ const filePath = resolveFilePath(entry.repoPath, entry.demoPath);
216
+
217
+ const previous = snapshots.get(demoId) ?? null;
218
+ const parsedAt = Date.now();
219
+ const fail = (error: string): DemoSnapshot => ({
220
+ demo: previous?.demo ?? null,
221
+ valid: false,
222
+ error,
223
+ filePath,
224
+ parsedAt,
225
+ });
226
+
227
+ let next: DemoSnapshot;
228
+ let raw: unknown = null;
229
+ let rawOk = false;
230
+
231
+ if (!existsSync(filePath)) {
232
+ next = fail(`Demo file not found: ${filePath}`);
233
+ } else {
234
+ let parseError: string | null = null;
235
+ try {
236
+ raw = JSON.parse(readFileSync(filePath, 'utf8'));
237
+ rawOk = true;
238
+ } catch (err) {
239
+ parseError = `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`;
240
+ }
241
+
242
+ if (!rawOk) {
243
+ next = fail(parseError ?? 'Invalid JSON');
244
+ } else {
245
+ const parsed = DemoSchema.safeParse(raw);
246
+ if (!parsed.success) {
247
+ const message = parsed.error.issues
248
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
249
+ .join('; ');
250
+ next = fail(`Schema validation failed: ${message}`);
251
+ } else {
252
+ next = { demo: parsed.data, valid: true, error: null, filePath, parsedAt };
253
+ }
254
+ }
255
+ }
256
+
257
+ snapshots.set(demoId, next);
258
+
259
+ // Recompute the referenced-files watch set whenever raw JSON parsed
260
+ // cleanly (regardless of schema validity). Schema errors shouldn't drop
261
+ // the watch set — the user is mid-edit and the referenced files are
262
+ // still valid targets.
263
+ if (rawOk) {
264
+ const handle = handles.get(demoId);
265
+ if (handle) {
266
+ reconcileFileWatchers(
267
+ demoId,
268
+ handle,
269
+ join(entry.repoPath, '.seeflow'),
270
+ collectReferencedPaths(raw),
271
+ );
272
+ }
273
+ }
274
+
275
+ return next;
276
+ };
277
+
278
+ const broadcastReload = (demoId: string, snap: DemoSnapshot) => {
279
+ events.broadcast({
280
+ type: 'demo:reload',
281
+ demoId,
282
+ payload: snap.valid ? { valid: true, demo: snap.demo } : { valid: false, error: snap.error },
283
+ });
284
+ };
285
+
286
+ const startWatch = (demoId: string) => {
287
+ const existing = handles.get(demoId);
288
+ if (existing) {
289
+ existing.fsWatcher.close();
290
+ if (existing.debounceTimer) clearTimeout(existing.debounceTimer);
291
+ closeFileWatchers(existing);
292
+ handles.delete(demoId);
293
+ }
294
+
295
+ const entry = registry.getById(demoId);
296
+ if (!entry) return;
297
+
298
+ const filePath = resolveFilePath(entry.repoPath, entry.demoPath);
299
+ const dir = dirname(filePath);
300
+ const base = basename(filePath);
301
+
302
+ if (!existsSync(dir)) {
303
+ // Directory missing — record an invalid snapshot but don't try to watch.
304
+ const snap = reparse(demoId);
305
+ if (snap) broadcastReload(demoId, snap);
306
+ return;
307
+ }
308
+
309
+ let fsWatcher: FSWatcher;
310
+ try {
311
+ fsWatcher = watch(dir, { persistent: true }, (_event, changed) => {
312
+ // Only react to the demo file (or to events with no filename, which
313
+ // some platforms emit for rename-on-save patterns).
314
+ if (changed && changed !== base) return;
315
+ const handle = handles.get(demoId);
316
+ if (!handle) return;
317
+ if (handle.debounceTimer) clearTimeout(handle.debounceTimer);
318
+ handle.debounceTimer = setTimeout(() => {
319
+ handle.debounceTimer = null;
320
+ const snap = reparse(demoId);
321
+ if (snap) broadcastReload(demoId, snap);
322
+ }, debounceMs);
323
+ });
324
+ } catch (err) {
325
+ console.error(`[watcher] failed to watch ${dir} for demo ${demoId}:`, err);
326
+ const snap = reparse(demoId);
327
+ if (snap) broadcastReload(demoId, snap);
328
+ return;
329
+ }
330
+
331
+ handles.set(demoId, {
332
+ fsWatcher,
333
+ debounceTimer: null,
334
+ filePath,
335
+ fileWatchers: new Map(),
336
+ });
337
+
338
+ // Seed the snapshot from disk so callers can serve GET /api/demos/:id
339
+ // without having to wait for the first fs event. Also seeds the
340
+ // referenced-file watch set via reconcileFileWatchers().
341
+ reparse(demoId);
342
+ };
343
+
344
+ return {
345
+ snapshot(demoId) {
346
+ return snapshots.get(demoId) ?? null;
347
+ },
348
+ watch(demoId) {
349
+ startWatch(demoId);
350
+ },
351
+ unwatch(demoId) {
352
+ const h = handles.get(demoId);
353
+ if (!h) return;
354
+ h.fsWatcher.close();
355
+ if (h.debounceTimer) clearTimeout(h.debounceTimer);
356
+ closeFileWatchers(h);
357
+ handles.delete(demoId);
358
+ snapshots.delete(demoId);
359
+ },
360
+ watchAll() {
361
+ for (const entry of registry.list()) startWatch(entry.id);
362
+ },
363
+ closeAll() {
364
+ for (const [, h] of handles) {
365
+ h.fsWatcher.close();
366
+ if (h.debounceTimer) clearTimeout(h.debounceTimer);
367
+ closeFileWatchers(h);
368
+ }
369
+ handles.clear();
370
+ snapshots.clear();
371
+ },
372
+ reparse,
373
+ referencedPaths(demoId) {
374
+ const h = handles.get(demoId);
375
+ if (!h) return [];
376
+ const paths: string[] = [];
377
+ for (const entry of h.fileWatchers.values()) {
378
+ for (const rel of entry.files.values()) paths.push(rel);
379
+ }
380
+ return paths.sort();
381
+ },
382
+ };
383
+ }