@1agh/maude 1.0.7 → 1.0.9
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/bin/_html-playwright.mjs +198 -19
- package/apps/studio/bin/_pdf-playwright.mjs +1 -0
- package/apps/studio/bin/_png-playwright.mjs +23 -6
- package/apps/studio/bin/_pptx-playwright.mjs +4 -1
- package/apps/studio/bin/_pw-launch.mjs +48 -0
- package/apps/studio/bin/_svg-playwright.mjs +45 -12
- package/apps/studio/bin/_video-playwright.mjs +135 -12
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +7 -0
- package/apps/studio/client/app.jsx +84 -23
- package/apps/studio/client/export-lane.js +37 -2
- package/apps/studio/dist/client.bundle.js +601 -601
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +659 -3
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +190 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/remote.ts +73 -4
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +74 -7
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
- package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
- package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
- package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
- package/apps/studio/test/exporters/jobs.test.ts +73 -0
- package/apps/studio/test/exporters/scope.test.ts +33 -0
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// format-scopes.ts — which export scopes a format can actually render.
|
|
2
|
+
//
|
|
3
|
+
// DDR-231 Phase 2 T4. This map existed in THREE places and was checked in
|
|
4
|
+
// none of them: `client/app.jsx` (`EXPORT_VALID_SCOPES`, the shell dialog),
|
|
5
|
+
// `export-dialog.tsx` (`VALID_SCOPES_PER_FORMAT`, the in-canvas dialog) — and
|
|
6
|
+
// nowhere on the server, which accepted whatever pair a client sent.
|
|
7
|
+
//
|
|
8
|
+
// That gap is the "PDF nefunguje vubec — invalid render job" the first live
|
|
9
|
+
// cloud export hit, reproduced locally: `POST /_api/export-jobs
|
|
10
|
+
// {format:'pdf', scope:'project-raw'}` is accepted, `resolveScope` returns a
|
|
11
|
+
// `file-tree` target (correct for that scope — it IS the whole project), and
|
|
12
|
+
// the render service's `validBody` rejects it with an opaque `invalid render
|
|
13
|
+
// job` because a file-tree target names repo paths that process must never
|
|
14
|
+
// read. Every layer behaved as specified; the pair was never legal, and
|
|
15
|
+
// nothing said so.
|
|
16
|
+
//
|
|
17
|
+
// The two dialogs keep the scope pickers honest for a user who is looking;
|
|
18
|
+
// this module is what makes the pair unrepresentable for one who is not (a
|
|
19
|
+
// stale scope carried across a format switch, a re-run of a history entry, a
|
|
20
|
+
// context-menu hint, a direct POST).
|
|
21
|
+
//
|
|
22
|
+
// PURE DATA, NO IMPORTS — `http.ts` (Bun), `apps/render/server.ts` (Bun) and
|
|
23
|
+
// both browser bundles all consume it, so it must not reach for `node:*`.
|
|
24
|
+
|
|
25
|
+
/** Every format the export pipeline knows. Mirrors `index.ts`'s `Format`. */
|
|
26
|
+
export type ExportFormatName =
|
|
27
|
+
| 'png'
|
|
28
|
+
| 'pdf'
|
|
29
|
+
| 'svg'
|
|
30
|
+
| 'html'
|
|
31
|
+
| 'pptx'
|
|
32
|
+
| 'canva'
|
|
33
|
+
| 'zip'
|
|
34
|
+
| 'mp4'
|
|
35
|
+
| 'webm'
|
|
36
|
+
| 'gif';
|
|
37
|
+
|
|
38
|
+
/** Mirrors `scope.ts`'s `Scope`. */
|
|
39
|
+
export type ExportScopeName = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Scopes each format accepts, in the order a picker should offer them — the
|
|
43
|
+
* FIRST entry is that format's default.
|
|
44
|
+
*
|
|
45
|
+
* The shape rules behind the table:
|
|
46
|
+
* - `project-raw` resolves to a `file-tree` target, which only `zip`
|
|
47
|
+
* consumes; every rendering format must therefore exclude it.
|
|
48
|
+
* - `pptx` is a deck: one slide per artboard, so canvas-wide only.
|
|
49
|
+
* - video renders one temporal artboard, so `artboard` only.
|
|
50
|
+
* - `html` has no meaningful "selection" unit (it emits a page per artboard).
|
|
51
|
+
*/
|
|
52
|
+
export const VALID_SCOPES_BY_FORMAT: Readonly<
|
|
53
|
+
Record<ExportFormatName, readonly ExportScopeName[]>
|
|
54
|
+
> = Object.freeze({
|
|
55
|
+
png: ['selection', 'artboard', 'canvas-as-separate'],
|
|
56
|
+
pdf: ['selection', 'artboard', 'canvas-as-separate'],
|
|
57
|
+
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
58
|
+
html: ['artboard', 'canvas-as-separate'],
|
|
59
|
+
pptx: ['canvas-as-separate'],
|
|
60
|
+
canva: ['canvas-as-separate'],
|
|
61
|
+
zip: ['project-raw'],
|
|
62
|
+
mp4: ['artboard'],
|
|
63
|
+
webm: ['artboard'],
|
|
64
|
+
gif: ['artboard'],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** The scope a picker should select when the format changes. */
|
|
68
|
+
export function defaultScopeForFormat(format: string): ExportScopeName {
|
|
69
|
+
return VALID_SCOPES_BY_FORMAT[format as ExportFormatName]?.[0] ?? 'artboard';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validScopesForFormat(format: string): readonly ExportScopeName[] {
|
|
73
|
+
return VALID_SCOPES_BY_FORMAT[format as ExportFormatName] ?? ['artboard'];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isScopeValidForFormat(format: string, scope: string): boolean {
|
|
77
|
+
return validScopesForFormat(format).includes(scope as ExportScopeName);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The refusal a caller sees for an incoherent pair. Written for a person and
|
|
82
|
+
* naming the way out, because it surfaces in the export dialog — the old
|
|
83
|
+
* `invalid render job` named neither the field nor the remedy.
|
|
84
|
+
*/
|
|
85
|
+
export function scopeRefusalMessage(format: string, scope: string): string {
|
|
86
|
+
const offered = validScopesForFormat(format).join(', ');
|
|
87
|
+
return `${format.toUpperCase()} can't export the "${scope}" scope — it supports: ${offered}.`;
|
|
88
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Phase 6.5 T5 — HTML adapter (standalone bundler).
|
|
2
2
|
//
|
|
3
3
|
// Renders the target through Playwright, emits a self-contained `index.html`
|
|
4
|
-
// with all stylesheets inlined
|
|
5
|
-
//
|
|
4
|
+
// with all stylesheets inlined and every asset (images, fonts, CSS url()s,
|
|
5
|
+
// SVG defs) embedded as data: URIs — see bin/_html-playwright.mjs. Output is
|
|
6
|
+
// always a ZIP per
|
|
6
7
|
// plan T5 ("Always zipped (multi-file)"), even for a single artboard,
|
|
7
8
|
// because the consuming workflow typically wants one bundle per export.
|
|
8
9
|
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
// pair had). The ledger is seeded from disk once at construction so history
|
|
24
24
|
// survives a server restart even though job state itself doesn't.
|
|
25
25
|
|
|
26
|
-
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
27
27
|
import { mkdir, rm } from 'node:fs/promises';
|
|
28
28
|
import path from 'node:path';
|
|
29
|
+
import { readAllArtboardPrintProps } from '../canvas-edit.ts';
|
|
29
30
|
|
|
30
31
|
import type { Bus } from '../context.ts';
|
|
31
32
|
import { resolveRenderLane } from '../workspace-mode.ts';
|
|
@@ -47,7 +48,8 @@ import {
|
|
|
47
48
|
renderRemotely,
|
|
48
49
|
resolveRenderService,
|
|
49
50
|
} from './remote.ts';
|
|
50
|
-
import { type ResolveScopeArgs, resolveScope } from './scope.ts';
|
|
51
|
+
import { type ResolveScopeArgs, resolveScope, type Target } from './scope.ts';
|
|
52
|
+
import { scanUnsupportedMedia } from './unsupported-media.ts';
|
|
51
53
|
|
|
52
54
|
/** Extended shape of the old Phase 6.5 T10 history entry — additive fields only. */
|
|
53
55
|
export interface ExportHistoryEntry {
|
|
@@ -63,6 +65,13 @@ export interface ExportHistoryEntry {
|
|
|
63
65
|
error?: string;
|
|
64
66
|
/** Present when the export produced less than was asked for (e.g. muted mp4). */
|
|
65
67
|
degraded?: ExportDegradation;
|
|
68
|
+
/**
|
|
69
|
+
* DDR-231 Phase 2 T6 — the browser lane captured and saved this export in
|
|
70
|
+
* the member's OWN browser; the cell holds no bytes for it. Present so the
|
|
71
|
+
* Recent tab and the notification center can list it without offering a
|
|
72
|
+
* download that would 404.
|
|
73
|
+
*/
|
|
74
|
+
deliveredInBrowser?: boolean;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
export type ExportJobStatus = 'queued' | 'running' | 'done' | 'failed';
|
|
@@ -87,6 +96,9 @@ export interface ExportJob {
|
|
|
87
96
|
* RCA `issue-mp4-audio-export-html5audio-silent-degrade`.
|
|
88
97
|
*/
|
|
89
98
|
degraded?: ExportDegradation;
|
|
99
|
+
/** DDR-231 browser lane — produced and saved in the member's own browser;
|
|
100
|
+
* this process stores no bytes for it. See {@link ExportHistoryEntry}. */
|
|
101
|
+
deliveredInBrowser?: boolean;
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
export interface EnqueueArgs {
|
|
@@ -111,6 +123,12 @@ export type DownloadResult =
|
|
|
111
123
|
|
|
112
124
|
export interface ExportJobQueue {
|
|
113
125
|
enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> };
|
|
126
|
+
/**
|
|
127
|
+
* Record an export the MEMBER'S BROWSER produced and saved (DDR-231 browser
|
|
128
|
+
* lane) so it appears in the same ledger every other export does. No bytes
|
|
129
|
+
* are kept — the file never passed through this process.
|
|
130
|
+
*/
|
|
131
|
+
recordBrowserExport(args: { format: Format; scope: Scope; filename: string }): ExportHistoryEntry;
|
|
114
132
|
get(id: string): ExportJob | undefined;
|
|
115
133
|
list(): ExportJob[];
|
|
116
134
|
loadHistory(): ExportHistoryEntry[];
|
|
@@ -211,6 +229,75 @@ function isFinished(job: ExportJob): boolean {
|
|
|
211
229
|
return job.status === 'done' || job.status === 'failed';
|
|
212
230
|
}
|
|
213
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Resolve a client-influenced canvas `file` to an absolute path INSIDE the
|
|
234
|
+
* design root, following symlinks (security review F2). `file` originates from
|
|
235
|
+
* `options.canvasFile`/`selection.file` — a viewer picks it — so the floor is
|
|
236
|
+
* the DESIGN root (not the whole checkout: no `.git`/`.env`/sibling files), the
|
|
237
|
+
* containment is re-checked AFTER `realpathSync` (a `startsWith` string prefix
|
|
238
|
+
* alone is escaped by an in-checkout symlink), and only `.tsx`/`.html` canvas
|
|
239
|
+
* files are eligible. Returns null on any failure — the reader then contributes
|
|
240
|
+
* nothing, exactly as an unreadable canvas does.
|
|
241
|
+
*/
|
|
242
|
+
function safeCanvasAbs(
|
|
243
|
+
repoRoot: string | undefined,
|
|
244
|
+
designRoot: string | undefined,
|
|
245
|
+
file: string
|
|
246
|
+
): string | null {
|
|
247
|
+
if (!repoRoot || !designRoot) return null;
|
|
248
|
+
if (!/\.(tsx|html)$/i.test(file)) return null;
|
|
249
|
+
let designReal: string;
|
|
250
|
+
let abs: string;
|
|
251
|
+
try {
|
|
252
|
+
designReal = realpathSync(path.resolve(designRoot));
|
|
253
|
+
// `file` is REPO-relative (it carries the `.design/` prefix) — resolve
|
|
254
|
+
// against repoRoot, then require the realpath sits under the DESIGN root.
|
|
255
|
+
abs = realpathSync(path.resolve(repoRoot, file));
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return abs === designReal || abs.startsWith(designReal + path.sep) ? abs : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The cell-side `scanUnsupportedMedia` for a remote video job — the first
|
|
263
|
+
* element target's canvas (video renders one artboard). */
|
|
264
|
+
function readUnsupportedMediaFor(
|
|
265
|
+
targets: Target[],
|
|
266
|
+
repoRoot: string | undefined,
|
|
267
|
+
designRoot: string | undefined
|
|
268
|
+
) {
|
|
269
|
+
const t = targets.find((x) => x.kind === 'element');
|
|
270
|
+
if (!t || t.kind !== 'element') return [];
|
|
271
|
+
const abs = safeCanvasAbs(repoRoot, designRoot, t.file);
|
|
272
|
+
return abs ? scanUnsupportedMedia(abs) : [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `{ [repoRelativeCanvasFile]: { [artboardId]: print } }` for every canvas an
|
|
277
|
+
* element target names — what exporters/pdf.ts reads as `options.printProps`
|
|
278
|
+
* when it renders on the worker. Read from the cell's own checkout; a canvas
|
|
279
|
+
* that won't parse or has no print artboard contributes nothing.
|
|
280
|
+
*/
|
|
281
|
+
function readPrintPropsFor(
|
|
282
|
+
targets: Target[],
|
|
283
|
+
repoRoot: string | undefined,
|
|
284
|
+
designRoot: string | undefined
|
|
285
|
+
): Record<string, Record<string, Record<string, unknown>>> {
|
|
286
|
+
const out: Record<string, Record<string, Record<string, unknown>>> = {};
|
|
287
|
+
for (const t of targets) {
|
|
288
|
+
if (t.kind !== 'element' || out[t.file]) continue;
|
|
289
|
+
const abs = safeCanvasAbs(repoRoot, designRoot, t.file);
|
|
290
|
+
if (!abs) continue;
|
|
291
|
+
try {
|
|
292
|
+
const props = readAllArtboardPrintProps(abs, readFileSync(abs, 'utf8'));
|
|
293
|
+
if (Object.keys(props).length) out[t.file] = props;
|
|
294
|
+
} catch {
|
|
295
|
+
/* unreadable canvas — the adapter treats it as non-print, same as local */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
214
301
|
export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQueue {
|
|
215
302
|
const jobsDir = path.join(designRoot, '_export-jobs');
|
|
216
303
|
const historyPath = path.join(designRoot, '_export-history.json');
|
|
@@ -283,6 +370,7 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
283
370
|
// A muted mp4 must not look like a clean one in the ledger either — the
|
|
284
371
|
// history entry is what a later session (or an agent) reads back.
|
|
285
372
|
degraded: j.degraded,
|
|
373
|
+
deliveredInBrowser: j.deliveredInBrowser,
|
|
286
374
|
}));
|
|
287
375
|
}
|
|
288
376
|
|
|
@@ -290,21 +378,36 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
290
378
|
const history = deriveHistory();
|
|
291
379
|
await Bun.write(historyPath, JSON.stringify(history, null, 2));
|
|
292
380
|
|
|
293
|
-
// Evict bytes (+ the in-memory record) for anything that rolled past the
|
|
294
|
-
// cap, or aged out, whichever comes first.
|
|
295
|
-
const finished = Array.from(jobs.values())
|
|
296
|
-
.filter(isFinished)
|
|
297
|
-
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
|
|
298
381
|
const now = Date.now();
|
|
299
|
-
|
|
382
|
+
|
|
383
|
+
// BYTE eviction is ranked among jobs that HAVE bytes on disk — never driven
|
|
384
|
+
// by browser-lane rows. A `deliveredInBrowser` row (recordBrowserExport)
|
|
385
|
+
// holds no bytes and is reachable by a viewer, so counting it toward the
|
|
386
|
+
// FIFO-`HISTORY_DEPTH` window would let a viewer flood fake rows and push a
|
|
387
|
+
// real member's not-yet-downloaded PDF/video past the cap — deleting its
|
|
388
|
+
// bytes (security review F2: an append that was secretly a delete
|
|
389
|
+
// primitive). The two concerns are now separate: the ledger above is a
|
|
390
|
+
// display list; this is GC of real artifacts only.
|
|
391
|
+
const withBytes = Array.from(jobs.values())
|
|
392
|
+
.filter((j) => isFinished(j) && !j.deliveredInBrowser && j.filename)
|
|
393
|
+
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
|
|
394
|
+
const staleBytes = withBytes.filter((j, i) => {
|
|
300
395
|
if (i >= HISTORY_DEPTH) return true;
|
|
301
396
|
const finishedAt = j.finishedAt ? Date.parse(j.finishedAt) : Number.NaN;
|
|
302
397
|
return Number.isFinite(finishedAt) && now - finishedAt > MAX_JOB_AGE_MS;
|
|
303
398
|
});
|
|
304
|
-
for (const job of
|
|
399
|
+
for (const job of staleBytes) {
|
|
305
400
|
jobs.delete(job.id);
|
|
306
401
|
await rm(path.join(jobsDir, job.id), { recursive: true, force: true }).catch(() => {});
|
|
307
402
|
}
|
|
403
|
+
|
|
404
|
+
// In-memory record eviction for the byte-free rows (browser-lane +
|
|
405
|
+
// aged/over-cap finished jobs whose bytes are already gone) — bound the Map
|
|
406
|
+
// so the ledger's own history depth caps memory. No disk to remove.
|
|
407
|
+
const byteless = Array.from(jobs.values())
|
|
408
|
+
.filter((j) => isFinished(j) && (j.deliveredInBrowser || !j.filename))
|
|
409
|
+
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
|
|
410
|
+
for (const job of byteless.slice(HISTORY_DEPTH)) jobs.delete(job.id);
|
|
308
411
|
}
|
|
309
412
|
|
|
310
413
|
function enqueue(args: EnqueueArgs): { id: string; result: Promise<ExportResult> } {
|
|
@@ -336,6 +439,9 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
336
439
|
// exercise the lanes without a reboot.
|
|
337
440
|
const lane = resolveRenderLane();
|
|
338
441
|
const dispatchRemote = lane !== 'local' && formatNeedsBrowser(args.format);
|
|
442
|
+
console.error(
|
|
443
|
+
`[jobs] ${args.format}: lane=${lane} dispatchRemote=${dispatchRemote} (renderUrl=${process.env.MAUDE_RENDER_URL ? 'set' : 'unset'})`
|
|
444
|
+
);
|
|
339
445
|
|
|
340
446
|
const result = (async (): Promise<ExportResult> => {
|
|
341
447
|
// Fail BEFORE taking a render slot: a job that can never render must not
|
|
@@ -367,18 +473,52 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
367
473
|
job.startedAt = new Date().toISOString();
|
|
368
474
|
emit(job);
|
|
369
475
|
|
|
476
|
+
// Scope resolves HERE, against the cell's own checkout — Target is
|
|
477
|
+
// pure data, and the render service holds no tenant store to resolve
|
|
478
|
+
// against (DDR-230 §1).
|
|
479
|
+
const remoteTargets = dispatchRemote
|
|
480
|
+
? await resolveScope({ scope: args.scope, ...args.resolve, options: args.options })
|
|
481
|
+
: [];
|
|
370
482
|
const res = dispatchRemote
|
|
371
483
|
? await renderRemotely({
|
|
372
484
|
format: args.format,
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
485
|
+
targets: remoteTargets,
|
|
486
|
+
// Same rule for anything ELSE the adapter would read off disk: a
|
|
487
|
+
// print artboard's `print` prop (bleed, paper) lives in the canvas
|
|
488
|
+
// source, which only this process has. Resolve it here and ship
|
|
489
|
+
// it as data, or the worker's PDF silently loses its boxes/marks.
|
|
490
|
+
options:
|
|
491
|
+
args.format === 'pdf'
|
|
492
|
+
? {
|
|
493
|
+
...args.options,
|
|
494
|
+
printProps: readPrintPropsFor(
|
|
495
|
+
remoteTargets,
|
|
496
|
+
args.resolve.repoRoot,
|
|
497
|
+
args.resolve.designRoot
|
|
498
|
+
),
|
|
499
|
+
}
|
|
500
|
+
: VIDEO_FORMATS.has(args.format)
|
|
501
|
+
? {
|
|
502
|
+
...args.options,
|
|
503
|
+
// The <Audio>/<OffthreadVideo> pre-flight reads the canvas
|
|
504
|
+
// source — same "only the cell has the checkout" rule.
|
|
505
|
+
unsupportedMedia: readUnsupportedMediaFor(
|
|
506
|
+
remoteTargets,
|
|
507
|
+
args.resolve.repoRoot,
|
|
508
|
+
args.resolve.designRoot
|
|
509
|
+
),
|
|
510
|
+
// Ship the FULL frame-scaled render budget to the worker.
|
|
511
|
+
// The cell sizes jobTimeoutMs to the frame count (a 704-
|
|
512
|
+
// frame comp gets ~30 min), but the worker's shim caps
|
|
513
|
+
// renderMediaOnWeb + its hard-kill on `options.timeoutSec`,
|
|
514
|
+
// which defaulted to 60 s — so every real video fell back
|
|
515
|
+
// to frame-step after 60 s and then hard-killed at 150 s,
|
|
516
|
+
// long before a legitimate render could finish. Without
|
|
517
|
+
// this the worker never knows the job it was handed is
|
|
518
|
+
// allowed minutes, not seconds.
|
|
519
|
+
timeoutSec: Math.ceil(jobTimeoutMs(args) / 1000),
|
|
520
|
+
}
|
|
521
|
+
: args.options,
|
|
382
522
|
canvas: args.remoteCanvas ?? { origin: '' },
|
|
383
523
|
// resolveRenderService() is non-null on the `remote` lane by
|
|
384
524
|
// construction (the lane IS the presence of MAUDE_RENDER_URL).
|
|
@@ -438,6 +578,38 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
438
578
|
enqueue,
|
|
439
579
|
get: (id) => jobs.get(id),
|
|
440
580
|
list: () => Array.from(jobs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
581
|
+
recordBrowserExport({ format, scope, filename }) {
|
|
582
|
+
const now = new Date().toISOString();
|
|
583
|
+
const job: ExportJob = {
|
|
584
|
+
id: crypto.randomUUID(),
|
|
585
|
+
format,
|
|
586
|
+
scope,
|
|
587
|
+
options: {},
|
|
588
|
+
status: 'done',
|
|
589
|
+
createdAt: now,
|
|
590
|
+
startedAt: now,
|
|
591
|
+
finishedAt: now,
|
|
592
|
+
filename,
|
|
593
|
+
deliveredInBrowser: true,
|
|
594
|
+
};
|
|
595
|
+
jobs.set(job.id, job);
|
|
596
|
+
emit(job);
|
|
597
|
+
// Fire-and-forget: the member already HAS the file, so a slow ledger
|
|
598
|
+
// write must not gate their UI, and a failed one must not fail an export
|
|
599
|
+
// that already succeeded.
|
|
600
|
+
void persistAndEvict().catch(() => {});
|
|
601
|
+
return {
|
|
602
|
+
id: job.id,
|
|
603
|
+
format,
|
|
604
|
+
scope,
|
|
605
|
+
filename,
|
|
606
|
+
at: now,
|
|
607
|
+
status: 'done',
|
|
608
|
+
startedAt: now,
|
|
609
|
+
finishedAt: now,
|
|
610
|
+
deliveredInBrowser: true,
|
|
611
|
+
};
|
|
612
|
+
},
|
|
441
613
|
loadHistory: deriveHistory,
|
|
442
614
|
async getBytes(id) {
|
|
443
615
|
const job = jobs.get(id);
|
|
@@ -65,6 +65,20 @@ export function artboardIdFromCssPath(cssPath: string): string | null {
|
|
|
65
65
|
return m ? (m[1] as string) : null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* `options.printProps` — `{ [repoRelativeCanvasFile]: { [artboardId]: print } }`,
|
|
70
|
+
* attached by the cell when the job dispatches to the render worker
|
|
71
|
+
* (exporters/jobs.ts). Shape-checked here because it crosses a trust boundary
|
|
72
|
+
* (the job body reaches the worker over HTTP); anything else is ignored.
|
|
73
|
+
*/
|
|
74
|
+
function shippedPrintProps(
|
|
75
|
+
options: ExportOptions
|
|
76
|
+
): Record<string, Record<string, Record<string, unknown>>> | null {
|
|
77
|
+
const raw = (options as { printProps?: unknown }).printProps;
|
|
78
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
79
|
+
return raw as Record<string, Record<string, Record<string, unknown>>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
/** Resolve a client-supplied `sourceFile` (from `options.selection.file`, a
|
|
69
83
|
* main-origin but caller-controlled string — see scope.ts's `readHints`)
|
|
70
84
|
* against `repoRoot`, rejecting anything that escapes it — mirrors
|
|
@@ -423,7 +437,13 @@ export async function run(
|
|
|
423
437
|
const pageIndex = out.getPageCount();
|
|
424
438
|
out.addPage(page);
|
|
425
439
|
let printProp: Record<string, unknown> | null = null;
|
|
426
|
-
|
|
440
|
+
// The cell ships the print props inside a REMOTE job (it has the
|
|
441
|
+
// checkout; this process may not — DDR-230). Prefer them; fall back to
|
|
442
|
+
// reading the source on disk for the local lane.
|
|
443
|
+
const shipped = shippedPrintProps(options);
|
|
444
|
+
if (w.artboardId && shipped) {
|
|
445
|
+
printProp = shipped[w.sourceFile]?.[w.artboardId] ?? null;
|
|
446
|
+
} else if (w.artboardId) {
|
|
427
447
|
const abs = resolveSourceFileUnderRoot(ctx.repoRoot, w.sourceFile);
|
|
428
448
|
if (abs) {
|
|
429
449
|
let text = sourceCache.get(abs);
|
|
@@ -149,6 +149,41 @@ export class NoRenderServiceError extends Error {
|
|
|
149
149
|
* artifact as a normal `ExportResult`. Degradation travels back as a JSON
|
|
150
150
|
* header rather than a wrapped body so the artifact bytes stream unmodified.
|
|
151
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* Keys a render adapter legitimately consumes (grepped from `exporters/*.ts`
|
|
154
|
+
* `options.*` reads). Scope hints (`canvasFile`/`artboardId`/`selection`) are
|
|
155
|
+
* deliberately absent — the cell resolves scope into `targets` before dispatch,
|
|
156
|
+
* so the worker never needs them. `printProps`/`unsupportedMedia` are re-set by
|
|
157
|
+
* the cell AFTER this pick (jobs.ts), so they pass through here too.
|
|
158
|
+
*/
|
|
159
|
+
const REMOTE_OPTION_KEYS = new Set([
|
|
160
|
+
'scale',
|
|
161
|
+
'dpi',
|
|
162
|
+
'mode',
|
|
163
|
+
'raster',
|
|
164
|
+
'pageFit',
|
|
165
|
+
'pdfPrint',
|
|
166
|
+
'audio',
|
|
167
|
+
'allowUnsupportedMedia',
|
|
168
|
+
'timeoutSec',
|
|
169
|
+
'durationMs',
|
|
170
|
+
'fps',
|
|
171
|
+
'frameFormat',
|
|
172
|
+
'frames',
|
|
173
|
+
'gifColors',
|
|
174
|
+
'maxFrames',
|
|
175
|
+
'printProps',
|
|
176
|
+
'unsupportedMedia',
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
function pickRemoteOptions(options: ExportOptions): ExportOptions {
|
|
180
|
+
const out: ExportOptions = {};
|
|
181
|
+
for (const [k, v] of Object.entries(options)) {
|
|
182
|
+
if (REMOTE_OPTION_KEYS.has(k)) out[k] = v;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
export async function renderRemotely(args: {
|
|
153
188
|
format: Format;
|
|
154
189
|
targets: Target[];
|
|
@@ -165,21 +200,42 @@ export async function renderRemotely(args: {
|
|
|
165
200
|
// sandbox on first access, so `_canvas-shell.html` can take far longer than
|
|
166
201
|
// 8s to fire `load`. Give the remote path a generous floor (unless the
|
|
167
202
|
// caller asked for more) so a cold-cell first render doesn't time out.
|
|
203
|
+
// ALLOWLIST what crosses to the worker (security review F3). `options` is a
|
|
204
|
+
// free-form bag the requesting member (a viewer, in a workspace) fills, and
|
|
205
|
+
// the worker is the fleet's least-trusted process (DDR-230) shared across
|
|
206
|
+
// tenants. Forward ONLY the keys the render adapters actually read — an
|
|
207
|
+
// unknown attacker key never reaches worker code, and the scope-only hints
|
|
208
|
+
// (canvasFile / artboardId / selection) that were already consumed cell-side
|
|
209
|
+
// to build `targets` don't leak the canvas path onward.
|
|
168
210
|
const remoteOptions: ExportOptions = {
|
|
169
|
-
...options,
|
|
211
|
+
...pickRemoteOptions(options),
|
|
170
212
|
timeoutSec: Math.max(Number((options as { timeoutSec?: number }).timeoutSec) || 0, 60),
|
|
171
213
|
};
|
|
172
214
|
let res: Response;
|
|
173
215
|
try {
|
|
174
|
-
|
|
216
|
+
// Bun's `fetch` has its OWN default ceiling — 300 s — independent of the
|
|
217
|
+
// signal, and it rejects with a plain TimeoutError that the catch below
|
|
218
|
+
// read as "the service didn't answer". Every video job longer than five
|
|
219
|
+
// minutes failed that way (measured: 300 s exactly; `timeout: false`
|
|
220
|
+
// lifts it). The JOB timeout (exporters/jobs.ts jobTimeoutMs, sized to
|
|
221
|
+
// the frame count) is the governor here, through `signal`; the fetch
|
|
222
|
+
// must not carry a second, shorter one.
|
|
223
|
+
// `timeout` is a Bun extension the bundled RequestInit types don't declare
|
|
224
|
+
// (bun 1.3.3); the runtime honours it — see the measurement above.
|
|
225
|
+
const init: RequestInit & { timeout: false } = {
|
|
175
226
|
method: 'POST',
|
|
176
227
|
signal,
|
|
228
|
+
timeout: false,
|
|
177
229
|
headers: {
|
|
178
230
|
'content-type': 'application/json',
|
|
179
231
|
authorization: `Bearer ${service.secret}`,
|
|
180
232
|
},
|
|
181
233
|
body: JSON.stringify({ format, targets, options: remoteOptions, canvas }),
|
|
182
|
-
}
|
|
234
|
+
};
|
|
235
|
+
const postStart = Date.now();
|
|
236
|
+
console.error(`[remote] POST ${format} → ${service.url}/render`);
|
|
237
|
+
res = await fetch(`${service.url}/render`, init);
|
|
238
|
+
console.error(`[remote] ${format} responded ${res.status} in ${Date.now() - postStart}ms`);
|
|
183
239
|
} catch (err) {
|
|
184
240
|
if (signal?.aborted) throw err;
|
|
185
241
|
// DDR-231 T7 — the member sees this in the export dialog / notification
|
|
@@ -194,6 +250,10 @@ export async function renderRemotely(args: {
|
|
|
194
250
|
}
|
|
195
251
|
if (!res.ok) {
|
|
196
252
|
const detail = (await res.text().catch(() => '')) || `${res.status}`;
|
|
253
|
+
// Surface the worker's failure reason on the STUDIO side — the worker's own
|
|
254
|
+
// stderr buffers under render load and is lost when the test kills it, so
|
|
255
|
+
// this is the only reliable window into WHY a render 500'd.
|
|
256
|
+
console.error(`[remote] ${format} FAILED ${res.status}: ${detail.slice(0, 800)}`);
|
|
197
257
|
throw new Error(`render service refused the job: ${detail}`);
|
|
198
258
|
}
|
|
199
259
|
// Sanitize the filename BEFORE it can reach `Bun.write` in jobs.ts — the
|
|
@@ -205,7 +265,16 @@ export async function renderRemotely(args: {
|
|
|
205
265
|
const degradedHeader = res.headers.get('x-maude-degraded');
|
|
206
266
|
if (degradedHeader) {
|
|
207
267
|
try {
|
|
208
|
-
|
|
268
|
+
// base64 on the wire — the degradation reason is free text (it can carry
|
|
269
|
+
// an em-dash, quotes, any Unicode from a renderer error), and a raw JSON
|
|
270
|
+
// header value is restricted to Latin-1 with no control chars: a `—` in
|
|
271
|
+
// the reason made the worker's Response construction throw 500 and lose
|
|
272
|
+
// the whole (successful, degraded) render. Decode base64 first; fall back
|
|
273
|
+
// to raw JSON so a mixed-version worker/cell still parses.
|
|
274
|
+
const raw = /^[A-Za-z0-9+/=]+$/.test(degradedHeader)
|
|
275
|
+
? Buffer.from(degradedHeader, 'base64').toString('utf8')
|
|
276
|
+
: degradedHeader;
|
|
277
|
+
degraded = JSON.parse(raw) as ExportDegradation;
|
|
209
278
|
} catch {
|
|
210
279
|
/* a malformed degradation note must not fail a real artifact */
|
|
211
280
|
}
|
|
@@ -73,6 +73,15 @@ export interface ExportScopeHints {
|
|
|
73
73
|
selection?: { selector?: string; file?: string } | null;
|
|
74
74
|
/** `data-dc-screen` id of the artboard to export for `scope=artboard`. */
|
|
75
75
|
artboardId?: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* The canvas FILE the dialog was exporting, captured at submit time. Wins
|
|
78
|
+
* over `activeJson.active`: the persisted `_active.json` is written
|
|
79
|
+
* asynchronously after a tab switch, so a job submitted right after opening
|
|
80
|
+
* another canvas rendered the PREVIOUS one — with the live `artboardId` of
|
|
81
|
+
* the new one, i.e. a selector that never appears (a 60 s waitFor timeout on
|
|
82
|
+
* the worker). The live dialog knows which file it is looking at; trust it.
|
|
83
|
+
*/
|
|
84
|
+
canvasFile?: string | null;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export interface ResolveScopeArgs {
|
|
@@ -108,7 +117,9 @@ function readHints(options: Record<string, unknown> | undefined): ExportScopeHin
|
|
|
108
117
|
: null;
|
|
109
118
|
const artboardId =
|
|
110
119
|
typeof options.artboardId === 'string' && options.artboardId ? options.artboardId : null;
|
|
111
|
-
|
|
120
|
+
const canvasFile =
|
|
121
|
+
typeof options.canvasFile === 'string' && options.canvasFile ? options.canvasFile : null;
|
|
122
|
+
return { selection, artboardId, canvasFile };
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
const RAW_EXCLUDES = new Set([
|
|
@@ -207,11 +218,11 @@ export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
|
|
|
207
218
|
return [{ kind: 'file-tree', paths }];
|
|
208
219
|
}
|
|
209
220
|
|
|
210
|
-
const
|
|
221
|
+
const hints = readHints(args.options);
|
|
222
|
+
const activeFile = hints.canvasFile ?? activeJson.active;
|
|
211
223
|
if (!activeFile) return [];
|
|
212
224
|
const slug = slugify(activeFile, designRel);
|
|
213
225
|
const sel = firstSelection(activeJson.selected);
|
|
214
|
-
const hints = readHints(args.options);
|
|
215
226
|
|
|
216
227
|
if (scope === 'selection') {
|
|
217
228
|
// Prefer the live submit-time snapshot over the persisted `_active.json`
|