@1agh/maude 0.41.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 (39) hide show
  1. package/apps/studio/api.ts +0 -41
  2. package/apps/studio/bin/_html-playwright.mjs +26 -4
  3. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  4. package/apps/studio/bin/_png-playwright.mjs +15 -2
  5. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  6. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  7. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  8. package/apps/studio/bin/screenshot.sh +53 -4
  9. package/apps/studio/client/app.jsx +25 -54
  10. package/apps/studio/client/export-center.jsx +426 -0
  11. package/apps/studio/client/styles/3-shell-maude.css +17 -0
  12. package/apps/studio/client/styles/4-components.css +150 -0
  13. package/apps/studio/config.schema.json +2 -2
  14. package/apps/studio/dist/client.bundle.js +17 -17
  15. package/apps/studio/dist/styles.css +1 -1
  16. package/apps/studio/export-dialog.tsx +18 -25
  17. package/apps/studio/exporters/_runtime.ts +104 -0
  18. package/apps/studio/exporters/html.ts +12 -20
  19. package/apps/studio/exporters/index.ts +14 -2
  20. package/apps/studio/exporters/jobs.ts +334 -0
  21. package/apps/studio/exporters/pdf.ts +16 -20
  22. package/apps/studio/exporters/png.ts +12 -20
  23. package/apps/studio/exporters/pptx.ts +22 -23
  24. package/apps/studio/exporters/scope.ts +1 -0
  25. package/apps/studio/exporters/svg.ts +14 -22
  26. package/apps/studio/exporters/video.ts +15 -17
  27. package/apps/studio/git/service.ts +3 -1
  28. package/apps/studio/http.ts +145 -50
  29. package/apps/studio/server.ts +3 -1
  30. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  31. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  32. package/apps/studio/test/export-center.test.tsx +287 -0
  33. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  34. package/apps/studio/test/exporters/history.test.ts +32 -3
  35. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  36. package/apps/studio/whats-new.json +17 -0
  37. package/apps/studio/ws.ts +6 -0
  38. package/cli/lib/gitignore-block.mjs +1 -0
  39. package/package.json +8 -8
@@ -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',
@@ -13,10 +13,11 @@ import path from 'node:path';
13
13
  import JSZip from 'jszip';
14
14
 
