@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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Process spawn abstraction. `runPlay` (US-003) and the StatusRunner (US-004)
3
+ * depend on this interface so tests can drive them with an in-memory fake
4
+ * instead of actually spawning child processes.
5
+ *
6
+ * The default implementation wraps `Bun.spawn`. Bun exposes stdin as a
7
+ * `FileSink`; we wrap it in a standard `WritableStream<Uint8Array>` so
8
+ * consumers can write a chunk and `close()` it through the same surface used
9
+ * by tests with a fake spawner.
10
+ */
11
+
12
+ export interface SpawnOptions {
13
+ cmd: string[];
14
+ cwd: string;
15
+ env: Record<string, string>;
16
+ stdin: 'pipe' | 'ignore';
17
+ }
18
+
19
+ export interface SpawnHandle {
20
+ pid: number;
21
+ stdout: ReadableStream<Uint8Array>;
22
+ stderr: ReadableStream<Uint8Array>;
23
+ /** Present iff `SpawnOptions.stdin === 'pipe'`. */
24
+ stdin?: WritableStream<Uint8Array>;
25
+ /** Resolves with the child's exit code (143 for SIGTERM, 137 for SIGKILL). */
26
+ exited: Promise<number>;
27
+ kill(signal: 'SIGTERM' | 'SIGKILL'): void;
28
+ }
29
+
30
+ export interface ProcessSpawner {
31
+ spawn(opts: SpawnOptions): SpawnHandle;
32
+ }
33
+
34
+ export const defaultProcessSpawner: ProcessSpawner = {
35
+ spawn(opts) {
36
+ const child = Bun.spawn({
37
+ cmd: opts.cmd,
38
+ cwd: opts.cwd,
39
+ env: opts.env,
40
+ stdin: opts.stdin === 'pipe' ? 'pipe' : 'ignore',
41
+ stdout: 'pipe',
42
+ stderr: 'pipe',
43
+ });
44
+
45
+ let stdinStream: WritableStream<Uint8Array> | undefined;
46
+ if (opts.stdin === 'pipe') {
47
+ const sink = child.stdin;
48
+ if (!sink) {
49
+ throw new Error('Bun.spawn returned no stdin sink despite stdin: pipe');
50
+ }
51
+ stdinStream = new WritableStream<Uint8Array>({
52
+ write(chunk) {
53
+ sink.write(chunk);
54
+ },
55
+ async close() {
56
+ await sink.end();
57
+ },
58
+ async abort() {
59
+ await sink.end();
60
+ },
61
+ });
62
+ }
63
+
64
+ return {
65
+ pid: child.pid,
66
+ stdout: child.stdout as ReadableStream<Uint8Array>,
67
+ stderr: child.stderr as ReadableStream<Uint8Array>,
68
+ stdin: stdinStream,
69
+ exited: child.exited,
70
+ kill(signal) {
71
+ child.kill(signal);
72
+ },
73
+ };
74
+ },
75
+ };
package/src/proxy.ts ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Play proxy: spawns the script declared on a node's `playAction` and
3
+ * broadcasts `node:running` / `node:done` / `node:error` SSE events around the
4
+ * spawn so the canvas can animate. The interpreter, args, scriptPath, optional
5
+ * stdin input, and timeoutMs come from the validated `PlayAction` (see
6
+ * schema.ts). Process spawning goes through the `ProcessSpawner` seam from
7
+ * US-002 so tests inject an in-memory fake.
8
+ *
9
+ * Defense-in-depth on scriptPath: schema.ts already rejects absolute paths
10
+ * and `..` traversal textually. Here we additionally realpath-resolve the
11
+ * script under `<cwd>/.seeflow/` and reject anything that escapes that root —
12
+ * symlink-escape defense in line with `resolveProjectFile` in api.ts.
13
+ */
14
+
15
+ import { realpathSync } from 'node:fs';
16
+ import { join, resolve, sep } from 'node:path';
17
+ import type { EventBus } from './events.ts';
18
+ import { type ProcessSpawner, type SpawnHandle, defaultProcessSpawner } from './process-spawner.ts';
19
+ import type { PlayAction, ResetAction } from './schema.ts';
20
+
21
+ export interface PlayResult {
22
+ /** Correlates this run across SSE events + the synchronous response. */
23
+ runId: string;
24
+ /** Synthetic status: 200 when the script exited 0, undefined otherwise. */
25
+ status?: number;
26
+ /** Parsed JSON stdout when valid JSON, else the raw stdout string. */
27
+ body?: unknown;
28
+ /** Spawn-level error (path escape, ENOENT, exit !== 0, timeout, …). */
29
+ error?: string;
30
+ }
31
+
32
+ export interface RunPlayOptions {
33
+ events: EventBus;
34
+ demoId: string;
35
+ nodeId: string;
36
+ /** Project root (`<repoPath>`). Script resolves under `<cwd>/.seeflow/`. */
37
+ cwd: string;
38
+ action: PlayAction;
39
+ /** Injectable for tests; defaults to `defaultProcessSpawner`. */
40
+ spawner?: ProcessSpawner;
41
+ }
42
+
43
+ const DEFAULT_TIMEOUT_MS = 30_000;
44
+ const SIGKILL_GRACE_MS = 2_000;
45
+ const STDERR_TRUNCATE = 500;
46
+ const SCRIPT_PATH_ESCAPE = 'scriptPath escapes project root';
47
+
48
+ type Resolved = { ok: true; absPath: string } | { ok: false };
49
+
50
+ // Resolve `<cwd>/.seeflow/<scriptPath>` and verify via realpath it stays inside
51
+ // the `.seeflow` root. Mirrors `resolveProjectFile` in api.ts.
52
+ function resolveScript(cwd: string, scriptPath: string): Resolved {
53
+ const seeflowRoot = join(cwd, '.seeflow');
54
+ let realRoot: string;
55
+ try {
56
+ realRoot = realpathSync(seeflowRoot);
57
+ } catch {
58
+ return { ok: false };
59
+ }
60
+ const target = resolve(seeflowRoot, scriptPath);
61
+ let realTarget: string;
62
+ try {
63
+ realTarget = realpathSync(target);
64
+ } catch {
65
+ return { ok: false };
66
+ }
67
+ const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
68
+ if (realTarget !== realRoot && !realTarget.startsWith(rootWithSep)) {
69
+ return { ok: false };
70
+ }
71
+ return { ok: true, absPath: realTarget };
72
+ }
73
+
74
+ // Copy `process.env` into a string-only record, then layer the per-run extras.
75
+ // Bun.spawn's env contract is `Record<string, string>` so the undefineds that
76
+ // `process.env` advertises in its type must be filtered out first.
77
+ function buildChildEnv(extra: Record<string, string>): Record<string, string> {
78
+ const env: Record<string, string> = {};
79
+ for (const [k, v] of Object.entries(process.env)) {
80
+ if (typeof v === 'string') env[k] = v;
81
+ }
82
+ return { ...env, ...extra };
83
+ }
84
+
85
+ function lastNonEmptyLine(s: string): string {
86
+ const lines = s.split(/\r?\n/);
87
+ for (let i = lines.length - 1; i >= 0; i--) {
88
+ const line = lines[i]?.trim();
89
+ if (line) return line;
90
+ }
91
+ return '';
92
+ }
93
+
94
+ async function writeStdinPayload(handle: SpawnHandle, input: unknown): Promise<void> {
95
+ if (!handle.stdin) return;
96
+ const writer = handle.stdin.getWriter();
97
+ try {
98
+ await writer.write(new TextEncoder().encode(JSON.stringify(input)));
99
+ } finally {
100
+ await writer.close().catch(() => {
101
+ /* stdin already closed by child — not fatal */
102
+ });
103
+ }
104
+ }
105
+
106
+ // Live play-script handles indexed by demoId. Populated by runPlay() on spawn;
107
+ // entries are removed when each handle's `exited` promise resolves (success
108
+ // AND error paths). `stopAllPlays(demoId)` consults this map to terminate
109
+ // every in-flight play for a demo on /reset.
110
+ const livePlayHandles = new Map<string, Set<SpawnHandle>>();
111
+
112
+ function registerLiveHandle(demoId: string, handle: SpawnHandle): void {
113
+ let set = livePlayHandles.get(demoId);
114
+ if (!set) {
115
+ set = new Set();
116
+ livePlayHandles.set(demoId, set);
117
+ }
118
+ set.add(handle);
119
+ handle.exited.finally(() => {
120
+ const current = livePlayHandles.get(demoId);
121
+ if (!current) return;
122
+ current.delete(handle);
123
+ if (current.size === 0) livePlayHandles.delete(demoId);
124
+ });
125
+ }
126
+
127
+ async function killWithGrace(handle: SpawnHandle): Promise<void> {
128
+ handle.kill('SIGTERM');
129
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
130
+ const gracePromise = new Promise<'grace'>((res) => {
131
+ graceTimer = setTimeout(() => res('grace'), SIGKILL_GRACE_MS);
132
+ });
133
+ const winner = await Promise.race([handle.exited.then(() => 'exited' as const), gracePromise]);
134
+ if (graceTimer) clearTimeout(graceTimer);
135
+ if (winner === 'grace') {
136
+ handle.kill('SIGKILL');
137
+ await handle.exited;
138
+ }
139
+ }
140
+
141
+ // Kill every live play-script for `demoId` (SIGTERM → 2s grace → SIGKILL in
142
+ // parallel) and wait for each to exit. Idempotent on an unknown demoId. The
143
+ // map is keyed by demoId so a stop on demo A never touches demo B.
144
+ export async function stopAllPlays(demoId: string): Promise<void> {
145
+ const set = livePlayHandles.get(demoId);
146
+ if (!set || set.size === 0) return;
147
+ const handles = [...set];
148
+ // Clear eagerly so a parallel runPlay can't double-count an entry we're
149
+ // about to await on. The exited.finally() will no-op the second delete.
150
+ livePlayHandles.delete(demoId);
151
+ await Promise.all(handles.map((h) => killWithGrace(h)));
152
+ }
153
+
154
+ export async function runPlay(options: RunPlayOptions): Promise<PlayResult> {
155
+ const { events, demoId, nodeId, cwd, action } = options;
156
+ const spawner = options.spawner ?? defaultProcessSpawner;
157
+ const runId = crypto.randomUUID();
158
+
159
+ const resolved = resolveScript(cwd, action.scriptPath);
160
+ if (!resolved.ok) {
161
+ events.broadcast({
162
+ type: 'node:error',
163
+ demoId,
164
+ payload: { nodeId, runId, message: SCRIPT_PATH_ESCAPE },
165
+ });
166
+ return { runId, error: SCRIPT_PATH_ESCAPE };
167
+ }
168
+
169
+ events.broadcast({
170
+ type: 'node:running',
171
+ demoId,
172
+ payload: {
173
+ nodeId,
174
+ runId,
175
+ interpreter: action.interpreter,
176
+ scriptPath: action.scriptPath,
177
+ },
178
+ });
179
+
180
+ const wantsStdin = action.input !== undefined;
181
+ const env = buildChildEnv({
182
+ SEEFLOW_DEMO_ID: demoId,
183
+ SEEFLOW_NODE_ID: nodeId,
184
+ SEEFLOW_RUN_ID: runId,
185
+ });
186
+
187
+ let handle: SpawnHandle;
188
+ try {
189
+ handle = spawner.spawn({
190
+ cmd: [action.interpreter, ...(action.args ?? []), resolved.absPath],
191
+ cwd,
192
+ env,
193
+ stdin: wantsStdin ? 'pipe' : 'ignore',
194
+ });
195
+ } catch (err) {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ events.broadcast({
198
+ type: 'node:error',
199
+ demoId,
200
+ payload: { nodeId, runId, message },
201
+ });
202
+ return { runId, error: message };
203
+ }
204
+
205
+ registerLiveHandle(demoId, handle);
206
+
207
+ // Drain stdout AND stderr CONCURRENTLY with the process running so OS pipe
208
+ // buffers (~64 KB) don't fill up and deadlock the child.
209
+ const stdoutPromise = new Response(handle.stdout).text();
210
+ const stderrPromise = new Response(handle.stderr).text();
211
+
212
+ // Write stdin and close BEFORE awaiting exit (otherwise a child blocked on
213
+ // `read(stdin)` and a parent blocked on `exited` deadlock each other).
214
+ if (wantsStdin) {
215
+ await writeStdinPayload(handle, action.input);
216
+ }
217
+
218
+ const timeoutMs = action.timeoutMs ?? DEFAULT_TIMEOUT_MS;
219
+ let timer: ReturnType<typeof setTimeout> | undefined;
220
+ const timeoutPromise = new Promise<'timeout'>((res) => {
221
+ timer = setTimeout(() => res('timeout'), timeoutMs);
222
+ });
223
+ const exitPromise = handle.exited.then((code) => ({ code }) as const);
224
+
225
+ const race = await Promise.race([exitPromise, timeoutPromise]);
226
+ if (timer) clearTimeout(timer);
227
+
228
+ if (race === 'timeout') {
229
+ handle.kill('SIGTERM');
230
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
231
+ const gracePromise = new Promise<'grace'>((res) => {
232
+ graceTimer = setTimeout(() => res('grace'), SIGKILL_GRACE_MS);
233
+ });
234
+ const winner = await Promise.race([handle.exited.then(() => 'exited' as const), gracePromise]);
235
+ if (graceTimer) clearTimeout(graceTimer);
236
+ if (winner === 'grace') {
237
+ handle.kill('SIGKILL');
238
+ await handle.exited;
239
+ }
240
+ // Best-effort drain so consumers don't leak an open ReadableStream.
241
+ await Promise.allSettled([stdoutPromise, stderrPromise]);
242
+ const message = `script timed out after ${timeoutMs}ms`;
243
+ events.broadcast({
244
+ type: 'node:error',
245
+ demoId,
246
+ payload: { nodeId, runId, message },
247
+ });
248
+ return { runId, error: message };
249
+ }
250
+
251
+ const code = race.code;
252
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
253
+
254
+ if (code === 0) {
255
+ let body: unknown;
256
+ try {
257
+ body = JSON.parse(stdout);
258
+ } catch {
259
+ body = stdout;
260
+ }
261
+ events.broadcast({
262
+ type: 'node:done',
263
+ demoId,
264
+ payload: { nodeId, runId, status: 200, body },
265
+ });
266
+ return { runId, status: 200, body };
267
+ }
268
+
269
+ const lastLine = lastNonEmptyLine(stderr);
270
+ const truncated = lastLine.slice(0, STDERR_TRUNCATE);
271
+ const message = truncated.length > 0 ? truncated : `script exited with code ${code}`;
272
+ events.broadcast({
273
+ type: 'node:error',
274
+ demoId,
275
+ payload: { nodeId, runId, message },
276
+ });
277
+ return { runId, error: message };
278
+ }
279
+
280
+ export interface RunResetOptions {
281
+ events: EventBus;
282
+ demoId: string;
283
+ /** Project root (`<repoPath>`). Script resolves under `<cwd>/.seeflow/`. */
284
+ cwd: string;
285
+ action: ResetAction;
286
+ /** Injectable for tests; defaults to `defaultProcessSpawner`. */
287
+ spawner?: ProcessSpawner;
288
+ }
289
+
290
+ export interface ResetResult {
291
+ ok: boolean;
292
+ body?: unknown;
293
+ error?: string;
294
+ }
295
+
296
+ // Run the demo's one-shot `resetAction` script. Same spawn discipline as
297
+ // runPlay (realpath-guarded scriptPath, concurrent stdout/stderr drain,
298
+ // optional stdin payload, SIGTERM→2s→SIGKILL escalation on timeout) but the
299
+ // lifecycle SSE event is the single `demo:reset` broadcast that mirrors the
300
+ // returned shape. Callers (the /reset endpoint) decide what HTTP status to
301
+ // surface; this returns `{ ok }` plus body/error so the endpoint can map.
302
+ export async function runReset(options: RunResetOptions): Promise<ResetResult> {
303
+ const { events, demoId, cwd, action } = options;
304
+ const spawner = options.spawner ?? defaultProcessSpawner;
305
+
306
+ const resolved = resolveScript(cwd, action.scriptPath);
307
+ if (!resolved.ok) {
308
+ events.broadcast({
309
+ type: 'demo:reset',
310
+ demoId,
311
+ payload: { ok: false, error: SCRIPT_PATH_ESCAPE },
312
+ });
313
+ return { ok: false, error: SCRIPT_PATH_ESCAPE };
314
+ }
315
+
316
+ const wantsStdin = action.input !== undefined;
317
+ const env = buildChildEnv({ SEEFLOW_DEMO_ID: demoId });
318
+
319
+ let handle: SpawnHandle;
320
+ try {
321
+ handle = spawner.spawn({
322
+ cmd: [action.interpreter, ...(action.args ?? []), resolved.absPath],
323
+ cwd,
324
+ env,
325
+ stdin: wantsStdin ? 'pipe' : 'ignore',
326
+ });
327
+ } catch (err) {
328
+ const message = err instanceof Error ? err.message : String(err);
329
+ events.broadcast({
330
+ type: 'demo:reset',
331
+ demoId,
332
+ payload: { ok: false, error: message },
333
+ });
334
+ return { ok: false, error: message };
335
+ }
336
+
337
+ const stdoutPromise = new Response(handle.stdout).text();
338
+ const stderrPromise = new Response(handle.stderr).text();
339
+
340
+ if (wantsStdin) {
341
+ await writeStdinPayload(handle, action.input);
342
+ }
343
+
344
+ const timeoutMs = action.timeoutMs ?? DEFAULT_TIMEOUT_MS;
345
+ let timer: ReturnType<typeof setTimeout> | undefined;
346
+ const timeoutPromise = new Promise<'timeout'>((res) => {
347
+ timer = setTimeout(() => res('timeout'), timeoutMs);
348
+ });
349
+ const exitPromise = handle.exited.then((code) => ({ code }) as const);
350
+
351
+ const race = await Promise.race([exitPromise, timeoutPromise]);
352
+ if (timer) clearTimeout(timer);
353
+
354
+ if (race === 'timeout') {
355
+ await killWithGrace(handle);
356
+ await Promise.allSettled([stdoutPromise, stderrPromise]);
357
+ const message = `reset script timed out after ${timeoutMs}ms`;
358
+ events.broadcast({
359
+ type: 'demo:reset',
360
+ demoId,
361
+ payload: { ok: false, error: message },
362
+ });
363
+ return { ok: false, error: message };
364
+ }
365
+
366
+ const code = race.code;
367
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
368
+
369
+ if (code === 0) {
370
+ let body: unknown;
371
+ try {
372
+ body = JSON.parse(stdout);
373
+ } catch {
374
+ body = stdout;
375
+ }
376
+ events.broadcast({
377
+ type: 'demo:reset',
378
+ demoId,
379
+ payload: { ok: true, body },
380
+ });
381
+ return { ok: true, body };
382
+ }
383
+
384
+ const lastLine = lastNonEmptyLine(stderr);
385
+ const truncated = lastLine.slice(0, STDERR_TRUNCATE);
386
+ const message = truncated.length > 0 ? truncated : `reset script exited with code ${code}`;
387
+ events.broadcast({
388
+ type: 'demo:reset',
389
+ demoId,
390
+ payload: { ok: false, error: message },
391
+ });
392
+ return { ok: false, error: message };
393
+ }
@@ -0,0 +1,139 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export interface DemoEntry {
6
+ id: string;
7
+ slug: string;
8
+ name: string;
9
+ repoPath: string;
10
+ demoPath: string;
11
+ lastModified: number;
12
+ valid: boolean;
13
+ }
14
+
15
+ export interface RegisterInput {
16
+ name: string;
17
+ repoPath: string;
18
+ demoPath: string;
19
+ valid?: boolean;
20
+ lastModified?: number;
21
+ }
22
+
23
+ export interface Registry {
24
+ list(): DemoEntry[];
25
+ getById(id: string): DemoEntry | undefined;
26
+ getBySlug(slug: string): DemoEntry | undefined;
27
+ getByRepoPath(repoPath: string): DemoEntry | undefined;
28
+ getByRepoPathAndDemoPath(repoPath: string, demoPath: string): DemoEntry | undefined;
29
+ upsert(input: RegisterInput): DemoEntry;
30
+ remove(id: string): boolean;
31
+ }
32
+
33
+ export function defaultRegistryPath(): string {
34
+ return join(homedir(), '.seeflow', 'registry.json');
35
+ }
36
+
37
+ export function slugify(name: string): string {
38
+ const slug = name
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ return slug || 'demo';
43
+ }
44
+
45
+ export function createRegistry(options: { path?: string } = {}): Registry {
46
+ const path = options.path ?? defaultRegistryPath();
47
+ const entries = new Map<string, DemoEntry>();
48
+
49
+ if (existsSync(path)) {
50
+ try {
51
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
52
+ if (Array.isArray(parsed)) {
53
+ for (const e of parsed) {
54
+ if (
55
+ e &&
56
+ typeof e.id === 'string' &&
57
+ typeof e.slug === 'string' &&
58
+ typeof e.repoPath === 'string'
59
+ ) {
60
+ entries.set(e.id, e as DemoEntry);
61
+ }
62
+ }
63
+ }
64
+ } catch (err) {
65
+ console.error(`[registry] failed to load ${path}, starting empty:`, err);
66
+ }
67
+ }
68
+
69
+ const persist = () => {
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ writeFileSync(path, JSON.stringify([...entries.values()], null, 2));
72
+ };
73
+
74
+ const findByRepoPath = (repoPath: string): DemoEntry | undefined => {
75
+ for (const e of entries.values()) {
76
+ if (e.repoPath === repoPath) return e;
77
+ }
78
+ return undefined;
79
+ };
80
+
81
+ const findByRepoPathAndDemoPath = (repoPath: string, demoPath: string): DemoEntry | undefined => {
82
+ for (const e of entries.values()) {
83
+ if (e.repoPath === repoPath && e.demoPath === demoPath) return e;
84
+ }
85
+ return undefined;
86
+ };
87
+
88
+ const uniqueSlug = (base: string): string => {
89
+ const taken = new Set([...entries.values()].map((e) => e.slug));
90
+ if (!taken.has(base)) return base;
91
+ let n = 2;
92
+ while (taken.has(`${base}-${n}`)) n++;
93
+ return `${base}-${n}`;
94
+ };
95
+
96
+ return {
97
+ list: () => [...entries.values()],
98
+ getById: (id) => entries.get(id),
99
+ getBySlug: (slug) => [...entries.values()].find((e) => e.slug === slug),
100
+ getByRepoPath: findByRepoPath,
101
+ getByRepoPathAndDemoPath: findByRepoPathAndDemoPath,
102
+ upsert(input) {
103
+ const lastModified = input.lastModified ?? Date.now();
104
+ const valid = input.valid ?? true;
105
+ const existing = findByRepoPathAndDemoPath(input.repoPath, input.demoPath);
106
+ if (existing) {
107
+ const updated: DemoEntry = {
108
+ ...existing,
109
+ name: input.name,
110
+ demoPath: input.demoPath,
111
+ lastModified,
112
+ valid,
113
+ };
114
+ entries.set(existing.id, updated);
115
+ persist();
116
+ return updated;
117
+ }
118
+ const id = crypto.randomUUID();
119
+ const slug = uniqueSlug(slugify(input.name));
120
+ const entry: DemoEntry = {
121
+ id,
122
+ slug,
123
+ name: input.name,
124
+ repoPath: input.repoPath,
125
+ demoPath: input.demoPath,
126
+ lastModified,
127
+ valid,
128
+ };
129
+ entries.set(id, entry);
130
+ persist();
131
+ return entry;
132
+ },
133
+ remove(id) {
134
+ const removed = entries.delete(id);
135
+ if (removed) persist();
136
+ return removed;
137
+ },
138
+ };
139
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export interface StudioConfig {
6
+ port: number;
7
+ host: string;
8
+ }
9
+
10
+ export const DEFAULT_CONFIG: StudioConfig = { port: 4321, host: '0.0.0.0' };
11
+
12
+ export function defaultConfigPath(): string {
13
+ return join(homedir(), '.seeflow', 'config.json');
14
+ }
15
+
16
+ export function defaultPidPath(): string {
17
+ return join(homedir(), '.seeflow', 'seeflow.pid');
18
+ }
19
+
20
+ export function readConfig(path = defaultConfigPath()): StudioConfig {
21
+ if (!existsSync(path)) return { ...DEFAULT_CONFIG };
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<StudioConfig>;
24
+ return {
25
+ port: typeof parsed.port === 'number' && parsed.port > 0 ? parsed.port : DEFAULT_CONFIG.port,
26
+ host:
27
+ typeof parsed.host === 'string' && parsed.host.length > 0
28
+ ? parsed.host
29
+ : DEFAULT_CONFIG.host,
30
+ };
31
+ } catch {
32
+ return { ...DEFAULT_CONFIG };
33
+ }
34
+ }
35
+
36
+ export function writeConfig(config: StudioConfig, path = defaultConfigPath()): void {
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ writeFileSync(path, JSON.stringify(config, null, 2));
39
+ }
40
+
41
+ export function studioUrl(config: StudioConfig = readConfig()): string {
42
+ return `http://${config.host}:${config.port}`;
43
+ }
44
+
45
+ export function writePid(pid: number, path = defaultPidPath()): void {
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ writeFileSync(path, String(pid));
48
+ }
49
+
50
+ export function readPid(path = defaultPidPath()): number | undefined {
51
+ if (!existsSync(path)) return undefined;
52
+ try {
53
+ const raw = readFileSync(path, 'utf8').trim();
54
+ const pid = Number(raw);
55
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
56
+ return pid;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ export function clearPid(path = defaultPidPath()): void {
63
+ if (!existsSync(path)) return;
64
+ try {
65
+ unlinkSync(path);
66
+ } catch {
67
+ // ignore — best-effort cleanup
68
+ }
69
+ }
70
+
71
+ export function isPidAlive(pid: number): boolean {
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }