pi-design-deck 0.1.0

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/deck-server.ts ADDED
@@ -0,0 +1,675 @@
1
+ import http from "node:http";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { randomUUID } from "node:crypto";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import {
8
+ getGitBranch,
9
+ MAX_BODY_SIZE,
10
+ normalizePath,
11
+ registerSession,
12
+ safeInlineJSON,
13
+ safeParseBody,
14
+ sendJson,
15
+ sendText,
16
+ touchSession,
17
+ unregisterSession,
18
+ validateTokenBody,
19
+ validateTokenQuery,
20
+ type SessionEntry,
21
+ } from "./server-utils.js";
22
+ import { isDeckOption, type DeckConfig, type DeckOption, type PreviewBlock } from "./deck-schema.js";
23
+ import { saveGenerateModel } from "./settings.js";
24
+
25
+ export interface ModelInfo {
26
+ provider: string;
27
+ id: string;
28
+ name: string;
29
+ reasoning: boolean;
30
+ }
31
+
32
+ export interface ModelsPayload {
33
+ current: string | null;
34
+ available: ModelInfo[];
35
+ defaultModel: string | null;
36
+ currentThinking: string;
37
+ currentModelReasoning: boolean;
38
+ }
39
+
40
+ const FORM_DIR = join(dirname(fileURLToPath(import.meta.url)), "form");
41
+ const DECK_TEMPLATE = readFileSync(join(FORM_DIR, "deck.html"), "utf-8");
42
+
43
+ // CSS modules - concatenated in order
44
+ const CSS_FILES = ["variables", "layout", "preview", "controls"];
45
+ const DECK_CSS = CSS_FILES
46
+ .map((name) => readFileSync(join(FORM_DIR, "css", `${name}.css`), "utf-8"))
47
+ .join("\n");
48
+
49
+ // JS modules - concatenated in order (core first, session last with init())
50
+ const JS_FILES = ["deck-core", "deck-render", "deck-interact", "deck-session"];
51
+ const DECK_JS = JS_FILES
52
+ .map((name) => readFileSync(join(FORM_DIR, "js", `${name}.js`), "utf-8"))
53
+ .join("\n");
54
+
55
+ const MIME_TYPES: Record<string, string> = {
56
+ ".png": "image/png",
57
+ ".jpg": "image/jpeg",
58
+ ".jpeg": "image/jpeg",
59
+ ".gif": "image/gif",
60
+ ".webp": "image/webp",
61
+ ".svg": "image/svg+xml",
62
+ ".avif": "image/avif",
63
+ };
64
+
65
+ const ABANDONED_GRACE_MS = 60000;
66
+ const WATCHDOG_INTERVAL_MS = 5000;
67
+ const GENERATE_TIMEOUT_MS = 90_000;
68
+
69
+ function toStringMap(value: unknown): Record<string, string> | null {
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
71
+ return null;
72
+ }
73
+ const out: Record<string, string> = {};
74
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
75
+ if (typeof entry !== "string") return null;
76
+ out[key] = entry;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function registerAsset(filePath: string, assetsDir: string): string {
82
+ if (!existsSync(filePath)) throw new Error(`Image not found: ${filePath}`);
83
+ const ext = extname(filePath);
84
+ const id = randomUUID();
85
+ const dest = join(assetsDir, `${id}${ext}`);
86
+ copyFileSync(filePath, dest);
87
+ return `/assets/${id}${ext}`;
88
+ }
89
+
90
+ function processImageBlocks(blocks: PreviewBlock[], assetsDir: string): PreviewBlock[] {
91
+ return blocks.map((block) => {
92
+ if (block.type !== "image") return block;
93
+ const servedSrc = registerAsset(block.src, assetsDir);
94
+ return { ...block, src: servedSrc };
95
+ });
96
+ }
97
+
98
+ function processOptionAssets(option: DeckOption, assetsDir: string): DeckOption {
99
+ if (!option.previewBlocks) return option;
100
+ return { ...option, previewBlocks: processImageBlocks(option.previewBlocks, assetsDir) };
101
+ }
102
+
103
+ const DECK_SNAPSHOTS_DIR = join(homedir(), ".pi", "deck-snapshots");
104
+
105
+ function sanitizeForFilename(value: string): string {
106
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40).replace(/_+$/, "") || "unknown";
107
+ }
108
+
109
+ function saveDeckSnapshot(
110
+ config: DeckConfig,
111
+ selections: Record<string, string>,
112
+ assetsDir: string,
113
+ normalizedCwd: string,
114
+ gitBranch: string | null,
115
+ sessionId: string,
116
+ baseDir: string,
117
+ suffix?: string
118
+ ): { path: string; relativePath: string } {
119
+ const now = new Date();
120
+ const date = now.toISOString().slice(0, 10);
121
+ const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
122
+ const titleSlug = sanitizeForFilename(config.title || "deck");
123
+ const project = sanitizeForFilename(basename(normalizedCwd) || "unknown");
124
+ const branch = sanitizeForFilename(gitBranch || "nogit");
125
+ const safeSuffix = suffix ? `-${suffix}` : "";
126
+ const folderName = `${titleSlug}-${project}-${branch}-${date}-${time}${safeSuffix}`;
127
+ const snapshotPath = join(baseDir, folderName);
128
+ const imagesPath = join(snapshotPath, "images");
129
+
130
+ mkdirSync(snapshotPath, { recursive: true });
131
+
132
+ const saved = structuredClone(config);
133
+ for (const slide of saved.slides) {
134
+ for (const option of slide.options) {
135
+ if (!option.previewBlocks) continue;
136
+ for (const block of option.previewBlocks) {
137
+ if (block.type !== "image" || !block.src.startsWith("/assets/")) continue;
138
+ const filename = block.src.slice("/assets/".length);
139
+ const srcFile = join(assetsDir, filename);
140
+ if (existsSync(srcFile)) {
141
+ mkdirSync(imagesPath, { recursive: true });
142
+ copyFileSync(srcFile, join(imagesPath, filename));
143
+ (block as { src: string }).src = `images/${filename}`;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ const data = {
150
+ config: saved,
151
+ selections,
152
+ savedAt: now.toISOString(),
153
+ savedFrom: { cwd: normalizedCwd, branch: gitBranch, sessionId },
154
+ };
155
+ writeFileSync(join(snapshotPath, "deck.json"), JSON.stringify(data, null, 2));
156
+
157
+ const home = homedir();
158
+ const relativePath = snapshotPath.startsWith(home) ? "~" + snapshotPath.slice(home.length) : snapshotPath;
159
+ return { path: snapshotPath, relativePath };
160
+ }
161
+
162
+ export interface DeckServerOptions {
163
+ config: DeckConfig;
164
+ sessionToken: string;
165
+ sessionId: string;
166
+ cwd: string;
167
+ port?: number;
168
+ theme?: { mode?: string; toggleHotkey?: string };
169
+ savedSelections?: Record<string, string>;
170
+ snapshotDir?: string;
171
+ autoSaveOnSubmit?: boolean;
172
+ models?: ModelsPayload;
173
+ }
174
+
175
+ export interface DeckServerCallbacks {
176
+ onSubmit: (selections: Record<string, string>) => void;
177
+ onCancel: (reason?: "user" | "stale" | "aborted") => void;
178
+ onGenerateMore: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
179
+ onRegenerateSlide: (slideId: string, prompt?: string, model?: string, thinking?: string) => void;
180
+ }
181
+
182
+ export interface DeckServerHandle {
183
+ url: string;
184
+ port: number;
185
+ close: () => void;
186
+ pushOption: (slideId: string, option: DeckOption) => void;
187
+ cancelGenerate: () => void;
188
+ replaceSlideOptions: (slideId: string, options: DeckOption[]) => void;
189
+ }
190
+
191
+ export async function startDeckServer(
192
+ options: DeckServerOptions,
193
+ callbacks: DeckServerCallbacks
194
+ ): Promise<DeckServerHandle> {
195
+ const { config, sessionToken, sessionId, cwd, port, theme, savedSelections, snapshotDir, autoSaveOnSubmit } = options;
196
+ const normalizedCwd = normalizePath(cwd);
197
+ const gitBranch = getGitBranch(cwd);
198
+
199
+ const assetsDir = mkdtempSync(join(tmpdir(), "deck-assets-"));
200
+
201
+ for (const slide of config.slides) {
202
+ slide.options = slide.options.map((opt) => processOptionAssets(opt, assetsDir));
203
+ }
204
+
205
+ const knownSlideIds = new Set(config.slides.map((s) => s.id));
206
+
207
+ const sseClients = new Set<http.ServerResponse>();
208
+ let pendingGenerate: { slideId: string; isRegen: boolean; timer: NodeJS.Timeout } | null = null;
209
+
210
+ const clearPendingGenerate = () => {
211
+ if (pendingGenerate) {
212
+ clearTimeout(pendingGenerate.timer);
213
+ pendingGenerate = null;
214
+ }
215
+ };
216
+
217
+ const setPendingGenerate = (slideId: string, isRegen: boolean) => {
218
+ clearPendingGenerate();
219
+ const timer = setTimeout(() => {
220
+ if (!pendingGenerate || completed) return;
221
+ const { slideId: sid, isRegen: regen } = pendingGenerate;
222
+ pendingGenerate = null;
223
+ pushEvent(regen ? "regenerate-failed" : "generate-failed", { slideId: sid, reason: "timeout" });
224
+ }, GENERATE_TIMEOUT_MS);
225
+ pendingGenerate = { slideId, isRegen, timer };
226
+ };
227
+
228
+ let completed = false;
229
+ let browserConnected = false;
230
+ let sessionEntry: SessionEntry | null = null;
231
+ let watchdog: NodeJS.Timeout | null = null;
232
+ let lastHeartbeatAt = Date.now();
233
+
234
+ const stopWatchdog = () => {
235
+ if (watchdog) {
236
+ clearInterval(watchdog);
237
+ watchdog = null;
238
+ }
239
+ };
240
+
241
+ const markCompleted = () => {
242
+ if (completed) return false;
243
+ completed = true;
244
+ stopWatchdog();
245
+ clearPendingGenerate();
246
+ return true;
247
+ };
248
+
249
+ const touchHeartbeat = () => {
250
+ lastHeartbeatAt = Date.now();
251
+ if (!browserConnected) {
252
+ browserConnected = true;
253
+ }
254
+ if (sessionEntry) {
255
+ touchSession(sessionEntry);
256
+ }
257
+ };
258
+
259
+ const pushEvent = (name: string, payload: unknown) => {
260
+ const encoded = JSON.stringify(payload);
261
+ const chunk = `event: ${name}\ndata: ${encoded}\n\n`;
262
+ for (const client of sseClients) {
263
+ try {
264
+ client.write(chunk);
265
+ } catch {
266
+ sseClients.delete(client);
267
+ }
268
+ }
269
+ };
270
+
271
+ const closeSSE = () => {
272
+ for (const client of sseClients) {
273
+ try {
274
+ client.end();
275
+ } catch {}
276
+ }
277
+ sseClients.clear();
278
+ };
279
+
280
+ const server = http.createServer(async (req, res) => {
281
+ try {
282
+ const method = req.method || "GET";
283
+ const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
284
+
285
+ if (method === "GET" && url.pathname === "/") {
286
+ if (!validateTokenQuery(url, sessionToken, res)) return;
287
+ touchHeartbeat();
288
+ const inlineData = safeInlineJSON({
289
+ config,
290
+ sessionToken,
291
+ sessionId,
292
+ cwd: normalizedCwd,
293
+ gitBranch,
294
+ theme,
295
+ savedSelections,
296
+ });
297
+ const title = config.title ? `${config.title} — Design Deck` : "Design Deck";
298
+ const html = DECK_TEMPLATE
299
+ .replace("/* __DECK_DATA_PLACEHOLDER__ */", inlineData)
300
+ .replace("<title>Design Deck</title>", `<title>${title.replace(/</g, "&lt;")}</title>`);
301
+ res.writeHead(200, {
302
+ "Content-Type": "text/html; charset=utf-8",
303
+ "Cache-Control": "no-store",
304
+ });
305
+ res.end(html);
306
+ return;
307
+ }
308
+
309
+ if (method === "GET" && url.pathname === "/deck.css") {
310
+ res.writeHead(200, {
311
+ "Content-Type": "text/css; charset=utf-8",
312
+ "Cache-Control": "no-store",
313
+ });
314
+ res.end(DECK_CSS);
315
+ return;
316
+ }
317
+
318
+ if (method === "GET" && url.pathname === "/deck.js") {
319
+ res.writeHead(200, {
320
+ "Content-Type": "application/javascript; charset=utf-8",
321
+ "Cache-Control": "no-store",
322
+ });
323
+ res.end(DECK_JS);
324
+ return;
325
+ }
326
+
327
+ if (method === "GET" && url.pathname.startsWith("/assets/")) {
328
+ const filename = url.pathname.slice("/assets/".length);
329
+ if (!filename || filename.includes("/") || filename.includes("..")) {
330
+ sendText(res, 400, "Invalid asset path");
331
+ return;
332
+ }
333
+ const filePath = resolve(assetsDir, filename);
334
+ if (!filePath.startsWith(assetsDir)) {
335
+ sendText(res, 403, "Forbidden");
336
+ return;
337
+ }
338
+ if (!existsSync(filePath)) {
339
+ sendText(res, 404, "Asset not found");
340
+ return;
341
+ }
342
+ const ext = extname(filename).toLowerCase();
343
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
344
+ const data = readFileSync(filePath);
345
+ res.writeHead(200, {
346
+ "Content-Type": contentType,
347
+ "Cache-Control": "public, max-age=86400",
348
+ "Content-Length": data.length,
349
+ });
350
+ res.end(data);
351
+ return;
352
+ }
353
+
354
+ if (method === "GET" && url.pathname === "/events") {
355
+ if (!validateTokenQuery(url, sessionToken, res)) return;
356
+ touchHeartbeat();
357
+ res.writeHead(200, {
358
+ "Content-Type": "text/event-stream",
359
+ "Cache-Control": "no-cache, no-transform",
360
+ Connection: "keep-alive",
361
+ "X-Accel-Buffering": "no",
362
+ });
363
+ res.write(": connected\n\n");
364
+ sseClients.add(res);
365
+ req.on("close", () => {
366
+ sseClients.delete(res);
367
+ });
368
+ return;
369
+ }
370
+
371
+ if (method === "GET" && url.pathname === "/health") {
372
+ if (!validateTokenQuery(url, sessionToken, res)) return;
373
+ sendJson(res, 200, { ok: true, maxBodySize: MAX_BODY_SIZE });
374
+ return;
375
+ }
376
+
377
+ if (method === "GET" && url.pathname === "/models") {
378
+ if (!validateTokenQuery(url, sessionToken, res)) return;
379
+ sendJson(res, 200, options.models ?? { current: null, available: [], defaultModel: null, currentThinking: "off", currentModelReasoning: false });
380
+ return;
381
+ }
382
+
383
+ if (method === "POST" && url.pathname === "/save-model-default") {
384
+ const body = await safeParseBody(req, res);
385
+ if (!body) return;
386
+ if (!validateTokenBody(body, sessionToken, res)) return;
387
+ const payload = body as { model?: string };
388
+ const model = typeof payload.model === "string" && payload.model.trim() ? payload.model.trim() : null;
389
+ try {
390
+ saveGenerateModel(model);
391
+ if (options.models) options.models.defaultModel = model;
392
+ sendJson(res, 200, { ok: true });
393
+ } catch {
394
+ sendJson(res, 500, { ok: false, error: "Failed to save setting" });
395
+ }
396
+ return;
397
+ }
398
+
399
+ if (method === "POST" && url.pathname === "/heartbeat") {
400
+ const body = await safeParseBody(req, res);
401
+ if (!body) return;
402
+ if (!validateTokenBody(body, sessionToken, res)) return;
403
+ touchHeartbeat();
404
+ sendJson(res, 200, { ok: true });
405
+ return;
406
+ }
407
+
408
+ if (method === "POST" && url.pathname === "/submit") {
409
+ const body = await safeParseBody(req, res);
410
+ if (!body) return;
411
+ if (!validateTokenBody(body, sessionToken, res)) return;
412
+ if (completed) {
413
+ sendJson(res, 409, { ok: false, error: "Session closed" });
414
+ return;
415
+ }
416
+
417
+ const payload = body as { selections?: unknown };
418
+ const selections = toStringMap(payload.selections);
419
+ if (!selections) {
420
+ sendJson(res, 400, { ok: false, error: "Invalid selections payload" });
421
+ return;
422
+ }
423
+
424
+ touchHeartbeat();
425
+ if (autoSaveOnSubmit !== false) {
426
+ try {
427
+ saveDeckSnapshot(config, selections, assetsDir, normalizedCwd, gitBranch, sessionId, snapshotDir || DECK_SNAPSHOTS_DIR, "submitted");
428
+ } catch {}
429
+ }
430
+ markCompleted();
431
+ unregisterSession(sessionId);
432
+ pushEvent("deck-close", { reason: "submitted" });
433
+ sendJson(res, 200, { ok: true });
434
+ setImmediate(() => callbacks.onSubmit(selections));
435
+ return;
436
+ }
437
+
438
+ if (method === "POST" && url.pathname === "/save") {
439
+ const body = await safeParseBody(req, res);
440
+ if (!body) return;
441
+ if (!validateTokenBody(body, sessionToken, res)) return;
442
+
443
+ const payload = body as { selections?: unknown };
444
+ const selections = toStringMap(payload.selections) ?? {};
445
+
446
+ try {
447
+ const result = saveDeckSnapshot(config, selections, assetsDir, normalizedCwd, gitBranch, sessionId, snapshotDir || DECK_SNAPSHOTS_DIR);
448
+ sendJson(res, 200, { ok: true, path: result.path, relativePath: result.relativePath });
449
+ } catch (err) {
450
+ const message = err instanceof Error ? err.message : "Save failed";
451
+ sendJson(res, 500, { ok: false, error: message });
452
+ }
453
+ return;
454
+ }
455
+
456
+ if (method === "POST" && url.pathname === "/cancel") {
457
+ const body = await safeParseBody(req, res);
458
+ if (!body) return;
459
+ if (!validateTokenBody(body, sessionToken, res)) return;
460
+ if (completed) {
461
+ sendJson(res, 200, { ok: true });
462
+ return;
463
+ }
464
+
465
+ const payload = body as { reason?: string; selections?: unknown };
466
+ const reason =
467
+ payload.reason === "stale" || payload.reason === "aborted" || payload.reason === "user"
468
+ ? payload.reason
469
+ : "user";
470
+
471
+ const cancelSelections = toStringMap(payload.selections);
472
+ if (cancelSelections && Object.keys(cancelSelections).length > 0) {
473
+ try {
474
+ saveDeckSnapshot(config, cancelSelections, assetsDir, normalizedCwd, gitBranch, sessionId, snapshotDir || DECK_SNAPSHOTS_DIR, "cancelled");
475
+ } catch {}
476
+ }
477
+
478
+ markCompleted();
479
+ unregisterSession(sessionId);
480
+ pushEvent("deck-close", { reason });
481
+ sendJson(res, 200, { ok: true });
482
+ setImmediate(() => callbacks.onCancel(reason));
483
+ return;
484
+ }
485
+
486
+ if (method === "POST" && url.pathname === "/generate-more") {
487
+ const body = await safeParseBody(req, res);
488
+ if (!body) return;
489
+ if (!validateTokenBody(body, sessionToken, res)) return;
490
+ if (completed) {
491
+ sendJson(res, 409, { ok: false, error: "Session closed" });
492
+ return;
493
+ }
494
+
495
+ const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string };
496
+ if (typeof payload.slideId !== "string" || payload.slideId.trim() === "") {
497
+ sendJson(res, 400, { ok: false, error: "slideId is required" });
498
+ return;
499
+ }
500
+ if (!knownSlideIds.has(payload.slideId)) {
501
+ sendJson(res, 404, { ok: false, error: "Unknown slide" });
502
+ return;
503
+ }
504
+ if (pendingGenerate) {
505
+ sendJson(res, 409, { ok: false, error: "A generation is already in progress" });
506
+ return;
507
+ }
508
+
509
+ const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() || undefined : undefined;
510
+ const model = typeof payload.model === "string" ? (payload.model.trim() || "") : undefined;
511
+ const thinking = typeof payload.thinking === "string" ? payload.thinking.trim() || undefined : undefined;
512
+
513
+ setPendingGenerate(payload.slideId as string, false);
514
+ touchHeartbeat();
515
+ sendJson(res, 200, { ok: true });
516
+ setImmediate(() => {
517
+ callbacks.onGenerateMore(payload.slideId as string, prompt, model, thinking);
518
+ });
519
+ return;
520
+ }
521
+
522
+ if (method === "POST" && url.pathname === "/regenerate-slide") {
523
+ const body = await safeParseBody(req, res);
524
+ if (!body) return;
525
+ if (!validateTokenBody(body, sessionToken, res)) return;
526
+ if (completed) {
527
+ sendJson(res, 409, { ok: false, error: "Session closed" });
528
+ return;
529
+ }
530
+
531
+ const payload = body as { slideId?: string; prompt?: string; model?: string; thinking?: string };
532
+ if (typeof payload.slideId !== "string" || payload.slideId.trim() === "") {
533
+ sendJson(res, 400, { ok: false, error: "slideId is required" });
534
+ return;
535
+ }
536
+ if (!knownSlideIds.has(payload.slideId)) {
537
+ sendJson(res, 404, { ok: false, error: "Unknown slide" });
538
+ return;
539
+ }
540
+ if (pendingGenerate) {
541
+ sendJson(res, 409, { ok: false, error: "A generation is already in progress" });
542
+ return;
543
+ }
544
+
545
+ const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() || undefined : undefined;
546
+ const model = typeof payload.model === "string" ? (payload.model.trim() || "") : undefined;
547
+ const thinking = typeof payload.thinking === "string" ? payload.thinking.trim() || undefined : undefined;
548
+
549
+ setPendingGenerate(payload.slideId as string, true);
550
+ touchHeartbeat();
551
+ sendJson(res, 200, { ok: true });
552
+ setImmediate(() => {
553
+ callbacks.onRegenerateSlide(payload.slideId as string, prompt, model, thinking);
554
+ });
555
+ return;
556
+ }
557
+
558
+ sendText(res, 404, "Not found");
559
+ } catch (err) {
560
+ const message = err instanceof Error ? err.message : "Server error";
561
+ sendJson(res, 500, { ok: false, error: message });
562
+ }
563
+ });
564
+
565
+ return new Promise((resolve, reject) => {
566
+ const onError = (err: Error) => {
567
+ reject(new Error(`Failed to start deck server: ${err.message}`));
568
+ };
569
+
570
+ server.once("error", onError);
571
+ server.listen(port ?? 0, "127.0.0.1", () => {
572
+ server.off("error", onError);
573
+ const addr = server.address();
574
+ if (!addr || typeof addr === "string") {
575
+ reject(new Error("Failed to start deck server: invalid address"));
576
+ return;
577
+ }
578
+
579
+ const url = `http://localhost:${addr.port}/?session=${sessionToken}`;
580
+ const now = Date.now();
581
+ sessionEntry = {
582
+ id: sessionId,
583
+ url,
584
+ cwd: normalizedCwd,
585
+ gitBranch,
586
+ title: config.title || "Design Deck",
587
+ startedAt: now,
588
+ lastSeen: now,
589
+ };
590
+ registerSession(sessionEntry);
591
+
592
+ if (!watchdog) {
593
+ watchdog = setInterval(() => {
594
+ if (completed || !browserConnected) return;
595
+ if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return;
596
+ if (!markCompleted()) return;
597
+ unregisterSession(sessionId);
598
+ pushEvent("deck-close", { reason: "stale" });
599
+ setImmediate(() => callbacks.onCancel("stale"));
600
+ }, WATCHDOG_INTERVAL_MS);
601
+ }
602
+
603
+ resolve({
604
+ url,
605
+ port: addr.port,
606
+ close: () => {
607
+ if (!completed) {
608
+ markCompleted();
609
+ unregisterSession(sessionId);
610
+ pushEvent("deck-close", { reason: "closed" });
611
+ }
612
+ try {
613
+ server.close();
614
+ } catch {}
615
+ closeSSE();
616
+ try {
617
+ rmSync(assetsDir, { recursive: true, force: true });
618
+ } catch {}
619
+ },
620
+ pushOption: (slideId: string, option: DeckOption) => {
621
+ if (completed) {
622
+ throw new Error("Deck session is closed");
623
+ }
624
+ try {
625
+ if (!isDeckOption(option)) {
626
+ throw new Error("Invalid deck option payload");
627
+ }
628
+ const slide = config.slides.find((s) => s.id === slideId);
629
+ if (!slide) {
630
+ throw new Error(`Unknown slide id: ${slideId}`);
631
+ }
632
+ const processed = processOptionAssets(option, assetsDir);
633
+ slide.options.push(processed);
634
+ clearPendingGenerate();
635
+ pushEvent("new-option", { slideId, option: processed });
636
+ } catch (err) {
637
+ clearPendingGenerate();
638
+ pushEvent("generate-failed", { slideId });
639
+ throw err;
640
+ }
641
+ },
642
+ cancelGenerate: () => {
643
+ if (!pendingGenerate) return;
644
+ const { slideId, isRegen } = pendingGenerate;
645
+ clearPendingGenerate();
646
+ pushEvent(isRegen ? "regenerate-failed" : "generate-failed", { slideId });
647
+ },
648
+ replaceSlideOptions: (slideId: string, options: DeckOption[]) => {
649
+ if (completed) {
650
+ throw new Error("Deck session is closed");
651
+ }
652
+ try {
653
+ const slide = config.slides.find((s) => s.id === slideId);
654
+ if (!slide) {
655
+ throw new Error(`Unknown slide id: ${slideId}`);
656
+ }
657
+ const processedOptions = options.map((opt) => {
658
+ if (!isDeckOption(opt)) {
659
+ throw new Error("Invalid deck option payload");
660
+ }
661
+ return processOptionAssets(opt, assetsDir);
662
+ });
663
+ slide.options = processedOptions;
664
+ clearPendingGenerate();
665
+ pushEvent("replace-options", { slideId, options: processedOptions });
666
+ } catch (err) {
667
+ clearPendingGenerate();
668
+ pushEvent("regenerate-failed", { slideId });
669
+ throw err;
670
+ }
671
+ },
672
+ });
673
+ });
674
+ });
675
+ }