15
15
  import { getBrowserBundle } from './_browser-bundles.ts';
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';
@@ -30,7 +31,8 @@ async function captureSvg(
30
31
  ctx: ExportContext,
31
32
  outDir: string,
32
33
  timeoutSec: number,
33
- bundlePath: string
34
+ bundlePath: string,
35
+ hooks?: ExportHooks
34
36
  ): Promise<string[]> {
35
37
  const args = [
36
38
  SVG_PLAYWRIGHT,
@@ -55,31 +57,20 @@ async function captureSvg(
55
57
  // Run via a resolved node/bun runtime so the shim's `import 'playwright'`
56
58
  // resolves against dev-server/node_modules (playwright is a devDep). `npm exec`
57
59
  // doesn't bridge the module path for ESM imports — confirmed against npm 10.x.
58
- // resolveExportRuntime() replaces a hardcoded `'node'` so a compiled binary
59
- // without `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
60
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
60
+ // runShim resolves the runtime internally so a compiled binary without
61
+ // `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
62
+ return runShim(args, {
61
63
  cwd: path.dirname(SVG_PLAYWRIGHT),
62
- stdout: 'pipe',
63
- stderr: 'pipe',
64
+ signal: hooks?.signal,
65
+ onProgress: hooks?.onProgress,
64
66
  });
65
- const [stdout, stderr] = await Promise.all([
66
- new Response(proc.stdout).text(),
67
- new Response(proc.stderr).text(),
68
- ]);
69
- const code = await proc.exited;
70
- if (code !== 0) {
71
- throw new Error(`_svg-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
72
- }
73
- return stdout
74
- .split('\n')
75
- .map((s) => s.trim())
76
- .filter(Boolean);
77
67
  }
78
68
 
79
69
  export async function run(
80
70
  targets: Target[],
81
71
  options: ExportOptions,
82
- ctx: ExportContext
72
+ ctx: ExportContext,
73
+ hooks?: ExportHooks
83
74
  ): Promise<ExportResult> {
84
75
  if (!targets.length) {
85
76
  return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
@@ -96,9 +87,10 @@ export async function run(
96
87
 
97
88
  try {
98
89
  const written: string[] = [];
99
- for (const t of elementTargets) {
100
- const paths = await captureSvg(t, ctx, tmp, timeoutSec, bundlePath);
90
+ for (let i = 0; i < elementTargets.length; i += 1) {
91
+ const paths = await captureSvg(elementTargets[i], ctx, tmp, timeoutSec, bundlePath, hooks);
101
92
  written.push(...paths);
93
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
102
94
  }
103
95
  if (!written.length) {
104
96
  return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
@@ -20,10 +20,11 @@ import { tmpdir } from 'node:os';
20
20
  import path from 'node:path';
21
21
 
22
22
  import { getEncodeLibBundle, getWebRendererBundle } from './_browser-bundles.ts';
23
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
23
+ import { exportShimPath, runShim } from './_runtime.ts';
24
24
  import {
25
25
  canvasShellUrl,
26
26
  type ExportContext,
27
+ type ExportHooks,
27
28
  type ExportOptions,
28
29
  type ExportResult,
29
30
  } from './index.ts';
@@ -47,7 +48,8 @@ async function runVideo(
47
48
  format: VideoFormat,
48
49
  targets: Target[],
49
50
  options: ExportOptions,
50
- ctx: ExportContext
51
+ ctx: ExportContext,
52
+ hooks?: ExportHooks
51
53
  ): Promise<ExportResult> {
52
54
  const el = targets.find((t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element');
53
55
  if (!el) {
@@ -119,25 +121,18 @@ async function runVideo(
119
121
  }
120
122
 
121
123
  try {
122
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
124
+ const stdoutLines = await runShim(args, {
123
125
  cwd: path.dirname(VIDEO_PLAYWRIGHT),
124
- stdout: 'pipe',
125
- stderr: 'pipe',
126
+ signal: hooks?.signal,
127
+ // No MAUDE_PROGRESS line from this shim yet — frame-count progress is a
128
+ // follow-up (video scope is always a single artboard, never multi).
126
129
  });
127
- const [stdout, stderr] = await Promise.all([
128
- new Response(proc.stdout).text(),
129
- new Response(proc.stderr).text(),
130
- ]);
131
- const code = await proc.exited;
132
- if (code !== 0) {
133
- throw new Error(`_video-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
134
- }
135
130
  const body = new Uint8Array(readFileSync(outPath));
136
131
  // The container can differ from the request (mp4 → webm fallback when the
137
132
  // browser has no H.264 encoder). Read the summary line for the real ext.
138
133
  let ext: string = format;
139
134
  try {
140
- const lastLine = stdout.trim().split('\n').filter(Boolean).pop() ?? '{}';
135
+ const lastLine = stdoutLines.at(-1) ?? '{}';
141
136
  const summary = JSON.parse(lastLine) as { container?: string };
142
137
  if (summary.container) ext = summary.container;
143
138
  } catch {
@@ -174,11 +169,14 @@ function resolveFrames(options: ExportOptions, fps: number | undefined): number
174
169
  }
175
170
 
176
171
  export const mp4 = {
177
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('mp4', t, o, c),
172
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
173
+ runVideo('mp4', t, o, c, h),
178
174
  };
179
175
  export const webm = {
180
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('webm', t, o, c),
176
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
177
+ runVideo('webm', t, o, c, h),
181
178
  };
182
179
  export const gif = {
183
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('gif', t, o, c),
180
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
181
+ runVideo('gif', t, o, c, h),
184
182
  };
@@ -211,7 +211,9 @@ function isMaudeRuntimeState(p: string): boolean {
211
211
  return (
212
212
  /(^|\/)_(?:server|active|sync|preflight|locator|export-history)\.json$/.test(p) ||
213
213
  /(^|\/)_server\.(?:lock|log)$/.test(p) ||
214
- /(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted)(?:\/|$)/.test(p)
214
+ /(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
215
+ p
216
+ )
215
217
  );
216
218
  }
217
219
 
@@ -16,7 +16,8 @@ import { canvasLibPath } from './canvas-lib-resolver.ts';
16
16
  import { TranspileError } from './canvas-pipeline.ts';
17
17
  import type { AiActivity } from './collab/ai-activity.ts';
18
18
  import type { Context } from './context.ts';
19
- import { isFormat, isScope, runExport } from './exporters/index.ts';
19
+ import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
20
+ import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
20
21
  import type { ActiveJsonShape } from './exporters/scope.ts';
21
22
  import { createGitEndpoints } from './git/endpoints.ts';
22
23
  import { gitShowFile } from './git/service.ts';
@@ -594,7 +595,13 @@ export interface Http {
594
595
  isCanvasSafeRoute(pathname: string): boolean;
595
596
  }
596
597
 
597
- export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActivity): Http {
598
+ export function createHttp(
599
+ ctx: Context,
600
+ api: Api,
601
+ inspect: Inspect,
602
+ ai: AiActivity,
603
+ exportJobs: ExportJobQueue
604
+ ): Http {
598
605
  // Cache invalidation — when canvas-lib changes, every cached canvas bundle
599
606
  // is stale because canvas-lib is inlined into each one via the resolver
600
607
  // plugin. Drop the whole cache so the next request rebuilds with the fresh
@@ -722,6 +729,38 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
722
729
  const gitJson = (r: { status: number; json: unknown }) =>
723
730
  Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
724
731
 
732
+ // Shared by /_api/export and /_api/export-jobs — build the exportJobs.enqueue()
733
+ // args from a validated request body. `inspect.state` is the live `_active.json`;
734
+ // readers narrow to the resolver's subset locally so the export pipeline doesn't
735
+ // pin the wider ActiveState interface.
736
+ function buildExportArgs(
737
+ req: Request,
738
+ body: { format: Format; scope: Scope; options?: Record<string, unknown> }
739
+ ) {
740
+ const activeJson = inspect.state as unknown as ActiveJsonShape;
741
+ return {
742
+ format: body.format,
743
+ scope: body.scope,
744
+ options: body.options ?? {},
745
+ resolve: { activeJson, designRoot: ctx.paths.designRoot, repoRoot: ctx.paths.repoRoot },
746
+ ctx: {
747
+ designRoot: ctx.paths.designRoot,
748
+ repoRoot: ctx.paths.repoRoot,
749
+ // Adapters reach back into the server via this origin only when they
750
+ // need Playwright rendering (PNG / PDF / SVG / HTML). The host that
751
+ // received this request is, by definition, the one serving the canvas.
752
+ serverOrigin: new URL(req.url).origin,
753
+ // Mirror `client/app.jsx:85` — the per-DS tokensCssRel wins over the
754
+ // legacy top-level default (which still points at the pre-multi-DS
755
+ // layout `system/colors_and_type.css`). Without the per-DS path, the
756
+ // standalone `_canvas-shell.html` 404s on the tokens link and the
757
+ // rendered DOM uses `var(--bg-0)` unresolved → screenshots come out
758
+ // blank. See canvasShellUrl().
759
+ tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
760
+ },
761
+ };
762
+ }
763
+
725
764
  const routes = {
726
765
  '/_health': () =>
727
766
  Response.json({
@@ -2041,17 +2080,28 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
2041
2080
 
2042
2081
  '/_api/export-history': async (req: Request) => {
2043
2082
  // Phase 6.5 T10 — read-only recent-exports feed for the dialog's
2044
- // Recent tab. Writes happen as a side-effect of `/_api/export`.
2083
+ // Recent tab. Writes happen as a side-effect of job completion
2084
+ // (exporters/jobs.ts persistAndEvict) rather than this handler.
2045
2085
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2046
- const history = await api.loadExportHistory();
2086
+ const history = exportJobs.loadHistory();
2047
2087
  return Response.json({ history }, { headers: { 'Cache-Control': 'no-store' } });
2048
2088
  },
2049
2089
 
2050
2090
  '/_api/export': async (req: Request) => {
2051
- // Phase 6.5 single dispatch endpoint for the export pipeline.
2052
- // POST body { format, scope, options? } binary stream with
2053
- // Content-Disposition + Content-Type set by the adapter.
2091
+ // feature-background-export-notification-centerthin wrapper over the
2092
+ // job queue: enqueue() then await the SAME job's result. Byte-for-byte
2093
+ // identical external contract to the old synchronous handler, so
2094
+ // `/design:export` (CLI) and any other blocking caller need zero changes.
2095
+ // sameOriginWrite CSRF + loopback-Host (DNS-rebinding) gated, matching
2096
+ // the write-route convention elsewhere in this file (e.g.
2097
+ // /_api/delete-element) — closed as part of the /flow:done security
2098
+ // fan-out (defender finding: this POST now has a disk-write + queue-
2099
+ // growth consequence a bare cross-origin form-POST could trigger).
2054
2100
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2101
+ if (!sameOriginWrite(req))
2102
+ return new Response('cross-origin write rejected', { status: 403 });
2103
+ if (!isLoopbackHost(req.headers.get('host')))
2104
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2055
2105
  const body = await readJson<{
2056
2106
  format?: unknown;
2057
2107
  scope?: unknown;
@@ -2060,63 +2110,108 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
2060
2110
  if (!body) return new Response('body required', { status: 400 });
2061
2111
  if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
2062
2112
  if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
2063
- // `inspect.state` is the live `_active.json` — readers narrow to the
2064
- // resolver's subset locally so the export pipeline doesn't pin the
2065
- // wider ActiveState interface.
2066
- const activeJson = inspect.state as unknown as ActiveJsonShape;
2113
+ const format = body.format;
2114
+ const scope = body.scope;
2067
2115
  try {
2068
- const result = await runExport({
2069
- format: body.format,
2070
- scope: body.scope,
2071
- options: body.options ?? {},
2072
- resolve: { activeJson, designRoot: ctx.paths.designRoot, repoRoot: ctx.paths.repoRoot },
2073
- ctx: {
2074
- designRoot: ctx.paths.designRoot,
2075
- repoRoot: ctx.paths.repoRoot,
2076
- // Adapters reach back into the server via this origin only when
2077
- // they need Playwright rendering (PNG / PDF / SVG / HTML). The
2078
- // host that received this request is, by definition, the one
2079
- // serving the canvas.
2080
- serverOrigin: new URL(req.url).origin,
2081
- // Mirror `client/app.jsx:85` — the per-DS tokensCssRel wins over
2082
- // the legacy top-level default (which still points at the pre-
2083
- // multi-DS layout `system/colors_and_type.css`). Without the
2084
- // per-DS path, the standalone `_canvas-shell.html` 404s on the
2085
- // tokens link and the rendered DOM uses `var(--bg-0)` unresolved
2086
- // → screenshots come out blank. See canvasShellUrl().
2087
- tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
2088
- },
2089
- });
2090
- // Fire-and-forget history append — failure here doesn't block the
2091
- // download. Synchronous await keeps the order: history reflects the
2092
- // export the moment the client sees a 200.
2093
- try {
2094
- await api.appendExportHistory({
2095
- format: body.format,
2096
- scope: body.scope,
2097
- options: body.options ?? {},
2098
- filename: result.filename,
2099
- at: new Date().toISOString(),
2100
- });
2101
- } catch {
2102
- /* ignore — history is best-effort */
2103
- }
2116
+ const { result } = exportJobs.enqueue(
2117
+ buildExportArgs(req, { format, scope, options: body.options })
2118
+ );
2119
+ const finished = await result;
2104
2120
  // Bun.serve accepts Uint8Array directly; the cast satisfies the
2105
2121
  // SharedArrayBuffer-strict BodyInit narrowing on @types/bun.
2106
- return new Response(result.body as unknown as BodyInit, {
2122
+ return new Response(finished.body as unknown as BodyInit, {
2107
2123
  status: 200,
2108
2124
  headers: {
2109
- 'Content-Type': result.contentType,
2110
- 'Content-Disposition': `attachment; filename="${result.filename}"`,
2125
+ 'Content-Type': finished.contentType,
2126
+ 'Content-Disposition': `attachment; filename="${finished.filename}"`,
2111
2127
  'Cache-Control': 'no-store',
2112
2128
  },
2113
2129
  });
2114
2130
  } catch (err) {
2131
+ if (err instanceof ExportQueueFullError) {
2132
+ return new Response(err.message, { status: 429 });
2133
+ }
2115
2134
  const msg = err instanceof Error ? err.message : String(err);
2116
2135
  return new Response(`export failed: ${msg}`, { status: 500 });
2117
2136
  }
2118
2137
  },
2119
2138
 
2139
+ '/_api/export-jobs': async (req: Request) => {
2140
+ // feature-background-export-notification-center — the non-blocking
2141
+ // sibling of /_api/export: same body, returns 202 { jobId } immediately
2142
+ // without awaiting the render. MAIN-ORIGIN ONLY (absent from
2143
+ // CANVAS_SAFE_API + startCanvasServer routes, same trust boundary as
2144
+ // /_api/export today — DDR-060). loopback-Host gated on every method;
2145
+ // the mutating POST is additionally sameOriginWrite CSRF gated (same
2146
+ // /flow:done security fan-out fix as /_api/export above).
2147
+ if (!isLoopbackHost(req.headers.get('host')))
2148
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2149
+ if (req.method === 'GET') {
2150
+ return Response.json(
2151
+ { jobs: exportJobs.list() },
2152
+ { headers: { 'Cache-Control': 'no-store' } }
2153
+ );
2154
+ }
2155
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2156
+ if (!sameOriginWrite(req))
2157
+ return new Response('cross-origin write rejected', { status: 403 });
2158
+ const body = await readJson<{
2159
+ format?: unknown;
2160
+ scope?: unknown;
2161
+ options?: Record<string, unknown>;
2162
+ }>(req, 64 * 1024);
2163
+ if (!body) return new Response('body required', { status: 400 });
2164
+ if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
2165
+ if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
2166
+ try {
2167
+ const { id, result } = exportJobs.enqueue(
2168
+ buildExportArgs(req, { format: body.format, scope: body.scope, options: body.options })
2169
+ );
2170
+ // This route deliberately doesn't await the render — the job's own
2171
+ // status (surfaced via GET /_api/export-jobs + the export:job WS
2172
+ // push) is the completion signal. A render failure still needs a
2173
+ // handler here or it's an unhandled rejection on every failed/timed-
2174
+ // out background job (security fan-out finding, /flow:done) — the
2175
+ // failure is already recorded on the job record, so this is a no-op.
2176
+ result.catch(() => {});
2177
+ return Response.json(
2178
+ { jobId: id },
2179
+ { status: 202, headers: { 'Cache-Control': 'no-store' } }
2180
+ );
2181
+ } catch (err) {
2182
+ if (err instanceof ExportQueueFullError) {
2183
+ return new Response(err.message, { status: 429 });
2184
+ }
2185
+ throw err;
2186
+ }
2187
+ },
2188
+
2189
+ '/_api/export-jobs/download': async (req: Request) => {
2190
+ // MAIN-ORIGIN ONLY, same boundary as /_api/export-jobs above.
2191
+ // loopback-Host gated (read-only — no sameOriginWrite needed — but an
2192
+ // unguessable job-scoped UUID plus this guard closes the DNS-rebinding
2193
+ // angle the /flow:done security fan-out checked for).
2194
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2195
+ if (!isLoopbackHost(req.headers.get('host')))
2196
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2197
+ const id = new URL(req.url).searchParams.get('id');
2198
+ if (!id) return new Response('id query param required', { status: 400 });
2199
+ const dl = await exportJobs.getBytes(id);
2200
+ if (!dl.ok) {
2201
+ return new Response(dl.reason === 'not-done' ? 'job not finished' : 'Not found', {
2202
+ status: dl.reason === 'not-done' ? 409 : 404,
2203
+ });
2204
+ }
2205
+ return new Response(dl.bytes as unknown as BodyInit, {
2206
+ status: 200,
2207
+ headers: {
2208
+ 'Content-Type': dl.contentType,
2209
+ 'Content-Disposition': `attachment; filename="${dl.filename}"`,
2210
+ 'Cache-Control': 'no-store',
2211
+ },
2212
+ });
2213
+ },
2214
+
2120
2215
  '/_canvas-state': async (req: Request) => {
2121
2216
  const url = new URL(req.url);
2122
2217
  if (req.method === 'GET') {
@@ -25,6 +25,7 @@ import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts'
25
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
26
26
  import { createCollab } from './collab/index.ts';
27
27
  import { createContext, reloadConfig } from './context.ts';
28
+ import { createExportJobQueue } from './exporters/jobs.ts';
28
29
  import { createFsWatch } from './fs-watch.ts';
29
30
  import { createGitWatch } from './git/watch.ts';
30
31
  import { createHttp } from './http.ts';
@@ -105,7 +106,8 @@ const activity = createActivity(ctx);
105
106
  // rca/issue-canvas-hmr-optimistic-update-consistency).
106
107
  const acp = createAcp(ctx, aiActivity);
107
108
  const ws = createWs(ctx, api, inspect, collab, activity, acp);
108
- const http = createHttp(ctx, api, inspect, aiActivity);
109
+ const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
110
+ const http = createHttp(ctx, api, inspect, aiActivity, exportJobs);
109
111
  const fsWatch = createFsWatch(ctx);
110
112
 
111
113
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.