@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.
- package/apps/studio/api.ts +0 -41
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/client/app.jsx +25 -54
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/styles/3-shell-maude.css +17 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/dist/client.bundle.js +17 -17
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/export-dialog.tsx +18 -25
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +145 -50
- package/apps/studio/server.ts +3 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/whats-new.json +17 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- 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,
|
|
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
|
-
|
|
54
|
+
return runShim(args, {
|
|
53
55
|
cwd: path.dirname(PDF_PLAYWRIGHT),
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
90
|
-
const paths = await capturePdf(
|
|
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,
|
|
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
|
-
|
|
73
|
+
return runShim(args, {
|
|
72
74
|
cwd: path.dirname(PNG_PLAYWRIGHT),
|
|
73
|
-
|
|
74
|
-
|
|
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 (
|
|
132
|
-
const paths = await captureElement(
|
|
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,
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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 (
|
|
345
|
+
for (let i = 0; i < elementTargets.length; i += 1) {
|
|
346
|
+
const t = elementTargets[i];
|
|
356
347
|
svgFiles.push(
|
|
357
|
-
...(await
|
|
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 (
|
|
386
|
-
|
|
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);
|
|
@@ -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,
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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 (
|
|
100
|
-
const paths = await captureSvg(
|
|
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,
|
|
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
|
|
124
|
+
const stdoutLines = await runShim(args, {
|
|
123
125
|
cwd: path.dirname(VIDEO_PLAYWRIGHT),
|
|
124
|
-
|
|
125
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
214
|
+
/(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
|
|
215
|
+
p
|
|
216
|
+
)
|
|
215
217
|
);
|
|
216
218
|
}
|
|
217
219
|
|
package/apps/studio/http.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
2052
|
-
//
|
|
2053
|
-
//
|
|
2091
|
+
// feature-background-export-notification-center — thin 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
|
-
|
|
2064
|
-
|
|
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 =
|
|
2069
|
-
format: body.
|
|
2070
|
-
|
|
2071
|
-
|
|
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(
|
|
2122
|
+
return new Response(finished.body as unknown as BodyInit, {
|
|
2107
2123
|
status: 200,
|
|
2108
2124
|
headers: {
|
|
2109
|
-
'Content-Type':
|
|
2110
|
-
'Content-Disposition': `attachment; 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') {
|
package/apps/studio/server.ts
CHANGED
|
@@ -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
|
|
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.
|