@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/README.md +95 -0
- package/bin/seeflow +32 -0
- package/bin/seeflow-mcp +23 -0
- package/dist/web/assets/html2canvas.esm-CBrSDip1.js +22 -0
- package/dist/web/assets/index-BlhIMoXf.js +8005 -0
- package/dist/web/assets/index-CIpouxGY.css +1 -0
- package/dist/web/assets/index.es-D6Hswegt.js +18 -0
- package/dist/web/assets/purify.es-CLGrRn1w.js +3 -0
- package/dist/web/index.html +13 -0
- package/examples/ecommerce-platform/.seeflow/scripts/play.ts +2 -0
- package/examples/ecommerce-platform/.seeflow/seeflow.json +250 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +18 -0
- package/examples/order-pipeline/.seeflow/seeflow.json +86 -0
- package/examples/order-pipeline/README.md +11 -0
- package/examples/order-pipeline/package.json +6 -0
- package/package.json +55 -0
- package/public/runtime/tailwind.js +24394 -0
- package/src/api.ts +1093 -0
- package/src/cli.ts +329 -0
- package/src/demo.ts +65 -0
- package/src/diagram.ts +432 -0
- package/src/events.ts +70 -0
- package/src/mcp-shim.ts +93 -0
- package/src/mcp.ts +540 -0
- package/src/operations.ts +1192 -0
- package/src/process-spawner.ts +75 -0
- package/src/proxy.ts +393 -0
- package/src/registry.ts +139 -0
- package/src/runtime.ts +78 -0
- package/src/schema.ts +441 -0
- package/src/sdk-template.ts +37 -0
- package/src/sdk-writer.ts +37 -0
- package/src/server.ts +211 -0
- package/src/shellout.ts +30 -0
- package/src/status-runner.ts +374 -0
- package/src/watcher.ts +383 -0
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
|
+
}
|