@deckflow/deckuse 1.0.1 → 1.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.
@@ -0,0 +1,884 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { watch } from 'node:fs';
4
+ import { createServer } from 'node:http';
5
+ import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
6
+ import office2html from '@deckflow/office2html';
7
+ import { ensureGitignore, indexPath, lockPath, monitorDir, operationsPath, previewDir, } from '@deckflow/deckuse-workspace';
8
+ const KEEPALIVE_MS = 15_000;
9
+ const DEBOUNCE_MS = 80;
10
+ const READ_RETRIES = 3;
11
+ const PREVIEW_META = 'meta.json';
12
+ const PACKAGE_PPTX = 'package.pptx';
13
+ /** True for package.pptx and its atomic-replace temp names (package.pptx.<uuid>.tmp). */
14
+ const isPackagePptxWatchFilename = (filename) => {
15
+ if (filename == null || filename === '')
16
+ return true;
17
+ const name = basename(typeof filename === 'string' ? filename : filename.toString());
18
+ return name === PACKAGE_PPTX || name.startsWith(`${PACKAGE_PPTX}.`);
19
+ };
20
+ const sleep = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds));
21
+ const slideCount = async (workspace) => {
22
+ const index = JSON.parse(await readFile(indexPath(workspace), 'utf8'));
23
+ return Math.max(1, index.elements?.filter((element) => element.kind === 'slide').length ?? 0);
24
+ };
25
+ const summarizeRecord = (record, page) => {
26
+ if (!record)
27
+ return 'No changes yet — showing initial preview';
28
+ const operation = record.operation && typeof record.operation === 'object'
29
+ ? record.operation
30
+ : undefined;
31
+ if (typeof operation?.['reason'] === 'string' && operation['reason'].trim())
32
+ return operation['reason'].trim();
33
+ const type = typeof operation?.['type'] === 'string' ? operation['type'] : 'write';
34
+ const slides = record.slides?.length
35
+ ? ` on slide${record.slides.length === 1 ? '' : 's'} ${record.slides.join(', ')}`
36
+ : ` (previewing slide ${String(page)})`;
37
+ if (type === 'replaceText' && typeof operation?.['find'] === 'string') {
38
+ return `Replaced “${operation['find']}” with “${String(operation['replace'] ?? '')}”${slides}`;
39
+ }
40
+ if (type === 'setText')
41
+ return `Updated text${slides}`;
42
+ if (record.revision)
43
+ return `${type} · revision ${record.revision}${slides}`;
44
+ return `${type}${slides}`;
45
+ };
46
+ const isWriteLocked = async (workspace) => {
47
+ try {
48
+ await stat(lockPath(workspace));
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ if (error.code === 'ENOENT')
53
+ return false;
54
+ throw error;
55
+ }
56
+ };
57
+ /** mtime+size of package.pptx so undo/repack with the same ops still re-converts. */
58
+ const packageFingerprint = async (workspace) => {
59
+ try {
60
+ const info = await stat(resolve(workspace, 'package.pptx'));
61
+ return `${String(info.mtimeMs)}:${String(info.size)}`;
62
+ }
63
+ catch (error) {
64
+ if (error.code === 'ENOENT')
65
+ return 'missing';
66
+ throw error;
67
+ }
68
+ };
69
+ const readState = async (workspace) => {
70
+ let lastError;
71
+ for (let attempt = 0; attempt < READ_RETRIES; attempt += 1) {
72
+ try {
73
+ const raw = await readFile(operationsPath(workspace), 'utf8').catch((error) => {
74
+ if (error.code === 'ENOENT')
75
+ return '';
76
+ throw error;
77
+ });
78
+ const lines = raw.split(/\r?\n/).filter(Boolean);
79
+ const lastLine = lines.at(-1);
80
+ const record = lastLine ? JSON.parse(lastLine) : undefined;
81
+ const totalSlides = await slideCount(workspace);
82
+ const page = Math.min(totalSlides, Math.max(1, record?.slides?.[0] ?? (record ? totalSlides : 1)));
83
+ const packageId = await packageFingerprint(workspace);
84
+ // Include package.pptx fingerprint so undo (ops roll back before pack finishes)
85
+ // still re-converts.
86
+ return {
87
+ page,
88
+ slideCount: totalSlides,
89
+ summary: summarizeRecord(record, page),
90
+ record: record ?? null,
91
+ signature: JSON.stringify({
92
+ revision: record?.revision ?? 'empty',
93
+ slides: record?.slides ?? [],
94
+ operation: record?.operation ?? null,
95
+ length: lines.length,
96
+ package: packageId,
97
+ }),
98
+ };
99
+ }
100
+ catch (error) {
101
+ lastError = error;
102
+ if (attempt + 1 < READ_RETRIES)
103
+ await sleep(20 * (attempt + 1));
104
+ }
105
+ }
106
+ throw lastError;
107
+ };
108
+ const CONTENT_TYPES = {
109
+ '.css': 'text/css; charset=utf-8',
110
+ '.gif': 'image/gif',
111
+ '.htm': 'text/html; charset=utf-8',
112
+ '.html': 'text/html; charset=utf-8',
113
+ '.jpeg': 'image/jpeg',
114
+ '.jpg': 'image/jpeg',
115
+ '.js': 'text/javascript; charset=utf-8',
116
+ '.json': 'application/json; charset=utf-8',
117
+ '.png': 'image/png',
118
+ '.svg': 'image/svg+xml',
119
+ '.woff': 'font/woff',
120
+ '.woff2': 'font/woff2',
121
+ };
122
+ const rootPage = `<!doctype html>
123
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
124
+ <title>Deckuse monitor</title><style>
125
+ *{box-sizing:border-box}
126
+ html,body{height:100%;margin:0;background:#171717;color:#e8e8e8;font:14px system-ui,sans-serif}
127
+ #app{display:flex;flex-direction:column;height:100%}
128
+ #preview{flex:1;min-height:0;position:relative;background:#0d0d0d}
129
+ #preview iframe{width:100%;height:100%;border:0;background:#0d0d0d}
130
+ #status{position:absolute;z-index:1;left:12px;bottom:12px;padding:7px 10px;border-radius:6px;background:#000b}
131
+ #dock{flex:0 0 auto;border-top:1px solid #2a2a2a;background:#1c1c1c}
132
+ #change{display:flex;align-items:flex-start;gap:8px;padding:10px 14px;cursor:pointer;border-bottom:1px solid #2a2a2a;user-select:none}
133
+ #change:hover{background:#222}
134
+ #change .chevron{flex:0 0 auto;color:#888;transition:transform .15s ease;line-height:1.4}
135
+ #change.open .chevron{transform:rotate(90deg)}
136
+ #summary{flex:1;min-width:0;line-height:1.4;color:#ddd}
137
+ #summary .muted{color:#888}
138
+ #full-preview{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;margin-top:-2px;padding:6px 12px;border-radius:6px;border:1px solid #3a3a3a;background:#262626;color:#e8e8e8;text-decoration:none;font-size:13px;cursor:pointer}
139
+ #full-preview:hover{background:#303030;border-color:#5b9fff;color:#fff}
140
+ #details{display:none;padding:0 14px 12px}
141
+ #details.open{display:block}
142
+ #details pre{margin:0;padding:10px;max-height:36vh;overflow:auto;border-radius:6px;background:#111;color:#c8c8c8;font:12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-word}
143
+ #slides{display:flex;gap:10px;padding:12px 14px;overflow-x:auto}
144
+ .slide{flex:0 0 auto;width:96px}
145
+ .slide .thumb{position:relative;aspect-ratio:16/9;border-radius:5px;border:2px solid #333;background:linear-gradient(160deg,#2c2c2c,#1a1a1a);overflow:hidden}
146
+ .slide .thumb .bars{position:absolute;inset:16% 12% auto;display:flex;flex-direction:column;gap:5px}
147
+ .slide .thumb .bars span{display:block;height:5px;border-radius:1px;background:#3a3a3a}
148
+ .slide .thumb .bars span:nth-child(1){width:78%}
149
+ .slide .thumb .bars span:nth-child(2){width:92%}
150
+ .slide .thumb .bars span:nth-child(3){width:54%}
151
+ .slide .label{margin-top:5px;text-align:center;font-size:11px;color:#888}
152
+ .slide.active .thumb{border-color:#5b9fff;box-shadow:0 0 0 1px #5b9fff55}
153
+ .slide.active .label{color:#5b9fff;font-weight:600}
154
+ .slide.affected:not(.active) .thumb{border-color:#5a6a3a}
155
+ </style></head><body>
156
+ <div id="app">
157
+ <div id="preview"><iframe title="Presentation preview"></iframe><div id="status">Waiting for preview…</div></div>
158
+ <div id="dock">
159
+ <div id="change"><span class="chevron">▸</span><div id="summary"><span class="muted">Waiting for workspace…</span></div><a id="full-preview" href="/preview" target="_blank" rel="noopener">完整预览</a></div>
160
+ <div id="details"><pre></pre></div>
161
+ <div id="slides"></div>
162
+ </div>
163
+ </div>
164
+ <script>
165
+ const frame=document.querySelector('iframe');
166
+ const status=document.querySelector('#status');
167
+ const change=document.querySelector('#change');
168
+ const summary=document.querySelector('#summary');
169
+ const details=document.querySelector('#details');
170
+ const detailsPre=document.querySelector('#details pre');
171
+ const slides=document.querySelector('#slides');
172
+ let open=false;
173
+ const applyMeta=(data)=>{
174
+ const page=Math.max(1,Number(data.page)||1);
175
+ const count=Math.max(page,Number(data.slideCount)||1);
176
+ const affected=new Set(Array.isArray(data.record?.slides)?data.record.slides:[]);
177
+ summary.textContent=data.summary||'No changes yet — showing initial preview';
178
+ detailsPre.textContent=data.record?JSON.stringify(data.record,null,2):'No operation record yet.';
179
+ slides.replaceChildren();
180
+ for(let i=1;i<=count;i++){
181
+ const item=document.createElement('div');
182
+ item.className='slide'+(i===page?' active':'')+(affected.has(i)?' affected':'');
183
+ item.innerHTML='<div class="thumb"><div class="bars"><span></span><span></span><span></span></div></div><div class="label">Slide '+i+'</div>';
184
+ slides.appendChild(item);
185
+ }
186
+ };
187
+ change.addEventListener('click',(event)=>{
188
+ if(event.target.closest('#full-preview')) return;
189
+ open=!open;
190
+ change.classList.toggle('open',open);
191
+ details.classList.toggle('open',open);
192
+ });
193
+ const events=new EventSource('/events');
194
+ events.addEventListener('state',event=>applyMeta(JSON.parse(event.data)));
195
+ events.addEventListener('render',event=>{
196
+ const data=JSON.parse(event.data);
197
+ frame.src=data.url;
198
+ applyMeta(data);
199
+ status.hidden=true;
200
+ });
201
+ events.addEventListener('error-message',event=>{status.hidden=false;status.textContent=JSON.parse(event.data).message});
202
+ events.onerror=()=>{status.hidden=false;status.textContent='Preview connection lost; reconnecting…'};
203
+ </script></body></html>`;
204
+ const previewPage = `<!doctype html>
205
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
206
+ <title>Deckuse full preview</title><style>
207
+ *{box-sizing:border-box}
208
+ html,body{height:100%;margin:0;background:#111;color:#e8e8e8;font:14px system-ui,sans-serif}
209
+ #app{display:flex;flex-direction:column;height:100%}
210
+ #bar{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid #2a2a2a;background:#1c1c1c;transition:opacity .25s ease,transform .25s ease,max-height .25s ease,padding .25s ease,border-width .25s ease;overflow:hidden;max-height:48px}
211
+ #bar.hidden{opacity:0;transform:translateY(-8px);max-height:0;padding-top:0;padding-bottom:0;border-bottom-width:0;pointer-events:none}
212
+ #bar .title{font-weight:600}
213
+ #bar .meta{color:#888;font-size:12px;flex:1}
214
+ #bar .badge{color:#aaa;font-size:12px}
215
+ #frame-wrap{flex:1;min-height:0;position:relative;background:#0d0d0d}
216
+ #frame-wrap iframe{width:100%;height:100%;border:0;background:#0d0d0d}
217
+ #status{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center;background:#0d0d0d}
218
+ #status.overlay{inset:auto;left:12px;bottom:12px;right:auto;padding:7px 10px;border-radius:6px;background:#000b}
219
+ #status.error{color:#f88}
220
+ #status[hidden]{display:none!important}
221
+ </style></head><body>
222
+ <div id="app">
223
+ <div id="bar"><span class="title">完整预览</span><span class="meta" id="meta"></span><span class="badge" id="badge"></span></div>
224
+ <div id="frame-wrap"><iframe title="Full presentation preview" hidden></iframe><div id="status">正在准备全量预览…</div></div>
225
+ </div>
226
+ <script>
227
+ const frame=document.querySelector('iframe');
228
+ const status=document.querySelector('#status');
229
+ const bar=document.querySelector('#bar');
230
+ const meta=document.querySelector('#meta');
231
+ const badge=document.querySelector('#badge');
232
+ let currentFingerprint='';
233
+ let hideBarTimer=0;
234
+ const BAR_VISIBLE_MS=3000;
235
+ const showBar=()=>{
236
+ bar.classList.remove('hidden');
237
+ clearTimeout(hideBarTimer);
238
+ hideBarTimer=setTimeout(()=>bar.classList.add('hidden'),BAR_VISIBLE_MS);
239
+ };
240
+ const showStatus=(message,overlay)=>{
241
+ status.hidden=false;
242
+ status.classList.toggle('overlay',!!overlay&&!frame.hidden);
243
+ status.classList.remove('error');
244
+ status.textContent=message;
245
+ };
246
+ const showError=(message)=>{
247
+ status.hidden=false;
248
+ status.classList.remove('overlay');
249
+ status.classList.add('error');
250
+ status.textContent=message;
251
+ badge.textContent='';
252
+ showBar();
253
+ };
254
+ const applyReady=(data)=>{
255
+ const fingerprint=data.fingerprint||'';
256
+ meta.textContent=data.revision?('revision '+data.revision):'';
257
+ badge.textContent=data.cached?'已缓存':'已更新';
258
+ showBar();
259
+ if(fingerprint&&fingerprint===currentFingerprint&&frame.src){
260
+ status.hidden=true;
261
+ return;
262
+ }
263
+ currentFingerprint=fingerprint;
264
+ frame.src=data.url+(fingerprint?((data.url.includes('?')?'&':'?')+'v='+encodeURIComponent(fingerprint)):'');
265
+ frame.hidden=false;
266
+ status.hidden=true;
267
+ status.classList.remove('error','overlay');
268
+ };
269
+ const events=new EventSource('/preview/events');
270
+ events.addEventListener('ready',event=>applyReady(JSON.parse(event.data)));
271
+ events.addEventListener('converting',event=>{
272
+ const data=JSON.parse(event.data);
273
+ showStatus(data.message||'正在转换全量预览…',true);
274
+ badge.textContent='更新中';
275
+ showBar();
276
+ });
277
+ events.addEventListener('error-message',event=>{
278
+ showError(JSON.parse(event.data).message||'预览失败');
279
+ });
280
+ events.onerror=()=>showStatus('预览连接断开,正在重连…',true);
281
+ showBar();
282
+ </script></body></html>`;
283
+ const sendEvent = (response, event, value) => {
284
+ response.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`);
285
+ };
286
+ const validateWorkspace = async (workspace) => {
287
+ const required = [
288
+ { path: resolve(workspace, '.deckuse'), kind: 'directory' },
289
+ { path: resolve(workspace, 'package.pptx'), kind: 'file' },
290
+ { path: indexPath(workspace), kind: 'file' },
291
+ ];
292
+ for (const item of required) {
293
+ try {
294
+ const info = await stat(item.path);
295
+ if (item.kind === 'directory' ? !info.isDirectory() : !info.isFile())
296
+ throw new Error(`Expected ${item.kind}: ${item.path}`);
297
+ }
298
+ catch (error) {
299
+ if (error instanceof Error && error.message.startsWith('Expected '))
300
+ throw error;
301
+ throw new Error(`Invalid deckuse workspace; missing ${item.kind}: ${item.path}`, {
302
+ cause: error,
303
+ });
304
+ }
305
+ }
306
+ };
307
+ const previewMetaPath = (output) => resolve(output, PREVIEW_META);
308
+ const readPreviewMeta = async (output) => {
309
+ try {
310
+ return JSON.parse(await readFile(previewMetaPath(output), 'utf8'));
311
+ }
312
+ catch (error) {
313
+ if (error.code === 'ENOENT')
314
+ return undefined;
315
+ throw error;
316
+ }
317
+ };
318
+ const previewEntryUrl = (fingerprint, indexHtmlRelative) => {
319
+ const segments = indexHtmlRelative.split(sep).filter(Boolean).map(encodeURIComponent);
320
+ return `/preview/files/${encodeURIComponent(fingerprint)}${segments.length ? `/${segments.join('/')}` : ''}`;
321
+ };
322
+ /** Strip /preview/files/<fingerprint>/… so slide relative URLs bust CDN/browser caches. */
323
+ const previewFilesServePath = (pathname) => {
324
+ const prefix = '/preview/files/';
325
+ if (!pathname.startsWith(prefix))
326
+ return undefined;
327
+ const rest = pathname.slice(prefix.length);
328
+ if (!rest)
329
+ return pathname;
330
+ let first;
331
+ let remainder;
332
+ try {
333
+ const slash = rest.indexOf('/');
334
+ first = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash));
335
+ remainder = slash === -1 ? '' : rest.slice(slash + 1);
336
+ }
337
+ catch {
338
+ return undefined;
339
+ }
340
+ // Fingerprints are `mtimeMs:size` (and the sentinel `missing`).
341
+ if (!first.includes(':') && first !== 'missing')
342
+ return pathname;
343
+ return `${prefix}${remainder}`;
344
+ };
345
+ const serveWorkspaceFile = (response, root, realRoot, pathname, prefix, headers = {}) => {
346
+ let segments;
347
+ try {
348
+ segments = pathname.slice(prefix.length).split('/').map(decodeURIComponent);
349
+ }
350
+ catch {
351
+ response.writeHead(400).end();
352
+ return;
353
+ }
354
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes(sep))) {
355
+ response.writeHead(403).end();
356
+ return;
357
+ }
358
+ const file = resolve(root, ...segments);
359
+ const relativeFile = relative(root, file);
360
+ if (relativeFile.startsWith(`..${sep}`) || relativeFile === '..') {
361
+ response.writeHead(403).end();
362
+ return;
363
+ }
364
+ void Promise.all([stat(file), realpath(file)])
365
+ .then(([info, realFile]) => {
366
+ const realRelative = relative(realRoot, realFile);
367
+ if (!info.isFile() ||
368
+ realRelative === '..' ||
369
+ realRelative.startsWith(`..${sep}`) ||
370
+ resolve(realRoot, realRelative) !== realFile)
371
+ throw Object.assign(new Error('Not a safe file'), { code: 'EACCES' });
372
+ response.writeHead(200, {
373
+ 'content-type': CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream',
374
+ 'x-content-type-options': 'nosniff',
375
+ ...headers,
376
+ });
377
+ createReadStream(file)
378
+ .on('error', () => response.destroy())
379
+ .pipe(response);
380
+ })
381
+ .catch((error) => {
382
+ const code = error.code;
383
+ response.writeHead(code === 'ENOENT' ? 404 : code === 'EACCES' ? 403 : 500).end();
384
+ });
385
+ };
386
+ export const startMonitor = async (workspace, options = {}) => {
387
+ const absoluteWorkspace = resolve(workspace);
388
+ await validateWorkspace(absoluteWorkspace);
389
+ await ensureGitignore(absoluteWorkspace);
390
+ const outputRoot = monitorDir(absoluteWorkspace);
391
+ await mkdir(outputRoot, { recursive: true });
392
+ const realOutputRoot = await realpath(outputRoot);
393
+ const fullPreviewRoot = previewDir(absoluteWorkspace);
394
+ await mkdir(fullPreviewRoot, { recursive: true });
395
+ let realFullPreviewRoot = await realpath(fullPreviewRoot);
396
+ const converter = options.dependencies?.convert ?? office2html.convert;
397
+ const createWatcher = options.dependencies?.watch ?? watch;
398
+ const debounceMs = options.dependencies?.debounceMs ?? DEBOUNCE_MS;
399
+ const keepaliveMs = options.dependencies?.keepaliveMs ?? KEEPALIVE_MS;
400
+ const packagePath = resolve(absoluteWorkspace, PACKAGE_PPTX);
401
+ const operationsWatchPath = dirname(operationsPath(absoluteWorkspace));
402
+ const clients = new Set();
403
+ const previewClients = new Set();
404
+ // Single-page preview: driven by operations/index, independent of full preview.
405
+ let mainWatcher;
406
+ let mainDebounce;
407
+ let mainKeepalive;
408
+ let mainActive = false;
409
+ let converting = false;
410
+ let queued;
411
+ let lastStateSignature;
412
+ let latestMeta;
413
+ let latestRenderUrl;
414
+ let version = 0;
415
+ let generation = 0;
416
+ // Full preview: driven only by package.pptx, independent of the ops watcher.
417
+ let previewWatcher;
418
+ let previewDebounce;
419
+ let previewKeepalive;
420
+ let previewActive = false;
421
+ let fullPreviewJob;
422
+ let fullPreviewJobIdentity;
423
+ let fullPreviewQueued;
424
+ let fullPreviewError;
425
+ let latestFullPreview;
426
+ const broadcast = (event, value) => {
427
+ for (const client of clients)
428
+ sendEvent(client, event, value);
429
+ };
430
+ const broadcastPreview = (event, value) => {
431
+ for (const client of previewClients)
432
+ sendEvent(client, event, value);
433
+ };
434
+ const publishMeta = async (state) => {
435
+ const meta = {
436
+ page: state.page,
437
+ slideCount: state.slideCount,
438
+ summary: summarizeRecord(state.record ?? undefined, state.page),
439
+ record: state.record,
440
+ };
441
+ latestMeta = meta;
442
+ broadcast('state', meta);
443
+ return meta;
444
+ };
445
+ const takeQueued = () => {
446
+ const next = queued;
447
+ queued = undefined;
448
+ return next;
449
+ };
450
+ const runConversion = async (state, runGeneration) => {
451
+ converting = true;
452
+ const versionName = `${String(Date.now())}-${String(++version)}`;
453
+ const output = resolve(outputRoot, versionName);
454
+ try {
455
+ // Writers hold write.lock across source/ops/pack. Never convert mid-mutation.
456
+ if (await isWriteLocked(absoluteWorkspace)) {
457
+ if (lastStateSignature === state.signature)
458
+ lastStateSignature = undefined;
459
+ scheduleMainRefresh();
460
+ return;
461
+ }
462
+ await mkdir(output, { recursive: true });
463
+ const result = await converter(packagePath, {
464
+ output,
465
+ pages: String(state.page),
466
+ });
467
+ if (result.exitCode !== 0)
468
+ throw new Error(result.stderr || 'office2html conversion failed');
469
+ const entry = resolve(result.indexHtmlPath);
470
+ const entryRelative = relative(output, entry);
471
+ if (entryRelative.startsWith(`..${sep}`) ||
472
+ entryRelative === '..' ||
473
+ resolve(output, entryRelative) !== entry)
474
+ throw new Error('office2html returned an output outside its version directory');
475
+ if (!queued && mainActive && generation === runGeneration) {
476
+ const meta = await publishMeta(state);
477
+ latestRenderUrl = `/render/${encodeURIComponent(versionName)}${entryRelative ? `/${entryRelative.split(sep).map(encodeURIComponent).join('/')}` : ''}`;
478
+ broadcast('render', { url: latestRenderUrl, ...meta });
479
+ }
480
+ }
481
+ catch (error) {
482
+ if (!queued && mainActive && generation === runGeneration)
483
+ broadcast('error-message', {
484
+ message: error instanceof Error ? error.message : 'Preview conversion failed',
485
+ });
486
+ }
487
+ finally {
488
+ converting = false;
489
+ const next = takeQueued();
490
+ if (next && mainActive && generation === next.generation)
491
+ void runConversion(next.state, next.generation);
492
+ }
493
+ };
494
+ const refreshMain = async () => {
495
+ if (!mainActive)
496
+ return;
497
+ const refreshGeneration = generation;
498
+ try {
499
+ // Skip while a write/undo holds the lock. Do not update lastStateSignature —
500
+ // otherwise the post-unlock consistent state can be treated as a no-op.
501
+ if (await isWriteLocked(absoluteWorkspace))
502
+ return;
503
+ const state = await readState(absoluteWorkspace);
504
+ if (generation !== refreshGeneration)
505
+ return;
506
+ if (await isWriteLocked(absoluteWorkspace))
507
+ return;
508
+ if (state.signature === lastStateSignature)
509
+ return;
510
+ lastStateSignature = state.signature;
511
+ await publishMeta(state);
512
+ if (clients.size === 0)
513
+ return;
514
+ if (converting)
515
+ queued = { state, generation };
516
+ else
517
+ void runConversion(state, generation);
518
+ }
519
+ catch (error) {
520
+ if (generation !== refreshGeneration)
521
+ return;
522
+ broadcast('error-message', {
523
+ message: error instanceof Error ? error.message : 'Could not read workspace state',
524
+ });
525
+ }
526
+ };
527
+ const scheduleMainRefresh = () => {
528
+ if (!mainActive)
529
+ return;
530
+ if (mainDebounce)
531
+ clearTimeout(mainDebounce);
532
+ mainDebounce = setTimeout(() => void refreshMain(), debounceMs);
533
+ };
534
+ const startMainWatching = () => {
535
+ if (mainActive)
536
+ return;
537
+ mainActive = true;
538
+ generation += 1;
539
+ try {
540
+ mainWatcher = createWatcher(operationsWatchPath, scheduleMainRefresh);
541
+ mainWatcher.on('error', (error) => {
542
+ if (!mainActive)
543
+ return;
544
+ broadcast('error-message', { message: `Workspace watcher failed: ${error.message}` });
545
+ stopMainWatching();
546
+ });
547
+ }
548
+ catch (error) {
549
+ const message = `Could not watch workspace: ${error instanceof Error ? error.message : 'Unknown error'}`;
550
+ broadcast('error-message', { message });
551
+ stopMainWatching();
552
+ return;
553
+ }
554
+ mainKeepalive = setInterval(() => {
555
+ for (const client of clients)
556
+ client.write(': keepalive\n\n');
557
+ }, keepaliveMs);
558
+ void refreshMain();
559
+ };
560
+ const stopMainWatching = () => {
561
+ if (!mainActive)
562
+ return;
563
+ mainActive = false;
564
+ generation += 1;
565
+ mainWatcher?.close();
566
+ mainWatcher = undefined;
567
+ if (mainDebounce)
568
+ clearTimeout(mainDebounce);
569
+ mainDebounce = undefined;
570
+ if (mainKeepalive)
571
+ clearInterval(mainKeepalive);
572
+ mainKeepalive = undefined;
573
+ queued = undefined;
574
+ lastStateSignature = undefined;
575
+ };
576
+ const currentPreviewIdentity = async () => {
577
+ const state = await readState(absoluteWorkspace);
578
+ const fingerprint = await packageFingerprint(absoluteWorkspace);
579
+ return {
580
+ revision: state.record?.revision ?? 'empty',
581
+ fingerprint,
582
+ };
583
+ };
584
+ const readyPreviewResult = (meta, cached) => ({
585
+ status: 'ready',
586
+ url: previewEntryUrl(meta.fingerprint, meta.indexHtml),
587
+ revision: meta.revision,
588
+ fingerprint: meta.fingerprint,
589
+ cached,
590
+ });
591
+ const cachedPreviewResult = async (fingerprint) => {
592
+ const existing = await readPreviewMeta(fullPreviewRoot);
593
+ if (!existing || existing.fingerprint !== fingerprint)
594
+ return undefined;
595
+ const entry = resolve(fullPreviewRoot, existing.indexHtml);
596
+ try {
597
+ const info = await stat(entry);
598
+ if (info.isFile())
599
+ return readyPreviewResult(existing, true);
600
+ }
601
+ catch (error) {
602
+ if (error.code !== 'ENOENT')
603
+ throw error;
604
+ }
605
+ return undefined;
606
+ };
607
+ const publishFullPreviewReady = (result) => {
608
+ latestFullPreview = result;
609
+ broadcastPreview('ready', result);
610
+ };
611
+ const startFullPreviewConversion = (identity) => {
612
+ if (fullPreviewJob) {
613
+ if (fullPreviewJobIdentity?.fingerprint !== identity.fingerprint)
614
+ fullPreviewQueued = identity;
615
+ return;
616
+ }
617
+ fullPreviewError = undefined;
618
+ fullPreviewQueued = undefined;
619
+ fullPreviewJobIdentity = identity;
620
+ fullPreviewJob = (async () => {
621
+ const staging = `${fullPreviewRoot}.next`;
622
+ await rm(staging, { recursive: true, force: true });
623
+ await mkdir(staging, { recursive: true });
624
+ try {
625
+ const result = await converter(packagePath, {
626
+ output: staging,
627
+ });
628
+ if (result.exitCode !== 0)
629
+ throw new Error(result.stderr || 'office2html conversion failed');
630
+ const entry = resolve(result.indexHtmlPath);
631
+ const entryRelative = relative(staging, entry);
632
+ if (entryRelative.startsWith(`..${sep}`) ||
633
+ entryRelative === '..' ||
634
+ resolve(staging, entryRelative) !== entry)
635
+ throw new Error('office2html returned an output outside the preview directory');
636
+ const meta = {
637
+ revision: identity.revision,
638
+ fingerprint: identity.fingerprint,
639
+ convertedAt: new Date().toISOString(),
640
+ indexHtml: entryRelative || 'index.html',
641
+ };
642
+ await writeFile(previewMetaPath(staging), `${JSON.stringify(meta, null, 2)}\n`);
643
+ await rm(fullPreviewRoot, { recursive: true, force: true });
644
+ await rename(staging, fullPreviewRoot);
645
+ realFullPreviewRoot = await realpath(fullPreviewRoot);
646
+ publishFullPreviewReady(readyPreviewResult(meta, false));
647
+ }
648
+ catch (error) {
649
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
650
+ throw error;
651
+ }
652
+ })()
653
+ .catch((error) => {
654
+ fullPreviewError =
655
+ error instanceof Error ? error.message : 'Full preview conversion failed';
656
+ broadcastPreview('error-message', { message: fullPreviewError });
657
+ })
658
+ .finally(() => {
659
+ fullPreviewJob = undefined;
660
+ fullPreviewJobIdentity = undefined;
661
+ const queuedIdentity = fullPreviewQueued;
662
+ fullPreviewQueued = undefined;
663
+ if (queuedIdentity)
664
+ startFullPreviewConversion(queuedIdentity);
665
+ else if (previewClients.size > 0)
666
+ void requestFullPreviewUpdate();
667
+ });
668
+ };
669
+ const ensureFullPreview = async () => {
670
+ if (await isWriteLocked(absoluteWorkspace)) {
671
+ // Writers hold write.lock across source/ops/pack. Retry after debounce so the
672
+ // (possibly sole) watch event that landed during the lock is not dropped.
673
+ schedulePreviewRefresh();
674
+ return { status: 'converting', message: 'Workspace is locked; waiting to convert…' };
675
+ }
676
+ const identity = await currentPreviewIdentity();
677
+ if (identity.fingerprint === 'missing') {
678
+ return { status: 'error', message: 'package.pptx is missing' };
679
+ }
680
+ const cached = await cachedPreviewResult(identity.fingerprint);
681
+ if (cached)
682
+ return cached;
683
+ if (fullPreviewError && !fullPreviewJob) {
684
+ const message = fullPreviewError;
685
+ fullPreviewError = undefined;
686
+ return { status: 'error', message };
687
+ }
688
+ startFullPreviewConversion(identity);
689
+ return { status: 'converting', message: '正在转换全量预览…' };
690
+ };
691
+ const requestFullPreviewUpdate = async () => {
692
+ try {
693
+ const result = await ensureFullPreview();
694
+ if (result.status === 'ready')
695
+ publishFullPreviewReady(result);
696
+ else if (result.status === 'converting')
697
+ broadcastPreview('converting', result);
698
+ else
699
+ broadcastPreview('error-message', { message: result.message || '预览失败' });
700
+ }
701
+ catch (error) {
702
+ broadcastPreview('error-message', {
703
+ message: error instanceof Error ? error.message : 'Full preview update failed',
704
+ });
705
+ }
706
+ };
707
+ const schedulePreviewRefresh = () => {
708
+ if (!previewActive)
709
+ return;
710
+ if (previewDebounce)
711
+ clearTimeout(previewDebounce);
712
+ previewDebounce = setTimeout(() => void requestFullPreviewUpdate(), debounceMs);
713
+ };
714
+ const onPackageWatchEvent = (_event, filename) => {
715
+ if (!isPackagePptxWatchFilename(filename))
716
+ return;
717
+ schedulePreviewRefresh();
718
+ };
719
+ const startPreviewWatching = () => {
720
+ if (previewActive)
721
+ return;
722
+ previewActive = true;
723
+ try {
724
+ // Watch the workspace directory (not package.pptx itself). Atomic temp+rename
725
+ // replace of package.pptx invalidates file watchers on Darwin after the first
726
+ // rename; directory watchers keep receiving subsequent events.
727
+ previewWatcher = createWatcher(absoluteWorkspace, onPackageWatchEvent);
728
+ previewWatcher.on('error', (error) => {
729
+ if (!previewActive)
730
+ return;
731
+ broadcastPreview('error-message', {
732
+ message: `Package watcher failed: ${error.message}`,
733
+ });
734
+ stopPreviewWatching();
735
+ });
736
+ }
737
+ catch (error) {
738
+ const message = `Could not watch package.pptx: ${error instanceof Error ? error.message : 'Unknown error'}`;
739
+ broadcastPreview('error-message', { message });
740
+ stopPreviewWatching();
741
+ return;
742
+ }
743
+ previewKeepalive = setInterval(() => {
744
+ for (const client of previewClients)
745
+ client.write(': keepalive\n\n');
746
+ }, keepaliveMs);
747
+ void requestFullPreviewUpdate();
748
+ };
749
+ const stopPreviewWatching = () => {
750
+ if (!previewActive)
751
+ return;
752
+ previewActive = false;
753
+ previewWatcher?.close();
754
+ previewWatcher = undefined;
755
+ if (previewDebounce)
756
+ clearTimeout(previewDebounce);
757
+ previewDebounce = undefined;
758
+ if (previewKeepalive)
759
+ clearInterval(previewKeepalive);
760
+ previewKeepalive = undefined;
761
+ };
762
+ const server = createServer((request, response) => {
763
+ const requestUrl = new URL(request.url ?? '/', 'http://localhost');
764
+ if (requestUrl.pathname === '/') {
765
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
766
+ response.end(rootPage);
767
+ return;
768
+ }
769
+ if (requestUrl.pathname === '/preview') {
770
+ response.writeHead(200, {
771
+ 'cache-control': 'no-store',
772
+ 'cdn-cache-control': 'no-store',
773
+ 'content-type': 'text/html; charset=utf-8',
774
+ });
775
+ response.end(previewPage);
776
+ return;
777
+ }
778
+ if (requestUrl.pathname === '/preview/events') {
779
+ response.writeHead(200, {
780
+ 'cache-control': 'no-cache, no-transform',
781
+ connection: 'keep-alive',
782
+ 'content-type': 'text/event-stream',
783
+ });
784
+ response.write(': connected\n\n');
785
+ previewClients.add(response);
786
+ startPreviewWatching();
787
+ // Always re-ensure on connect. startPreviewWatching() is a no-op when another
788
+ // client (or a refresh race) already holds previewActive, which previously left
789
+ // reconnecting clients stuck on a stale in-memory ready / dead file watcher.
790
+ sendEvent(response, 'converting', { message: '正在准备全量预览…' });
791
+ void requestFullPreviewUpdate();
792
+ request.on('close', () => {
793
+ previewClients.delete(response);
794
+ if (previewClients.size === 0)
795
+ stopPreviewWatching();
796
+ });
797
+ return;
798
+ }
799
+ if (requestUrl.pathname === '/preview/ensure') {
800
+ void ensureFullPreview().then((result) => {
801
+ if (result.status === 'ready')
802
+ latestFullPreview = result;
803
+ const status = result.status === 'ready' ? 200 : result.status === 'converting' ? 202 : 500;
804
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
805
+ response.end(`${JSON.stringify(result)}\n`);
806
+ });
807
+ return;
808
+ }
809
+ if (requestUrl.pathname.startsWith('/preview/files/')) {
810
+ const servePath = previewFilesServePath(requestUrl.pathname);
811
+ if (servePath === undefined) {
812
+ response.writeHead(400).end();
813
+ return;
814
+ }
815
+ serveWorkspaceFile(response, fullPreviewRoot, realFullPreviewRoot, servePath, '/preview/files/', {
816
+ 'cache-control': 'no-store',
817
+ 'cdn-cache-control': 'no-store',
818
+ });
819
+ return;
820
+ }
821
+ if (requestUrl.pathname === '/events') {
822
+ response.writeHead(200, {
823
+ 'cache-control': 'no-cache, no-transform',
824
+ connection: 'keep-alive',
825
+ 'content-type': 'text/event-stream',
826
+ });
827
+ response.write(': connected\n\n');
828
+ clients.add(response);
829
+ startMainWatching();
830
+ if (latestMeta)
831
+ sendEvent(response, 'state', latestMeta);
832
+ if (latestRenderUrl && latestMeta)
833
+ sendEvent(response, 'render', { url: latestRenderUrl, ...latestMeta });
834
+ else if (latestRenderUrl)
835
+ sendEvent(response, 'render', { url: latestRenderUrl });
836
+ request.on('close', () => {
837
+ clients.delete(response);
838
+ if (clients.size === 0)
839
+ stopMainWatching();
840
+ });
841
+ return;
842
+ }
843
+ if (requestUrl.pathname.startsWith('/render/')) {
844
+ serveWorkspaceFile(response, outputRoot, realOutputRoot, requestUrl.pathname, '/render/');
845
+ return;
846
+ }
847
+ response.writeHead(404).end();
848
+ });
849
+ const host = options.host ?? '0.0.0.0';
850
+ const port = options.port ?? 4173;
851
+ await new Promise((done, reject) => {
852
+ server.once('error', reject);
853
+ server.listen(port, host, () => {
854
+ server.off('error', reject);
855
+ done();
856
+ });
857
+ });
858
+ const address = server.address();
859
+ const actualPort = typeof address === 'object' && address ? address.port : port;
860
+ const displayHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
861
+ return {
862
+ server,
863
+ url: `http://${displayHost}:${String(actualPort)}/`,
864
+ close: async () => {
865
+ for (const client of clients)
866
+ client.end();
867
+ clients.clear();
868
+ for (const client of previewClients)
869
+ client.end();
870
+ previewClients.clear();
871
+ stopMainWatching();
872
+ stopPreviewWatching();
873
+ await new Promise((done, reject) => {
874
+ server.close((error) => {
875
+ if (error)
876
+ reject(error);
877
+ else
878
+ done();
879
+ });
880
+ });
881
+ },
882
+ };
883
+ };
884
+ //# sourceMappingURL=monitor.js.map