@1agh/maude 0.40.0 → 0.42.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.
Files changed (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -11,10 +11,11 @@ import { tmpdir } from 'node:os';
11
11
  import path from 'node:path';
12
12
 
13
13
  import JSZip from 'jszip';
14
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
14
+ import { exportShimPath, runShim } from './_runtime.ts';
15
15
  import {
16
16
  canvasShellUrl,
17
17
  type ExportContext,
18
+ type ExportHooks,
18
19
  type ExportOptions,
19
20
  type ExportResult,
20
21
  } from './index.ts';
@@ -27,7 +28,8 @@ async function captureHtml(
27
28
  target: Extract<Target, { kind: 'element' }>,
28
29
  ctx: ExportContext,
29
30
  outDir: string,
30
- timeoutSec: number
31
+ timeoutSec: number,
32
+ hooks?: ExportHooks
31
33
  ): Promise<string[]> {
32
34
  const args = [
33
35
  HTML_PLAYWRIGHT,
@@ -46,29 +48,18 @@ async function captureHtml(
46
48
  if (target.widen) args.push('--widen-to-artboard', '1');
47
49
  args.push('--out', path.join(outDir, `${target.canvasSlug}.html`));
48
50
  }
49
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
51
+ return runShim(args, {
50
52
  cwd: path.dirname(HTML_PLAYWRIGHT),
51
- stdout: 'pipe',
52
- stderr: 'pipe',
53
+ signal: hooks?.signal,
54
+ onProgress: hooks?.onProgress,
53
55
  });
54
- const [stdout, stderr] = await Promise.all([
55
- new Response(proc.stdout).text(),
56
- new Response(proc.stderr).text(),
57
- ]);
58
- const code = await proc.exited;
59
- if (code !== 0) {
60
- throw new Error(`_html-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
61
- }
62
- return stdout
63
- .split('\n')
64
- .map((s) => s.trim())
65
- .filter(Boolean);
66
56
  }
67
57
 
68
58
  export async function run(
69
59
  targets: Target[],
70
60
  options: ExportOptions,
71
- ctx: ExportContext
61
+ ctx: ExportContext,
62
+ hooks?: ExportHooks
72
63
  ): Promise<ExportResult> {
73
64
  if (!targets.length) {
74
65
  return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
@@ -83,9 +74,10 @@ export async function run(
83
74
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-html-'));
84
75
  try {
85
76
  const written: string[] = [];
86
- for (const t of elementTargets) {
87
- const paths = await captureHtml(t, ctx, tmp, timeoutSec);
77
+ for (let i = 0; i < elementTargets.length; i += 1) {
78
+ const paths = await captureHtml(elementTargets[i], ctx, tmp, timeoutSec, hooks);
88
79
  written.push(...paths);
80
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
89
81
  }
90
82
  if (!written.length) {
91
83
  return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
@@ -74,8 +74,19 @@ export interface ExportResult {
74
74
  body: Uint8Array;
75
75
  }
76
76
 
77
+ /** Progress + cancellation hooks threaded through to the shim-spawning layer. */
78
+ export interface ExportHooks {
79
+ onProgress?: (update: { current: number; total: number }) => void;
80
+ signal?: AbortSignal;
81
+ }
82
+
77
83
  export interface Adapter {
78
- run(targets: Target[], options: ExportOptions, ctx: ExportContext): Promise<ExportResult>;
84
+ run(
85
+ targets: Target[],
86
+ options: ExportOptions,
87
+ ctx: ExportContext,
88
+ hooks?: ExportHooks
89
+ ): Promise<ExportResult>;
79
90
  }
80
91
 
81
92
  const REGISTRY: Record<Format, Adapter> = {
@@ -114,6 +125,7 @@ export async function runExport(args: {
114
125
  options: ExportOptions;
115
126
  resolve: Omit<ResolveScopeArgs, 'scope'>;
116
127
  ctx: ExportContext;
128
+ hooks?: ExportHooks;
117
129
  }): Promise<ExportResult> {
118
130
  // Thread the options bag into the resolver so submit-time selection /
119
131
  // active-artboard hints (DDR — selection-passthrough) win over the persisted
@@ -123,7 +135,7 @@ export async function runExport(args: {
123
135
  if (!adapter) {
124
136
  throw new Error(`unknown format: ${args.format}`);
125
137
  }
126
- return adapter.run(targets, args.options, args.ctx);
138
+ return adapter.run(targets, args.options, args.ctx, args.hooks);
127
139
  }
128
140
 
129
141
  export type { Scope, Target };
@@ -0,0 +1,334 @@
1
+ // Background export job queue (feature-background-export-notification-center).
2
+ //
3
+ // Turns the previously-synchronous `POST /_api/export` into a backgroundable
4
+ // operation: `enqueue()` returns immediately with a job id + a Promise that
5
+ // resolves with the same `ExportResult` the old synchronous call produced, so
6
+ // `/_api/export` stays byte-for-byte contract-unchanged (it just awaits the
7
+ // Promise). `POST /_api/export-jobs` returns the id WITHOUT awaiting, and the
8
+ // job progresses queued → running → done|failed in the background, emitting
9
+ // `bus.emit('export:job', job)` on every transition so ws.ts can broadcast a
10
+ // live snapshot to the notification center.
11
+ //
12
+ // Concurrency is capped by a small hand-rolled counting semaphore (same
13
+ // "no new dependency" convention as `writeLocator()` in http.ts) so N
14
+ // Playwright/Chromium-heavy renders don't all launch at once — MAUDE_EXPORT_
15
+ // MAX_CONCURRENT (default 2) lets a quick PNG start while a slow PDF/video is
16
+ // still running, without letting an unbounded number of Chromiums spawn.
17
+ //
18
+ // History persistence: the in-memory `jobs` Map IS the source of truth. The
19
+ // on-disk `_export-history.json` ledger is re-derived from it (done/failed
20
+ // jobs, newest-first, capped) and overwritten in one shot on every
21
+ // completion — no read-modify-write, so concurrent completions can't drop
22
+ // entries (the race the old api.ts loadExportHistory/appendExportHistory
23
+ // pair had). The ledger is seeded from disk once at construction so history
24
+ // survives a server restart even though job state itself doesn't.
25
+
26
+ import { readFileSync } from 'node:fs';
27
+ import { mkdir, rm } from 'node:fs/promises';
28
+ import path from 'node:path';
29
+
30
+ import type { Bus } from '../context.ts';
31
+ import {
32
+ type ExportContext,
33
+ type ExportOptions,
34
+ type ExportResult,
35
+ type Format,
36
+ runExport,
37
+ type Scope,
38
+ } from './index.ts';
39
+ import type { ResolveScopeArgs } from './scope.ts';
40
+
41
+ /** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
42
+ export interface ExportHistoryEntry {
43
+ format: string;
44
+ scope: string;
45
+ options?: Record<string, unknown>;
46
+ filename: string;
47
+ at: string;
48
+ id?: string;
49
+ status?: 'done' | 'failed';
50
+ startedAt?: string;
51
+ finishedAt?: string;
52
+ error?: string;
53
+ }
54
+
55
+ export type ExportJobStatus = 'queued' | 'running' | 'done' | 'failed';
56
+
57
+ export interface ExportJob {
58
+ id: string;
59
+ format: Format;
60
+ scope: Scope;
61
+ options: ExportOptions;
62
+ status: ExportJobStatus;
63
+ progress?: { current: number; total: number };
64
+ createdAt: string;
65
+ startedAt?: string;
66
+ finishedAt?: string;
67
+ filename?: string;
68
+ contentType?: string;
69
+ error?: string;
70
+ }
71
+
72
+ export interface EnqueueArgs {
73
+ format: Format;
74
+ scope: Scope;
75
+ options: ExportOptions;
76
+ resolve: Omit<ResolveScopeArgs, 'scope'>;
77
+ ctx: ExportContext;
78
+ }
79
+
80
+ export type DownloadResult =
81
+ | { ok: true; bytes: Uint8Array; filename: string; contentType: string }
82
+ | { ok: false; reason: 'missing' | 'not-done' };
83
+
84
+ export interface ExportJobQueue {
85
+ enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> };
86
+ get(id: string): ExportJob | undefined;
87
+ list(): ExportJob[];
88
+ loadHistory(): ExportHistoryEntry[];
89
+ getBytes(id: string): Promise<DownloadResult>;
90
+ }
91
+
92
+ const HISTORY_DEPTH = 20;
93
+ const MAX_JOB_AGE_MS = 24 * 60 * 60 * 1000;
94
+ const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1000;
95
+
96
+ /**
97
+ * Security fan-out finding (defender, /flow:done) — MEDIUM: `enqueue()` had
98
+ * no cap on queued/running jobs, so a flood of `POST /_api/export-jobs`
99
+ * (unauthenticated same-origin-omitted-Origin callers pass `sameOriginWrite`)
100
+ * could grow the in-memory Map and fill `_export-jobs/` on disk unbounded —
101
+ * the old synchronous `/_api/export` had natural backpressure (one render at
102
+ * a time from the client's own perspective) that the background queue
103
+ * removed. `MAX_PENDING` bounds queued+running jobs; `enqueue()` throws
104
+ * `ExportQueueFullError` past it and http.ts maps that to 429.
105
+ */
106
+ const MAX_PENDING = Math.max(1, Number(process.env.MAUDE_EXPORT_MAX_QUEUED) || 20);
107
+
108
+ /** Thrown by `enqueue()` when `MAX_PENDING` queued/running jobs are already in flight. */
109
+ export class ExportQueueFullError extends Error {
110
+ constructor() {
111
+ super('export queue is full — too many pending exports, try again shortly');
112
+ this.name = 'ExportQueueFullError';
113
+ }
114
+ }
115
+
116
+ /** Small counting semaphore — no new dependency, mirrors writeLocator()'s style. */
117
+ class Semaphore {
118
+ private active = 0;
119
+ private readonly waiters: Array<() => void> = [];
120
+
121
+ constructor(private readonly max: number) {}
122
+
123
+ async acquire(): Promise<() => void> {
124
+ if (this.active >= this.max) {
125
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
126
+ }
127
+ this.active += 1;
128
+ let released = false;
129
+ return () => {
130
+ if (released) return;
131
+ released = true;
132
+ this.active -= 1;
133
+ const next = this.waiters.shift();
134
+ if (next) next();
135
+ };
136
+ }
137
+ }
138
+
139
+ function isFinished(job: ExportJob): boolean {
140
+ return job.status === 'done' || job.status === 'failed';
141
+ }
142
+
143
+ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQueue {
144
+ const jobsDir = path.join(designRoot, '_export-jobs');
145
+ const historyPath = path.join(designRoot, '_export-history.json');
146
+ const maxConcurrent = Math.max(1, Number(process.env.MAUDE_EXPORT_MAX_CONCURRENT) || 2);
147
+ const semaphore = new Semaphore(maxConcurrent);
148
+ const jobs = new Map<string, ExportJob>();
149
+
150
+ // Seed the ledger from disk ONCE — the only read of the history file. Every
151
+ // later persist derives fresh from `jobs` and overwrites; there is no
152
+ // subsequent read-then-write step, which is what eliminates the race.
153
+ try {
154
+ const raw = readFileSync(historyPath, 'utf8');
155
+ const parsed = JSON.parse(raw) as unknown;
156
+ if (Array.isArray(parsed)) {
157
+ for (const entry of parsed) {
158
+ if (!entry || typeof entry !== 'object') continue;
159
+ const e = entry as Record<string, unknown>;
160
+ const id = typeof e.id === 'string' && e.id ? e.id : crypto.randomUUID();
161
+ const finishedAt =
162
+ typeof e.finishedAt === 'string'
163
+ ? e.finishedAt
164
+ : typeof e.at === 'string'
165
+ ? e.at
166
+ : undefined;
167
+ jobs.set(id, {
168
+ id,
169
+ format: e.format as Format,
170
+ scope: e.scope as Scope,
171
+ options: (e.options as ExportOptions) ?? {},
172
+ status: e.status === 'failed' ? 'failed' : 'done',
173
+ createdAt: (e.startedAt as string) ?? finishedAt ?? new Date().toISOString(),
174
+ startedAt: e.startedAt as string | undefined,
175
+ finishedAt,
176
+ filename: e.filename as string | undefined,
177
+ error: e.error as string | undefined,
178
+ });
179
+ }
180
+ }
181
+ } catch {
182
+ /* no ledger yet, or unreadable — start empty */
183
+ }
184
+
185
+ // Orphaned `_export-jobs/*` dirs from a process that died mid-export — job
186
+ // state is in-memory only and doesn't survive a restart, so anything on
187
+ // disk from a prior run is stale. Best-effort, never blocks boot.
188
+ void rm(jobsDir, { recursive: true, force: true })
189
+ .catch(() => {})
190
+ .then(() => mkdir(jobsDir, { recursive: true }).catch(() => {}));
191
+
192
+ function emit(job: ExportJob): void {
193
+ bus.emit('export:job', { ...job });
194
+ }
195
+
196
+ function deriveHistory(): ExportHistoryEntry[] {
197
+ return Array.from(jobs.values())
198
+ .filter(isFinished)
199
+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''))
200
+ .slice(0, HISTORY_DEPTH)
201
+ .map((j) => ({
202
+ id: j.id,
203
+ format: j.format,
204
+ scope: j.scope,
205
+ options: j.options,
206
+ filename: j.filename ?? '',
207
+ at: j.finishedAt ?? j.createdAt,
208
+ status: j.status as 'done' | 'failed',
209
+ startedAt: j.startedAt,
210
+ finishedAt: j.finishedAt,
211
+ error: j.error,
212
+ }));
213
+ }
214
+
215
+ async function persistAndEvict(): Promise<void> {
216
+ const history = deriveHistory();
217
+ await Bun.write(historyPath, JSON.stringify(history, null, 2));
218
+
219
+ // Evict bytes (+ the in-memory record) for anything that rolled past the
220
+ // cap, or aged out, whichever comes first.
221
+ const finished = Array.from(jobs.values())
222
+ .filter(isFinished)
223
+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
224
+ const now = Date.now();
225
+ const stale = finished.filter((j, i) => {
226
+ if (i >= HISTORY_DEPTH) return true;
227
+ const finishedAt = j.finishedAt ? Date.parse(j.finishedAt) : Number.NaN;
228
+ return Number.isFinite(finishedAt) && now - finishedAt > MAX_JOB_AGE_MS;
229
+ });
230
+ for (const job of stale) {
231
+ jobs.delete(job.id);
232
+ await rm(path.join(jobsDir, job.id), { recursive: true, force: true }).catch(() => {});
233
+ }
234
+ }
235
+
236
+ function enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> } {
237
+ let pending = 0;
238
+ for (const job of jobs.values()) {
239
+ if (job.status === 'queued' || job.status === 'running') pending += 1;
240
+ }
241
+ if (pending >= MAX_PENDING) throw new ExportQueueFullError();
242
+
243
+ const id = crypto.randomUUID();
244
+ const job: ExportJob = {
245
+ id,
246
+ format: args.format,
247
+ scope: args.scope,
248
+ options: args.options,
249
+ status: 'queued',
250
+ createdAt: new Date().toISOString(),
251
+ };
252
+ jobs.set(id, job);
253
+ emit(job);
254
+
255
+ const controller = new AbortController();
256
+
257
+ const result = (async (): Promise<ExportResult> => {
258
+ const release = await semaphore.acquire();
259
+ // Started here, not at enqueue — the timeout bounds RENDER time (the
260
+ // intent), not queue-wait time. A job can legitimately sit `queued`
261
+ // behind MAUDE_EXPORT_MAX_CONCURRENT other jobs for longer than
262
+ // DEFAULT_JOB_TIMEOUT_MS; arming the timer before the semaphore
263
+ // resolves would abort it before it ever ran (code-review finding,
264
+ // /flow:done).
265
+ const timer = setTimeout(() => controller.abort(), DEFAULT_JOB_TIMEOUT_MS);
266
+ try {
267
+ job.status = 'running';
268
+ job.startedAt = new Date().toISOString();
269
+ emit(job);
270
+
271
+ const res = await runExport({
272
+ format: args.format,
273
+ scope: args.scope,
274
+ options: args.options,
275
+ resolve: args.resolve,
276
+ ctx: args.ctx,
277
+ hooks: {
278
+ signal: controller.signal,
279
+ onProgress: (update) => {
280
+ job.progress = update;
281
+ emit(job);
282
+ },
283
+ },
284
+ });
285
+
286
+ job.status = 'done';
287
+ job.finishedAt = new Date().toISOString();
288
+ job.filename = res.filename;
289
+ job.contentType = res.contentType;
290
+ if (res.body.byteLength) {
291
+ const dir = path.join(jobsDir, id);
292
+ await mkdir(dir, { recursive: true });
293
+ await Bun.write(path.join(dir, res.filename), res.body);
294
+ }
295
+ emit(job);
296
+ await persistAndEvict();
297
+ return res;
298
+ } catch (err) {
299
+ job.status = 'failed';
300
+ job.finishedAt = new Date().toISOString();
301
+ job.error = err instanceof Error ? err.message : String(err);
302
+ emit(job);
303
+ await persistAndEvict();
304
+ throw err;
305
+ } finally {
306
+ clearTimeout(timer);
307
+ release();
308
+ }
309
+ })();
310
+
311
+ return { id, result };
312
+ }
313
+
314
+ return {
315
+ enqueue,
316
+ get: (id) => jobs.get(id),
317
+ list: () => Array.from(jobs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
318
+ loadHistory: deriveHistory,
319
+ async getBytes(id) {
320
+ const job = jobs.get(id);
321
+ if (!job) return { ok: false, reason: 'missing' };
322
+ if (job.status !== 'done') return { ok: false, reason: 'not-done' };
323
+ if (!job.filename) return { ok: false, reason: 'missing' };
324
+ const file = Bun.file(path.join(jobsDir, id, job.filename));
325
+ if (!(await file.exists())) return { ok: false, reason: 'missing' };
326
+ return {
327
+ ok: true,
328
+ bytes: new Uint8Array(await file.arrayBuffer()),
329
+ filename: job.filename,
330
+ contentType: job.contentType ?? 'application/octet-stream',
331
+ };
332
+ },
333
+ };
334
+ }
@@ -13,10 +13,11 @@ import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
 
15
15
  import { PDFDocument } from 'pdf-lib';
16
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
16
+ import { exportShimPath, runShim } from './_runtime.ts';
17
17
  import {
18
18
  canvasShellUrl,
19
19
  type ExportContext,
20
+ type ExportHooks,
20
21
  type ExportOptions,
21
22
  type ExportResult,
22
23
  } from './index.ts';
@@ -29,7 +30,8 @@ async function capturePdf(
29
30
  target: Extract<Target, { kind: 'element' }>,
30
31
  ctx: ExportContext,
31
32
  outDir: string,
32
- timeoutSec: number
33
+ timeoutSec: number,
34
+ hooks?: ExportHooks
33
35
  ): Promise<string[]> {
34
36
  const args = [
35
37
  PDF_PLAYWRIGHT,
@@ -49,29 +51,18 @@ async function capturePdf(
49
51
  args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
50
52
  }
51
53
 
52
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
54
+ return runShim(args, {
53
55
  cwd: path.dirname(PDF_PLAYWRIGHT),
54
- stdout: 'pipe',
55
- stderr: 'pipe',
56
+ signal: hooks?.signal,
57
+ onProgress: hooks?.onProgress,
56
58
  });
57
- const [stdout, stderr] = await Promise.all([
58
- new Response(proc.stdout).text(),
59
- new Response(proc.stderr).text(),
60
- ]);
61
- const code = await proc.exited;
62
- if (code !== 0) {
63
- throw new Error(`_pdf-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
64
- }
65
- return stdout
66
- .split('\n')
67
- .map((s) => s.trim())
68
- .filter(Boolean);
69
59
  }
70
60
 
71
61
  export async function run(
72
62
  targets: Target[],
73
63
  options: ExportOptions,
74
- ctx: ExportContext
64
+ ctx: ExportContext,
65
+ hooks?: ExportHooks
75
66
  ): Promise<ExportResult> {
76
67
  if (!targets.length) {
77
68
  return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
@@ -86,9 +77,14 @@ export async function run(
86
77
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-'));
87
78
  try {
88
79
  const written: string[] = [];
89
- for (const t of elementTargets) {
90
- const paths = await capturePdf(t, ctx, tmp, timeoutSec);
80
+ for (let i = 0; i < elementTargets.length; i += 1) {
81
+ const paths = await capturePdf(elementTargets[i], ctx, tmp, timeoutSec, hooks);
91
82
  written.push(...paths);
83
+ // Outer-loop tick (N separate top-level Targets — rare). The granular
84
+ // "K of M artboards" signal for canvas-as-separate comes from the
85
+ // shim's own MAUDE_PROGRESS lines via spawnShim's onProgress inside
86
+ // capturePdf, above — both funnel into the same hooks.onProgress.
87
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
92
88
  }
93
89
  if (!written.length) {
94
90
  return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
@@ -12,10 +12,11 @@ import { tmpdir } from 'node:os';
12
12
  import path from 'node:path';
13
13
 
14
14
  import JSZip from 'jszip';
15
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
15
+ import { exportShimPath, runShim } from './_runtime.ts';
16
16
  import {
17
17
  canvasShellUrl,
18
18
  type ExportContext,
19
+ type ExportHooks,
19
20
  type ExportOptions,
20
21
  type ExportResult,
21
22
  } from './index.ts';
@@ -45,7 +46,8 @@ async function captureElement(
45
46
  target: Extract<Target, { kind: 'element' }>,
46
47
  ctx: ExportContext,
47
48
  outDir: string,
48
- options: CaptureOptions
49
+ options: CaptureOptions,
50
+ hooks?: ExportHooks
49
51
  ): Promise<string[]> {
50
52
  const args = [
51
53
  PNG_PLAYWRIGHT,
@@ -68,23 +70,11 @@ async function captureElement(
68
70
  if (target.widen) args.push('--widen-to-artboard', '1');
69
71
  args.push('--out', path.join(outDir, `${target.canvasSlug}.png`));
70
72
  }
71
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
73
+ return runShim(args, {
72
74
  cwd: path.dirname(PNG_PLAYWRIGHT),
73
- stdout: 'pipe',
74
- stderr: 'pipe',
75
+ signal: hooks?.signal,
76
+ onProgress: hooks?.onProgress,
75
77
  });
76
- const [stdout, stderr] = await Promise.all([
77
- new Response(proc.stdout).text(),
78
- new Response(proc.stderr).text(),
79
- ]);
80
- const code = await proc.exited;
81
- if (code !== 0) {
82
- throw new Error(`_png-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
83
- }
84
- return stdout
85
- .split('\n')
86
- .map((s) => s.trim())
87
- .filter(Boolean);
88
78
  }
89
79
 
90
80
  function readBytes(paths: string[]): Array<{ name: string; bytes: Uint8Array }> {
@@ -105,7 +95,8 @@ async function bundleZip(entries: Array<{ name: string; bytes: Uint8Array }>): P
105
95
  export async function run(
106
96
  targets: Target[],
107
97
  options: ExportOptions,
108
- ctx: ExportContext
98
+ ctx: ExportContext,
99
+ hooks?: ExportHooks
109
100
  ): Promise<ExportResult> {
110
101
  if (!targets.length) {
111
102
  return { filename: 'export.png', contentType: 'image/png', body: new Uint8Array(0) };
@@ -128,9 +119,10 @@ export async function run(
128
119
 
129
120
  try {
130
121
  const written: string[] = [];
131
- for (const t of elementTargets) {
132
- const paths = await captureElement(t, ctx, tmp, captureOpts);
122
+ for (let i = 0; i < elementTargets.length; i += 1) {
123
+ const paths = await captureElement(elementTargets[i], ctx, tmp, captureOpts, hooks);
133
124
  written.push(...paths);
125
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
134
126
  }
135
127
  const entries = readBytes(written);
136
128
 
@@ -27,10 +27,11 @@ import path from 'node:path';
27
27
  import PptxGenJS from 'pptxgenjs';
28
28
 
29
29
  import { getBrowserBundle } from './_browser-bundles.ts';
30
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
30
+ import { exportShimPath, runShim } from './_runtime.ts';
31
31
  import {
32
32
  canvasShellUrl,
33
33
  type ExportContext,
34
+ type ExportHooks,
34
35
  type ExportOptions,
35
36
  type ExportResult,
36
37
  } from './index.ts';
@@ -51,25 +52,13 @@ function isElementTarget(t: Target): t is ElementTarget {
51
52
  return t.kind === 'element';
52
53
  }
53
54
 
54
- /** Spawn a playwright shim via a resolved runtime, returning the written file paths (stdout). */
55
- async function spawnShim(args: string[]): Promise<string[]> {
56
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
55
+ /** Spawn a playwright shim via the shared runtime, returning the written file paths (stdout). */
56
+ function spawnPlaywrightShim(args: string[], hooks?: ExportHooks): Promise<string[]> {
57
+ return runShim(args, {
57
58
  cwd: path.dirname(args[0]),
58
- stdout: 'pipe',
59
- stderr: 'pipe',
59
+ signal: hooks?.signal,
60
+ onProgress: hooks?.onProgress,
60
61
  });
61
- const [stdout, stderr] = await Promise.all([
62
- new Response(proc.stdout).text(),
63
- new Response(proc.stderr).text(),
64
- ]);
65
- const code = await proc.exited;
66
- if (code !== 0) {
67
- throw new Error(`${path.basename(args[0])} exited ${code}: ${stderr.trim() || stdout.trim()}`);
68
- }
69
- return stdout
70
- .split('\n')
71
- .map((s) => s.trim())
72
- .filter(Boolean);
73
62
  }
74
63
 
75
64
  // ─── SVG pre-processing (the fix that makes svg2pptx faithful) ────────────────
@@ -324,7 +313,8 @@ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
324
313
  export async function run(
325
314
  targets: Target[],
326
315
  options: ExportOptions,
327
- ctx: ExportContext
316
+ ctx: ExportContext,
317
+ hooks?: ExportHooks
328
318
  ): Promise<ExportResult> {
329
319
  const empty: ExportResult = {
330
320
  filename: 'export.pptx',
@@ -352,10 +342,15 @@ export async function run(
352
342
  try {
353
343
  const bundle = await getBrowserBundle('dom-to-svg', 'domToSvg');
354
344
  const svgFiles: string[] = [];
355
- for (const t of elementTargets) {
345
+ for (let i = 0; i < elementTargets.length; i += 1) {
346
+ const t = elementTargets[i];
356
347
  svgFiles.push(
357
- ...(await spawnShim(svgArgsFor(t, canvasShellUrl(ctx, t.file), svgDir, bundle)))
348
+ ...(await spawnPlaywrightShim(
349
+ svgArgsFor(t, canvasShellUrl(ctx, t.file), svgDir, bundle),
350
+ hooks
351
+ ))
358
352
  );
353
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
359
354
  }
360
355
  if (!svgFiles.length) return empty;
361
356
  const deckBuffers: Uint8Array[] = [];
@@ -382,8 +377,12 @@ export async function run(
382
377
 
383
378
  // PNG fallback (faithful, universal, Canva-safe, not editable).
384
379
  const pngFiles: string[] = [];
385
- for (const t of elementTargets) {
386
- pngFiles.push(...(await spawnShim(pngArgsFor(t, canvasShellUrl(ctx, t.file), pngDir))));
380
+ for (let i = 0; i < elementTargets.length; i += 1) {
381
+ const t = elementTargets[i];
382
+ pngFiles.push(
383
+ ...(await spawnPlaywrightShim(pngArgsFor(t, canvasShellUrl(ctx, t.file), pngDir), hooks))
384
+ );
385
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
387
386
  }
388
387
  if (!pngFiles.length) return empty;
389
388
  const body = await buildPngDeck(pngFiles);
@@ -115,6 +115,7 @@ const RAW_EXCLUDES = new Set([
115
115
  '_server.json',
116
116
  '_active.json',
117
117
  '_export-history.json',
118
+ '_export-jobs',
118
119
  '_history',
119
120
  '_comments',
120
121
  '_canvas-state',